diff --git a/.github/workflows/dotnet-cd-prod.yml b/.github/workflows/dotnet-cd-prod.yml new file mode 100644 index 0000000..a33ee96 --- /dev/null +++ b/.github/workflows/dotnet-cd-prod.yml @@ -0,0 +1,73 @@ +name: prod-deploy + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: windows-latest + environment: AZ-Prod + + steps: + - uses: actions/checkout@v4 + + - name: dependency caching + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: configure production front-end environment + run: | + set-content "rubberduckvba.client\src\environments\environment.prod.ts" -value "export const environment = { production: true, apiBaseUrl: '${{ vars.API_ROOT_URL }}' };" + set-content "rubberduckvba.client\src\environments\environment.ts" -value "export const environment = { production: true, apiBaseUrl: '${{ vars.API_ROOT_URL }}' };" + + - name: dotnet build + run: dotnet build rubberduckvba.Server --configuration Release + + - name: dotnet publish + run: dotnet publish "rubberduckvba.Server\rubberduckvba.Server.csproj" --configuration Release --output ${{env.DOTNET_ROOT}}\pub + + - name: upload artifacts + uses: actions/upload-artifact@v4 + with: + name: pub + path: ${{env.DOTNET_ROOT}}\pub + + deploy: + if: github.ref == 'refs/heads/main' + runs-on: self-hosted + needs: build + steps: + - name: prepare staging + run: remove-item C:/pub/webroot/rubberduckvba.com -Recurse -Force + + - name: download artifacts + uses: actions/download-artifact@v4.1.8 + with: + name: pub + path: C:/pub/pub-prod.zip + + - name: staging + run: move-item C:/pub/pub-prod.zip C:/pub/webroot/rubberduckvba.com -force + + - name: deploy iis site + run: | + stop-webapppool -name "rubberduckvba-prod" + stop-webapppool -name "api-prod" + stop-iissite -name api-prod -confirm: $false + stop-iissite -name rubberduckvba-prod -confirm: $false + start-sleep -seconds 10 + copy-item C:/pub/webroot/rubberduckvba.com/* C:/inetpub/wwwroot/rubberduckvba.com -Recurse -Force + copy-item C:/inetpub/appsettings.prod.json C:/inetpub/wwwroot/rubberduckvba.com/appsettings.json -Force + copy-item C:/inetpub/__Web.config C:/inetpub/wwwroot/rubberduckvba.com/wwwroot/browser/Web.config -Force + start-webapppool api-prod + start-iissite api-prod + start-webapppool rubberduckvba-prod + start-iissite rubberduckvba-prod + diff --git a/.github/workflows/dotnet-cd.yml b/.github/workflows/dotnet-cd.yml index 68379b8..f6f7249 100644 --- a/.github/workflows/dotnet-cd.yml +++ b/.github/workflows/dotnet-cd.yml @@ -10,12 +10,13 @@ permissions: jobs: build: - runs-on: self-hosted + runs-on: windows-latest + environment: AZ-Test steps: - uses: actions/checkout@v4 - - name: Set up dependency caching for faster builds + - name: dependency caching uses: actions/cache@v4 with: path: ~/.nuget/packages @@ -23,20 +24,51 @@ jobs: restore-keys: | ${{ runner.os }}-nuget- - - name: Build with dotnet + - name: configure test front-end environment + run: | + set-content "rubberduckvba.client\src\environments\environment.test.ts" -value "export const environment = { production: false, apiBaseUrl: '${{ vars.API_ROOT_URL }}' };" + set-content "rubberduckvba.client\src\environments\environment.prod.ts" -value "export const environment = { production: false, apiBaseUrl: '${{ vars.API_ROOT_URL }}' };" + set-content "rubberduckvba.client\src\environments\environment.ts" -value "export const environment = { production: false, apiBaseUrl: '${{ vars.API_ROOT_URL }}' };" + + - name: dotnet build run: dotnet build rubberduckvba.Server --configuration Release - name: dotnet publish run: dotnet publish "rubberduckvba.Server\rubberduckvba.Server.csproj" --configuration Release --output ${{env.DOTNET_ROOT}}\pub + - name: upload artifacts + uses: actions/upload-artifact@v4 + with: + name: pub + path: ${{env.DOTNET_ROOT}}\pub + deploy: runs-on: self-hosted needs: build steps: - - name: Deploy to IIS + - name: clear staging + run: remove-item C:/pub/webroot/test.rubberduckvba.com -Recurse -Force + + - name: download artifacts + uses: actions/download-artifact@v4.1.8 + with: + name: pub + path: C:/pub/pub-test.zip + + - name: staging + run: move-item C:/pub/pub-test.zip C:/pub/webroot/test.rubberduckvba.com -force + + - name: deploy iis site run: | - stop-webapppool rubberduckvba - stop-iissite -Name api -Confirm: $false - Copy-Item ${{env.DOTNET_ROOT}}\pub\* C:/inetpub/wwwroot -Recurse -Force + stop-webapppool -name "DefaultAppPool" + stop-webapppool -name "rubberduckvba" + stop-iissite -name api -confirm: $false + stop-iissite -name rubberduckvba -confirm: $false + start-sleep -seconds 10 + copy-item C:/pub/webroot/test.rubberduckvba.com/* C:/inetpub/wwwroot/test.rubberduckvba.com -Recurse -Force + copy-item C:/inetpub/appsettings.test.json C:/inetpub/wwwroot/test.rubberduckvba.com/appsettings.json -Force + copy-item C:/inetpub/__Web.config C:/inetpub/wwwroot/test.rubberduckvba.com/wwwroot/browser/Web.config -Force start-webapppool rubberduckvba start-iissite api + start-webapppool DefaultAppPool + start-iissite rubberduckvba diff --git a/rubberduckvba.Server/Api/Admin/AdminController.cs b/rubberduckvba.Server/Api/Admin/AdminController.cs index 1c56ba9..c478ca3 100644 --- a/rubberduckvba.Server/Api/Admin/AdminController.cs +++ b/rubberduckvba.Server/Api/Admin/AdminController.cs @@ -1,31 +1,25 @@ -using Hangfire; -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using rubberduckvba.Server; -using rubberduckvba.Server.ContentSynchronization; -using rubberduckvba.Server.Hangfire; using rubberduckvba.Server.Services; namespace rubberduckvba.Server.Api.Admin; - [ApiController] -public class AdminController(ConfigurationOptions options, IBackgroundJobClient backgroundJob, ILogger logger) : ControllerBase +public class AdminController(ConfigurationOptions options, HangfireLauncherService hangfire, CacheService cache) : ControllerBase { /// /// Enqueues a job that updates xmldoc content from the latest release/pre-release tags. /// /// The unique identifier of the enqueued job. [Authorize("github")] + [EnableCors(CorsPolicies.AllowAuthenticated)] [HttpPost("admin/update/xmldoc")] - public async ValueTask UpdateXmldocContent() + public IActionResult UpdateXmldocContent() { - var parameters = new XmldocSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() }; - var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateXmldocContent(parameters, null!)); - logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for xmldoc sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId); - - return await ValueTask.FromResult(Ok(jobId)); + var jobId = hangfire.UpdateXmldocContent(); + return Ok(jobId); } /// @@ -33,21 +27,32 @@ public async ValueTask UpdateXmldocContent() /// /// The unique identifier of the enqueued job. [Authorize("github")] + [EnableCors(CorsPolicies.AllowAuthenticated)] [HttpPost("admin/update/tags")] - public async ValueTask UpdateTagMetadata() + public IActionResult UpdateTagMetadata() { - var parameters = new TagSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() }; - var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateInstallerDownloadStats(parameters, null!)); - logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for tag sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId); + var jobId = hangfire.UpdateTagMetadata(); + return Ok(jobId); + } - return await ValueTask.FromResult(Ok(jobId)); + [Authorize("github")] + [EnableCors(CorsPolicies.AllowAuthenticated)] + [HttpPost("admin/cache/clear")] + public IActionResult ClearCache() + { + cache.Clear(); + return Ok(); } +#if DEBUG + [AllowAnonymous] + [EnableCors(CorsPolicies.AllowAll)] [HttpGet("admin/config/current")] - public async ValueTask Config() + public IActionResult Config() { - return await ValueTask.FromResult(Ok(options)); + return Ok(options); } +#endif } public record class ConfigurationOptions( @@ -55,5 +60,4 @@ public record class ConfigurationOptions( IOptions GitHubOptions, IOptions HangfireOptions) { - } diff --git a/rubberduckvba.Server/Api/Admin/GitRef.cs b/rubberduckvba.Server/Api/Admin/GitRef.cs new file mode 100644 index 0000000..fadb9a1 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/GitRef.cs @@ -0,0 +1,18 @@ +namespace rubberduckvba.Server.Api.Admin; + +public readonly record struct GitRef +{ + private readonly string _value; + + public GitRef(string value) + { + _value = value; + IsTag = value?.StartsWith("refs/tags/") ?? false; + Name = value?.Split('/').Last() ?? string.Empty; + } + + public bool IsTag { get; } + public string Name { get; } + + public override string ToString() => _value; +} diff --git a/rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs b/rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs new file mode 100644 index 0000000..41fac4e --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/HangfireLauncherService.cs @@ -0,0 +1,43 @@ +using Hangfire; +using rubberduckvba.Server; +using rubberduckvba.Server.ContentSynchronization; +using rubberduckvba.Server.Hangfire; +using rubberduckvba.Server.Services; + +namespace rubberduckvba.Server.Api.Admin; + +public class HangfireLauncherService(IBackgroundJobClient backgroundJob, ILogger logger) +{ + public string UpdateXmldocContent() + { + var parameters = new XmldocSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() }; + var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateXmldocContent(parameters, null!)); + + if (string.IsNullOrWhiteSpace(jobId)) + { + throw new InvalidOperationException("UpdateXmldocContent was requested but enqueueing a Hangfire job did not return a JobId."); + } + else + { + logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for xmldoc sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId); + } + + return jobId; + } + + public string UpdateTagMetadata() + { + var parameters = new TagSyncRequestParameters { RepositoryId = RepositoryId.Rubberduck, RequestId = Guid.NewGuid() }; + var jobId = backgroundJob.Enqueue(HangfireConstants.ManualQueueName, () => QueuedUpdateOrchestrator.UpdateInstallerDownloadStats(parameters, null!)); + + if (string.IsNullOrWhiteSpace(jobId)) + { + throw new InvalidOperationException("UpdateXmldocContent was requested but enqueueing a Hangfire job did not return a JobId."); + } + else + { + logger.LogInformation("JobId {jobId} was enqueued (queue: {queueName}) for tag sync request {requestId}", jobId, HangfireConstants.ManualQueueName, parameters.RequestId); + } + return jobId; + } +} diff --git a/rubberduckvba.Server/Api/Admin/PushWebhookPayload.cs b/rubberduckvba.Server/Api/Admin/PushWebhookPayload.cs new file mode 100644 index 0000000..154f6a1 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/PushWebhookPayload.cs @@ -0,0 +1,32 @@ +namespace rubberduckvba.Server.Api.Admin; + +public record class PushWebhookPayload +{ + public string Ref { get; set; } + + public bool Created { get; set; } + public bool Deleted { get; set; } + public bool Forced { get; set; } + + public WebhookPayloadSender Sender { get; set; } + public WebhookPayloadRepository Repository { get; set; } + + public record class WebhookPayloadSender + { + public string Login { get; set; } + } + + public record class WebhookPayloadRepository + { + public int Id { get; set; } + public string Name { get; set; } + public WebhookPayloadRepositoryOwner Owner { get; set; } + + public record class WebhookPayloadRepositoryOwner + { + public int Id { get; set; } + public string Login { get; set; } + public string Type { get; set; } + } + } +} diff --git a/rubberduckvba.Server/Api/Admin/WebhookController.cs b/rubberduckvba.Server/Api/Admin/WebhookController.cs new file mode 100644 index 0000000..94cf711 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/WebhookController.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +namespace rubberduckvba.Server.Api.Admin; + +[ApiController] +public class WebhookController : RubberduckApiController +{ + private readonly WebhookPayloadValidationService _validator; + private readonly HangfireLauncherService _hangfire; + + public WebhookController( + ILogger logger, + HangfireLauncherService hangfire, + WebhookPayloadValidationService validator) + : base(logger) + { + _validator = validator; + _hangfire = hangfire; + } + + [Authorize("webhook")] + [EnableCors(CorsPolicies.AllowAll)] + [HttpPost("webhook/github")] + public async Task GitHub([FromBody] dynamic body) => + GuardInternalAction(() => + { + string json = body.ToString(); + + var payload = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? throw new InvalidOperationException("Could not deserialize payload"); + var eventType = _validator.Validate(payload, json, Request.Headers, out var content, out var gitref); + + if (eventType == WebhookPayloadType.Push) + { + var jobId = _hangfire.UpdateXmldocContent(); + var message = $"Webhook push event was accepted. Tag '{gitref?.Name}' associated to the payload will be processed by JobId '{jobId}'."; + + Logger.LogInformation(message); + return Ok(message); + } + else if (eventType == WebhookPayloadType.Ping) + { + Logger.LogInformation("Webhook ping event was accepted; nothing to process."); + return Ok(); + } + else if (eventType == WebhookPayloadType.Greeting) + { + Logger.LogInformation("Webhook push event was accepted; nothing to process. {content}", content); + return string.IsNullOrWhiteSpace(content) ? NoContent() : Ok(content); + } + + // reject the payload + return BadRequest(); + }); +} diff --git a/rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs b/rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs new file mode 100644 index 0000000..3ab8bab --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/WebhookPayloadType.cs @@ -0,0 +1,9 @@ +namespace rubberduckvba.Server.Api.Admin; + +public enum WebhookPayloadType +{ + BadRequest, + Greeting, + Ping, + Push +} diff --git a/rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs b/rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs new file mode 100644 index 0000000..a904273 --- /dev/null +++ b/rubberduckvba.Server/Api/Admin/WebhookPayloadValidationService.cs @@ -0,0 +1,31 @@ +namespace rubberduckvba.Server.Api.Admin; + +public class WebhookPayloadValidationService(ConfigurationOptions options, WebhookSignatureValidationService signatureValidation) +{ + public WebhookPayloadType Validate(PushWebhookPayload payload, string body, IHeaderDictionary headers, out string? content, out GitRef? gitref) + { + content = default; + if (!signatureValidation.Validate(body, headers["X-Hub-Signature-256"].OfType().ToArray())) + { + gitref = default; + return WebhookPayloadType.BadRequest; + } + + gitref = new GitRef(payload.Ref); + if (headers["X-GitHub-Event"].FirstOrDefault() == "ping") + { + if (!(payload.Created && gitref.Value.IsTag)) + { + return WebhookPayloadType.Greeting; + } + return WebhookPayloadType.Ping; + } + + if (headers["X-GitHub-Event"].FirstOrDefault() == "push") + { + return WebhookPayloadType.Push; + } + + return WebhookPayloadType.BadRequest; + } +} diff --git a/rubberduckvba.Server/Api/Auth/AuthController.cs b/rubberduckvba.Server/Api/Auth/AuthController.cs index 10c8c3d..31f50b6 100644 --- a/rubberduckvba.Server/Api/Auth/AuthController.cs +++ b/rubberduckvba.Server/Api/Auth/AuthController.cs @@ -1,119 +1,143 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Octokit; using Octokit.Internal; using System.Security.Claims; -using System.Text; namespace rubberduckvba.Server.Api.Auth; public record class UserViewModel { - public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", HasOrgRole = false }; + public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", IsAuthenticated = false, IsAdmin = false }; public string Name { get; init; } = default!; - public bool HasOrgRole { get; init; } + public bool IsAuthenticated { get; init; } + public bool IsAdmin { get; init; } } - +public record class SignInViewModel +{ + public string? State { get; init; } + public string? Code { get; init; } + public string? Token { get; init; } +} [ApiController] [AllowAnonymous] -public class AuthController(IOptions configuration, IOptions api) : ControllerBase +public class AuthController : RubberduckApiController { + private readonly IOptions configuration; + + public AuthController(IOptions configuration, IOptions api, ILogger logger) + : base(logger) + { + this.configuration = configuration; + } + [HttpGet("auth")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public ActionResult Index() + public IActionResult Index() { - var claims = HttpContext.User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value); - var hasName = claims.TryGetValue(ClaimTypes.Name, out var name); - var hasRole = claims.TryGetValue(ClaimTypes.Role, out var role); - - if (hasName && hasRole) + return GuardInternalAction(() => { - if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(role)) - { - return BadRequest(); - } + var claims = HttpContext.User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value); + var hasName = claims.TryGetValue(ClaimTypes.Name, out var name); + var hasRole = claims.TryGetValue(ClaimTypes.Role, out var role); - var model = new UserViewModel + if (hasName && hasRole) { - Name = name, - HasOrgRole = (HttpContext.User.Identity?.IsAuthenticated ?? false) && role == configuration.Value.OwnerOrg - }; + if (string.IsNullOrEmpty(name) || string.IsNullOrWhiteSpace(role)) + { + return BadRequest(); + } - return Ok(model); - } - else - { - return Ok(UserViewModel.Anonymous); - } + var isAuthenticated = HttpContext.User.Identity?.IsAuthenticated ?? false; + var model = new UserViewModel + { + Name = name, + IsAuthenticated = isAuthenticated, + IsAdmin = role == configuration.Value.OwnerOrg + }; + + return Ok(model); + } + else + { + return Ok(UserViewModel.Anonymous); + } + }); } [HttpPost("auth/signin")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task SignIn() + public IActionResult SessionSignIn(SignInViewModel vm) { - var xsrf = Guid.NewGuid().ToString(); - HttpContext.Session.SetString("xsrf:state", xsrf); - await HttpContext.Session.CommitAsync(); + return GuardInternalAction(() => + { + if (User.Identity?.IsAuthenticated ?? false) + { + Logger.LogInformation("Signin was requested, but user is already authenticated. Redirecting to home page..."); + return Redirect("/"); + } - var clientId = configuration.Value.ClientId; - var agent = configuration.Value.UserAgent; + var clientId = configuration.Value.ClientId; + var agent = configuration.Value.UserAgent; - var github = new GitHubClient(new ProductHeaderValue(agent)); - var request = new OauthLoginRequest(clientId) - { - AllowSignup = false, - Scopes = { "read:user", "read:org" }, - State = xsrf - }; + var github = new GitHubClient(new ProductHeaderValue(agent)); + var request = new OauthLoginRequest(clientId) + { + AllowSignup = false, + Scopes = { "read:user", "read:org" }, + State = vm.State + }; - var url = github.Oauth.GetGitHubLoginUrl(request); - if (url is null) - { - return Forbid(); - } + Logger.LogInformation("Requesting OAuth app GitHub login url..."); + var url = github.Oauth.GetGitHubLoginUrl(request); + if (url is null) + { + Logger.LogInformation("OAuth login was cancelled by the user or did not return a url."); + return Forbid(); + } - // TODO log url - //return Redirect(url.ToString()); - return RedirectToAction("Index", "Home"); + Logger.LogInformation("Returning the login url for the client to redirect. State: {xsrf}", vm.State); + return Ok(url.ToString()); + }); } - [HttpGet("auth/github")] + [HttpPost("auth/github")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task GitHubCallback(string code, string state) + public IActionResult OnGitHubCallback(SignInViewModel vm) { - if (string.IsNullOrWhiteSpace(code)) - { - return BadRequest(); - } - - var expected = HttpContext.Session.GetString("xsrf:state"); - HttpContext.Session.Clear(); - await HttpContext.Session.CommitAsync(); - - if (state != expected) + return GuardInternalAction(() => { - return BadRequest(); - } + Logger.LogInformation("OAuth token was received. State: {state}", vm.State); + var clientId = configuration.Value.ClientId; + var clientSecret = configuration.Value.ClientSecret; + var agent = configuration.Value.UserAgent; - var clientId = configuration.Value.ClientId; - var clientSecret = configuration.Value.ClientSecret; - var agent = configuration.Value.UserAgent; + var github = new GitHubClient(new ProductHeaderValue(agent)); - var github = new GitHubClient(new ProductHeaderValue(agent)); - - var request = new OauthTokenRequest(clientId, clientSecret, code); - var token = await github.Oauth.CreateAccessToken(request); + var request = new OauthTokenRequest(clientId, clientSecret, vm.Code); + var token = github.Oauth.CreateAccessToken(request).GetAwaiter().GetResult(); + if (token is null) + { + Logger.LogWarning("OAuth access token was not created."); + return Unauthorized(); + } - await AuthorizeAsync(token.AccessToken); + Logger.LogInformation("OAuth access token was created. Authorizing..."); + var authorizedToken = AuthorizeAsync(token.AccessToken).GetAwaiter().GetResult(); - return Ok(); + return authorizedToken is null ? Unauthorized() : Ok(vm with { Token = authorizedToken }); + }); } - private async Task AuthorizeAsync(string token) + private async Task AuthorizeAsync(string token) { try { @@ -122,42 +146,44 @@ private async Task AuthorizeAsync(string token) var githubUser = await github.User.Current(); if (githubUser.Suspended) { - throw new InvalidOperationException("User is suspended"); + Logger.LogWarning("User login '{login}' ({name}) is a suspended GitHub account and will not be authorized.", githubUser.Login, githubUser.Name); + return default; } - var emailClaim = new Claim(ClaimTypes.Email, githubUser.Email); - var identity = new ClaimsIdentity("github", ClaimTypes.Name, ClaimTypes.Role); - if (identity != null) - { - identity.AddClaim(new Claim(ClaimTypes.Name, githubUser.Login)); + identity.AddClaim(new Claim(ClaimTypes.Name, githubUser.Login)); + Logger.LogInformation("Creating claims identity for GitHub login '{login}'...", githubUser.Login); - var orgs = await github.Organization.GetAllForUser(githubUser.Login); - var rdOrg = orgs.SingleOrDefault(org => org.Id == configuration.Value.RubberduckOrgId); + var orgs = await github.Organization.GetAllForUser(githubUser.Login); + var rdOrg = orgs.SingleOrDefault(org => org.Id == configuration.Value.RubberduckOrgId); - if (rdOrg != null) - { - identity.AddClaim(new Claim(ClaimTypes.Role, configuration.Value.OwnerOrg)); - identity.AddClaim(new Claim(ClaimTypes.Authentication, token)); - identity.AddClaim(new Claim("access_token", token)); - - var principal = new ClaimsPrincipal(identity); + if (rdOrg != null) + { + identity.AddClaim(new Claim(ClaimTypes.Role, configuration.Value.OwnerOrg)); + identity.AddClaim(new Claim(ClaimTypes.Authentication, token)); + identity.AddClaim(new Claim("access_token", token)); + Logger.LogDebug("GitHub Organization claims were granted. Creating claims principal..."); - var issued = DateTime.UtcNow; - var expires = issued.Add(TimeSpan.FromMinutes(50)); - var roles = string.Join(",", identity.Claims.Where(claim => claim.Type == ClaimTypes.Role).Select(claim => claim.Value)); + var principal = new ClaimsPrincipal(identity); + var roles = string.Join(",", identity.Claims.Where(claim => claim.Type == ClaimTypes.Role).Select(claim => claim.Value)); - HttpContext.User = principal; - Thread.CurrentPrincipal = HttpContext.User; + HttpContext.User = principal; + Thread.CurrentPrincipal = HttpContext.User; - var jwt = principal.AsJWT(api.Value.SymetricKey, configuration.Value.JwtIssuer, configuration.Value.JwtAudience); - HttpContext.Session.SetString("jwt", jwt); - } + Logger.LogInformation("GitHub user with login {login} has signed in with role authorizations '{role}'.", githubUser.Login, configuration.Value.OwnerOrg); + return token; + } + else + { + Logger.LogWarning("User {name} ({email}) with login '{login}' is not a member of organization ID {org} and will not be authorized.", githubUser.Name, githubUser.Email, githubUser.Login, configuration.Value.RubberduckOrgId); + return default; } } catch (Exception) { // just ignore: configuration needs the org (prod) client app id to avoid throwing this exception + Logger.LogWarning("An exception was thrown. Verify GitHub:ClientId and GitHub:ClientSecret configuration; authorization fails."); + return default; } } } diff --git a/rubberduckvba.Server/Api/Downloads/DownloadsController.cs b/rubberduckvba.Server/Api/Downloads/DownloadsController.cs index 3a69fc1..5efb567 100644 --- a/rubberduckvba.Server/Api/Downloads/DownloadsController.cs +++ b/rubberduckvba.Server/Api/Downloads/DownloadsController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using rubberduckvba.Server.Services; using System.Collections.Immutable; @@ -6,37 +7,55 @@ namespace rubberduckvba.Server.Api.Downloads; -[ApiController] [AllowAnonymous] -public class DownloadsController(IContentCacheService cache, IRubberduckDbService db) : Controller +[EnableCors(CorsPolicies.AllowAll)] +public class DownloadsController : RubberduckApiController { + private readonly CacheService cache; + private readonly IRubberduckDbService db; + + public DownloadsController(CacheService cache, IRubberduckDbService db, ILogger logger) + : base(logger) + { + this.cache = cache; + this.db = db; + } + [HttpGet("downloads")] - public async Task>> GetAvailableDownloadsAsync() + public IActionResult GetAvailableDownloadsAsync() { - var cacheKey = $"{nameof(GetAvailableDownloadsAsync)}"; - if (!cache.TryGetValue(cacheKey, out IEnumerable downloads)) + return GuardInternalAction(() => { - var tags = (await db.GetLatestTagsAsync(RepositoryId.Rubberduck)).ToImmutableArray(); - var main = tags[0]; - var next = tags[1]; + AvailableDownload[] result = []; + if (!cache.TryGetAvailableDownloads(out var cached)) + { + var tags = db.GetLatestTagsAsync(RepositoryId.Rubberduck).GetAwaiter().GetResult().ToImmutableArray(); + var main = tags[0]; + var next = tags[1]; + + var pdfStyleGuide = new AvailableDownload + { + Name = "Rubberduck Style Guide (PDF)", + Title = "Free (pay what you want) PDF download", + Kind = "pdf", + DownloadUrl = "https://ko-fi.com/s/d91bfd610c" + }; - var pdfStyleGuide = new AvailableDownload + result = + [ + AvailableDownload.FromTag(main), + AvailableDownload.FromTag(next), + pdfStyleGuide + ]; + + cache.Invalidate(result); + } + else { - Name = "Rubberduck Style Guide (PDF)", - Title = "Free (pay what you want) PDF download", - Kind = "pdf", - DownloadUrl = "https://ko-fi.com/s/d91bfd610c" - }; - - downloads = [ - AvailableDownload.FromTag(main), - AvailableDownload.FromTag(next), - pdfStyleGuide - ]; - - cache.SetValue(cacheKey, downloads); - } - - return downloads.Any() ? Ok(downloads) : NoContent(); + result = cached ?? []; + } + + return result.Any() ? Ok(result) : NoContent(); + }); } } diff --git a/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs b/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs index 38707d5..62868d6 100644 --- a/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs +++ b/rubberduckvba.Server/Api/Features/FeatureEditViewModel.cs @@ -29,7 +29,7 @@ public Feature ToFeature() return new Feature { Id = Id ?? default, - ParentId = ParentId, + FeatureId = ParentId, RepositoryId = RepositoryId, Name = Name, Title = Title, @@ -43,7 +43,7 @@ public Feature ToFeature() public FeatureEditViewModel(Feature model, FeatureOptionViewModel[] features, RepositoryOptionViewModel[] repositories) { Id = model.Id; - ParentId = model.ParentId; + ParentId = model.FeatureId; RepositoryId = model.RepositoryId; Name = model.Name; diff --git a/rubberduckvba.Server/Api/Features/FeatureViewModel.cs b/rubberduckvba.Server/Api/Features/FeatureViewModel.cs index 12b9220..a47439d 100644 --- a/rubberduckvba.Server/Api/Features/FeatureViewModel.cs +++ b/rubberduckvba.Server/Api/Features/FeatureViewModel.cs @@ -1,27 +1,35 @@ using rubberduckvba.Server.Model; +using rubberduckvba.Server.Model.Entity; namespace rubberduckvba.Server.Api.Features; public class FeatureViewModel { - public FeatureViewModel(Feature model) + public FeatureViewModel(Feature model, bool summaryOnly = false) { Id = model.Id; DateInserted = model.DateTimeInserted; DateUpdated = model.DateTimeUpdated; + FeatureId = model.FeatureId; + FeatureName = model.FeatureName; + Name = model.Name; - Title = model.Title; + Title = string.IsNullOrWhiteSpace(model.Title) ? model.Name : model.Title; + ShortDescription = model.ShortDescription; - Description = model.Description; + Description = summaryOnly ? string.Empty : model.Description; + IsNew = model.IsNew; + IsDiscontinued = model.IsDiscontinued; IsHidden = model.IsHidden; + HasImage = model.HasImage; + Links = model.Links.ToArray(); - if (model is FeatureGraph graph) + if (!summaryOnly && model is FeatureGraph graph) { - Features = graph.Features.Select(e => new FeatureViewModel(e) { FeatureId = e.ParentId, FeatureName = graph.Name, FeatureTitle = graph.Title }).ToArray(); - Inspections = graph.Inspections.ToArray(); + Features = graph.Features.Select(e => new FeatureViewModel(e) { FeatureId = graph.Id, FeatureName = graph.Name }).ToArray(); } } @@ -31,21 +39,234 @@ public FeatureViewModel(Feature model) public int? FeatureId { get; init; } public string? FeatureName { get; init; } - public string? FeatureTitle { get; init; } public string Name { get; init; } public string Title { get; init; } public string ShortDescription { get; init; } public string Description { get; init; } public bool IsNew { get; init; } + public bool IsDiscontinued { get; init; } public bool IsHidden { get; init; } public bool HasImage { get; init; } public FeatureViewModel[] Features { get; init; } = []; - public Inspection[] Inspections { get; init; } = []; // InspectionViewModel[] + public BlogLink[] Links { get; init; } = []; +} + +public class InspectionViewModel +{ + public InspectionViewModel(Inspection model, IEnumerable quickFixes, IDictionary tagsByAssetId) + { + Id = model.Id; + DateTimeInserted = model.DateTimeInserted; + DateTimeUpdated = model.DateTimeUpdated; + + FeatureId = model.FeatureId; + FeatureName = model.FeatureName; + + SourceUrl = model.SourceUrl; + TagAssetId = model.TagAssetId; + TagName = tagsByAssetId[model.TagAssetId].Name; + + IsHidden = model.IsHidden; + IsNew = model.IsNew; + IsDiscontinued = model.IsDiscontinued; + + Name = model.Name; + Summary = model.Summary; + Remarks = model.Remarks; + + InspectionType = model.InspectionType; + DefaultSeverity = model.DefaultSeverity; + QuickFixes = quickFixes.Where(e => model.QuickFixes.Any(name => string.Equals(e.Name, name, StringComparison.InvariantCultureIgnoreCase))).ToArray(); + + Reasoning = model.Reasoning; + HostApp = model.HostApp; + References = model.References; + + Examples = model.Examples; + } + + public int Id { get; init; } + public DateTime DateTimeInserted { get; init; } + public DateTime? DateTimeUpdated { get; init; } + public int TagAssetId { get; init; } + public string SourceUrl { get; init; } = string.Empty; + + public int FeatureId { get; init; } + public string FeatureName { get; init; } + + public string TagName { get; init; } + + public bool IsNew { get; init; } + public bool IsDiscontinued { get; init; } + public bool IsHidden { get; init; } + + public string Name { get; init; } = string.Empty; + public string InspectionType { get; init; } = string.Empty; + public string DefaultSeverity { get; init; } = string.Empty; + public string Summary { get; init; } = string.Empty; + public string Reasoning { get; init; } = string.Empty; + public string? Remarks { get; init; } + public string? HostApp { get; init; } + public string[] References { get; init; } = []; + public QuickFixViewModel[] QuickFixes { get; init; } = []; + public InspectionExample[] Examples { get; init; } = []; } -public class FeatureXmlDocViewModel +public class AnnotationViewModel { + public AnnotationViewModel(Annotation model, IDictionary tagsByAssetId) + { + Id = model.Id; + DateTimeInserted = model.DateTimeInserted; + DateTimeUpdated = model.DateTimeUpdated; + FeatureId = model.FeatureId; + + SourceUrl = model.SourceUrl; + TagAssetId = model.TagAssetId; + TagName = tagsByAssetId[model.TagAssetId].Name; + + IsHidden = model.IsHidden; + IsNew = model.IsNew; + IsDiscontinued = model.IsDiscontinued; + + Name = model.Name; + Summary = model.Summary; + Remarks = model.Remarks; + + Parameters = model.Parameters; + Examples = model.Examples; + } + + public int Id { get; init; } + public DateTime DateTimeInserted { get; init; } + public DateTime? DateTimeUpdated { get; init; } + public int FeatureId { get; init; } + public int TagAssetId { get; init; } + public string SourceUrl { get; init; } = string.Empty; + + public string TagName { get; init; } + + public bool IsNew { get; init; } + public bool IsDiscontinued { get; init; } + public bool IsHidden { get; init; } + + public string Name { get; init; } = string.Empty; + + public string Summary { get; init; } = string.Empty; + public string? Remarks { get; init; } + + public AnnotationParameter[] Parameters { get; init; } = []; + public AnnotationExample[] Examples { get; init; } = []; +} -} \ No newline at end of file +public class QuickFixViewModel +{ + public QuickFixViewModel(QuickFix model, IDictionary tagsByAssetId, IDictionary inspectionsByName) + { + Id = model.Id; + DateTimeInserted = model.DateTimeInserted; + DateTimeUpdated = model.DateTimeUpdated; + FeatureId = model.FeatureId; + + SourceUrl = model.SourceUrl; + TagAssetId = model.TagAssetId; + TagName = tagsByAssetId[model.TagAssetId].Name; + + IsHidden = model.IsHidden; + IsNew = model.IsNew; + IsDiscontinued = model.IsDiscontinued; + + Name = model.Name; + Summary = model.Summary; + Remarks = model.Remarks; + + CanFixAll = model.CanFixAll; + CanFixMultiple = model.CanFixMultiple; + CanFixProcedure = model.CanFixProcedure; + CanFixModule = model.CanFixModule; + CanFixProject = model.CanFixProject; + + Inspections = (from name in model.Inspections + let inspection = inspectionsByName[name] + select new QuickFixInspectionLinkViewModel + { + Name = inspection.Name, + Summary = inspection.Summary, + InspectionType = inspection.InspectionType, + DefaultSeverity = inspection.DefaultSeverity + }).ToArray(); + + Examples = model.Examples; + } + + public int Id { get; init; } + public DateTime DateTimeInserted { get; init; } + public DateTime? DateTimeUpdated { get; init; } + public int FeatureId { get; init; } + public int TagAssetId { get; init; } + public string SourceUrl { get; init; } = string.Empty; + + public string TagName { get; init; } + + public bool IsNew { get; init; } + public bool IsDiscontinued { get; init; } + public bool IsHidden { get; init; } + + public string Name { get; init; } = string.Empty; + + public string Summary { get; init; } = string.Empty; + public string? Remarks { get; init; } + + public bool CanFixProcedure { get; init; } + public bool CanFixModule { get; init; } + public bool CanFixProject { get; init; } + public bool CanFixAll { get; init; } + public bool CanFixMultiple { get; init; } + + public QuickFixInspectionLinkViewModel[] Inspections { get; init; } = []; + public QuickFixExample[] Examples { get; init; } = []; +} + +public record class QuickFixInspectionLinkViewModel +{ + public string Name { get; init; } + public string Summary { get; init; } + public string InspectionType { get; init; } + public string DefaultSeverity { get; init; } +} + +public class InspectionsFeatureViewModel : FeatureViewModel +{ + public InspectionsFeatureViewModel(FeatureGraph model, IEnumerable quickFixes, IDictionary tagsByAssetId, bool summaryOnly = false) + : base(model, summaryOnly) + { + + Inspections = model.Inspections.OrderBy(e => e.Name).Select(e => new InspectionViewModel(e, quickFixes, tagsByAssetId)).ToArray(); + } + + public InspectionViewModel[] Inspections { get; init; } = []; +} + +public class QuickFixesFeatureViewModel : FeatureViewModel +{ + public QuickFixesFeatureViewModel(FeatureGraph model, IDictionary tagsByAssetId, IDictionary inspectionsByName, bool summaryOnly = false) + : base(model, summaryOnly) + { + QuickFixes = model.QuickFixes.OrderBy(e => e.Name).Select(e => new QuickFixViewModel(e, tagsByAssetId, inspectionsByName)).ToArray(); + } + + public QuickFixViewModel[] QuickFixes { get; init; } = []; +} + +public class AnnotationsFeatureViewModel : FeatureViewModel +{ + public AnnotationsFeatureViewModel(FeatureGraph model, IDictionary tagsByAssetId, bool summaryOnly = false) + : base(model, summaryOnly) + { + Annotations = model.Annotations.OrderBy(e => e.Name).Select(e => new AnnotationViewModel(e, tagsByAssetId)).ToArray(); + } + + public AnnotationViewModel[] Annotations { get; init; } = []; +} diff --git a/rubberduckvba.Server/Api/Features/FeaturesController.cs b/rubberduckvba.Server/Api/Features/FeaturesController.cs index 43f60c1..6868419 100644 --- a/rubberduckvba.Server/Api/Features/FeaturesController.cs +++ b/rubberduckvba.Server/Api/Features/FeaturesController.cs @@ -1,24 +1,35 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; +using rubberduckvba.Server.Data; using rubberduckvba.Server.Model; +using rubberduckvba.Server.Model.Entity; using rubberduckvba.Server.Services; using rubberduckvba.Server.Services.rubberduckdb; -using System.ComponentModel; -using System.Reflection; namespace rubberduckvba.Server.Api.Features; -public record class MarkdownFormattingRequestViewModel -{ - public string MarkdownContent { get; init; } - public bool WithVbeCodeBlocks { get; init; } -} - -[ApiController] [AllowAnonymous] -public class FeaturesController(IRubberduckDbService db, FeatureServices features, IMarkdownFormattingService md, ICacheService cache) : ControllerBase +public class FeaturesController : RubberduckApiController { + private readonly CacheService cache; + private readonly IRubberduckDbService db; + private readonly FeatureServices features; + private readonly IRepository assetsRepository; + private readonly IRepository tagsRepository; + + public FeaturesController(CacheService cache, IRubberduckDbService db, FeatureServices features, + IRepository assetsRepository, IRepository tagsRepository, ILogger logger) + : base(logger) + { + this.cache = cache; + this.db = db; + this.features = features; + this.assetsRepository = assetsRepository; + this.tagsRepository = tagsRepository; + } + private static RepositoryOptionViewModel[] RepositoryOptions { get; } = Enum.GetValues().Select(e => new RepositoryOptionViewModel { Id = e, Name = e.ToString() }).ToArray(); @@ -27,59 +38,120 @@ await db.GetTopLevelFeatures(repositoryId) .ContinueWith(t => t.Result.Select(e => new FeatureOptionViewModel { Id = e.Id, Name = e.Name, Title = e.Title }).ToArray()); [HttpGet("features")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task>> Index() + public IActionResult Index() { - //if (cache.TryGet(HttpContext.Request.Path, out var cached)) - //{ - // return cached; - //} - - var features = await db.GetTopLevelFeatures(RepositoryId.Rubberduck); - if (!features.Any()) + return GuardInternalAction(() => { - return NoContent(); - } + FeatureViewModel[]? result = []; + if (!cache.TryGetFeatures(out result)) + { + var features = db.GetTopLevelFeatures(RepositoryId.Rubberduck).GetAwaiter().GetResult(); + if (!features.Any()) + { + return NoContent(); + } - var model = features.Select(feature => new FeatureViewModel(feature)).ToArray(); - //cache.Write(HttpContext.Request.Path, model); + result = features + .Select(e => new FeatureViewModel(e, summaryOnly: true)) + .ToArray(); - return Ok(model); - } + if (result.Length > 0) + { + cache.Invalidate(result); + } + } - private static readonly IDictionary _moduleTypeNames = typeof(ExampleModuleType).GetMembers().Where(e => e.GetCustomAttribute() != null) - .ToDictionary(member => member.Name, member => member.GetCustomAttribute()?.Description ?? member.Name); + return result is not null && result.Length != 0 ? Ok(result) : NoContent(); + }); + } [HttpGet("features/{name}")] + [EnableCors(CorsPolicies.AllowAll)] + [AllowAnonymous] + public IActionResult Info([FromRoute] string name) + { + return GuardInternalAction(() => + { + return name.ToLowerInvariant() switch + { + "inspections" => Ok(GetInspections()), + "quickfixes" => Ok(GetQuickFixes()), + "annotations" => Ok(GetAnnotations()), + _ => Ok(GetFeature(name)) + }; + }); + } + + [HttpGet("inspections/{name}")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task> Info([FromRoute] string name) + public IActionResult Inspection([FromRoute] string name) { - //if (cache.TryGet(HttpContext.Request.Path, out var cached)) - //{ - // return cached; - //} + return GuardInternalAction(() => + { + InspectionViewModel? result; + if (!cache.TryGetInspection(name, out result)) + { + _ = GetInspections(); // caches all inspections + } + + if (!cache.TryGetInspection(name, out result)) + { + return NotFound(); + } + + return Ok(result); + }); + } - var feature = features.Get(name); - if (feature is null) + [HttpGet("annotations/{name}")] + [EnableCors(CorsPolicies.AllowAll)] + [AllowAnonymous] + public IActionResult Annotation([FromRoute] string name) + { + return GuardInternalAction(() => { - return NotFound(); - } + AnnotationViewModel? result; + if (!cache.TryGetAnnotation(name, out result)) + { + _ = GetAnnotations(); // caches all annotations + } - var model = new FeatureViewModel(feature); - //cache.Write(HttpContext.Request.Path, model); - return Ok(model); + if (!cache.TryGetAnnotation(name, out result)) + { + return NotFound(); + } + + return Ok(result); + }); } - [HttpGet("features/resolve")] + [HttpGet("quickfixes/{name}")] + [EnableCors(CorsPolicies.AllowAll)] [AllowAnonymous] - public async Task Resolve([FromQuery] RepositoryId repository, [FromQuery] string name) + public IActionResult QuickFix([FromRoute] string name) { - var graph = await db.ResolveFeature(repository, name); - var markdown = md.FormatMarkdownDocument(graph.Description, withSyntaxHighlighting: true); - return Ok(graph with { Description = markdown }); + return GuardInternalAction(() => + { + QuickFixViewModel? result; + if (!cache.TryGetQuickFix(name, out result)) + { + _ = GetAnnotations(); // caches all quickfixes + } + + if (!cache.TryGetQuickFix(name, out result)) + { + return NotFound(); + } + + return Ok(result); + }); } [HttpGet("features/create")] + [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Create([FromQuery] RepositoryId repository = RepositoryId.Rubberduck, [FromQuery] int? parentId = default) { @@ -91,6 +163,7 @@ public async Task> Create([FromQuery] Reposit } [HttpPost("create")] + [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Create([FromBody] FeatureEditViewModel model) { @@ -113,6 +186,7 @@ public async Task> Create([FromBody] FeatureE } [HttpPost("features/update")] + [EnableCors(CorsPolicies.AllowAuthenticated)] [Authorize("github")] public async Task> Update([FromBody] FeatureEditViewModel model) { @@ -133,9 +207,72 @@ public async Task> Update([FromBody] FeatureE return new FeatureEditViewModel(result, features, RepositoryOptions); } - [HttpPost("features/markdown")] - public IActionResult FormatMarkdown([FromBody] MarkdownFormattingRequestViewModel model) + private InspectionsFeatureViewModel GetInspections() + { + InspectionsFeatureViewModel result; + if (!cache.TryGetInspections(out result!)) + { + var quickfixesModel = GetQuickFixes(); + + var feature = features.Get("Inspections") as FeatureGraph; + result = new InspectionsFeatureViewModel(feature, quickfixesModel.QuickFixes, + feature.Inspections + .Select(e => e.TagAssetId).Distinct() + .ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId)))); + + cache.Invalidate(result); + } + + return result; + } + + private QuickFixesFeatureViewModel GetQuickFixes() + { + QuickFixesFeatureViewModel result; + if (!cache.TryGetQuickFixes(out result!)) + { + var feature = features.Get("QuickFixes") as FeatureGraph; + result = new QuickFixesFeatureViewModel(feature, + feature.QuickFixes + .Select(e => e.TagAssetId).Distinct() + .ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId))), + features.Get("Inspections").Inspections.ToDictionary(inspection => inspection.Name)); + + cache.Invalidate(result); + } + + return result; + } + + private AnnotationsFeatureViewModel GetAnnotations() + { + AnnotationsFeatureViewModel result; + if (!cache.TryGetAnnotations(out result!)) + { + var feature = features.Get("Annotations") as FeatureGraph; + result = new AnnotationsFeatureViewModel(feature, + feature.Annotations + .Select(e => e.TagAssetId).Distinct() + .ToDictionary(id => id, id => new Tag(tagsRepository.GetById(assetsRepository.GetById(id).TagId)))); + + cache.Invalidate(result); + } + + return result; + } + + private FeatureViewModel GetFeature(string name) { - return Ok(md.FormatMarkdownDocument(model.MarkdownContent, model.WithVbeCodeBlocks)); + FeatureViewModel result; + if (!cache.TryGetFeature(name, out result!)) + { + var feature = features.Get(name); + result = new FeatureViewModel(feature); + + cache.Invalidate(result); + } + + return result; } + } diff --git a/rubberduckvba.Server/Api/Indenter/IndenterController.cs b/rubberduckvba.Server/Api/Indenter/IndenterController.cs new file mode 100644 index 0000000..11a9eed --- /dev/null +++ b/rubberduckvba.Server/Api/Indenter/IndenterController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using RubberduckServices; + +namespace rubberduckvba.Server.Api.Indenter; + +[AllowAnonymous] +[EnableCors(CorsPolicies.AllowAll)] +public class IndenterController : RubberduckApiController +{ + private readonly IIndenterService service; + + public IndenterController(IIndenterService service, ILogger logger) + : base(logger) + { + this.service = service; + } + + [HttpGet("indenter/version")] + [AllowAnonymous] + public IActionResult Version() => + GuardInternalAction(() => Ok(service.IndenterVersion())); + + [HttpGet("indenter/defaults")] + [AllowAnonymous] + public IActionResult DefaultSettings() => + GuardInternalAction(() => + { + var result = new IndenterViewModel + { + IndenterVersion = service.IndenterVersion(), + Code = "Option Explicit\n\n'...comments...\n\nPublic Sub DoSomething()\n'...comments...\n\nEnd Sub\nPublic Sub DoSomethingElse()\n'...comments...\n\nIf True Then\nMsgBox \"Hello, world!\"\nElse\n'...comments...\nExit Sub\nEnd If\nEnd Sub\n", + AlignCommentsWithCode = true, + EmptyLineHandlingMethod = IndenterEmptyLineHandling.Indent, + ForceCompilerDirectivesInColumn1 = true, + GroupRelatedProperties = false, + IndentSpaces = 4, + IndentCase = true, + IndentEntireProcedureBody = true, + IndentEnumTypeAsProcedure = true, + VerticallySpaceProcedures = true, + LinesBetweenProcedures = 1, + IndentFirstCommentBlock = true, + IndentFirstDeclarationBlock = true, + EndOfLineCommentStyle = IndenterEndOfLineCommentStyle.SameGap, + }; + + return Ok(result); + }); + + [HttpPost("indenter/indent")] + [AllowAnonymous] + public IActionResult Indent(IndenterViewModel model) => + GuardInternalAction(() => + { + if (model is null) + { + throw new ArgumentNullException(nameof(model)); + } + + var result = service.IndentAsync(model).GetAwaiter().GetResult(); + return Ok(result); + }); +} diff --git a/rubberduckvba.Server/Api/Tags/TagsController.cs b/rubberduckvba.Server/Api/Tags/TagsController.cs index 2302514..84c0584 100644 --- a/rubberduckvba.Server/Api/Tags/TagsController.cs +++ b/rubberduckvba.Server/Api/Tags/TagsController.cs @@ -1,31 +1,49 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using rubberduckvba.Server.Services; namespace rubberduckvba.Server.Api.Tags; -[ApiController] [AllowAnonymous] -public class TagsController(IRubberduckDbService db) : ControllerBase +[EnableCors(CorsPolicies.AllowAll)] +public class TagsController : RubberduckApiController { + private readonly CacheService cache; + private readonly IRubberduckDbService db; + + public TagsController(CacheService cache, IRubberduckDbService db, ILogger logger) + : base(logger) + { + this.cache = cache; + this.db = db; + } + /// /// Gets information about the latest release tags. /// + [HttpGet("api/v1/public/tags")] // legacy route [HttpGet("tags/latest")] - [AllowAnonymous] - public async Task> Latest() + public IActionResult Latest() { - var model = await db.GetLatestTagsAsync(RepositoryId.Rubberduck); - var main = model.SingleOrDefault(tag => !tag.IsPreRelease); - var next = model.SingleOrDefault(tag => tag.IsPreRelease); - - if (main == default) + return GuardInternalAction(() => { - return NoContent(); - } + LatestTagsViewModel? result; + if (!cache.TryGetLatestTags(out result)) + { + var model = db.GetLatestTagsAsync(RepositoryId.Rubberduck).GetAwaiter().GetResult(); + var main = model.SingleOrDefault(tag => !tag.IsPreRelease); + var next = model.SingleOrDefault(tag => tag.IsPreRelease); + + if (main != default) + { + result = new LatestTagsViewModel(main, next); + cache.Invalidate(result.Value); + } + } - var tags = new LatestTagsViewModel(main, next); - return Ok(tags); + return result is null ? NoContent() : Ok(result); + }); } } diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/ExecutionDataflowBlockBase.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/ExecutionDataflowBlockBase.cs index 0fc2885..294e1a1 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/ExecutionDataflowBlockBase.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/ExecutionDataflowBlockBase.cs @@ -163,14 +163,14 @@ protected void LinkToSources(params ISectionBlock[] sources) completionTasks.Add(sourceBlock.Completion); } } - else if (srcBlock != null) + else if (srcBlock != null && !propagate) { - //completionTasks.Add(srcBlock.Completion); - Logger.LogWarning(Context.Parameters, $"{Name} | ⚠️ Source block ({srcBlock.GetType().Name}) is not ISourceBlock<{typeof(TInput).Name}>. Pipeline may not complete."); - throw new InvalidOperationException($"Source block ({srcBlock.GetType().Name}) is not ISourceBlock<{typeof(TInput).Name}>"); + completionTasks.Add(srcBlock.Completion); } else { + //completionTasks.Add(srcBlock.Completion); + Logger.LogWarning(Context.Parameters, $"{Name} | ⚠️ Source block ({srcBlock?.GetType().Name}) is not ISourceBlock<{typeof(TInput).Name}>. Pipeline may not complete."); throw new InvalidOperationException($"Source block is not defined."); } } diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/SynchronizationPipelineFactory.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/SynchronizationPipelineFactory.cs index ca01999..4ef7648 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/SynchronizationPipelineFactory.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Abstract/SynchronizationPipelineFactory.cs @@ -5,6 +5,7 @@ using rubberduckvba.Server.Data; using rubberduckvba.Server.Model.Entity; using rubberduckvba.Server.Services; +using rubberduckvba.Server.Services.rubberduckdb; namespace rubberduckvba.Server.ContentSynchronization.Pipeline.Abstract; @@ -20,13 +21,16 @@ public class SynchronizationPipelineFactory : ISynchronizationPipelineFactory _quickfixes; private readonly IRepository _annotations; + private readonly TagServices _tagServices; + private readonly XmlDocAnnotationParser _annotationParser; private readonly XmlDocQuickFixParser _quickFixParser; private readonly XmlDocInspectionParser _inspectionParser; public SynchronizationPipelineFactory(ILogger logger, IRubberduckDbService content, IGitHubClientService github, IXmlDocMerge merge, IStagingServices staging, IMarkdownFormattingService markdown, IRepository inspections, IRepository quickfixes, IRepository annotations, - XmlDocAnnotationParser xmlAnnotationParser, XmlDocQuickFixParser xmlQuickFixParser, XmlDocInspectionParser xmlInspectionParser) + XmlDocAnnotationParser xmlAnnotationParser, XmlDocQuickFixParser xmlQuickFixParser, XmlDocInspectionParser xmlInspectionParser, + TagServices tagServices) { _logger = logger; _content = content; @@ -38,6 +42,8 @@ public SynchronizationPipelineFactory(ILogger logger, IRubberduc _quickfixes = quickfixes; _annotations = annotations; + _tagServices = tagServices; + _annotationParser = xmlAnnotationParser; _quickFixParser = xmlQuickFixParser; _inspectionParser = xmlInspectionParser; @@ -47,8 +53,8 @@ public ISynchronizationPipeline Create(TParamete { return parameters switch { - XmldocSyncRequestParameters => new SynchronizeXmlPipeline(parameters, _logger, _content, _github, _merge, _staging, _markdown, tokenSource, _inspections, _quickfixes, _annotations, _annotationParser, _quickFixParser, _inspectionParser), - TagSyncRequestParameters => new SynchronizeTagsPipeline(parameters, _logger, _content, _github, _merge, _staging, tokenSource), + XmldocSyncRequestParameters => new SynchronizeXmlPipeline(parameters, _logger, _content, _github, _merge, _staging, _markdown, tokenSource, _inspections, _quickfixes, _annotations, _annotationParser, _quickFixParser, _inspectionParser, _tagServices), + TagSyncRequestParameters => new SynchronizeTagsPipeline(parameters, _logger, _content, _tagServices, _github, _merge, _staging, tokenSource), _ => throw new NotSupportedException(), }; } diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs index cdafb1f..ee71d83 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/Context/SyncContext.cs @@ -1,5 +1,6 @@ using rubberduckvba.Server.Model; using System.Collections.Immutable; +using System.Xml.Linq; namespace rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.Context; @@ -20,7 +21,6 @@ public SyncContext(IRequestParameters parameters) IRequestParameters IPipelineContext.Parameters => Parameters; public void LoadParameters(SyncRequestParameters parameters) { - InvalidContextParameterException.ThrowIfNull(nameof(parameters), parameters); _parameters = parameters; _staging = new StagingContext(parameters); } @@ -104,6 +104,13 @@ public ImmutableHashSet QuickFixes public ImmutableHashSet Annotations => ContextNotInitializedException.ThrowIfNull(_annotations); + private readonly List<(TagAsset, XDocument)> _documents = []; + public void LoadCodeAnalysisXDocument((TagAsset, XDocument) document) + { + _documents.Add(document); + } + + public ImmutableHashSet<(TagAsset, XDocument)> XDocuments => _documents.ToImmutableHashSet(); public void LoadInspections(IEnumerable inspections) { diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/LoadGitHubTagsBlock.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/LoadGitHubTagsBlock.cs index d9dfc63..9a86535 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/LoadGitHubTagsBlock.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/LoadGitHubTagsBlock.cs @@ -4,7 +4,7 @@ namespace rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncTags; -public class LoadGitHubTagsBlock : TransformBlockBase +public class LoadGitHubTagsBlock : TransformBlockBase { private readonly IGitHubClientService _github; @@ -14,9 +14,9 @@ public LoadGitHubTagsBlock(PipelineSection parent, CancellationToke _github = github; } - public override async Task TransformAsync(SyncRequestParameters input) + public override async Task TransformAsync(SyncContext input) { - var githubTags = await _github.GetAllTagsAsync(); // does not get the assets + var githubTags = await _github.GetAllTagsAsync(); var (gitHubMain, gitHubNext, gitHubOthers) = githubTags.GetLatestTags(); Context.LoadGitHubTags(gitHubMain, gitHubNext, gitHubOthers); diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/SyncTagsSection.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/SyncTagsSection.cs index 143576b..0f1ae3d 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/SyncTagsSection.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncTags/SyncTagsSection.cs @@ -2,84 +2,147 @@ using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.Context; using rubberduckvba.Server.Model; using rubberduckvba.Server.Services; +using rubberduckvba.Server.Services.rubberduckdb; using System.Threading.Tasks.Dataflow; namespace rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncTags; public class SyncTagsSection : PipelineSection { - public SyncTagsSection(IPipeline parent, CancellationTokenSource tokenSource, ILogger logger, IRubberduckDbService content, IGitHubClientService github, IStagingServices staging) + public SyncTagsSection(IPipeline parent, CancellationTokenSource tokenSource, ILogger logger, TagServices tagServices, IGitHubClientService github, IStagingServices staging) : base(parent, tokenSource, logger) { - ReceiveRequest = new ReceiveRequestBlock(this, tokenSource, logger); - BroadcastParameters = new BroadcastParametersBlock(this, tokenSource, logger); - AcquireDbMainTag = new AcquireDbMainTagGraphBlock(this, tokenSource, content, logger); - AcquireDbNextTag = new AcquireDbNextTagGraphBlock(this, tokenSource, content, logger); - JoinDbTags = new DataflowJoinBlock(this, tokenSource, logger, nameof(JoinDbTags)); - LoadDbTags = new LoadDbLatestTagsBlock(this, tokenSource, logger); - LoadGitHubTags = new LoadGitHubTagsBlock(this, tokenSource, github, logger); - JoinTags = new DataflowJoinBlock(this, tokenSource, logger, nameof(JoinTags)); - BroadcastTags = new BroadcastTagsBlock(this, tokenSource, logger); - StreamGitHubTags = new StreamGitHubTagsBlock(this, tokenSource, logger); - GetTagAssets = new GetTagAssetsBlock(this, tokenSource, github, logger); - TagBuffer = new TagBufferBlock(this, tokenSource, logger); - AccumulateProcessedTags = new AccumulateProcessedTagsBlock(this, tokenSource, logger); - SaveTags = new BulkSaveStagingBlock(this, tokenSource, staging, logger); + SynchronizeTags = new SynchronizeTagsBlock(this, tokenSource, logger, tagServices, github); + //ReceiveRequest = new ReceiveRequestBlock(this, tokenSource, logger); + //BroadcastParameters = new BroadcastParametersBlock(this, tokenSource, logger); + //AcquireDbMainTag = new AcquireDbMainTagGraphBlock(this, tokenSource, content, logger); + //AcquireDbNextTag = new AcquireDbNextTagGraphBlock(this, tokenSource, content, logger); + //JoinDbTags = new DataflowJoinBlock(this, tokenSource, logger, nameof(JoinDbTags)); + //LoadDbTags = new LoadDbLatestTagsBlock(this, tokenSource, logger); + //LoadGitHubTags = new LoadGitHubTagsBlock(this, tokenSource, github, logger); + //JoinTags = new DataflowJoinBlock(this, tokenSource, logger, nameof(JoinTags)); + //BroadcastTags = new BroadcastTagsBlock(this, tokenSource, logger); + //StreamGitHubTags = new StreamGitHubTagsBlock(this, tokenSource, logger); + //GetTagAssets = new GetTagAssetsBlock(this, tokenSource, github, logger); + //TagBuffer = new TagBufferBlock(this, tokenSource, logger); + //AccumulateProcessedTags = new AccumulateProcessedTagsBlock(this, tokenSource, logger); + //SaveTags = new BulkSaveStagingBlock(this, tokenSource, staging, logger); } - #region blocks - private ReceiveRequestBlock ReceiveRequest { get; } - private BroadcastParametersBlock BroadcastParameters { get; } - private AcquireDbMainTagGraphBlock AcquireDbMainTag { get; } - private AcquireDbNextTagGraphBlock AcquireDbNextTag { get; } - private DataflowJoinBlock JoinDbTags { get; } - private LoadDbLatestTagsBlock LoadDbTags { get; } - private LoadGitHubTagsBlock LoadGitHubTags { get; } - private DataflowJoinBlock JoinTags { get; } - private BroadcastTagsBlock BroadcastTags { get; } - private StreamGitHubTagsBlock StreamGitHubTags { get; } - private GetTagAssetsBlock GetTagAssets { get; } - private TagBufferBlock TagBuffer { get; } - private AccumulateProcessedTagsBlock AccumulateProcessedTags { get; } - private BulkSaveStagingBlock SaveTags { get; } + //#region blocks + private SynchronizeTagsBlock SynchronizeTags { get; } + //private ReceiveRequestBlock ReceiveRequest { get; } + //private BroadcastParametersBlock BroadcastParameters { get; } + //private AcquireDbMainTagGraphBlock AcquireDbMainTag { get; } + //private AcquireDbNextTagGraphBlock AcquireDbNextTag { get; } + //private DataflowJoinBlock JoinDbTags { get; } + //private LoadDbLatestTagsBlock LoadDbTags { get; } + //private LoadGitHubTagsBlock LoadGitHubTags { get; } + //private DataflowJoinBlock JoinTags { get; } + //private BroadcastTagsBlock BroadcastTags { get; } + //private StreamGitHubTagsBlock StreamGitHubTags { get; } + //private GetTagAssetsBlock GetTagAssets { get; } + //private TagBufferBlock TagBuffer { get; } + //private AccumulateProcessedTagsBlock AccumulateProcessedTags { get; } + //private BulkSaveStagingBlock SaveTags { get; } - public ITargetBlock InputBlock => ReceiveRequest.Block; - public Task OutputTask => SaveTags.Block.Completion; + public ITargetBlock InputBlock => SynchronizeTags.Block!; + public Task OutputTask => SynchronizeTags.Block.Completion; protected override IReadOnlyDictionary Blocks => new Dictionary { - [nameof(ReceiveRequest)] = ReceiveRequest.Block, - [nameof(BroadcastParameters)] = BroadcastParameters.Block, - [nameof(AcquireDbMainTag)] = AcquireDbMainTag.Block, - [nameof(AcquireDbNextTag)] = AcquireDbNextTag.Block, - [nameof(JoinDbTags)] = JoinDbTags.Block, - [nameof(LoadDbTags)] = LoadDbTags.Block, - [nameof(LoadGitHubTags)] = LoadGitHubTags.Block, - [nameof(JoinTags)] = JoinTags.Block, - [nameof(BroadcastTags)] = BroadcastTags.Block, - [nameof(StreamGitHubTags)] = StreamGitHubTags.Block, - [nameof(GetTagAssets)] = GetTagAssets.Block, - [nameof(TagBuffer)] = TagBuffer.Block, - [nameof(AccumulateProcessedTags)] = AccumulateProcessedTags.Block, - [nameof(SaveTags)] = SaveTags.Block, + [nameof(SynchronizeTags)] = SynchronizeTags.Block, + // [nameof(ReceiveRequest)] = ReceiveRequest.Block, + // [nameof(BroadcastParameters)] = BroadcastParameters.Block, + // [nameof(AcquireDbMainTag)] = AcquireDbMainTag.Block, + // [nameof(AcquireDbNextTag)] = AcquireDbNextTag.Block, + // [nameof(JoinDbTags)] = JoinDbTags.Block, + // [nameof(LoadDbTags)] = LoadDbTags.Block, + // [nameof(LoadGitHubTags)] = LoadGitHubTags.Block, + // [nameof(JoinTags)] = JoinTags.Block, + // [nameof(BroadcastTags)] = BroadcastTags.Block, + // [nameof(StreamGitHubTags)] = StreamGitHubTags.Block, + // [nameof(GetTagAssets)] = GetTagAssets.Block, + // [nameof(TagBuffer)] = TagBuffer.Block, + // [nameof(AccumulateProcessedTags)] = AccumulateProcessedTags.Block, + // [nameof(SaveTags)] = SaveTags.Block, }; - #endregion + //#endregion public override void CreateBlocks() { - ReceiveRequest.CreateBlock(); - BroadcastParameters.CreateBlock(ReceiveRequest); - AcquireDbMainTag.CreateBlock(BroadcastParameters); - AcquireDbNextTag.CreateBlock(BroadcastParameters); - JoinDbTags.CreateBlock(AcquireDbMainTag, AcquireDbNextTag); - LoadDbTags.CreateBlock(JoinDbTags); - LoadGitHubTags.CreateBlock(BroadcastParameters); - JoinTags.CreateBlock(LoadDbTags, LoadGitHubTags); - BroadcastTags.CreateBlock(JoinTags); - StreamGitHubTags.CreateBlock(BroadcastTags); - GetTagAssets.CreateBlock(StreamGitHubTags); - TagBuffer.CreateBlock(GetTagAssets); - AccumulateProcessedTags.CreateBlock(TagBuffer); - SaveTags.CreateBlock(AccumulateProcessedTags); + SynchronizeTags.CreateBlock(); + //ReceiveRequest.CreateBlock(); + //BroadcastParameters.CreateBlock(ReceiveRequest); + //AcquireDbMainTag.CreateBlock(BroadcastParameters); + //AcquireDbNextTag.CreateBlock(BroadcastParameters); + //JoinDbTags.CreateBlock(AcquireDbMainTag, AcquireDbNextTag); + //LoadDbTags.CreateBlock(JoinDbTags); + //LoadGitHubTags.CreateBlock(LoadDbTags); + //JoinTags.CreateBlock(LoadDbTags, LoadGitHubTags); + //BroadcastTags.CreateBlock(JoinTags); + //StreamGitHubTags.CreateBlock(BroadcastTags); + //GetTagAssets.CreateBlock(StreamGitHubTags); + //TagBuffer.CreateBlock(GetTagAssets); + //AccumulateProcessedTags.CreateBlock(TagBuffer); + //SaveTags.CreateBlock(AccumulateProcessedTags); } } + +public class SynchronizeTagsBlock : ActionBlockBase +{ + private readonly IGitHubClientService _github; + private readonly TagServices _tagServices; + + public SynchronizeTagsBlock(PipelineSection parent, CancellationTokenSource tokenSource, ILogger logger, + TagServices tagServices, + IGitHubClientService github) + : base(parent, tokenSource, logger) + { + _tagServices = tagServices; + _github = github; + } + + protected override async Task ActionAsync(TagSyncRequestParameters input) + { + var getGithubTags = _github.GetAllTagsAsync(); + var dbMain = _tagServices.GetLatestTag(false); + var dbNext = _tagServices.GetLatestTag(true); + + var githubTags = await getGithubTags; + var (gitHubMain, gitHubNext, _) = githubTags.GetLatestTags(); + + var mergedMain = (dbMain ?? gitHubMain) with { InstallerDownloads = gitHubMain.InstallerDownloads }; + var mergedNext = (dbNext ?? gitHubNext) with { InstallerDownloads = gitHubNext.InstallerDownloads }; + + var inserts = new List(); + var updates = new List(); + + if (dbMain is null) + { + inserts.Add(mergedMain); + } + else + { + updates.Add(mergedMain); + } + + if (dbNext is null) + { + inserts.Add(mergedNext); + } + else + { + updates.Add(mergedNext); + } + + if (inserts.Any()) + { + _tagServices.Create(inserts); + } + if (updates.Any()) + { + _tagServices.Update(updates); + } + } +} \ No newline at end of file diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/AcceptInspectionsXDocumentBlock.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/AcceptInspectionsXDocumentBlock.cs new file mode 100644 index 0000000..53e6ba3 --- /dev/null +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/AcceptInspectionsXDocumentBlock.cs @@ -0,0 +1,30 @@ +using rubberduckvba.Server.ContentSynchronization.Pipeline.Abstract; +using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.Context; +using rubberduckvba.Server.Model; +using System.Xml.Linq; + +namespace rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncXmldoc; + +public class AcceptInspectionsXDocumentBlock : TransformBlockBase<(TagAsset, XDocument), SyncContext, SyncContext> +{ + public AcceptInspectionsXDocumentBlock(PipelineSection parent, CancellationTokenSource tokenSource, ILogger logger) + : base(parent, tokenSource, logger) + { + } + + public override SyncContext Transform((TagAsset, XDocument) input) + { + if (input.Item1.Name.Equals("Rubberduck.CodeAnalysis.xml", StringComparison.InvariantCultureIgnoreCase)) + { + Context.LoadCodeAnalysisXDocument(input); + + if (Context.XDocuments.Any(e => e.Item1.TagId == Context.RubberduckDbMain.Id) + && Context.XDocuments.Any(e => e.Item1.TagId == Context.RubberduckDbNext.Id)) + { + Block.Complete(); + } + } + + return Context; + } +} \ No newline at end of file diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/LoadDbTagsBlock.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/LoadDbTagsBlock.cs index c7c4759..037f39c 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/LoadDbTagsBlock.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/LoadDbTagsBlock.cs @@ -1,24 +1,24 @@ using rubberduckvba.Server.ContentSynchronization.Pipeline.Abstract; using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.Context; using rubberduckvba.Server.Model; -using rubberduckvba.Server.Services; +using rubberduckvba.Server.Services.rubberduckdb; namespace rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncXmldoc; public class AcquireDbTagsBlock : TransformBlockBase, SyncContext> { - private readonly IRubberduckDbService _service; + private readonly TagServices _service; public AcquireDbTagsBlock(PipelineSection parent, CancellationTokenSource tokenSource, ILogger logger, - IRubberduckDbService service) + TagServices service) : base(parent, tokenSource, logger) { _service = service; } - public override async Task> TransformAsync(SyncRequestParameters input) + public override IEnumerable Transform(SyncRequestParameters input) { - return await _service.GetAllTagsAsync(); + return _service.GetAllTags(); } } diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/StreamInspectionNodesBlock.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/StreamInspectionNodesBlock.cs index 8f4e894..7aa0a4e 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/StreamInspectionNodesBlock.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/StreamInspectionNodesBlock.cs @@ -5,29 +5,28 @@ namespace rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncXmldoc; -public class StreamInspectionNodesBlock : TransformManyBlockBase>, (TagAsset, XElementInfo, IEnumerable), SyncContext> +public class StreamInspectionNodesBlock : TransformManyBlockBase), SyncContext> { public StreamInspectionNodesBlock(PipelineSection parent, CancellationTokenSource tokenSource, ILogger logger) : base(parent, tokenSource, logger) { } - public override IEnumerable<(TagAsset, XElementInfo, IEnumerable)> Transform(Tuple<(TagAsset, XDocument), IEnumerable> input) + public override IEnumerable<(TagAsset, XElementInfo, IEnumerable)> Transform(SyncContext context) { - if (input.Item1.Item1 is null) + foreach (var xmldoc in context.XDocuments.Select(x => (asset: x.Item1, nodes: x.Item2))) { - return []; - } - - var result = (from node in input.Item1.Item2.Descendants("member").AsParallel() - let fullName = GetNameOrDefault(node, "Inspection") - where !string.IsNullOrWhiteSpace(fullName) - let typeName = fullName.Substring(fullName.LastIndexOf(".", StringComparison.Ordinal) + 1) - select (input.Item1.Item1, new XElementInfo(typeName, node), input.Item2)) - .ToList(); + foreach (var result in + from node in xmldoc.nodes.Descendants("member") + let fullName = GetNameOrDefault(node, "Inspection") + where !string.IsNullOrWhiteSpace(fullName) + let typeName = fullName.Substring(fullName.LastIndexOf(".", StringComparison.Ordinal) + 1) + select (xmldoc.asset, new XElementInfo(typeName, node), context.StagingContext.QuickFixes)) + { + yield return result; + } - Logger.LogInformation(Context.Parameters, $"{Name} | Extracted {result.Count} inspection nodes from tag asset {input.Item1.Item1.Name}"); - return result; + } } private static string GetNameOrDefault(XElement memberNode, string suffix) diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs index d34d90a..8da95bd 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/Sections/SyncXmldoc/SyncXmldocSection.cs @@ -1,17 +1,319 @@ using rubberduckvba.Server.ContentSynchronization.Pipeline.Abstract; using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.Context; -using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncTags; using rubberduckvba.Server.ContentSynchronization.XmlDoc; using rubberduckvba.Server.ContentSynchronization.XmlDoc.Abstract; using rubberduckvba.Server.Data; using rubberduckvba.Server.Model; using rubberduckvba.Server.Model.Entity; using rubberduckvba.Server.Services; +using rubberduckvba.Server.Services.rubberduckdb; using System.Threading.Tasks.Dataflow; using System.Xml.Linq; namespace rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncXmldoc; + +public class SynchronizeXmlDocSection : PipelineSection +{ + public SynchronizeXmlDocSection(IPipeline parent, CancellationTokenSource tokenSource, ILogger logger, + IRubberduckDbService content, + IRepository inspections, + IRepository quickfixes, + IRepository annotations, + TagServices tagServices, + IGitHubClientService github, + IXmlDocMerge mergeService, + IStagingServices staging, + XmlDocAnnotationParser xmlAnnotationParser, + XmlDocQuickFixParser xmlQuickFixParser, + XmlDocInspectionParser xmlInspectionParser) + : base(parent, tokenSource, logger) + { + Block = new SynchronizeXmlDocBlock(this, tokenSource, logger, content, inspections, quickfixes, annotations, tagServices, github, mergeService, staging, xmlAnnotationParser, xmlQuickFixParser, xmlInspectionParser); + } + + public SynchronizeXmlDocBlock Block { get; } + + protected override IReadOnlyDictionary Blocks => new Dictionary + { + [nameof(Block)] = Block.Block + }; + + public override void CreateBlocks() + { + Block.CreateBlock(); + } +} + +public class SynchronizeXmlDocBlock : ActionBlockBase +{ + private readonly IRubberduckDbService _content; + private readonly IRepository _inspections; + private readonly IRepository _quickfixes; + private readonly IRepository _annotations; + private readonly TagServices _tagServices; + private readonly IGitHubClientService _github; + private readonly IXmlDocMerge _mergeService; + private readonly IStagingServices _staging; + private readonly XmlDocAnnotationParser _xmlAnnotationParser; + private readonly XmlDocQuickFixParser _xmlQuickFixParser; + private readonly XmlDocInspectionParser _xmlInspectionParser; + + public SynchronizeXmlDocBlock(PipelineSection parent, CancellationTokenSource tokenSource, ILogger logger, + IRubberduckDbService content, + IRepository inspections, + IRepository quickfixes, + IRepository annotations, + TagServices tagServices, + IGitHubClientService github, + IXmlDocMerge mergeService, + IStagingServices staging, + XmlDocAnnotationParser xmlAnnotationParser, + XmlDocQuickFixParser xmlQuickFixParser, + XmlDocInspectionParser xmlInspectionParser) + : base(parent, tokenSource, logger) + { + _content = content; + _inspections = inspections; + _quickfixes = quickfixes; + _annotations = annotations; + _tagServices = tagServices; + _github = github; + _mergeService = mergeService; + _staging = staging; + _xmlAnnotationParser = xmlAnnotationParser; + _xmlQuickFixParser = xmlQuickFixParser; + _xmlInspectionParser = xmlInspectionParser; + } + + protected override async Task ActionAsync(SyncRequestParameters input) + { + Context.LoadParameters(input); + + var dbMain = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: false); + Context.LoadRubberduckDbMain(dbMain); + + var githubTags = await _github.GetAllTagsAsync(); + // LoadInspectionDefaultConfig + var config = await _github.GetCodeAnalysisDefaultsConfigAsync(); + Context.LoadInspectionDefaultConfig(config); + + // LoadFeatures + var inspections = await _content.ResolveFeature(input.RepositoryId, "inspections"); + var quickfixes = await _content.ResolveFeature(input.RepositoryId, "quickfixes"); + var annotations = await _content.ResolveFeature(input.RepositoryId, "annotations"); + Context.LoadFeatures([inspections, quickfixes, annotations]); + + // LoadDbFeatureItems + await Task.WhenAll([ + Task.Run(() => _inspections.GetAll()).ContinueWith(t => Context.LoadInspections(t.Result.Select(e => new Inspection(e)))), + Task.Run(() => _quickfixes.GetAll()).ContinueWith(t => Context.LoadQuickFixes(t.Result.Select(e => new QuickFix(e)))), + Task.Run(() => _annotations.GetAll()).ContinueWith(t => Context.LoadAnnotations(t.Result.Select(e => new Annotation(e)))) + ]); + + // AcquireDbTags + var ghMain = githubTags.Where(tag => !tag.IsPreRelease).OrderByDescending(tag => tag.DateCreated).ThenByDescending(tag => tag.ReleaseId).Take(1).Single(); + var ghNext = githubTags.Where(tag => tag.IsPreRelease).OrderByDescending(tag => tag.DateCreated).ThenByDescending(tag => tag.ReleaseId).Take(1).Single(); + + await Task.Delay(TimeSpan.FromSeconds(2)); // just in case the tags job was scheduled at/around the same time + + var dbNext = await _content.GetLatestTagAsync(RepositoryId.Rubberduck, includePreRelease: true); + + var dbTags = _tagServices.GetAllTags().ToDictionary(e => e.Name); + List newTags = []; + if (ghMain.Name != dbMain.Name) + { + if (!dbTags.ContainsKey(ghMain.Name)) + { + newTags.Add(ghMain); + } + else + { + // could be an old tag, ...or the db is just out of date + Logger.LogWarning($"Tag metadata mismatch, xmldoc update will not proceed; GitHub@main:{ghMain.Name} ({ghMain.DateCreated}) | rubberduckdb@main: {dbMain.Name} ({dbMain.DateCreated})"); + } + } + if (ghNext.Name != dbNext.Name) + { + if (!dbTags.ContainsKey(ghMain.Name)) + { + newTags.Add(ghMain); + } + else + { + // could be an old tag, ...or the db is just out of date + Logger.LogWarning($"Tag metadata mismatch, xmldoc update will not proceed; GitHub@main:{ghNext.Name} ({ghNext.DateCreated}) | rubberduckdb@main: {dbNext.Name} ({dbNext.DateCreated})"); + } + } + + _tagServices.Create(newTags); + + Context.LoadRubberduckDbMain(dbMain); + Context.LoadRubberduckDbNext(dbNext); + + Context.LoadDbTags([dbMain, dbNext]); + + // StreamTagAssets + var xmldocInfo = new Dictionary nodes)>>() + { + [nameof(Annotation)] = [], + [nameof(QuickFix)] = [], + [nameof(Inspection)] = [] + }; + foreach (var tag in new[] { dbMain, dbNext }) + { + foreach (var asset in tag.Assets) + { + // DownloadXmlAsset + if (asset.DownloadUrl is null) + { + Logger.LogWarning(Context.Parameters, "Download url for asset ID {asset.Id} is unexpectedly null.", asset.Id); + continue; + } + if (Uri.TryCreate(asset.DownloadUrl, UriKind.Absolute, out var uri) && uri.Host != "github.com") + { + Logger.LogWarning(Context.Parameters, $"Unexpected host in download URL '{uri}' from asset ID {asset.Id}"); + continue; + } + + using (var client = new HttpClient()) + using (var response = await client.GetAsync(uri)) + { + if (response.IsSuccessStatusCode) + { + using (var stream = await response.Content.ReadAsStreamAsync()) + { + Logger.LogInformation(Context.Parameters, $"Loading XDocument from asset {asset.DownloadUrl}..."); + var document = XDocument.Load(stream, LoadOptions.None); + + if (asset.Name.Contains("Rubberduck.Parsing", StringComparison.InvariantCultureIgnoreCase)) + { + var annotationNodes = from node in document.Descendants("member").AsParallel() + let fullName = GetNameOrDefault(node, "Annotation") + where !string.IsNullOrWhiteSpace(fullName) + let typeName = fullName.Substring(fullName.LastIndexOf(".", StringComparison.Ordinal) + 1) + select new XElementInfo(typeName, node); + + + xmldocInfo[nameof(Annotation)].Add(tag, (asset, annotationNodes)); + } + else + { + var quickFixNodes = from node in document.Descendants("member").AsParallel() + let fullName = GetNameOrDefault(node, "QuickFix") + where !string.IsNullOrWhiteSpace(fullName) + let typeName = fullName.Substring(fullName.LastIndexOf(".", StringComparison.Ordinal) + 1) + select new XElementInfo(typeName, node); + xmldocInfo[nameof(QuickFix)].Add(tag, (asset, quickFixNodes)); + + var inspectionNodes = from node in document.Descendants("member").AsParallel() + let fullName = GetNameOrDefault(node, "Inspection") + where !string.IsNullOrWhiteSpace(fullName) + let typeName = fullName.Substring(fullName.LastIndexOf(".", StringComparison.Ordinal) + 1) + select new XElementInfo(typeName, node); + xmldocInfo[nameof(Inspection)].Add(tag, (asset, inspectionNodes)); + } + } + } + else + { + Logger.LogWarning(Context.Parameters, $"HTTP GET ({uri}) failed with status code {(int)response.StatusCode}: {response.ReasonPhrase}"); + continue; + } + } + } + + } + + // parse annotations xmldoc + var xmlAnnotations = xmldocInfo[nameof(Annotation)]; + foreach (var kvp in xmlAnnotations) + { + var (asset, nodes) = kvp.Value; + foreach (var node in nodes) + { + var annotation = _xmlAnnotationParser.Parse(asset.Id, annotations.Id, node.Name, node.Element, kvp.Key.IsPreRelease); + Context.StagingContext.Annotations.Add(annotation); + } + } + + var dbAnnotations = Context.Annotations.ToDictionary(e => e.Name); + var mergedAnnotations = _mergeService.Merge(dbAnnotations, Context.StagingContext.Annotations.Where(e => !e.IsNew), Context.StagingContext.Annotations.Where(e => e.IsNew)); + + // parse quickfix xmldoc + var xmlQuickFixes = xmldocInfo[nameof(QuickFix)]; + foreach (var kvp in xmlQuickFixes) + { + var (asset, nodes) = kvp.Value; + foreach (var node in nodes) + { + var quickfix = _xmlQuickFixParser.Parse(node.Name, asset.Id, quickfixes.Id, node.Element, kvp.Key.IsPreRelease); + Context.StagingContext.QuickFixes.Add(quickfix); + } + } + + var dbQuickfixes = Context.QuickFixes.ToDictionary(e => e.Name); + var mergedQuickfixes = _mergeService.Merge(dbQuickfixes, Context.StagingContext.QuickFixes.Where(e => !e.IsNew), Context.StagingContext.QuickFixes.Where(e => e.IsNew)); + var unchangedQuickfixes = dbQuickfixes.Values.Where(e => !mergedQuickfixes.Any(q => q.Name == e.Name)); + + // parse inspections xmldoc + var xmlInspections = xmldocInfo[nameof(Inspection)]; + var parseInspections = new List>(); + foreach (var kvp in xmlInspections) + { + var (asset, nodes) = kvp.Value; + foreach (var node in nodes) + { + if (!Context.InspectionDefaultConfig.TryGetValue(node.Name, out var defaultConfig)) + { + defaultConfig = new InspectionDefaultConfig + { + DefaultSeverity = "Warning", + InspectionType = "CodeQualityIssues", + InspectionName = node.Name, + }; + + Logger.LogWarning(Context.Parameters, "Default configuration was not found for inspection '{0}'", node.Name); + } + + var inspection = _xmlInspectionParser.ParseAsync(asset.Id, inspections.Id, mergedQuickfixes.Concat(unchangedQuickfixes), node.Name, node.Element, defaultConfig, kvp.Key.IsPreRelease); + parseInspections.Add(inspection); + } + } + + await Task.WhenAll(parseInspections).ContinueWith(t => + { + foreach (var inspection in t.Result) + { + Context.StagingContext.Inspections.Add(inspection); + } + }); + + var dbInspections = Context.Inspections.ToDictionary(e => e.Name); + var mergedInspections = _mergeService.Merge(dbInspections, Context.StagingContext.Inspections.Where(e => !e.IsNew), Context.StagingContext.Inspections.Where(e => e.IsNew)); + + var staging = new StagingContext(input) + { + Annotations = new(mergedAnnotations), + QuickFixes = new(mergedQuickfixes), + Inspections = new(mergedInspections) + }; + + await _staging.StageAsync(staging, Token); + } + + protected static string GetNameOrDefault(XElement memberNode, string suffix) + { + var name = memberNode.Attribute("name")?.Value; + if (name == null || !name.StartsWith("T:") || !name.EndsWith(suffix) || name.EndsWith($"I{suffix}")) + { + return default!; + } + + return name.Substring(2); + } +} + public class SyncXmldocSection : PipelineSection { public SyncXmldocSection(IPipeline parent, CancellationTokenSource tokenSource, ILogger logger, @@ -19,6 +321,7 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS IRepository inspections, IRepository quickfixes, IRepository annotations, + TagServices tagServices, IGitHubClientService github, IXmlDocMerge mergeService, IStagingServices staging, @@ -27,6 +330,7 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS XmlDocInspectionParser xmlInspectionParser) : base(parent, tokenSource, logger) { + /* ReceiveRequest = new ReceiveRequestBlock(this, tokenSource, logger); BroadcastParameters = new BroadcastParametersBlock(this, tokenSource, logger); LoadInspectionDefaultConfig = new LoadInspectionDefaultConfigBlock(this, tokenSource, github, logger); @@ -34,7 +338,7 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS LoadDbFeatureItems = new LoadDbFeatureItemsBlock(this, tokenSource, logger, inspections, quickfixes, annotations); AcquireDbMainTagGraph = new AcquireDbMainTagGraphBlock(this, tokenSource, content, logger); AcquireDbNextTagGraph = new AcquireDbNextTagGraphBlock(this, tokenSource, content, logger); - AcquireDbTags = new AcquireDbTagsBlock(this, tokenSource, logger, content); + AcquireDbTags = new AcquireDbTagsBlock(this, tokenSource, logger, tagServices); JoinDbTags = new DataflowJoinBlock>(this, tokenSource, logger, nameof(JoinDbTags)); LoadDbTags = new LoadDbTagsBlock(this, tokenSource, logger); JoinAsyncSources = new DataflowJoinBlock(this, tokenSource, logger, nameof(JoinAsyncSources)); @@ -43,7 +347,8 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS BufferXmlAsset = new XmlTagAssetBufferBlock(this, tokenSource, logger); DownloadXmlAsset = new DownloadXmlTagAssetBlock(this, tokenSource, logger); BroadcastXDocument = new BroadcastXDocumentBlock(this, tokenSource, logger); - JoinQuickFixes = new DataflowJoinBlock<(TagAsset, XDocument), IEnumerable>(this, tokenSource, logger, nameof(JoinQuickFixes)); + AcceptInspectionsXDocument = new AcceptInspectionsXDocumentBlock(this, tokenSource, logger); + //JoinQuickFixes = new DataflowJoinBlock<(TagAsset, XDocument), IEnumerable>(this, tokenSource, logger, nameof(JoinQuickFixes)); StreamInspectionNodes = new StreamInspectionNodesBlock(this, tokenSource, logger); StreamQuickFixNodes = new StreamQuickFixNodesBlock(this, tokenSource, logger); StreamAnnotationNodes = new StreamAnnotationNodesBlock(this, tokenSource, logger); @@ -65,9 +370,13 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS JoinStagingSources = new DataflowJoinBlock, IEnumerable, IEnumerable>(this, tokenSource, logger, nameof(JoinStagingSources)); PrepareStaging = new PrepareStagingBlock(this, tokenSource, logger); SaveStaging = new BulkSaveStagingBlock(this, tokenSource, staging, logger); + */ + SynchronizeXmlDoc = new SynchronizeXmlDocBlock(this, tokenSource, logger, content, inspections, quickfixes, annotations, tagServices, github, mergeService, staging, xmlAnnotationParser, xmlQuickFixParser, xmlInspectionParser); } #region blocks + private SynchronizeXmlDocBlock SynchronizeXmlDoc { get; } + /* private ReceiveRequestBlock ReceiveRequest { get; } private BroadcastParametersBlock BroadcastParameters { get; } private LoadInspectionDefaultConfigBlock LoadInspectionDefaultConfig { get; } @@ -92,7 +401,8 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS private BroadcastQuickFixesBlock BroadcastQuickFixes { get; } private AccumulateProcessedQuickFixesBlock AccumulateProcessedQuickFixes { get; } - private DataflowJoinBlock<(TagAsset, XDocument), IEnumerable> JoinQuickFixes { get; } + private AcceptInspectionsXDocumentBlock AcceptInspectionsXDocument { get; } + //private DataflowJoinBlock<(TagAsset, XDocument), IEnumerable> JoinQuickFixes { get; } private StreamInspectionNodesBlock StreamInspectionNodes { get; } private InspectionBufferBlock BufferInspections { get; } private ParseInspectionXElementInfoBlock ParseXmlDocInspections { get; } @@ -110,12 +420,13 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS private DataflowJoinBlock, IEnumerable, IEnumerable> JoinStagingSources { get; } private PrepareStagingBlock PrepareStaging { get; } private BulkSaveStagingBlock SaveStaging { get; } - - public ITargetBlock InputBlock => ReceiveRequest.Block; - public IDataflowBlock OutputBlock => SaveStaging.Block; + */ + public ITargetBlock InputBlock => SynchronizeXmlDoc.Block; + public IDataflowBlock OutputBlock => SynchronizeXmlDoc.Block; protected override IReadOnlyDictionary Blocks => new Dictionary { + /* [nameof(ReceiveRequest)] = ReceiveRequest.Block, [nameof(BroadcastParameters)] = BroadcastParameters.Block, [nameof(LoadInspectionDefaultConfig)] = LoadInspectionDefaultConfig.Block, @@ -148,7 +459,8 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS [nameof(BufferQuickFixes)] = BufferQuickFixes.Block, [nameof(BroadcastQuickFixes)] = BroadcastQuickFixes.Block, - [nameof(JoinQuickFixes)] = JoinQuickFixes.Block, + [nameof(AcceptInspectionsXDocument)] = AcceptInspectionsXDocument.Block, + //[nameof(JoinQuickFixes)] = JoinQuickFixes.Block, [nameof(StreamInspectionNodes)] = StreamInspectionNodes.Block, [nameof(ParseXmlDocInspections)] = ParseXmlDocInspections.Block, [nameof(AccumulateProcessedInspections)] = AccumulateProcessedInspections.Block, @@ -159,11 +471,14 @@ public SyncXmldocSection(IPipeline parent, CancellationTokenS [nameof(JoinStagingSources)] = JoinStagingSources.Block, [nameof(PrepareStaging)] = PrepareStaging.Block, [nameof(SaveStaging)] = SaveStaging.Block, + */ + [nameof(SynchronizeXmlDoc)] = SynchronizeXmlDoc.Block, }; #endregion public override void CreateBlocks() { + /* ReceiveRequest.CreateBlock(); BroadcastParameters.CreateBlock(ReceiveRequest); LoadInspectionDefaultConfig.CreateBlock(BroadcastParameters); @@ -196,9 +511,9 @@ public override void CreateBlocks() BufferQuickFixes.CreateBlock(MergeQuickFixes); BroadcastQuickFixes.CreateBlock(BufferQuickFixes); - JoinQuickFixes.CreateBlock(BroadcastXDocument, BroadcastQuickFixes); + AcceptInspectionsXDocument.CreateBlock(BroadcastXDocument); - StreamInspectionNodes.CreateBlock(JoinQuickFixes); + StreamInspectionNodes.CreateBlock(2, () => Context, AcceptInspectionsXDocument.Block.Completion); ParseXmlDocInspections.CreateBlock(StreamInspectionNodes); AccumulateProcessedInspections.CreateBlock(ParseXmlDocInspections); MergeInspections.CreateBlock(() => Context.StagingContext.Inspections, AccumulateProcessedInspections.Block.Completion); @@ -208,5 +523,7 @@ public override void CreateBlocks() JoinStagingSources.CreateBlock(BroadcastAnnotations, BroadcastQuickFixes, BroadcastInspections); PrepareStaging.CreateBlock(JoinStagingSources); SaveStaging.CreateBlock(PrepareStaging); + */ + SynchronizeXmlDoc.CreateBlock(); } } diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeTagsPipeline.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeTagsPipeline.cs index e8ed1ba..4501e83 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeTagsPipeline.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeTagsPipeline.cs @@ -3,6 +3,7 @@ using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.SyncTags; using rubberduckvba.Server.ContentSynchronization.XmlDoc.Abstract; using rubberduckvba.Server.Services; +using rubberduckvba.Server.Services.rubberduckdb; namespace rubberduckvba.Server.ContentSynchronization.Pipeline; @@ -12,14 +13,17 @@ public class SynchronizeTagsPipeline : PipelineBase, ISynchro private readonly IGitHubClientService _github; private readonly IXmlDocMerge _merge; private readonly IStagingServices _staging; + private readonly TagServices _tagServices; - public SynchronizeTagsPipeline(IRequestParameters parameters, ILogger logger, IRubberduckDbService content, IGitHubClientService github, IXmlDocMerge merge, IStagingServices staging, CancellationTokenSource tokenSource) + public SynchronizeTagsPipeline(IRequestParameters parameters, ILogger logger, + IRubberduckDbService content, TagServices tagServices, IGitHubClientService github, IXmlDocMerge merge, IStagingServices staging, CancellationTokenSource tokenSource) : base(new SyncContext(parameters), tokenSource, logger) { _content = content; _github = github; _merge = merge; _staging = staging; + _tagServices = tagServices; } public async Task> ExecuteAsync(SyncRequestParameters parameters, CancellationTokenSource tokenSource) @@ -31,14 +35,14 @@ public async Task> ExecuteAsync(SyncRequestParameters para } // 01. Create the pipeline sections - var synchronizeTags = new SyncTagsSection(this, tokenSource, Logger, _content, _github, _staging); + var synchronizeTags = new SyncTagsSection(this, tokenSource, Logger, _tagServices, _github, _staging); // 02. Wire up the pipeline AddSections(parameters, synchronizeTags); DisposeAfter(synchronizeTags.WhenAllBlocksCompleted); // 03. Light it up - Start(synchronizeTags.InputBlock, parameters); + Start(synchronizeTags.InputBlock, (TagSyncRequestParameters)parameters); // 04. await completion await synchronizeTags.OutputTask; diff --git a/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeXmlPipeline.cs b/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeXmlPipeline.cs index 4e50ac3..075b823 100644 --- a/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeXmlPipeline.cs +++ b/rubberduckvba.Server/ContentSynchronization/Pipeline/SynchronizeXmlPipeline.cs @@ -6,6 +6,7 @@ using rubberduckvba.Server.Data; using rubberduckvba.Server.Model.Entity; using rubberduckvba.Server.Services; +using rubberduckvba.Server.Services.rubberduckdb; namespace rubberduckvba.Server.ContentSynchronization.Pipeline; @@ -19,13 +20,15 @@ public class SynchronizeXmlPipeline : PipelineBase, ISynchron private readonly IRepository _inspections; private readonly IRepository _quickfixes; private readonly IRepository _annotations; + private readonly TagServices _tagServices; private readonly XmlDocAnnotationParser _annotationParser; private readonly XmlDocQuickFixParser _quickFixParser; private readonly XmlDocInspectionParser _inspectionParser; public SynchronizeXmlPipeline(IRequestParameters parameters, ILogger logger, IRubberduckDbService content, IGitHubClientService github, IXmlDocMerge merge, IStagingServices staging, IMarkdownFormattingService markdown, CancellationTokenSource tokenSource, IRepository inspections, IRepository quickfixes, IRepository annotations, - XmlDocAnnotationParser xmlAnnotationParser, XmlDocQuickFixParser xmlQuickFixParser, XmlDocInspectionParser xmlInspectionParser) + XmlDocAnnotationParser xmlAnnotationParser, XmlDocQuickFixParser xmlQuickFixParser, XmlDocInspectionParser xmlInspectionParser, + TagServices tagServices) : base(new SyncContext(parameters), tokenSource, logger) { _content = content; @@ -37,6 +40,8 @@ public SynchronizeXmlPipeline(IRequestParameters parameters, ILogger logger, IRu _quickfixes = quickfixes; _annotations = annotations; + _tagServices = tagServices; + _annotationParser = xmlAnnotationParser; _quickFixParser = xmlQuickFixParser; _inspectionParser = xmlInspectionParser; @@ -51,7 +56,7 @@ public async Task> ExecuteAsync(SyncRequestParameters para } // 01. Create the pipeline sections - var synchronizeFeatureItems = new SyncXmldocSection(this, tokenSource, Logger, _content, _inspections, _quickfixes, _annotations, _github, _merge, _staging, _annotationParser, _quickFixParser, _inspectionParser); + var synchronizeFeatureItems = new SyncXmldocSection(this, tokenSource, Logger, _content, _inspections, _quickfixes, _annotations, _tagServices, _github, _merge, _staging, _annotationParser, _quickFixParser, _inspectionParser); // 02. Wire up the pipeline AddSections(parameters, synchronizeFeatureItems); diff --git a/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs b/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs index 4571e31..8c9f85e 100644 --- a/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs +++ b/rubberduckvba.Server/ContentSynchronization/SyncRequestParameters.cs @@ -24,5 +24,4 @@ public record class XmldocSyncRequestParameters : SyncRequestParameters public record class TagSyncRequestParameters : SyncRequestParameters { - public string? Tag { get; init; } } diff --git a/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocAnnotationParser.cs b/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocAnnotationParser.cs index 17d79f7..bb80571 100644 --- a/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocAnnotationParser.cs +++ b/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocAnnotationParser.cs @@ -1,17 +1,16 @@ using rubberduckvba.Server.ContentSynchronization.XmlDoc.Schema; using rubberduckvba.Server.Model; +using rubberduckvba.Server.Services; using System.Reflection; using System.Xml.Linq; namespace rubberduckvba.Server.ContentSynchronization.XmlDoc; -public class XmlDocAnnotationParser +public class XmlDocAnnotationParser(IMarkdownFormattingService markdownService) { public Annotation Parse(int assetId, int featureId, string name, XElement node, bool isPreRelease) { - var sourceObject = node.Attribute("name")!.Value[2..][(name.LastIndexOf('.') + 1)..]; - //var sourceEditUrl = $"https://github.com/rubberduck-vba/Rubberduck/edit/next/{sourceObject}.cs"; - //var sourceViewUrl = $"https://github.com/rubberduck-vba/Rubberduck/blob/{tagName}/{sourceObject}.cs"; + var sourceObject = node.Attribute("name").Value[2..].Replace('.', '/').Replace("Rubberduck/Parsing/", "Rubberduck.Parsing/"); var summary = node.Element(XmlDocSchema.Annotation.Summary.ElementName)?.Value.Trim() ?? string.Empty; var remarks = node.Element(XmlDocSchema.Annotation.Remarks.ElementName)?.Value.Trim() ?? string.Empty; @@ -112,6 +111,9 @@ private IEnumerable ParseExamples(XElement node) */ before = example.Elements(XmlDocSchema.Annotation.Example.Before.ElementName) .Select((e, i) => ExtractCodeModule(e.Element(XmlDocSchema.Annotation.Example.Before.Module.ElementName)!, i, "(code pane)")); + + after = example.Elements(XmlDocSchema.Annotation.Example.After.ElementName) + .Select((e, i) => ExtractCodeModule(e.Element(XmlDocSchema.Annotation.Example.Before.Module.ElementName)!, i, "(exported code)")); } if (before.Any() && (after?.Any() ?? false)) @@ -163,7 +165,7 @@ private ExampleModule ExtractCodeModule(XElement cdataParent, int index, string? ModuleName = name, ModuleType = moduleType, Description = description, - HtmlContent = code.Trim().Replace(" ", " ") + HtmlContent = markdownService.FormatMarkdownDocument("" + code.Trim().Replace(" ", " ") + "", withSyntaxHighlighting: true) }; return model; diff --git a/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocInspectionParser.cs b/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocInspectionParser.cs index 4f5ed6b..0578b8a 100644 --- a/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocInspectionParser.cs +++ b/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocInspectionParser.cs @@ -1,7 +1,6 @@ using rubberduckvba.Server.ContentSynchronization.XmlDoc.Schema; using rubberduckvba.Server.Model; using rubberduckvba.Server.Services; -using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Reflection; using System.Xml.Linq; @@ -21,7 +20,7 @@ public async Task ParseAsync(int assetId, int featureId, IEnumerable var fixesByName = quickFixes.ToLookup(e => e.Name, e => e.Inspections); var filteredFixes = quickFixes.Where(fix => fixesByName[fix.Name].FirstOrDefault()?.Contains(inspectionName) ?? false).ToList(); - var sourceObject = name[2..].Replace('.', '/').Replace("Rubberduck/CodeAnalysis/", "Rubberduck.CodeAnalysis/"); + var sourceObject = node.Attribute("name").Value[2..].Replace('.', '/').Replace("Rubberduck/CodeAnalysis/", "Rubberduck.CodeAnalysis/"); //var sourceEditUrl = $"https://github.com/rubberduck-vba/Rubberduck/edit/next/{sourceObject}.cs"; //var sourceViewUrl = $"https://github.com/rubberduck-vba/Rubberduck/blob/{tagName}/{sourceObject}.cs"; @@ -74,13 +73,13 @@ public async Task ParseAsync(int assetId, int featureId, IEnumerable private static readonly Dictionary ModuleTypes = Enum.GetValues() .Select(m => (Name: m.ToString(), Description: typeof(ExampleModuleType).GetMember(m.ToString()).Single() - .GetCustomAttributes().OfType().SingleOrDefault()?.Description ?? string.Empty)) + .GetCustomAttributes().OfType().SingleOrDefault()?.Name ?? string.Empty)) .ToDictionary(m => m.Description, m => Enum.Parse(m.Name, ignoreCase: true)); private static readonly Dictionary InspectionTypes = Enum.GetValues() .Select(m => (Name: m.ToString(), Description: typeof(CodeInspectionType).GetMember(m.ToString()).Single() - .GetCustomAttributes().OfType().SingleOrDefault()?.Description ?? string.Empty)) + .GetCustomAttributes().OfType().SingleOrDefault()?.Name ?? string.Empty)) .ToDictionary(m => Enum.Parse(m.Name, ignoreCase: true), m => m.Description); private enum CodeInspectionType @@ -89,8 +88,8 @@ private enum CodeInspectionType RubberduckOpportunities, [Display(Name = "Language Opportunities")] LanguageOpportunities, - [Display(Name = "Maintainability/Readability Issues")] - MaintainabilityAndReadabilityIssues, + [Display(Name = "Naming and Convention Issues")] + NamingAndConventionsIssues, [Display(Name = "Code Quality Issues")] CodeQualityIssues, [Display(Name = "Performance Opportunities")] diff --git a/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocMerge.cs b/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocMerge.cs index 19491b3..33f5caa 100644 --- a/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocMerge.cs +++ b/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocMerge.cs @@ -67,14 +67,23 @@ select item with var comparer = new XmlDocBranchIntersectComparer(); var insertItems = new HashSet( - from item in nextBranch.Intersect(mainBranch, comparer) - where !dbItems.ContainsKey(item.Name) - select item with - { - IsNew = mainBranch.Any() && !mainBranch.Any(a => a.Name == item.Name), - IsDiscontinued = !nextBranch.Any(a => a.Name == item.Name), - DateTimeInserted = timestamp - } + (from item in nextBranch.Intersect(mainBranch, comparer) + where !dbItems.ContainsKey(item.Name) + select item with + { + IsNew = mainBranch.Any() && !mainBranch.Any(a => a.Name == item.Name), + IsDiscontinued = !nextBranch.Any(a => a.Name == item.Name), + DateTimeInserted = timestamp + }) + .Concat( + from item in nextBranch.Except(mainBranch, comparer) + where !dbItems.ContainsKey(item.Name) + select item with + { + IsNew = mainBranch.Any() && !mainBranch.Any(a => a.Name == item.Name), + IsDiscontinued = !nextBranch.Any(a => a.Name == item.Name), + DateTimeInserted = timestamp + }) ); merged.UnionWith(updatedItems); diff --git a/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocQuickFixParser.cs b/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocQuickFixParser.cs index fa89574..5af89d3 100644 --- a/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocQuickFixParser.cs +++ b/rubberduckvba.Server/ContentSynchronization/XmlDoc/XmlDocQuickFixParser.cs @@ -1,16 +1,16 @@ using rubberduckvba.Server.ContentSynchronization.XmlDoc.Schema; using rubberduckvba.Server.Model; +using rubberduckvba.Server.Services; using System.Reflection; using System.Xml.Linq; namespace rubberduckvba.Server.ContentSynchronization.XmlDoc; -public class XmlDocQuickFixParser +public class XmlDocQuickFixParser(IMarkdownFormattingService markdownService) { public QuickFix Parse(string name, int assetId, int featureId, XElement node, bool isPreRelease) { - var sourceObject = name[2..].Replace('.', '/').Replace("Rubberduck/CodeAnalysis/", "Rubberduck.CodeAnalysis/"); - var typeName = name/*.Substring(name.LastIndexOf(".", StringComparison.Ordinal) + 1)/*.Replace("QuickFix", string.Empty)*/; + var sourceObject = node.Attribute("name").Value[2..].Replace('.', '/').Replace("Rubberduck/CodeAnalysis/", "Rubberduck.CodeAnalysis/"); var summary = node.Element(XmlDocSchema.QuickFix.Summary.ElementName)?.Value.Trim().Replace(" ", " ") ?? string.Empty; var remarks = node.Element(XmlDocSchema.QuickFix.Remarks.ElementName)?.Value.Trim().Replace(" ", " ") ?? string.Empty; @@ -67,7 +67,7 @@ public QuickFix Parse(string name, int assetId, int featureId, XElement node, bo .Where(m => m.Description != null) .ToDictionary(m => m.Description, m => (ExampleModuleType)Enum.Parse(typeof(ExampleModuleType), m.Name, true)); - private static List ParseExamples(XElement node) + private List ParseExamples(XElement node) { var examples = new List(); foreach (var exampleNode in node.Elements(XmlDocSchema.QuickFix.Example.ElementName)) @@ -78,14 +78,14 @@ private static List ParseExamples(XElement node) { ModuleName = m.Attribute(XmlDocSchema.QuickFix.Example.Before.Module.ModuleNameAttribute)?.Value ?? string.Empty, ModuleType = ModuleTypes.TryGetValue(m.Attribute(XmlDocSchema.QuickFix.Example.Before.Module.ModuleTypeAttribute)?.Value ?? string.Empty, out var type) ? type : ExampleModuleType.Any, - HtmlContent = m.Nodes().OfType().Single().Value.Trim().Replace(" ", " ") + HtmlContent = markdownService.FormatMarkdownDocument("" + m.Nodes().OfType().Single().Value.Trim().Replace(" ", " ") + "", withSyntaxHighlighting: true) }) .Concat(exampleNode.Element(XmlDocSchema.QuickFix.Example.Before.ElementName)?.Nodes().OfType().Take(1).Select(x => new ExampleModule { ModuleName = "Module1", ModuleType = ExampleModuleType.Any, - HtmlContent = x.Value.Trim().Replace(" ", " ") + HtmlContent = markdownService.FormatMarkdownDocument("" + x.Value.Trim().Replace(" ", " ") + "", withSyntaxHighlighting: true) }) ?? []); var after = exampleNode.Element(XmlDocSchema.QuickFix.Example.After.ElementName)? @@ -94,14 +94,14 @@ private static List ParseExamples(XElement node) { ModuleName = m.Attribute(XmlDocSchema.QuickFix.Example.After.Module.ModuleNameAttribute)?.Value ?? string.Empty, ModuleType = ModuleTypes.TryGetValue(m.Attribute(XmlDocSchema.QuickFix.Example.After.Module.ModuleTypeAttribute)?.Value ?? string.Empty, out var type) ? type : ExampleModuleType.Any, - HtmlContent = m.Nodes().OfType().Single().Value.Trim().Replace(" ", " ") + HtmlContent = markdownService.FormatMarkdownDocument("" + m.Nodes().OfType().Single().Value.Trim().Replace(" ", " ") + "", withSyntaxHighlighting: true) }) .Concat(exampleNode.Element(XmlDocSchema.QuickFix.Example.After.ElementName)?.Nodes().OfType().Take(1).Select(x => new ExampleModule { ModuleName = "Module1", ModuleType = ExampleModuleType.Any, - HtmlContent = x.Value.Trim().Replace(" ", " ") + HtmlContent = markdownService.FormatMarkdownDocument("" + x.Value.Trim().Replace(" ", " ") + "", withSyntaxHighlighting: true) }) ?? []); if (before != null && after != null) diff --git a/rubberduckvba.Server/Data/AnnotationsRepository.cs b/rubberduckvba.Server/Data/AnnotationsRepository.cs index 37d7d87..c4a603c 100644 --- a/rubberduckvba.Server/Data/AnnotationsRepository.cs +++ b/rubberduckvba.Server/Data/AnnotationsRepository.cs @@ -11,17 +11,23 @@ public AnnotationsRepository(IOptions settings) protected override string TableName { get; } = "Annotations"; protected override string SelectSql { get; } = @" SELECT - [Id], - [DateTimeInserted], - [DateTimeUpdated], - [FeatureId], - [TagAssetId], - [SourceUrl], - [Name], - [Remarks], - [JsonParameters], - [JsonExamples] -FROM [dbo].[Annotations]"; + a.[Id], + a.[DateTimeInserted], + a.[DateTimeUpdated], + a.[FeatureId], + f.[Name] AS [FeatureName], + a.[TagAssetId], + a.[SourceUrl], + a.[Name], + a.[Summary], + a.[Remarks], + a.[JsonParameters], + a.[JsonExamples], + a.[IsNew], + a.[IsDiscontinued], + a.[IsHidden] +FROM [dbo].[Annotations] a +INNER JOIN [dbo].[Features] f ON a.[FeatureId]=f.[Id]"; protected override string InsertSql { get; } = @" INSERT INTO [dbo].[Annotations] ( @@ -30,26 +36,39 @@ INSERT INTO [dbo].[Annotations] ( [TagAssetId], [SourceUrl], [Name], + [Summary], [Remarks], [JsonParameters], - [JsonExamples]) + [JsonExamples], + [IsNew], + [IsDiscontinued], + [IsHidden]) VALUES ( GETDATE(), @featureId, @tagAssetId, @sourceUrl, @name, + @summary, @remarks, @jsonParameters, - @jsonExamples)"; + @jsonExamples, + @isNew, + @isDiscontinued, + @isHidden)"; protected override string UpdateSql { get; } = @" UPDATE [dbo].[Annotations] SET [DateTimeUpdated]=GETDATE(), [TagAssetId]=@tagAssetId, + [SourceUrl]=@sourceUrl, + [Summary]=@summary, [Remarks]=@remarks, [JsonParameters]=@jsonParameters, - [JsonExamples]=@jsonExamples + [JsonExamples]=@jsonExamples, + [IsNew]=@isNew, + [IsDiscontinued]=@isDiscontinued, + [IsHidden]=@isHidden WHERE [Id]=@id"; } \ No newline at end of file diff --git a/rubberduckvba.Server/Data/FeaturesRepository.cs b/rubberduckvba.Server/Data/FeaturesRepository.cs index 94cb0b8..ba987ea 100644 --- a/rubberduckvba.Server/Data/FeaturesRepository.cs +++ b/rubberduckvba.Server/Data/FeaturesRepository.cs @@ -13,18 +13,21 @@ public FeaturesRepository(IOptions settings) protected override string SelectSql { get; } = @" SELECT - [Id], - [DateTimeInserted], - [DateTimeUpdated], - [RepositoryId], - [ParentId], - [Name], - [Title], - [ShortDescription], - [Description], - [IsNew], - [HasImage] -FROM [dbo].[Features]"; + a.[Id], + a.[DateTimeInserted], + a.[DateTimeUpdated], + a.[RepositoryId], + a.[ParentId] AS [FeatureId], + f.[Name] AS [FeatureName], + a.[Name], + a.[Title], + a.[ShortDescription], + a.[Description], + a.[IsNew], + a.[HasImage], + a.[Links] +FROM [dbo].[Features] a +LEFT JOIN [dbo].[Features] f ON a.[ParentId]=f.[Id]"; protected override string InsertSql { get; } = @" INSERT INTO [dbo].[Features] ( diff --git a/rubberduckvba.Server/Data/HangfireJobStateRepository.cs b/rubberduckvba.Server/Data/HangfireJobStateRepository.cs new file mode 100644 index 0000000..1f9f99f --- /dev/null +++ b/rubberduckvba.Server/Data/HangfireJobStateRepository.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Options; +using rubberduckvba.Server.Model; + +namespace rubberduckvba.Server.Data; + +public class HangfireJobStateRepository : QueryableRepository +{ + public HangfireJobStateRepository(IOptions settings) + : base(settings) { } + + protected override string SelectSql => "SELECT [JobName],[LastJobId],[CreatedAt],[NextExecution],[StateName],[StateTimestamp] FROM [dbo].[HangfireJobState];"; +} diff --git a/rubberduckvba.Server/Data/InspectionsRepository.cs b/rubberduckvba.Server/Data/InspectionsRepository.cs index 8e3a3aa..ddc3e6a 100644 --- a/rubberduckvba.Server/Data/InspectionsRepository.cs +++ b/rubberduckvba.Server/Data/InspectionsRepository.cs @@ -11,23 +11,28 @@ public InspectionsRepository(IOptions settings) protected override string TableName { get; } = "Inspections"; protected override string SelectSql { get; } = @" SELECT - [Id], - [DateTimeInserted], - [DateTimeUpdated], - [FeatureId], - [TagAssetId], - [SourceUrl], - [Name], - [InspectionType], - [DefaultSeverity], - [Summary], - [Reasoning], - [Remarks], - [HostApp], - [References], - [QuickFixes], - [JsonExamples] -FROM [dbo].[Inspections]"; + a.[Id], + a.[DateTimeInserted], + a.[DateTimeUpdated], + a.[FeatureId], + f.[Name] AS [FeatureName], + a.[TagAssetId], + a.[SourceUrl], + a.[Name], + a.[InspectionType], + a.[DefaultSeverity], + a.[Summary], + a.[Reasoning], + a.[Remarks], + a.[HostApp], + a.[References], + a.[QuickFixes], + a.[JsonExamples], + a.[IsNew], + a.[IsDiscontinued], + a.[IsHidden] +FROM [dbo].[Inspections] a +INNER JOIN [dbo].[Features] f ON a.[FeatureId]=f.[Id]"; protected override string InsertSql { get; } = @" INSERT INTO [dbo].[Inspections] ( @@ -44,7 +49,10 @@ INSERT INTO [dbo].[Inspections] ( [HostApp], [References], [QuickFixes], - [JsonExamples]) + [JsonExamples], + [IsNew], + [IsDiscontinued], + [IsHidden]) VALUES ( GETDATE(), @featureId, @@ -59,12 +67,16 @@ INSERT INTO [dbo].[Inspections] ( @hostApp, @references, @quickfixes, - @jsonExamples)"; + @jsonExamples, + @isNew, + @isDiscontinued, + @isHidden)"; protected override string UpdateSql { get; } = @" UPDATE [dbo].[Inspections] SET [DateTimeUpdated]=GETDATE(), [TagAssetId]=@tagAssetId, + [SourceUrl]=@sourceUrl, [InspectionType]=@inspectionType, [DefaultSeverity]=@defaultSeverity, [Summary]=@summary, @@ -73,7 +85,10 @@ UPDATE [dbo].[Inspections] [HostApp]=@hostApp, [References]=@references, [QuickFixes]=@quickFixes, - [JsonExamples]=@jsonExamples + [JsonExamples]=@jsonExamples, + [IsNew]=@isNew, + [IsDiscontinued]=@isDiscontinued, + [IsHidden]=@isHidden WHERE [Id]=@id"; } diff --git a/rubberduckvba.Server/Data/QuickFixRepository.cs b/rubberduckvba.Server/Data/QuickFixRepository.cs index 9247fb2..b99c08d 100644 --- a/rubberduckvba.Server/Data/QuickFixRepository.cs +++ b/rubberduckvba.Server/Data/QuickFixRepository.cs @@ -11,23 +11,27 @@ public QuickFixRepository(IOptions settings) protected override string SelectSql { get; } = @" SELECT - [Id], - [DateTimeInserted], - [DateTimeUpdated], - [FeatureId], - [TagAssetId], - [SourceUrl], - [Name], - [Summary], - [Remarks], - [CanFixMultiple], - [CanFixProcedure], - [CanFixModule], - [CanFixProject], - [CanFixAll], - [Inspections], - [JsonExamples] -FROM [dbo].[QuickFixes]"; + a.[Id], + a.[DateTimeInserted], + a.[DateTimeUpdated], + a.[FeatureId], + f.[Name] AS [FeatureName], + a.[TagAssetId], + a.[SourceUrl], + a.[Name], + a.[Summary], + a.[Remarks], + a.[CanFixMultiple], + a.[CanFixProcedure], + a.[CanFixModule], + a.[CanFixProject], + a.[CanFixAll], + a.[Inspections], + a.[JsonExamples], + a.[IsNew], + a.[IsDiscontinued] +FROM [dbo].[QuickFixes] a +INNER JOIN [dbo].[Features] f ON a.[FeatureId]=f.[Id]"; protected override string InsertSql { get; } = @" INSERT INTO [dbo].[QuickFixes] ( @@ -44,7 +48,9 @@ INSERT INTO [dbo].[QuickFixes] ( [CanFixProject], [CanFixAll], [Inspections], - [JsonExamples]) + [JsonExamples], + [IsNew], + [IsDiscontinued]) VALUES ( GETDATE(), @featureId, @@ -59,12 +65,15 @@ INSERT INTO [dbo].[QuickFixes] ( @canFixProject, @canFixAll, @inspections, - @jsonExamples)"; + @jsonExamples, + @isNew, + @isDiscontinued)"; protected override string UpdateSql { get; } = @" UPDATE [dbo].[QuickFixes] SET [DateTimeUpdated]=GETDATE(), [TagAssetId]=@tagAssetId, + [SourceUrl]=@sourceUrl, [Summary]=@summary, [Remarks]=@remarks, [CanFixMultiple]=@canFixMultiple, @@ -72,7 +81,9 @@ UPDATE [dbo].[QuickFixes] [CanFixModule]=@canFixModule, [CanFixProject]=@canFixProject, [CanFixAll]=@canFixAll, - [JsonExamples]=@jsonExamples + [JsonExamples]=@jsonExamples, + [IsNew]=@isNew, + [IsDiscontinued]=@isDiscontinued WHERE [Id]=@id"; } \ No newline at end of file diff --git a/rubberduckvba.Server/Data/Repository.cs b/rubberduckvba.Server/Data/Repository.cs index c884b64..d2bdbc8 100644 --- a/rubberduckvba.Server/Data/Repository.cs +++ b/rubberduckvba.Server/Data/Repository.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using rubberduckvba.Server.Model.Entity; using System.Data; +using static Dapper.SqlMapper; namespace rubberduckvba.Server.Data; @@ -10,60 +11,72 @@ public interface IRepository where TEntity : Entity { int GetId(string name); TEntity GetById(int id); - IEnumerable GetAll(int? parentId = default); + IEnumerable GetAll(); + IEnumerable GetAll(int? parentId); TEntity Insert(TEntity entity); IEnumerable Insert(IEnumerable entities); void Update(TEntity entity); void Update(IEnumerable entities); } -public abstract class Repository : IRepository where TEntity : Entity +public abstract class QueryableRepository where T : class { - private readonly string _connectionString; - - protected Repository(IOptions settings) + protected QueryableRepository(IOptions settings) { - _connectionString = settings.Value.RubberduckDb ?? throw new InvalidOperationException(); + ConnectionString = settings.Value.RubberduckDb ?? throw new InvalidOperationException(); } + protected string ConnectionString { get; } + protected abstract string SelectSql { get; } + + protected IEnumerable Query(Func> query) { - using var db = new SqlConnection(_connectionString); + using var db = new SqlConnection(ConnectionString); db.Open(); return query(db); } protected T Get(Func query) { - using var db = new SqlConnection(_connectionString); + using var db = new SqlConnection(ConnectionString); db.Open(); return query(db); } + public virtual IEnumerable GetAll() => Query(db => db.Query(SelectSql)); +} + +public abstract class Repository : QueryableRepository, IRepository where TEntity : Entity +{ + protected Repository(IOptions settings) + : base(settings) { } + protected void Execute(Action action) { - using var db = new SqlConnection(_connectionString); + using var db = new SqlConnection(ConnectionString); db.Open(); action(db); } protected abstract string TableName { get; } - protected abstract string SelectSql { get; } + protected virtual string? ParentFKColumnName => null; + protected abstract string InsertSql { get; } protected abstract string UpdateSql { get; } - protected virtual string? ParentFKColumnName => null; - public virtual int GetId(string name) => Get(db => db.QuerySingle($"SELECT [Id] FROM [dbo].[{TableName}] WHERE [Name]=@name", new { name })); - public virtual TEntity GetById(int id) => Get(db => db.QuerySingle(SelectSql + " WHERE [Id]=@id", new { id })); - public virtual IEnumerable GetAll(int? parentId = default) => + public virtual IEnumerable GetAll(int? parentId) => ParentFKColumnName is null || !parentId.HasValue - ? Query(db => db.Query(SelectSql)) - : Query(db => db.Query($"{SelectSql} WHERE [{ParentFKColumnName}]=@parentId", new { parentId })); + ? GetAll() + : Query(db => db.Query($"{SelectSql} WHERE a.[{ParentFKColumnName}]=@parentId", new { parentId })); + + public virtual int GetId(string name) => Get(db => db.QuerySingle($"SELECT [Id] FROM [dbo].[{TableName}] WHERE [Name]=@name", new { name })); + public virtual TEntity GetById(int id) => Get(db => db.QuerySingle(SelectSql + " WHERE a.[Id]=@id", new { id })); public virtual TEntity Insert(TEntity entity) => Insert([entity]).Single(); public virtual IEnumerable Insert(IEnumerable entities) { - using var db = new SqlConnection(_connectionString); + using var db = new SqlConnection(ConnectionString); db.Open(); using var transaction = db.BeginTransaction(); @@ -81,7 +94,7 @@ public virtual IEnumerable Insert(IEnumerable entities) public virtual void Update(TEntity entity) => Update([entity]); public virtual void Update(IEnumerable entities) { - using var db = new SqlConnection(_connectionString); + using var db = new SqlConnection(ConnectionString); db.Open(); using var transaction = db.BeginTransaction(); diff --git a/rubberduckvba.Server/Data/TagAssetsRepository.cs b/rubberduckvba.Server/Data/TagAssetsRepository.cs index 0e36264..a5ca17a 100644 --- a/rubberduckvba.Server/Data/TagAssetsRepository.cs +++ b/rubberduckvba.Server/Data/TagAssetsRepository.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Options; -using rubberduckvba.Server; using rubberduckvba.Server.Model.Entity; namespace rubberduckvba.Server.Data; @@ -20,7 +19,7 @@ public TagAssetsRepository(IOptions settings) [TagId], [Name], [DownloadUrl] -FROM [dbo].[TagAssets]"; +FROM [dbo].[TagAssets] a"; protected override string InsertSql { get; } = @" INSERT INTO [dbo].[TagAssets] ( diff --git a/rubberduckvba.Server/Data/TagsRepository.cs b/rubberduckvba.Server/Data/TagsRepository.cs index f847f96..d26d436 100644 --- a/rubberduckvba.Server/Data/TagsRepository.cs +++ b/rubberduckvba.Server/Data/TagsRepository.cs @@ -21,7 +21,7 @@ public TagsRepository(IOptions settings) [InstallerDownloadUrl], [InstallerDownloads], [IsPreRelease] -FROM [dbo].[Tags]"; +FROM [dbo].[Tags] a"; protected override string InsertSql { get; } = @" INSERT INTO [Tags] ( @@ -29,6 +29,7 @@ INSERT INTO [Tags] ( [RepositoryId], [Name], [DateCreated], + [ReleaseId], [InstallerDownloadUrl], [InstallerDownloads], [IsPreRelease]) @@ -37,6 +38,7 @@ INSERT INTO [Tags] ( @repositoryId, @name, @dateCreated, + @releaseId, @installerDownloadUrl, @installerDownloads, @isPreRelease)"; diff --git a/rubberduckvba.Server/GitHubAuthenticationHandler.cs b/rubberduckvba.Server/GitHubAuthenticationHandler.cs index 5e0a85e..51ee239 100644 --- a/rubberduckvba.Server/GitHubAuthenticationHandler.cs +++ b/rubberduckvba.Server/GitHubAuthenticationHandler.cs @@ -20,15 +20,28 @@ public GitHubAuthenticationHandler(IGitHubClientService github, protected async override Task HandleAuthenticateAsync() { - var token = Context.Request.Headers["X-ACCESS-TOKEN"].SingleOrDefault(); - if (token is null) + try { + var token = Context.Request.Headers["X-ACCESS-TOKEN"].SingleOrDefault(); + if (string.IsNullOrWhiteSpace(token)) + { + return AuthenticateResult.Fail("Access token was not provided"); + } + + var principal = await _github.ValidateTokenAsync(token); + if (principal is ClaimsPrincipal) + { + Context.User = principal; + Thread.CurrentPrincipal = principal; + return AuthenticateResult.Success(new AuthenticationTicket(principal, "github")); + } + + return AuthenticateResult.Fail("An invalid access token was provided"); + } + catch (InvalidOperationException e) + { + Logger.LogError(e, e.Message); return AuthenticateResult.NoResult(); } - - var principal = await _github.ValidateTokenAsync(token); - return principal is ClaimsPrincipal - ? AuthenticateResult.Success(new AuthenticationTicket(principal, "github")) - : AuthenticateResult.Fail("Could not validate token"); } } diff --git a/rubberduckvba.Server/GitHubSettings.cs b/rubberduckvba.Server/GitHubSettings.cs index fc941b6..4ca4143 100644 --- a/rubberduckvba.Server/GitHubSettings.cs +++ b/rubberduckvba.Server/GitHubSettings.cs @@ -47,6 +47,9 @@ public record class GitHubSettings public record class HangfireSettings { + public int MaxInitializationAttempts { get; set; } = 5; + public int InitializationRetryDelaySeconds { get; set; } = 10; + public int ServerCheckIntervalMinutes { get; set; } = 15; public int QueuePollIntervalSeconds { get; set; } = 30; public int SchedulePollIntervalSeconds { get; set; } = 30; diff --git a/rubberduckvba.Server/Hangfire/QueuedUpdateOrchestrator.cs b/rubberduckvba.Server/Hangfire/QueuedUpdateOrchestrator.cs index 1e64a03..5ab7dc7 100644 --- a/rubberduckvba.Server/Hangfire/QueuedUpdateOrchestrator.cs +++ b/rubberduckvba.Server/Hangfire/QueuedUpdateOrchestrator.cs @@ -109,6 +109,7 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton, InspectionsRepository>(); services.AddSingleton, QuickFixRepository>(); services.AddSingleton, AnnotationsRepository>(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton, SynchronizationPipelineFactory>(); diff --git a/rubberduckvba.Server/Model/Annotation.cs b/rubberduckvba.Server/Model/Annotation.cs index 97f91c7..98884de 100644 --- a/rubberduckvba.Server/Model/Annotation.cs +++ b/rubberduckvba.Server/Model/Annotation.cs @@ -14,8 +14,15 @@ public Annotation(AnnotationEntity entity) : this() TagAssetId = entity.TagAssetId; SourceUrl = entity.SourceUrl; Name = entity.Name; + Summary = entity.Summary; + Remarks = entity.Remarks; + Parameters = entity.JsonParameters is null ? [] : JsonSerializer.Deserialize(entity.JsonParameters) ?? []; Examples = entity.JsonExamples is null ? [] : JsonSerializer.Deserialize(entity.JsonExamples) ?? []; + + IsNew = entity.IsNew; + IsDiscontinued = entity.IsDiscontinued; + IsHidden = entity.IsHidden; } public int Id { get; init; } @@ -44,6 +51,9 @@ public Annotation(AnnotationEntity entity) : this() Name = Name, Summary = Summary, Remarks = Remarks, + IsNew = IsNew, + IsDiscontinued = IsDiscontinued, + IsHidden = IsHidden, JsonParameters = JsonSerializer.Serialize(Parameters), JsonExamples = JsonSerializer.Serialize(Examples), }; @@ -54,6 +64,9 @@ public int GetContentHash() hash.Add(Name); hash.Add(Summary); hash.Add(Remarks); + hash.Add(IsNew); + hash.Add(IsDiscontinued); + hash.Add(IsHidden); hash.Add(JsonSerializer.Serialize(Parameters)); hash.Add(JsonSerializer.Serialize(Examples)); return hash.ToHashCode(); diff --git a/rubberduckvba.Server/Model/Entity/AnnotationEntity.cs b/rubberduckvba.Server/Model/Entity/AnnotationEntity.cs index 5c586ba..21af244 100644 --- a/rubberduckvba.Server/Model/Entity/AnnotationEntity.cs +++ b/rubberduckvba.Server/Model/Entity/AnnotationEntity.cs @@ -3,10 +3,14 @@ public record class AnnotationEntity : Entity { public int FeatureId { get; init; } + public string FeatureName { get; init; } = string.Empty; public int TagAssetId { get; init; } public string SourceUrl { get; init; } = string.Empty; public string Summary { get; init; } = string.Empty; public string Remarks { get; init; } = string.Empty; + public bool IsNew { get; init; } + public bool IsDiscontinued { get; init; } + public bool IsHidden { get; init; } public string? JsonParameters { get; init; } public string? JsonExamples { get; init; } } diff --git a/rubberduckvba.Server/Model/Entity/FeatureEntity.cs b/rubberduckvba.Server/Model/Entity/FeatureEntity.cs index 85db719..38b2803 100644 --- a/rubberduckvba.Server/Model/Entity/FeatureEntity.cs +++ b/rubberduckvba.Server/Model/Entity/FeatureEntity.cs @@ -1,8 +1,11 @@ -namespace rubberduckvba.Server.Model.Entity; +using System.Text.Json; + +namespace rubberduckvba.Server.Model.Entity; public record class FeatureEntity : Entity { - public int? ParentId { get; init; } + public int? FeatureId { get; init; } + public string FeatureName { get; init; } = default!; public int RepositoryId { get; init; } public string Title { get; init; } = default!; public string ShortDescription { get; init; } = default!; @@ -10,4 +13,9 @@ public record class FeatureEntity : Entity public bool IsHidden { get; init; } public bool IsNew { get; init; } public bool HasImage { get; init; } + + public string Links { get; init; } = string.Empty; + public BlogLink[] BlogLinks => JsonSerializer.Deserialize(Links) ?? []; } + +public record class BlogLink(string Name, string Url, string Author, string Published) { } diff --git a/rubberduckvba.Server/Model/Entity/InspectionEntity.cs b/rubberduckvba.Server/Model/Entity/InspectionEntity.cs index ae207dd..f91d0b6 100644 --- a/rubberduckvba.Server/Model/Entity/InspectionEntity.cs +++ b/rubberduckvba.Server/Model/Entity/InspectionEntity.cs @@ -3,6 +3,7 @@ public record class InspectionEntity : Entity { public int FeatureId { get; init; } + public string FeatureName { get; init; } = string.Empty; public int TagAssetId { get; init; } public string SourceUrl { get; init; } = string.Empty; public string InspectionType { get; init; } = string.Empty; diff --git a/rubberduckvba.Server/Model/Entity/QuickFixEntity.cs b/rubberduckvba.Server/Model/Entity/QuickFixEntity.cs index afc4d3e..bf1ee61 100644 --- a/rubberduckvba.Server/Model/Entity/QuickFixEntity.cs +++ b/rubberduckvba.Server/Model/Entity/QuickFixEntity.cs @@ -3,6 +3,7 @@ public record class QuickFixEntity : Entity { public int FeatureId { get; init; } + public string FeatureName { get; init; } = string.Empty; public int TagAssetId { get; init; } public string SourceUrl { get; init; } = string.Empty; public bool IsNew { get; init; } diff --git a/rubberduckvba.Server/Model/Entity/TagEntity.cs b/rubberduckvba.Server/Model/Entity/TagEntity.cs index d656e65..fb360a5 100644 --- a/rubberduckvba.Server/Model/Entity/TagEntity.cs +++ b/rubberduckvba.Server/Model/Entity/TagEntity.cs @@ -1,7 +1,10 @@ -namespace rubberduckvba.Server.Model.Entity; +using rubberduckvba.Server.Services; + +namespace rubberduckvba.Server.Model.Entity; public record class TagEntity : Entity { + public RepositoryId RepositoryId { get; init; } = RepositoryId.Rubberduck; public long ReleaseId { get; init; } public DateTime DateCreated { get; init; } public string InstallerDownloadUrl { get; init; } = default!; diff --git a/rubberduckvba.Server/Model/ExampleModule.cs b/rubberduckvba.Server/Model/ExampleModule.cs index a6446ab..cbbd923 100644 --- a/rubberduckvba.Server/Model/ExampleModule.cs +++ b/rubberduckvba.Server/Model/ExampleModule.cs @@ -1,4 +1,5 @@ -using System.Reflection; +using System.ComponentModel.DataAnnotations; +using System.Reflection; namespace rubberduckvba.Server.Model; @@ -7,7 +8,7 @@ public record class ExampleModule private static readonly IDictionary ModuleTypes = typeof(ExampleModuleType) .GetMembers() - .Select(m => (m.Name, m.GetCustomAttributes().OfType().SingleOrDefault()?.Description)) + .Select(m => (m.Name, Description: m.GetCustomAttributes().OfType().SingleOrDefault()?.Name)) .Where(m => m.Description != null) .ToDictionary(m => (ExampleModuleType)Enum.Parse(typeof(ExampleModuleType), m.Name, true), m => m.Description!); diff --git a/rubberduckvba.Server/Model/ExampleModuleType.cs b/rubberduckvba.Server/Model/ExampleModuleType.cs index 9a78bf9..5d3c531 100644 --- a/rubberduckvba.Server/Model/ExampleModuleType.cs +++ b/rubberduckvba.Server/Model/ExampleModuleType.cs @@ -1,15 +1,15 @@ -using System.ComponentModel; +using System.ComponentModel.DataAnnotations; namespace rubberduckvba.Server.Model; public enum ExampleModuleType { None = 0, - [Description("(Any)")] Any, - [Description("Class Module")] ClassModule, - [Description("Document Module")] DocumentModule, - [Description("Interface Module")] InterfaceModule, - [Description("Predeclared Class")] PredeclaredClass, - [Description("Standard Module")] StandardModule, - [Description("UserForm Module")] UserFormModule + [Display(Name = "(Any)")] Any, + [Display(Name = "Class Module")] ClassModule, + [Display(Name = "Document Module")] DocumentModule, + [Display(Name = "Interface Module")] InterfaceModule, + [Display(Name = "Predeclared Class")] PredeclaredClass, + [Display(Name = "Standard Module")] StandardModule, + [Display(Name = "UserForm Module")] UserFormModule } diff --git a/rubberduckvba.Server/Model/Feature.cs b/rubberduckvba.Server/Model/Feature.cs index d1aef69..b7d0b30 100644 --- a/rubberduckvba.Server/Model/Feature.cs +++ b/rubberduckvba.Server/Model/Feature.cs @@ -1,4 +1,5 @@ using rubberduckvba.Server.Model.Entity; +using System.Text.Json; namespace rubberduckvba.Server.Model; @@ -13,6 +14,8 @@ public interface IFeature bool IsHidden { get; init; } bool IsDiscontinued { get; init; } + //BlogLink[] Links { get; init; } + int GetContentHash(); } @@ -24,7 +27,8 @@ public Feature(FeatureEntity entity) : this() DateTimeInserted = entity.DateTimeInserted; DateTimeUpdated = entity.DateTimeUpdated; Name = entity.Name; - ParentId = entity.ParentId; + FeatureId = entity.FeatureId; + FeatureName = entity.FeatureName; RepositoryId = (Services.RepositoryId)entity.RepositoryId; Title = entity.Title; ShortDescription = entity.ShortDescription; @@ -32,6 +36,8 @@ public Feature(FeatureEntity entity) : this() IsHidden = entity.IsHidden; IsNew = entity.IsNew; HasImage = entity.HasImage; + Links = string.IsNullOrWhiteSpace(entity.Links) ? [] + : JsonSerializer.Deserialize(entity.Links, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? []; } public int Id { get; init; } @@ -39,7 +45,8 @@ public Feature(FeatureEntity entity) : this() public DateTime? DateTimeUpdated { get; init; } public string Name { get; init; } = string.Empty; - public int? ParentId { get; init; } + public int? FeatureId { get; init; } + public string FeatureName { get; init; } = string.Empty; public Services.RepositoryId RepositoryId { get; init; } = Services.RepositoryId.Rubberduck; public string Title { get; init; } = string.Empty; public string ShortDescription { get; init; } = string.Empty; @@ -49,6 +56,8 @@ public Feature(FeatureEntity entity) : this() public bool IsDiscontinued { get; init; } public bool HasImage { get; init; } + public BlogLink[] Links { get; init; } = []; + public FeatureEntity ToEntity() => new() { Id = Id, @@ -60,21 +69,33 @@ public Feature(FeatureEntity entity) : this() IsNew = IsNew, Name = Name, ShortDescription = ShortDescription, - ParentId = ParentId, + FeatureId = FeatureId, + FeatureName = FeatureName, RepositoryId = (int)Services.RepositoryId.Rubberduck, Title = Title, + Links = Links.Length == 0 ? string.Empty : JsonSerializer.Serialize(Links) }; - public int GetContentHash() => HashCode.Combine(Name, Title, ShortDescription, Description, HasImage, IsHidden, IsNew, IsDiscontinued); + public int GetContentHash() + { + var hash = new HashCode(); + hash.Add(Name); + hash.Add(Title); + hash.Add(ShortDescription); + hash.Add(Description); + hash.Add(HasImage); + hash.Add(IsHidden); + hash.Add(IsNew); + hash.Add(IsDiscontinued); + hash.Add(Links); + return hash.ToHashCode(); + } } public record class FeatureGraph : Feature { public FeatureGraph(FeatureEntity entity) : base(entity) { } - public string? ParentName { get; init; } - public string? ParentTitle { get; init; } - public IEnumerable Features { get; init; } = []; public IEnumerable Inspections { get; init; } = []; public IEnumerable QuickFixes { get; init; } = []; diff --git a/rubberduckvba.Server/Model/HangfireJobState.cs b/rubberduckvba.Server/Model/HangfireJobState.cs new file mode 100644 index 0000000..b309273 --- /dev/null +++ b/rubberduckvba.Server/Model/HangfireJobState.cs @@ -0,0 +1,11 @@ +namespace rubberduckvba.Server.Model; + +public record class HangfireJobState +{ + public string JobName { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public int? LastJobId { get; set; } + public string? NextExecution { get; set; } + public string? StateName { get; set; } + public string? StateTimestamp { get; set; } +} diff --git a/rubberduckvba.Server/Model/Inspection.cs b/rubberduckvba.Server/Model/Inspection.cs index 817faaf..d0880da 100644 --- a/rubberduckvba.Server/Model/Inspection.cs +++ b/rubberduckvba.Server/Model/Inspection.cs @@ -10,10 +10,12 @@ public Inspection(InspectionEntity entity) : this() Id = entity.Id; DateTimeInserted = entity.DateTimeInserted; DateTimeUpdated = entity.DateTimeUpdated; - FeatureId = entity.FeatureId; TagAssetId = entity.TagAssetId; SourceUrl = entity.SourceUrl; + FeatureId = entity.FeatureId; + FeatureName = entity.FeatureName; + IsNew = entity.IsNew; IsDiscontinued = entity.IsDiscontinued; IsHidden = entity.IsHidden; @@ -33,10 +35,12 @@ public Inspection(InspectionEntity entity) : this() public int Id { get; init; } public DateTime DateTimeInserted { get; init; } public DateTime? DateTimeUpdated { get; init; } - public int FeatureId { get; init; } public int TagAssetId { get; init; } public string SourceUrl { get; init; } = string.Empty; + public int FeatureId { get; init; } + public string FeatureName { get; init; } + public bool IsNew { get; init; } public bool IsDiscontinued { get; init; } public bool IsHidden { get; init; } @@ -56,6 +60,7 @@ public Inspection(InspectionEntity entity) : this() { Id = Id, FeatureId = FeatureId, + FeatureName = FeatureName, DateTimeInserted = DateTimeInserted, DateTimeUpdated = DateTimeUpdated, TagAssetId = TagAssetId, diff --git a/rubberduckvba.Server/Model/QuickFix.cs b/rubberduckvba.Server/Model/QuickFix.cs index 08b9631..306ba31 100644 --- a/rubberduckvba.Server/Model/QuickFix.cs +++ b/rubberduckvba.Server/Model/QuickFix.cs @@ -84,6 +84,7 @@ public int GetContentHash() hash.Add(CanFixProject); hash.Add(JsonSerializer.Serialize(Examples)); hash.Add(string.Join(',', Inspections)); + hash.Add(SourceUrl); return hash.ToHashCode(); } } \ No newline at end of file diff --git a/rubberduckvba.Server/Program.cs b/rubberduckvba.Server/Program.cs index fd06fbe..05feb62 100644 --- a/rubberduckvba.Server/Program.cs +++ b/rubberduckvba.Server/Program.cs @@ -7,6 +7,9 @@ using NLog.Config; using NLog.Extensions.Logging; using NLog.Targets; +using Polly; +using Polly.Retry; +using Rubberduck.SmartIndenter; using RubberduckServices; using rubberduckvba.Server.Api.Admin; using rubberduckvba.Server.ContentSynchronization; @@ -29,6 +32,12 @@ public class HangfireAuthenticationFilter : IDashboardAuthorizationFilter public bool Authorize([NotNull] DashboardContext context) => Debugger.IsAttached || context.Request.RemoteIpAddress == "20.220.30.154"; } +public static class CorsPolicies +{ + public const string AllowAll = "AllowAll"; + public const string AllowAuthenticated = "AllowAuthenticated"; +} + public class Program { public static void Main(string[] args) @@ -42,20 +51,51 @@ public static void Main(string[] args) builder.Services.Configure(options => builder.Configuration.GetSection("Api").Bind(options)); builder.Services.Configure(options => builder.Configuration.GetSection("Hangfire").Bind(options)); + builder.Services.AddCors(builder => + { + builder.AddPolicy(CorsPolicies.AllowAll, policy => + { + policy + .SetIsOriginAllowed(origin => true) + .AllowAnyHeader() + .WithMethods("OPTIONS", "GET", "POST") + .Build(); + }); + builder.AddPolicy(CorsPolicies.AllowAuthenticated, policy => + { + policy + .SetIsOriginAllowed(origin => true) + .WithHeaders("X-ACCESS-TOKEN") + .WithMethods("OPTIONS", "GET", "POST") + .AllowCredentials() + .Build(); + }); + }); builder.Services.AddAuthentication(options => { options.RequireAuthenticatedSignIn = false; + options.DefaultAuthenticateScheme = "github"; + options.AddScheme("github", builder => { builder.HandlerType = typeof(GitHubAuthenticationHandler); }); + options.AddScheme("webhook-signature", builder => + { + builder.HandlerType = typeof(WebhookAuthenticationHandler); + }); }); + builder.Services.AddAuthorization(options => { options.AddPolicy("github", builder => { - builder.RequireAuthenticatedUser(); + builder.RequireAuthenticatedUser().AddAuthenticationSchemes("github"); + }); + options.AddPolicy("webhook", builder => + { + builder.RequireAuthenticatedUser().AddAuthenticationSchemes("webhook-signature"); }); }); @@ -78,18 +118,34 @@ public static void Main(string[] args) app.UseHttpsRedirection(); app.UseRouting(); + app.UseCors(); + + app.UseAuthentication(); app.UseAuthorization(); + app.UseSession(); app.MapControllers(); app.MapFallbackToFile("/index.html"); - app.UseCors(policy => + var logger = app.Services.GetRequiredService>(); + logger.LogInformation("App configuration completed. Starting hangfire..."); + + var hangfireOptions = app.Services.GetService>()?.Value ?? new(); + new ResiliencePipelineBuilder().AddRetry(new RetryStrategyOptions { - policy.SetIsOriginAllowed(origin => true); - }); - app.UseSession(); + Delay = TimeSpan.FromSeconds(30), + MaxRetryAttempts = hangfireOptions.MaxInitializationAttempts, + OnRetry = (context) => + { + var retryCount = context.AttemptNumber; + var delay = context.RetryDelay; + + logger.LogError(context.Outcome.Exception, $"Hangfire failed to start | Retrying storage connection in {delay.TotalSeconds} seconds. Attempt {retryCount} of {hangfireOptions.MaxInitializationAttempts}"); + return ValueTask.CompletedTask; + } + }).Build().Execute(() => StartHangfire(app)); - StartHangfire(app); + logger.LogInformation("Hangfire initialization completed. Starting application..."); app.Run(); } @@ -147,6 +203,12 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -157,8 +219,8 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton, InspectionsRepository>(); services.AddSingleton, QuickFixRepository>(); services.AddSingleton, AnnotationsRepository>(); + services.AddSingleton(); - //services.AddSingleton(); services.AddSingleton(); services.AddSingleton, InstallerDownloadStatsOrchestrator>(); services.AddSingleton, XmldocContentOrchestrator>(); @@ -170,13 +232,8 @@ private static void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); // TODO deprecate + services.AddSingleton(); - services.AddMemoryCache(cache => - { - cache.ExpirationScanFrequency = TimeSpan.FromMinutes(20); - }); services.AddSession(ConfigureSession); } diff --git a/rubberduckvba.Server/RubberduckApiController.cs b/rubberduckvba.Server/RubberduckApiController.cs new file mode 100644 index 0000000..5a59290 --- /dev/null +++ b/rubberduckvba.Server/RubberduckApiController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using rubberduckvba.Server; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace rubberduckvba.Server; + +[ApiController] +[EnableCors("CorsPolicy")] +public abstract class RubberduckApiController : ControllerBase +{ + private readonly ILogger _logger; + + protected RubberduckApiController(ILogger logger) + { + _logger = logger; + } + + protected ILogger Logger => _logger; + + protected IActionResult GuardInternalAction(Func method, [CallerMemberName] string name = default!) + { + var sw = Stopwatch.StartNew(); + IActionResult result = NoContent(); + var success = false; + try + { + _logger.LogTrace("GuardInternalAction:{name} | ▶ Invoking controller action", name); + result = method.Invoke(); + success = true; + } + catch (Exception exception) + { + _logger.LogError(exception, "GuardInternalAction:{name} | ❌ An exception was caught", name); + throw; + } + finally + { + sw.Stop(); + if (success) + { + _logger.LogTrace("GuardInternalAction:{name} | ✔️ Controller action completed | ⏱️ {elapsed}", name, sw.Elapsed); + } + else + { + _logger.LogWarning("GuardInternalAction:{name} | ⚠️ Controller action completed with errors", name); + } + } + + //Response.Headers.AccessControlAllowOrigin = "*"; + return result; + } +} diff --git a/rubberduckvba.Server/Services/CacheService.cs b/rubberduckvba.Server/Services/CacheService.cs new file mode 100644 index 0000000..1e97775 --- /dev/null +++ b/rubberduckvba.Server/Services/CacheService.cs @@ -0,0 +1,180 @@ +using Microsoft.Extensions.Caching.Memory; +using rubberduckvba.Server.Api.Downloads; +using rubberduckvba.Server.Api.Features; +using rubberduckvba.Server.Api.Tags; +using rubberduckvba.Server.Data; +using rubberduckvba.Server.Model; + +namespace rubberduckvba.Server.Services; + +public class CacheService +{ + private readonly MemoryCache _cache; + private readonly MemoryCacheEntryOptions _options; + + private readonly HangfireJobStateRepository _repository; + private readonly ILogger _logger; + + private const string JobStateSucceeded = "Succeeded"; + private const string TagsJobName = "update_installer_downloads"; + private const string XmldocJobName = "update_xmldoc_content"; + + public CacheService(HangfireJobStateRepository repository, ILogger logger) + { + _cache = new MemoryCache(new MemoryCacheOptions()); + _options = new MemoryCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromHours(1), + }; + + _repository = repository; + _logger = logger; + } + + public HangfireJobState? TagsJobState { get; private set; } + public HangfireJobState? XmldocJobState { get; private set; } + + private void GetCurrentJobState() + { + var state = _repository.GetAll().ToDictionary(e => e.JobName); + TagsJobState = state.TryGetValue(TagsJobName, out var tagsJobState) ? tagsJobState : null; + XmldocJobState = state.TryGetValue(XmldocJobName, out var xmldocsJobState) ? xmldocsJobState : null; + } + + public bool TryGetLatestTags(out LatestTagsViewModel? cached) => TryReadFromTagsCache("tags/latest", out cached); + public bool TryGetAvailableDownloads(out AvailableDownload[]? cached) => TryReadFromTagsCache("downloads", out cached); + public bool TryGetFeatures(out FeatureViewModel[]? cached) => TryReadXmldocCache("features", out cached); + public bool TryGetFeature(string name, out FeatureViewModel? cached) => TryReadXmldocCache($"features/{name.ToLowerInvariant()}", out cached); + public bool TryGetInspections(out InspectionsFeatureViewModel? cached) => TryReadXmldocCache($"inspections", out cached); + public bool TryGetInspection(string name, out InspectionViewModel? cached) => TryReadXmldocCache($"inspections/{name.ToLowerInvariant()}", out cached); + public bool TryGetQuickFixes(out QuickFixesFeatureViewModel? cached) => TryReadXmldocCache($"quickfixes", out cached); + public bool TryGetQuickFix(string name, out QuickFixViewModel? cached) => TryReadXmldocCache($"quickfixes/{name.ToLowerInvariant()}", out cached); + public bool TryGetAnnotations(out AnnotationsFeatureViewModel? cached) => TryReadXmldocCache($"annotations", out cached); + public bool TryGetAnnotation(string name, out AnnotationViewModel? cached) => TryReadXmldocCache($"annotations/{name.ToLowerInvariant()}", out cached); + + public void Invalidate(LatestTagsViewModel newContent) + { + GetCurrentJobState(); + if (TagsJobState?.StateName == JobStateSucceeded) + { + Write("tags/latest", newContent); + } + else + { + _logger.LogWarning($"TagsJobState is not '{JobStateSucceeded}' as expected (LastJobId: {TagsJobState?.LastJobId}); content will not be cached"); + } + } + + public void Invalidate(AvailableDownload[] newContent) + { + GetCurrentJobState(); + if (TagsJobState?.StateName == JobStateSucceeded) + { + Write("downloads", newContent); + } + else + { + _logger.LogWarning($"TagsJobState is not '{JobStateSucceeded}' as expected (LastJobId: {TagsJobState?.LastJobId}); content will not be cached"); + } + } + public void Invalidate(FeatureViewModel newContent) + { + GetCurrentJobState(); + if (XmldocJobState?.StateName == JobStateSucceeded) + { + Write($"features/{newContent.Name.ToLowerInvariant()}", newContent); + } + else + { + _logger.LogWarning($"XmldocJobState is not '{JobStateSucceeded}' as expected (LastJobId: {XmldocJobState?.LastJobId}); content will not be cached"); + } + } + public void Invalidate(FeatureViewModel[] newContent) + { + GetCurrentJobState(); + if (XmldocJobState?.StateName == JobStateSucceeded) + { + Write($"features", newContent); + } + else + { + _logger.LogWarning($"XmldocJobState is not '{JobStateSucceeded}' as expected (LastJobId: {XmldocJobState?.LastJobId}); content will not be cached"); + } + } + public void Invalidate(InspectionsFeatureViewModel newContent) + { + GetCurrentJobState(); + if (!TryReadXmldocCache("inspections", out _) || XmldocJobState?.StateName == JobStateSucceeded) + { + Write("inspections", newContent); + foreach (var item in newContent.Inspections) + { + Write($"inspections/{item.Name.ToLowerInvariant()}", item); + } + } + else + { + _logger.LogWarning($"XmldocJobState is not '{JobStateSucceeded}' as expected (LastJobId: {XmldocJobState?.LastJobId}); content will not be cached"); + } + } + public void Invalidate(QuickFixesFeatureViewModel newContent) + { + GetCurrentJobState(); + if (!TryReadXmldocCache("quickfixes", out _) || XmldocJobState?.StateName == JobStateSucceeded) + { + Write("quickfixes", newContent); + foreach (var item in newContent.QuickFixes) + { + Write($"quickfixes/{item.Name.ToLowerInvariant()}", item); + } + } + else + { + _logger.LogWarning($"XmldocJobState is not '{JobStateSucceeded}' as expected (LastJobId: {XmldocJobState?.LastJobId}); content will not be cached"); + } + } + public void Invalidate(AnnotationsFeatureViewModel newContent) + { + GetCurrentJobState(); + if (!TryReadXmldocCache("annotations", out _) || XmldocJobState?.StateName == JobStateSucceeded) + { + Write("annotations", newContent); + foreach (var item in newContent.Annotations) + { + Write($"annotations/{item.Name.ToLowerInvariant()}", item); + } + } + else + { + _logger.LogWarning($"XmldocJobState is not '{JobStateSucceeded}' as expected (LastJobId: {XmldocJobState?.LastJobId}); content will not be cached"); + } + } + + public void Clear() + { + _cache.Clear(); + _logger.LogInformation("Cache was cleared."); + } + + private void Write(string key, T value) + { + _cache.Set(key, value, _options); + _logger.LogInformation("Cache key '{key}' was invalidated.", key); + } + + private bool TryReadFromTagsCache(string key, out T? cached) + { + var result = _cache.TryGetValue(key, out cached); + _logger.LogDebug("TagsCache hit: '{key}' (valid: {result})", key, result); + + return result; + } + + private bool TryReadXmldocCache(string key, out T? cached) + { + var result = _cache.TryGetValue(key, out cached); + _logger.LogDebug("XmldocCache hit: '{key}' (valid: {result})", key, result); + + return result; + } +} \ No newline at end of file diff --git a/rubberduckvba.Server/Services/ContentCacheService.cs b/rubberduckvba.Server/Services/ContentCacheService.cs deleted file mode 100644 index 084e49a..0000000 --- a/rubberduckvba.Server/Services/ContentCacheService.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using System.Collections.Concurrent; -using System.Diagnostics; - -namespace rubberduckvba.Server.Services; - -public interface IContentCacheService : IDisposable -{ - int Count { get; } - void Clear(); - void Invalidate(string key, StringComparison stringComparison = StringComparison.InvariantCultureIgnoreCase); - void Invalidate(Func predicate); - void SetValue(string key, T value); - bool TryGetValue(string key, out T value); -} - -public class ContentCacheService : IContentCacheService -{ - private readonly IMemoryCache _cache; - private readonly ConcurrentDictionary _cacheKeys = new ConcurrentDictionary(); - - private static readonly MemoryCacheEntryOptions _cacheOptions = - new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2), - }; - - public ContentCacheService(IMemoryCache cache) - { - _cache = cache; - } - - public int Count => _cacheKeys.Count; - - public void Clear() - { - foreach (var key in _cacheKeys) - { - _cache.Remove(key); - } - _cacheKeys.Clear(); - Debug.WriteLine("**Cache cleared"); - } - - public void Invalidate(string key, StringComparison stringComparison = StringComparison.InvariantCultureIgnoreCase) => - Invalidate((e) => key.IndexOf(e, stringComparison) >= 0); - - public void Invalidate(Func predicate) - { - var keys = _cacheKeys.Keys.Where(predicate).ToArray(); - foreach (var key in keys) - { - _cache.Remove(key); - _cacheKeys.TryRemove(key, out _); - } - } - - public void SetValue(string key, T value) - { - _cache.Set(key, value, _cacheOptions); - _cacheKeys.TryAdd(key, key); - //Debug.WriteLine($"**Cache WRITE for key: '{key}'"); - } - - public bool TryGetValue(string key, out T value) - { - if (_cache.TryGetValue(key, out value)) - { - //Debug.WriteLine($"**Cache READ for key: '{key}'"); - return true; - } - - //Debug.WriteLine($"**Cache MISS for key: '{key}'"); - _cacheKeys.TryRemove(key, out _); - return false; - } - - public void Dispose() - { - Clear(); - _cache.Dispose(); - //Debug.WriteLine("**Cache disposed"); - } -} diff --git a/rubberduckvba.Server/Services/DistributedCacheService.cs b/rubberduckvba.Server/Services/DistributedCacheService.cs deleted file mode 100644 index b1c80c1..0000000 --- a/rubberduckvba.Server/Services/DistributedCacheService.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; -using NLog.Targets; -using System.Text; -using System.Text.Json; - -namespace rubberduckvba.Server.Services; - -public interface ICacheService -{ - bool TryGet(string key, out T value); - void Write(string key, T value); - - void Invalidate(string key); -} - -public class CacheService(IDistributedCache cache) : ICacheService -{ - private static DistributedCacheEntryOptions CacheOptions { get; } = new DistributedCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromHours(24), - }; - - public void Invalidate(string key) - { - cache.Remove(key); - } - - public bool TryGet(string key, out T value) - { - var bytes = cache.Get(key); - if (bytes == null) - { - value = default!; - return false; - } - - value = JsonSerializer.Deserialize(bytes)!; - return value != null; - } - - public void Write(string key, T value) - { - var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value)); - cache.Set(key, bytes, CacheOptions); - } -} diff --git a/rubberduckvba.Server/Services/GitHubClientService.cs b/rubberduckvba.Server/Services/GitHubClientService.cs index 80e6f49..76a1b7d 100644 --- a/rubberduckvba.Server/Services/GitHubClientService.cs +++ b/rubberduckvba.Server/Services/GitHubClientService.cs @@ -6,6 +6,7 @@ using rubberduckvba.Server.ContentSynchronization.XmlDoc.Schema; using rubberduckvba.Server.Model; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Security.Claims; using System.Text; using System.Web; @@ -23,6 +24,13 @@ public interface IGitHubClientService public class GitHubClientService(IOptions configuration, ILogger logger) : IGitHubClientService { + private class ReleaseComparer : IEqualityComparer + { + public bool Equals(Release? x, Release? y) => x?.Name == y?.Name; + + public int GetHashCode([DisallowNull] Release obj) => HashCode.Combine(obj.Name); + } + public async Task ValidateTokenAsync(string? token) { if (token is null) @@ -44,11 +52,11 @@ public class GitHubClientService(IOptions configuration, ILogger var user = await client.User.Current(); var identity = new ClaimsIdentity(new[] { - new Claim(ClaimTypes.Name, user.Name), - new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Name, user.Login), new Claim(ClaimTypes.Role, config.OwnerOrg), + new Claim(ClaimTypes.Authentication, token), new Claim("access_token", token) - }); + }, "github"); return new ClaimsPrincipal(identity); } @@ -58,7 +66,12 @@ public async Task> GetAllTagsAsync() var credentials = new Credentials(config.OrgToken); var client = new GitHubClient(new ProductHeaderValue(config.UserAgent), new InMemoryCredentialStore(credentials)); - var releases = await client.Repository.Release.GetAll(config.OwnerOrg, config.Rubberduck, new ApiOptions { PageCount = 1, PageSize = 10 }); + + var getReleases = client.Repository.Release.GetAll(config.OwnerOrg, config.Rubberduck, new ApiOptions { PageCount = 1, PageSize = 10 }); + var getLatest = client.Repository.Release.GetLatest(config.OwnerOrg, config.Rubberduck); + await Task.WhenAll(getReleases, getLatest); + + var releases = (await getReleases).Append(await getLatest).ToHashSet(new ReleaseComparer()); return (from release in releases let installer = release.Assets.SingleOrDefault(asset => asset.Name.EndsWith(".exe") && asset.Name.StartsWith("Rubberduck.Setup")) diff --git a/rubberduckvba.Server/Services/MarkdownFormattingService.cs b/rubberduckvba.Server/Services/MarkdownFormattingService.cs index 00a6171..b61f225 100644 --- a/rubberduckvba.Server/Services/MarkdownFormattingService.cs +++ b/rubberduckvba.Server/Services/MarkdownFormattingService.cs @@ -1,5 +1,5 @@ -using HtmlAgilityPack; -using MarkdownSharp; +using HeyRed.MarkdownSharp; +using HtmlAgilityPack; using RubberduckServices; namespace rubberduckvba.Server.Services; @@ -9,7 +9,7 @@ public interface IMarkdownFormattingService string FormatMarkdownDocument(string content, bool withSyntaxHighlighting = false); } -public class MarkdownFormattingService(ISyntaxHighlighterService service) : IMarkdownFormattingService +public class MarkdownFormattingService(ISyntaxHighlighterService syntaxHighlighterService) : IMarkdownFormattingService { private static readonly Markdown _service = new(); @@ -21,47 +21,38 @@ public string FormatMarkdownDocument(string content, bool withSyntaxHighlighting } var markdown = content.Replace("\n", "\r\n"); - var html = string.Empty; - - if (withSyntaxHighlighting) - { - // HTML tags makes the markdown formatter silently fail, - // so we pull out any blocks and format everything between them. - - markdown = PreProcessMarkdownString(markdown); - - var lastSectionStart = markdown.LastIndexOf(""); - if (lastSectionStart > 0) - { - var sectionStart = 0; - while (sectionStart < lastSectionStart) - { - var sectionEnd = markdown.IndexOf("", sectionEnd) > 0 - ? markdown.IndexOf("", sectionEnd) + 8 - : lastSectionStart; - - var block = markdown.Substring(sectionEnd, sectionStart - sectionEnd).Replace("", ""); - html += section + block; - } - } - else - { - html = _service.Transform(markdown); - } - } - else - { - html = _service.Transform(markdown); - } + var isCodeOnly = content.StartsWith("") && content.EndsWith(""); + + markdown = PreProcessMarkdownString(markdown); + var html = isCodeOnly ? markdown : _service.Transform(markdown); + + //var lastSectionStart = markdown.LastIndexOf(""); + //if (lastSectionStart > 0) + //{ + // var sectionStart = 0; + // while (sectionStart < lastSectionStart) + // { + // var sectionEnd = markdown.IndexOf("", sectionEnd) > 0 + // ? markdown.IndexOf("", sectionEnd) + 8 + // : lastSectionStart; + + // var block = markdown.Substring(sectionEnd, sectionStart - sectionEnd); + // html += section + block; + // } + //} + //else + //{ + // html = _service.Transform(markdown); + //} return PostProcessHtml(html); } @@ -74,10 +65,10 @@ private string PreProcessMarkdownString(string content) var codeNodes = document.DocumentNode.Descendants("code").ToList(); foreach (var node in codeNodes) { - var code = service.Format(node.InnerText); + var code = syntaxHighlighterService.Format(node.InnerText); - //node.Name = "div"; - //node.EndNode.Name = "div"; + node.Name = "div"; + node.EndNode.Name = "div"; node.AddClass("vbe-mock-debugger"); node.InnerHtml = string.Empty; @@ -99,19 +90,19 @@ private string PreProcessMarkdownString(string content) node.AppendChild(codeAreaDiv); } - return document.DocumentNode.InnerHtml; + return document.DocumentNode.FirstChild.InnerHtml; } private string PostProcessHtml(string html) { var document = new HtmlDocument(); - document.LoadHtml($"
{html}
"); + document.LoadHtml(html); foreach (var node in document.DocumentNode.Descendants("img").ToList()) { node.AddClass("document-img"); } - return document.DocumentNode.FirstChild.InnerHtml; + return document.DocumentNode.InnerHtml; } } diff --git a/rubberduckvba.Server/Services/RubberduckDbService.cs b/rubberduckvba.Server/Services/RubberduckDbService.cs index 458c5cd..95a88ad 100644 --- a/rubberduckvba.Server/Services/RubberduckDbService.cs +++ b/rubberduckvba.Server/Services/RubberduckDbService.cs @@ -1,6 +1,7 @@ using Microsoft.Data.SqlClient; using Microsoft.Extensions.Options; using rubberduckvba.Server.ContentSynchronization.Pipeline.Sections.Context; +using rubberduckvba.Server.Data; using rubberduckvba.Server.Model; using rubberduckvba.Server.Services.rubberduckdb; @@ -57,7 +58,8 @@ public enum RepositoryId public interface IRubberduckDbService { - Task> GetAllTagsAsync(); + Task> GetJobStateAsync(); + Task> GetLatestTagsAsync(RepositoryId repositoryId); Task GetLatestTagAsync(RepositoryId repositoryId, bool includePreRelease); Task UpdateAsync(IEnumerable tags); @@ -74,15 +76,17 @@ public class RubberduckDbService : IRubberduckDbService private readonly string _connectionString; private readonly TagServices _tagServices; private readonly FeatureServices _featureServices; + private readonly HangfireJobStateRepository _hangfireJobState; public RubberduckDbService(IOptions settings, ILogger logger, - TagServices tagServices, FeatureServices featureServices) + TagServices tagServices, FeatureServices featureServices, HangfireJobStateRepository hangfireJobState) { _connectionString = settings.Value.RubberduckDb ?? throw new InvalidOperationException("ConnectionString 'RubberduckDb' could not be retrieved."); Logger = logger; _tagServices = tagServices; _featureServices = featureServices; + _hangfireJobState = hangfireJobState; } private ILogger Logger { get; } @@ -156,7 +160,7 @@ public async Task ResolveFeature(RepositoryId repositoryId, string { var features = _featureServices.Get(topLevelOnly: false).ToList(); var feature = features.Single(e => string.Equals(e.Name, name, StringComparison.OrdinalIgnoreCase)); - var children = features.Where(e => e.ParentId == feature.Id); + var children = features.Where(e => e.FeatureId == feature.Id); return new FeatureGraph(feature.ToEntity()) { Features = children.ToArray() @@ -314,17 +318,20 @@ public async Task SaveFeature(Feature feature) } public async Task CreateAsync(IEnumerable tags, RepositoryId repositoryId) - => _tagServices.Create(tags); + => await Task.Run(() => _tagServices.Create(tags)); public async Task> GetLatestTagsAsync(RepositoryId repositoryId) - => _tagServices.GetLatestTags(); + => await Task.Run(() => _tagServices.GetLatestTags()); public async Task GetLatestTagAsync(RepositoryId repositoryId, bool preRelease) - => _tagServices.GetLatestTag(preRelease); + => await Task.Run(() => _tagServices.GetLatestTag(preRelease)); public async Task UpdateAsync(IEnumerable tags) - => _tagServices.Update(tags); + => await Task.Run(() => _tagServices.Update(tags)); public async Task GetFeatureId(RepositoryId repositoryId, string name) - => _featureServices.GetId(name); -} \ No newline at end of file + => await Task.Run(() => _featureServices.GetId(name)); + + public async Task> GetJobStateAsync() + => await Task.Run(() => _hangfireJobState.GetAll()); +} diff --git a/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs b/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs index 44402a4..2a0033f 100644 --- a/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs +++ b/rubberduckvba.Server/Services/rubberduckdb/FeatureServices.cs @@ -5,6 +5,7 @@ namespace rubberduckvba.Server.Services.rubberduckdb; public class FeatureServices( + IMarkdownFormattingService markdown, IRepository featureRepository, IRepository inspectionRepository, IRepository quickfixRepository, @@ -14,15 +15,36 @@ public class FeatureServices( public IEnumerable Get(bool topLevelOnly = true) { return featureRepository.GetAll() - .Where(e => !topLevelOnly || e.ParentId is null) + .Where(e => !topLevelOnly || e.FeatureId is null) .Select(e => new Feature(e)); } + public Inspection GetInspection(string name) + { + var id = inspectionRepository.GetId(name); + return new Inspection(inspectionRepository.GetById(id)); + } + public Annotation GetAnnotation(string name) + { + var id = annotationRepository.GetId(name); + return new Annotation(annotationRepository.GetById(id)); + } + public QuickFix GetQuickFix(string name) + { + var id = quickfixRepository.GetId(name); + return new QuickFix(quickfixRepository.GetById(id)); + } + public FeatureGraph Get(string name) { var id = featureRepository.GetId(name); var feature = featureRepository.GetById(id); - var children = featureRepository.GetAll(parentId: id).Select(e => new Feature(e)); + var children = featureRepository.GetAll(parentId: id).Select(e => + new Feature(e with + { + Description = markdown.FormatMarkdownDocument(e.Description, withSyntaxHighlighting: true), + ShortDescription = markdown.FormatMarkdownDocument(e.ShortDescription), + })).ToList(); IEnumerable inspections = []; IEnumerable quickfixes = []; @@ -30,18 +52,29 @@ public FeatureGraph Get(string name) if (string.Equals(name, "inspections", StringComparison.InvariantCultureIgnoreCase)) { - inspections = inspectionRepository.GetAll().Select(e => new Inspection(e)).ToList(); + inspections = inspectionRepository.GetAll() + .Select(e => new Inspection(e)) + .ToList(); } else if (string.Equals(name, "quickfixes", StringComparison.InvariantCultureIgnoreCase)) { - quickfixes = quickfixRepository.GetAll().Select(e => new QuickFix(e)).ToList(); + quickfixes = quickfixRepository.GetAll() + .Select(e => new QuickFix(e)) + .ToList(); } else if (string.Equals(name, "annotations", StringComparison.InvariantCultureIgnoreCase)) { - annotations = annotationRepository.GetAll().Select(e => new Annotation(e)).ToList(); + annotations = annotationRepository.GetAll() + .Select(e => new Annotation(e)) + .ToList(); } - return new FeatureGraph(feature) + return new FeatureGraph( + feature with + { + Description = markdown.FormatMarkdownDocument(feature.Description, withSyntaxHighlighting: true), + ShortDescription = markdown.FormatMarkdownDocument(feature.ShortDescription), + }) { Features = children, Annotations = annotations, diff --git a/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs b/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs index 8d8abcf..b20f524 100644 --- a/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs +++ b/rubberduckvba.Server/Services/rubberduckdb/TagServices.cs @@ -6,71 +6,65 @@ namespace rubberduckvba.Server.Services.rubberduckdb; public class TagServices(IRepository tagsRepository, IRepository tagAssetsRepository) { - private IEnumerable _allAssets = []; - private IEnumerable _allTags = []; - private IEnumerable _latestTags = []; - private TagGraph? _main; - private TagGraph? _next; - private bool _mustInvalidate = true; - - public IEnumerable GetAllTags() + public bool TryGetTag(string name, out Tag tag) { - if (_mustInvalidate || !_allTags.Any()) + var entity = tagsRepository.GetAll().SingleOrDefault(tag => tag.Name == name); + if (entity is null) { - _allTags = tagsRepository.GetAll().ToList(); - _latestTags = _allTags - .GroupBy(tag => tag.IsPreRelease) - .Select(tags => tags.OrderByDescending(tag => tag.DateCreated)) - .SelectMany(tags => tags.Take(1)) - .ToList(); - _mustInvalidate = false; + tag = default!; + return false; } - return _allTags.Select(e => new Tag(e)); + tag = new Tag(entity); + return true; } - public IEnumerable GetLatestTags() + public IEnumerable GetAllTags() { - if (_mustInvalidate || !_latestTags.Any()) - { - _ = GetAllTags(); - } - - return _latestTags.Select(e => new Tag(e)); + return tagsRepository.GetAll().Select(e => new Tag(e)); } - public TagGraph GetLatestTag(bool isPreRelease) + public IEnumerable GetLatestTags() => GetLatestTags(tagsRepository.GetAll().Select(e => new Tag(e))); + + public IEnumerable GetLatestTags(IEnumerable allTags) => allTags + .GroupBy(tag => tag.IsPreRelease) + .Select(tags => tags.OrderByDescending(tag => tag.DateCreated)) + .SelectMany(tags => tags.Take(1)) + .ToList(); + + public TagGraph? GetLatestTag(bool isPreRelease) { - var mustInvalidate = _mustInvalidate; - if (mustInvalidate || !_latestTags.Any()) + var latestTags = GetLatestTags(); + if (!latestTags.Any()) { - _ = GetAllTags(); // _mustInvalidate => false + return null; } - if (!mustInvalidate && !isPreRelease && _main != null) + if (!isPreRelease) { - return _main; + var mainTag = latestTags.First(e => !e.IsPreRelease); + var mainAssets = tagAssetsRepository.GetAll(mainTag.Id); + return new TagGraph(mainTag.ToEntity(), mainAssets); } - if (!mustInvalidate && isPreRelease && _next != null) + else { - return _next; + var nextTag = latestTags.First(e => e.IsPreRelease); + var nextAssets = tagAssetsRepository.GetAll(nextTag.Id); + return new TagGraph(nextTag.ToEntity(), nextAssets); } - - var mainTag = _latestTags.First(e => !e.IsPreRelease); - var mainAssets = tagAssetsRepository.GetAll(mainTag.Id); - _main = new TagGraph(mainTag, mainAssets); - - var nextTag = _latestTags.First(e => e.IsPreRelease); - var nextAssets = tagAssetsRepository.GetAll(nextTag.Id); - _next = new TagGraph(nextTag, nextAssets); - - return isPreRelease ? _next : _main; } public void Create(IEnumerable tags) { - var tagsByName = tags.ToDictionary(tag => tag.Name); + if (!tags.Any()) + { + return; + } + var tagEntities = tagsRepository.Insert(tags.Select(tag => tag.ToEntity())); + var tagsByName = tagEntities.ToDictionary( + tag => tag.Name, + tag => new TagGraph(tag, tags.Single(t => t.Name == tag.Name).Assets.Select(a => a.ToEntity()))); var assets = new List(); foreach (var tagEntity in tagEntities) @@ -80,12 +74,15 @@ public void Create(IEnumerable tags) } _ = tagAssetsRepository.Insert(assets); - _mustInvalidate = true; } public void Update(IEnumerable tags) { + if (!tags.Any()) + { + return; + } + tagsRepository.Update(tags.Select(tag => tag.ToEntity())); - _mustInvalidate = true; } } \ No newline at end of file diff --git a/rubberduckvba.Server/WebhookAuthenticationHandler.cs b/rubberduckvba.Server/WebhookAuthenticationHandler.cs new file mode 100644 index 0000000..f409ab0 --- /dev/null +++ b/rubberduckvba.Server/WebhookAuthenticationHandler.cs @@ -0,0 +1,53 @@ + +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text.Encodings.Web; + +namespace rubberduckvba.Server; + +public class WebhookAuthenticationHandler : AuthenticationHandler +{ + private readonly WebhookHeaderValidationService _service; + + public WebhookAuthenticationHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, + WebhookHeaderValidationService service) + : base(options, logger, encoder) + { + _service = service; + } + + protected async override Task HandleAuthenticateAsync() + { + return await Task.Run(() => + { + var userAgent = Context.Request.Headers.UserAgent; + var xGitHubEvent = Context.Request.Headers["X-GitHub-Event"].OfType().ToArray(); + var xGitHubDelivery = Context.Request.Headers["X-GitHub-Delivery"].OfType().ToArray(); + var xHubSignature = Context.Request.Headers["X-Hub-Signature"].OfType().ToArray(); + var xHubSignature256 = Context.Request.Headers["X-Hub-Signature-256"].OfType().ToArray(); + + if (_service.Validate(userAgent, xGitHubEvent, xGitHubDelivery, xHubSignature, xHubSignature256)) + { + var principal = CreatePrincipal(); + var ticket = new AuthenticationTicket(principal, "webhook-signature"); + + return AuthenticateResult.Success(ticket); + } + + return AuthenticateResult.NoResult(); + }); + } + + private static ClaimsPrincipal CreatePrincipal() + { + var identity = new ClaimsIdentity("webhook", ClaimTypes.Name, ClaimTypes.Role); + + identity.AddClaim(new Claim(ClaimTypes.Name, "rubberduck-vba-releasebot")); + identity.AddClaim(new Claim(ClaimTypes.Role, "rubberduck-webhook")); + identity.AddClaim(new Claim(ClaimTypes.Authentication, "webhook-signature")); + + var principal = new ClaimsPrincipal(identity); + return principal; + } +} \ No newline at end of file diff --git a/rubberduckvba.Server/WebhookHeaderValidationService.cs b/rubberduckvba.Server/WebhookHeaderValidationService.cs new file mode 100644 index 0000000..2d32ecb --- /dev/null +++ b/rubberduckvba.Server/WebhookHeaderValidationService.cs @@ -0,0 +1,91 @@ +using Newtonsoft.Json; +using rubberduckvba.Server.Api.Admin; +using System.Security.Cryptography; +using System.Text; + +namespace rubberduckvba.Server; + +public class WebhookSignatureValidationService(ConfigurationOptions configuration, ILogger logger) +{ + public bool Validate(string payload, string[] xHubSignature256) + { + var signature = xHubSignature256.SingleOrDefault(); + if (signature == default) + { + // SHA-256 signature header must be present + return false; + } + + if (!IsValidSignature(signature, payload)) + { + // SHA-256 signature must match + return false; + } + + return true; + } + + private bool IsValidSignature(string? signature, string payload) + { + if (string.IsNullOrWhiteSpace(signature)) + { + return false; + } + + var secret = configuration.GitHubOptions.Value.WebhookToken; + if (string.IsNullOrWhiteSpace(secret)) + { + logger.LogWarning("Webhook secret was not found; signature will not be validated."); + return false; + } + + var secretBytes = Encoding.UTF8.GetBytes(secret); + var payloadBytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(JsonConvert.DeserializeObject(payload))); + + using var digest = new HMACSHA256(secretBytes); + var hash = digest.ComputeHash(payloadBytes); + + var check = $"sha256={Convert.ToHexString(hash).ToLowerInvariant()}"; + + return (signature == check); + } +} + + +public class WebhookHeaderValidationService(ConfigurationOptions configuration, ILogger logger) +{ + public bool Validate( + string? userAgent, + string[] xGitHubEvent, + string[] xGitHubDelivery, + string[] xHubSignature, + string[] xHubSignature256 + ) + { + if (!(userAgent ?? string.Empty).StartsWith("GitHub-Hookshot/")) + { + // user agent must be GitHub hookshot + return false; + } + + if (!xGitHubEvent.Contains("push")) + { + if (xGitHubEvent.Contains("ping")) + { + // no harm just returning 200-OK on ping + return true; + } + + // only authenticate ping and push events + return false; + } + + if (!Guid.TryParse(xGitHubDelivery.SingleOrDefault(), out _)) + { + // delivery should parse as a GUID + return false; + } + + return true; + } +} diff --git a/rubberduckvba.Server/appsettings.Development.json b/rubberduckvba.Server/appsettings.Development.json new file mode 100644 index 0000000..7959222 --- /dev/null +++ b/rubberduckvba.Server/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "RubberduckDb": "Data Source=(localdb)\\MSSQLLocalDB;Integrated Security=True;Trust Server Certificate=True;", + "HangfireDb": "Data Source=(localdb)\\MSSQLLocalDB;Integrated Security=True;Trust Server Certificate=True;" + }, + "AllowedHosts": "*" +} diff --git a/rubberduckvba.Server/appsettings.json b/rubberduckvba.Server/appsettings.json new file mode 100644 index 0000000..ac1c760 --- /dev/null +++ b/rubberduckvba.Server/appsettings.json @@ -0,0 +1,24 @@ +/* + This file gets created or overwritten upon deployment to either AZ-TEST or AZ-PROD. +*/ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Hangfire": { + "QueuePollIntervalSeconds": 30, + "AutoRetryAttempts": 2, + "AutoRetryDelaySeconds": [ 2, 5, 10, 30, 60 ], + "HeartbeatIntervalMinutes": 1, + "CancellationCheckIntervalSeconds": 300, + "ConfigureHangfireServerOnStartup": true, + "CreateUpdateInstallerDownloadsJob": true, + "CreateUpdateXmldocContentJob": true, + "UpdateInstallerDownloadsSchedule": "0 0 * * *", // daily + "UpdateXmldocContentSchedule": "0 0 31 2 *" // never + }, + "AllowedHosts": "*" +} diff --git a/rubberduckvba.Server/rubberduckvba.Server.csproj b/rubberduckvba.Server/rubberduckvba.Server.csproj index 0589674..58c4d89 100644 --- a/rubberduckvba.Server/rubberduckvba.Server.csproj +++ b/rubberduckvba.Server/rubberduckvba.Server.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -18,20 +18,21 @@ - - - + + + 8.*-* - - - - - - - - + + + + + + + + + @@ -42,7 +43,9 @@ - + + ..\RubberduckServices\Libs\Rubberduck.SmartIndenter.dll + diff --git a/rubberduckvba.Server/rubberduckvba.Server.http b/rubberduckvba.Server/rubberduckvba.Server.http deleted file mode 100644 index 326553b..0000000 --- a/rubberduckvba.Server/rubberduckvba.Server.http +++ /dev/null @@ -1,6 +0,0 @@ -@rubberduckvba.Server_HostAddress = http://localhost:5033 - -GET {{rubberduckvba.Server_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/rubberduckvba.client/angular.json b/rubberduckvba.client/angular.json index 09e7533..2997cb2 100644 --- a/rubberduckvba.client/angular.json +++ b/rubberduckvba.client/angular.json @@ -34,6 +34,27 @@ "browser": "src/main.ts" }, "configurations": { + "test": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "2.5mb", + "maximumError": "4mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "10kb", + "maximumError": "15kb" + } + ], + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.test.ts" + } + ], + "outputHashing": "all" + }, "production": { "budgets": [ { @@ -43,8 +64,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "5kb", - "maximumError": "10kb" + "maximumWarning": "10kb", + "maximumError": "15kb" } ], "fileReplacements": [ diff --git a/rubberduckvba.client/rubberduckvba.client.esproj b/rubberduckvba.client/rubberduckvba.client.esproj index 449bb36..0bf23c4 100644 --- a/rubberduckvba.client/rubberduckvba.client.esproj +++ b/rubberduckvba.client/rubberduckvba.client.esproj @@ -7,4 +7,7 @@ $(MSBuildProjectDirectory)\dist\rubberduckvba.client\ + + + \ No newline at end of file diff --git a/rubberduckvba.client/src/app/app.component.html b/rubberduckvba.client/src/app/app.component.html index 8fa0bec..ad19940 100644 --- a/rubberduckvba.client/src/app/app.component.html +++ b/rubberduckvba.client/src/app/app.component.html @@ -1,12 +1,18 @@ - -
- -
-
-
-
© 2014-2024 Rubberduck Project Contributors
+ +
+ +
+
+ +
+
+ +
+
+
+
© 2014-{{currentYear}} Rubberduck Project Contributors
diff --git a/rubberduckvba.client/src/app/app.component.ts b/rubberduckvba.client/src/app/app.component.ts index 5799259..58dc64c 100644 --- a/rubberduckvba.client/src/app/app.component.ts +++ b/rubberduckvba.client/src/app/app.component.ts @@ -1,4 +1,3 @@ -import { HttpClient } from '@angular/common/http'; import { Component, OnInit } from '@angular/core'; @Component({ @@ -7,4 +6,5 @@ import { Component, OnInit } from '@angular/core'; }) export class AppComponent { title = 'rubberduckvba.com'; + currentYear = new Date().getFullYear(); } diff --git a/rubberduckvba.client/src/app/app.module.ts b/rubberduckvba.client/src/app/app.module.ts index ab024be..86f5ce9 100644 --- a/rubberduckvba.client/src/app/app.module.ts +++ b/rubberduckvba.client/src/app/app.module.ts @@ -1,34 +1,65 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule, UrlSerializer } from '@angular/router'; + import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { DataService } from './services/data.service'; import { ApiClientService } from './services/api-client.service'; -import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { AppComponent } from './app.component'; import { NavMenuComponent } from './components/nav-menu/nav-menu.component'; -import { HomeComponent } from './routes/home/home.component'; +import { LoadingContentComponent } from './components/loading-content/loading-content.component'; import { TagDownloadComponent } from './components/tag-download/tag-download.component'; import { DownloadItemComponent } from './components/download-item/download-item.component'; import { FeatureBoxComponent } from './components/feature-box/feature-box.component'; -import { FeaturesComponent } from './routes/features/features.component'; import { FeatureInfoComponent } from './components/feature-info/feature-info.component'; import { FeatureItemBoxComponent } from './components/feature-item-box/feature-item-box.component'; -import { FeatureComponent } from './routes/feature/feature.component'; import { ExampleBoxComponent } from './components/example-box/example-box.component'; import { FeatureItemExampleComponent } from './components/quickfix-example.modal/quickfix-example.modal.component'; -import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { BrowserModule } from '@angular/platform-browser'; -import { RouterModule } from '@angular/router'; -import { LoadingContentComponent } from './components/loading-content/loading-content.component'; -//import { httpInterceptorProviders } from './services/HttpRequestInterceptor'; +import { InspectionItemBoxComponent } from './components/feature-item-box/inspection-item-box/inspection-item-box.component'; +import { AnnotationItemBoxComponent } from './components/feature-item-box/annotation-item-box/annotation-item-box.component'; +import { BlogLinkBoxComponent } from './components/blog-link-box/blog-link-box.component'; +import { QuickFixItemBoxComponent } from './components/feature-item-box/quickfix-item-box/quickfix-item-box.component'; + +import { HomeComponent } from './routes/home/home.component'; +import { AboutComponent } from './routes/about/about.component'; +import { FeaturesComponent } from './routes/features/features.component'; +import { FeatureComponent } from './routes/feature/feature.component'; +import { InspectionComponent } from './routes/inspection/inspection.component'; +import { AnnotationComponent } from './routes/annotation/annotation.component'; +import { QuickFixComponent } from './routes/quickfixes/quickfix.component'; +import { IndenterComponent } from './routes/indenter/indenter.component'; +import { DefaultUrlSerializer, UrlTree } from '@angular/router'; +import { AuthComponent } from './routes/auth/auth.component'; +import { AuthMenuComponent } from './components/auth-menu/auth-menu.component'; + +/** + * https://stackoverflow.com/a/39560520 + */ +export class LowerCaseUrlSerializer extends DefaultUrlSerializer { + override parse(url: string): UrlTree { + // Optional Step: Do some stuff with the url if needed. + + // If you lower it in the optional step + // you don't need to use "toLowerCase" + // when you pass it down to the next function + return super.parse(url.toLowerCase()); + } +} @NgModule({ declarations: [ AppComponent, HomeComponent, + AuthComponent, + AuthMenuComponent, + IndenterComponent, FeaturesComponent, FeatureComponent, TagDownloadComponent, @@ -39,16 +70,34 @@ import { LoadingContentComponent } from './components/loading-content/loading-co FeatureItemBoxComponent, ExampleBoxComponent, FeatureItemExampleComponent, - LoadingContentComponent + LoadingContentComponent, + AnnotationItemBoxComponent, + InspectionItemBoxComponent, + QuickFixItemBoxComponent, + BlogLinkBoxComponent, + InspectionComponent, + AnnotationComponent, + QuickFixComponent, + AboutComponent ], bootstrap: [AppComponent], imports: [ - BrowserModule, CommonModule, + BrowserModule, + FormsModule, RouterModule.forRoot([ + // legacy routes: + { path: 'inspections/details/:name', redirectTo: 'inspections/:name' }, + // actual routes: + { path: 'auth/github', component: AuthComponent }, + { path: 'features', component: FeaturesComponent }, + { path: 'features/:name', component: FeatureComponent }, + { path: 'inspections/:name', component: InspectionComponent }, + { path: 'annotations/:name', component: AnnotationComponent }, + { path: 'quickfixes/:name', component: QuickFixComponent }, + { path: 'about', component: AboutComponent }, + { path: 'indenter', component: IndenterComponent }, { path: '', component: HomeComponent, pathMatch: 'full' }, - { path: 'features', component: FeaturesComponent, pathMatch: 'full' }, - { path: 'features/:name', component: FeatureComponent, pathMatch: 'full' } ]), FontAwesomeModule, NgbModule @@ -57,7 +106,11 @@ import { LoadingContentComponent } from './components/loading-content/loading-co DataService, ApiClientService, provideHttpClient(withInterceptorsFromDi()), - //httpInterceptorProviders + { + provide: UrlSerializer, + useClass: LowerCaseUrlSerializer + } ] }) + export class AppModule { } diff --git a/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html new file mode 100644 index 0000000..1f6b45b --- /dev/null +++ b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.html @@ -0,0 +1,131 @@ + +
+ +
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.ts b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.ts new file mode 100644 index 0000000..0411a94 --- /dev/null +++ b/rubberduckvba.client/src/app/components/auth-menu/auth-menu.component.ts @@ -0,0 +1,88 @@ +import { Component, OnInit, TemplateRef, ViewChild, inject } from "@angular/core"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { BehaviorSubject } from "rxjs"; +import { UserViewModel } from "../../model/feature.model"; +import { AuthService } from "src/app/services/auth.service"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { fab } from "@fortawesome/free-brands-svg-icons"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { ApiClientService } from "../../services/api-client.service"; + +@Component({ + selector: 'auth-menu', + templateUrl: './auth-menu.component.html' +}) +export class AuthMenuComponent implements OnInit { + + private readonly _user: BehaviorSubject = new BehaviorSubject(null!); + + public set user(value: UserViewModel) { + this._user.next(value); + } + public get user(): UserViewModel { + return this._user.getValue(); + } + + @ViewChild('confirmbox', { read: TemplateRef }) confirmbox: TemplateRef | undefined; + @ViewChild('confirmtagsbox', { read: TemplateRef }) confirmtagsbox: TemplateRef | undefined; + @ViewChild('confirmxmldocsbox', { read: TemplateRef }) confirmxmldocsbox: TemplateRef | undefined; + @ViewChild('confirmclearcachebox', { read: TemplateRef }) confirmclearcachebox: TemplateRef | undefined; + public modal = inject(NgbModal); + + constructor(private auth: AuthService, private api: ApiClientService, private fa: FaIconLibrary) { + fa.addIconPacks(fas); + fa.addIconPacks(fab); + } + + ngOnInit(): void { + this.getUserInfo(); + } + + getUserInfo(): void { + this.auth.getUser().subscribe(result => { + if (result) { + this._user.next(result); + } + }); + } + + public confirm(): void { + this.modal.open(this.confirmbox); + } + + public confirmUpdateTags(): void { + this.modal.open(this.confirmtagsbox); + } + + public confirmUpdateXmldocs(): void { + this.modal.open(this.confirmxmldocsbox); + } + + public confirmClearCache(): void { + this.modal.open(this.confirmclearcachebox); + } + + public signin(): void { + this.auth.signin(); + } + + public signout(): void { + this.auth.signout(); + this.getUserInfo(); + } + + public updateTags(): void { + this.modal.dismissAll(); + this.api.updateTagMetadata().subscribe(jobId => console.log(`UpdateTagMetadata has scheduled job id ${jobId}`)); + } + + public updateXmldocs(): void { + this.modal.dismissAll(); + this.api.updateXmldocMetadata().subscribe(jobId => console.log(`UpdateXmldocMetadata has scheduled job id ${jobId}`)); + } + + public clearCache(): void { + this.modal.dismissAll(); + this.api.clearCache().subscribe(() => console.log(`Cache has been cleared`)); + } +} diff --git a/rubberduckvba.client/src/app/components/blog-link-box/blog-link-box.component.html b/rubberduckvba.client/src/app/components/blog-link-box/blog-link-box.component.html new file mode 100644 index 0000000..6d2dce8 --- /dev/null +++ b/rubberduckvba.client/src/app/components/blog-link-box/blog-link-box.component.html @@ -0,0 +1,8 @@ +
+
+
+
{{link.name}}
+ Published {{link.published}} +
+
+
diff --git a/rubberduckvba.client/src/app/components/blog-link-box/blog-link-box.component.ts b/rubberduckvba.client/src/app/components/blog-link-box/blog-link-box.component.ts new file mode 100644 index 0000000..1e993dc --- /dev/null +++ b/rubberduckvba.client/src/app/components/blog-link-box/blog-link-box.component.ts @@ -0,0 +1,39 @@ +import { Component, Input, OnInit } from "@angular/core"; +import { BlogLink } from "../../model/feature.model"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { fab } from "@fortawesome/free-brands-svg-icons"; +import { BehaviorSubject } from "rxjs"; + +@Component({ + selector: 'blog-link-box', + templateUrl: './blog-link-box.component.html', +}) +export class BlogLinkBoxComponent implements OnInit { + + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + + @Input() + public parentFeatureItemName: string = ''; + + @Input() + public set link(value: BlogLink | undefined) { + if (value != null) { + this._info.next(value); + } + } + + public get link(): BlogLink | undefined { + return this._info.value as BlogLink; + } + + + + constructor(private fa: FaIconLibrary) { + fa.addIconPacks(fas); + fa.addIconPacks(fab); + } + + ngOnInit(): void { + } +} diff --git a/rubberduckvba.client/src/app/components/example-box/example-box.component.html b/rubberduckvba.client/src/app/components/example-box/example-box.component.html index 44a5952..d4a189c 100644 --- a/rubberduckvba.client/src/app/components/example-box/example-box.component.html +++ b/rubberduckvba.client/src/app/components/example-box/example-box.component.html @@ -1,7 +1,7 @@
- - - + + +
-
+ +
+

This inspection would produce a result with the code in this example.

This inspection would NOT produce a result with the code in this example.

@@ -33,14 +70,109 @@

- -
- +
+ + {{module.moduleName}} | {{module.moduleTypeName}} +
+
+
+
+
+
+
+

+
+
+
+ +
+
+
+ +

Module(s) before executing the quickfix action:

+
+
+
+
+
+ + {{module.moduleName}} | {{module.moduleTypeName}} +
+
+
+
+
+
+
+
+
+

Module(s) after executing the quickfix action:

+
+
+
+
+
+ + {{module.moduleName}} | {{module.moduleTypeName}} +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + {{module.moduleName}} +
+
+
+
+
+
+
+
+ +

Module(s) before synchronizing attributes

+
+
+
+
+
+ + {{module.moduleName}} +
+
+
+
+
+
+
+
+

Module(s) after synchronizing attributes

+
+
+
+
+
+ {{module.moduleName}}
-
+
diff --git a/rubberduckvba.client/src/app/components/example-box/example-box.component.ts b/rubberduckvba.client/src/app/components/example-box/example-box.component.ts index 4b7240a..989563b 100644 --- a/rubberduckvba.client/src/app/components/example-box/example-box.component.ts +++ b/rubberduckvba.client/src/app/components/example-box/example-box.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnChanges, OnInit, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; -import { XmlDocExample, InspectionExampleViewModel, ExampleModuleViewModel } from '../../model/feature.model'; +import { Example, InspectionExample, ExampleModule, AnnotationExample, QuickFixExample, XmlDocExample } from '../../model/feature.model'; import { BehaviorSubject } from 'rxjs'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; @@ -13,23 +13,60 @@ import { ApiClientService } from '../../services/api-client.service'; }) export class ExampleBoxComponent implements OnInit { - private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); //@ViewChild('content', { read: TemplateRef }) content: TemplateRef | undefined; //public modal = inject(NgbModal); + private _isInspectionExample: boolean = false; + private _isAnnotationExample: boolean = false; + private _isQuickFixExample: boolean = false; + @Input() - public parentFeatureItemName: string = ''; + public set inspectionExample(value: XmlDocExample | undefined) { + if (value != null) { + this._info.next(value); + this._isInspectionExample = true; + } + } + + public get inspectionExample(): InspectionExample | undefined { + return this._info.value as InspectionExample; + } + + @Input() + public set annotationExample(value: XmlDocExample | undefined) { + if (value != null) { + this._info.next(value); + this._isAnnotationExample = true; + } + } + + public get annotationExample(): AnnotationExample | undefined { + return this._info.value as AnnotationExample; + } @Input() - public set inspectionExample(value: InspectionExampleViewModel | undefined) { + public set quickFixExample(value: XmlDocExample | undefined) { if (value != null) { this._info.next(value); + this._isQuickFixExample = true; } } - public get inspectionExample(): InspectionExampleViewModel | undefined { - return this._info.value; + public get quickFixExample(): QuickFixExample | undefined { + return this._info.value as QuickFixExample; + } + + public get isInspection(): boolean { + return this._isInspectionExample; + } + + public get isAnnotation(): boolean { + return this._isAnnotationExample; + } + public get isQuickFix(): boolean { + return this._isQuickFixExample; } constructor(private fa: FaIconLibrary, private api: ApiClientService) { @@ -39,7 +76,20 @@ export class ExampleBoxComponent implements OnInit { ngOnInit(): void { } -// public showDetails(): void { -// this.modal.open(this.content); -// } + public getModuleIconClass(module: ExampleModule): string { + switch (module.moduleTypeName) { + case "Class Module": + return "icon-class-module"; + case "Document Module": + return "icon-document-module"; + case "Interface Module": + return "icon-interface-module"; + case "Predeclared Class": + return "icon-predeclared-class"; + case "UserForm Module": + return "icon-userform-module"; + default: + return "icon-standard-module"; + } + } } diff --git a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html index 645fff3..a63f3e1 100644 --- a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html +++ b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.html @@ -1,32 +1,16 @@
+
+

{{feature.title}}

+
-
-
-
-

{{feature.title}}

-

- - Details ▸ - -
-
-
+ + Details ▸ + -
-
-

{{feature.title}}

-

-
-
- {{feature.title}} -
- +
+ {{feature.title}}
@@ -39,39 +23,11 @@

{{feature.title}}

Show less ▴
-
-
- -
-
-
-
-

+
+
- - - diff --git a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts index f089d25..7a6eb20 100644 --- a/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts +++ b/rubberduckvba.client/src/app/components/feature-box/feature-box.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnChanges, OnInit, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; -import { Feature, FeatureItem, FeatureViewModel, QuickFixViewModel } from '../../model/feature.model'; +import { FeatureViewModel, QuickFixViewModel, SubFeatureViewModel } from '../../model/feature.model'; import { BehaviorSubject } from 'rxjs'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; @@ -30,19 +30,23 @@ export class FeatureBoxComponent implements OnInit { } public get feature(): FeatureViewModel | undefined { - return this._info.value; + return this._info.value as FeatureViewModel; } - private readonly _quickfixes: BehaviorSubject = new BehaviorSubject(null!); + public get subFeature(): SubFeatureViewModel | undefined { + return this._info.value as SubFeatureViewModel; + } + + private readonly _quickfixes: BehaviorSubject = new BehaviorSubject(null!); @Input() - public set quickFixes(value: FeatureItem[]) { + public set quickFixes(value: QuickFixViewModel[]) { if (value != null) { this._quickfixes.next(value); } } - public get quickFixes(): FeatureItem[] { + public get quickFixes(): QuickFixViewModel[] { return this._quickfixes.value; } diff --git a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html index 7059838..7e4620a 100644 --- a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html +++ b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.html @@ -1,41 +1,74 @@ -
-

{{feature?.title}}

-
-
-
+
+

HomeFeatures

+

HomeFeatures{{feature?.featureName}}

+
+

{{feature?.title}}

-
+
+ +
+
+ +
+
+ +
+
+ +
+
+
-
+ + +
- Rubberduck logo + Rubberduck logo
+ -
-
- +
+
+
+ -
+

Search & Filter

- Showing {{filteredItems.length}} of {{feature!.items.length}} items + Showing {{filteredItems.length}} of {{inspectionItems.length}} items + Showing {{filteredItems.length}} of {{annotationItems.length}} items + Showing {{filteredItems.length}} of {{quickfixItems.length}} items
-
Default Severity
- - - - - +
+
+
Default Severity
+ + + + + +
+
+
+
+
Inspection Type
+ + + + +
+
+
@@ -47,14 +80,30 @@
Search
-
- + +
+
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
diff --git a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts index 0868501..1c51009 100644 --- a/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts +++ b/rubberduckvba.client/src/app/components/feature-info/feature-info.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; -import { Feature, FeatureItem, FeatureItemViewModel, FeatureViewModel, InspectionInfoViewModel, QuickFixViewModel } from '../../model/feature.model'; +import { AnnotationViewModel, AnnotationsFeatureViewModel, BlogLink, FeatureViewModel, InspectionViewModel, InspectionsFeatureViewModel, QuickFixViewModel, QuickFixesFeatureViewModel, SubFeatureViewModel, XmlDocItemViewModel, XmlDocOrFeatureViewModel, XmlDocViewModel } from '../../model/feature.model'; import { BehaviorSubject } from 'rxjs'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; @@ -11,45 +11,61 @@ import { ApiClientService } from '../../services/api-client.service'; }) export class FeatureInfoComponent implements OnInit, OnChanges { - private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); public filterState = { + // searchbox filterText: '', + // inspection severity donotshow: false, hint: true, suggestion: true, warning: true, error: true, + // inspection type + codeQualityIssues: true, + languageOpportunities: true, + namingAndConventionsIssues: true, + rubberduckOpportunities: true }; @Input() - public set feature(value: FeatureViewModel | undefined) { + public set feature(value: XmlDocOrFeatureViewModel | undefined) { if (value != null) { this._info.next(value); this.filterByNameOrDescription(this.filterState.filterText) } } - private _filteredItems: FeatureItemViewModel[] = []; - public get filteredItems(): FeatureItemViewModel[] { + private _filteredItems: XmlDocItemViewModel[] = []; + public get filteredItems(): XmlDocItemViewModel[] { return this._filteredItems; } - public get feature(): FeatureViewModel | undefined { + public get feature(): XmlDocOrFeatureViewModel | undefined { return this._info.value; } - private readonly _quickfixes: BehaviorSubject = new BehaviorSubject(null!); + public get inspectionItems(): InspectionViewModel[] { + return (this.feature as InspectionsFeatureViewModel)?.inspections?.filter(e => !e.isHidden) ?? []; + } - @Input() - public set quickFixes(value: FeatureItem[]) { - if (value != null) { - this._quickfixes.next(value); - } + public get annotationItems(): AnnotationViewModel[] { + return (this.feature as AnnotationsFeatureViewModel)?.annotations?.filter(e => !e.isHidden) ?? []; + } + + public get quickfixItems(): QuickFixViewModel[] { + return (this.feature as QuickFixesFeatureViewModel)?.quickFixes?.filter(e => !e.isHidden) ?? []; } + private readonly _quickfixes: BehaviorSubject = new BehaviorSubject(null!); - public get quickFixes(): FeatureItem[] { - return this._quickfixes.value; + public get subfeatures(): FeatureViewModel[] { + return (this.feature as FeatureViewModel)?.features ?? []; + } + + public get links(): BlogLink[] { + let feature = this.feature; + return feature?.links ?? []; } constructor(private api: ApiClientService, private fa: FaIconLibrary) { @@ -57,9 +73,9 @@ export class FeatureInfoComponent implements OnInit, OnChanges { } ngOnInit(): void { - this.api.getFeature('QuickFixes').subscribe(result => { + this.api.getFeature('quickfixes').subscribe(result => { if (result) { - this._quickfixes.next(result.items.slice()); + this._quickfixes.next((result as QuickFixesFeatureViewModel).quickFixes.slice()); } }); } @@ -74,34 +90,68 @@ export class FeatureInfoComponent implements OnInit, OnChanges { private onSeverityFilter(): void { this._filteredItems = this._filteredItems.filter(item => { - const vm = item.info; + const vm = item; if (vm.isHidden /* !this.filterState.showHiddenStuff? */) { return false; } - if (!this.filterState.donotshow && vm.defaultSeverity == 'DoNotShow') { - return false; - } - if (!this.filterState.hint && vm.defaultSeverity == 'Hint') { - return false; - } - if (!this.filterState.suggestion && vm.defaultSeverity == 'Suggestion') { - return false; + + if (this.feature?.name == 'Inspections') { + if (!this.filterState.donotshow && vm.defaultSeverity == 'DoNotShow') { + return false; + } + if (!this.filterState.hint && vm.defaultSeverity == 'Hint') { + return false; + } + if (!this.filterState.suggestion && vm.defaultSeverity == 'Suggestion') { + return false; + } + if (!this.filterState.warning && vm.defaultSeverity == 'Warning') { + return false; + } + if (!this.filterState.error && vm.defaultSeverity == 'Error') { + return false; + } } - if (!this.filterState.warning && vm.defaultSeverity == 'Warning') { + return true; + }); + } + + private onInspectionTypeFilter(): void { + this._filteredItems = this._filteredItems.filter(item => { + const vm = item; + if (vm.isHidden) { return false; } - if (!this.filterState.error && vm.defaultSeverity == 'Error') { - return false; + + if (this.feature?.name == 'Inspections') { + if (!this.filterState.codeQualityIssues && vm.inspectionType == 'Code Quality Issues') { + return false; + } + if (!this.filterState.languageOpportunities && vm.inspectionType == 'Language Opportunities') { + return false; + } + if (!this.filterState.namingAndConventionsIssues && vm.inspectionType == 'Naming and Convention Issues') { + return false; + } + if (!this.filterState.rubberduckOpportunities && vm.inspectionType == 'Rubberduck Opportunities') { + return false; + } } return true; - }); + }) } private filterByNameOrDescription(filter: string) { const contains = (value: string, filter: string): boolean => value ? value.toLowerCase().indexOf(filter.toLowerCase()) >= 0 : false; - this._filteredItems = this.feature - ? this.feature.items.filter(item => filter === '' + + const features = (this.feature as InspectionsFeatureViewModel).inspections + || (this.feature as QuickFixesFeatureViewModel).quickFixes + || (this.feature as AnnotationsFeatureViewModel).annotations + || (this.feature as FeatureViewModel).features; + + this._filteredItems = features != undefined + ? features.filter(item => filter === '' || contains(item.name, filter) || contains(item.summary, filter) || contains(item.reasoning, filter) @@ -109,6 +159,7 @@ export class FeatureInfoComponent implements OnInit, OnChanges { if (this.feature?.name === 'Inspections') { this.onSeverityFilter(); + this.onInspectionTypeFilter(); } } diff --git a/rubberduckvba.client/src/app/components/feature-item-box/annotation-item-box/annotation-item-box.component.html b/rubberduckvba.client/src/app/components/feature-item-box/annotation-item-box/annotation-item-box.component.html new file mode 100644 index 0000000..6bff154 --- /dev/null +++ b/rubberduckvba.client/src/app/components/feature-item-box/annotation-item-box/annotation-item-box.component.html @@ -0,0 +1,108 @@ +
+
+ +
+
+ + + + diff --git a/rubberduckvba.client/src/app/components/feature-item-box/annotation-item-box/annotation-item-box.component.ts b/rubberduckvba.client/src/app/components/feature-item-box/annotation-item-box/annotation-item-box.component.ts new file mode 100644 index 0000000..d40356a --- /dev/null +++ b/rubberduckvba.client/src/app/components/feature-item-box/annotation-item-box/annotation-item-box.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; +import { AnnotationViewModel, XmlDocItemViewModel } from '../../../model/feature.model'; +import { BehaviorSubject } from 'rxjs'; +import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; +import { fas } from '@fortawesome/free-solid-svg-icons'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'annotation-item-box', + templateUrl: './annotation-item-box.component.html' +}) +export class AnnotationItemBoxComponent implements OnInit, OnChanges { + + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _annotationInfo: BehaviorSubject = new BehaviorSubject(null!); + + @ViewChild('annotationDetails', { read: TemplateRef }) annotationDetails: TemplateRef | undefined; + + constructor(private fa: FaIconLibrary) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + } + + ngOnChanges(changes: SimpleChanges): void { + } + + @ViewChild('content', { read: TemplateRef }) content: TemplateRef | undefined; + public modal = inject(NgbModal); + + @Input() + public set item(value: XmlDocItemViewModel) { + if (value != null) { + this._info.next(value); + + this._annotationInfo.next(value as AnnotationViewModel); + } + } + + public get item(): XmlDocItemViewModel { + return this._info.value; + } + + public showDetailsModal(): void { + console.log(`Showing details for annotation: ${this.annotationInfo.name}`); + this.modal.open(this.annotationDetails, { modalDialogClass: this.annotationInfo.parameters.length > 0 || this.annotationInfo.examples.length > 0 ? 'modal-xl' : 'modal-l' }); + } + + public get annotationInfo(): AnnotationViewModel { + return this._annotationInfo.value; + } +} diff --git a/rubberduckvba.client/src/app/components/feature-item-box/feature-item-box.component.html b/rubberduckvba.client/src/app/components/feature-item-box/feature-item-box.component.html index 82b9d26..3963e94 100644 --- a/rubberduckvba.client/src/app/components/feature-item-box/feature-item-box.component.html +++ b/rubberduckvba.client/src/app/components/feature-item-box/feature-item-box.component.html @@ -1,171 +1,24 @@ -
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-  {{inspectionInfo.InspectionType}} -
-
-
-
-
-
-
-
-
- Default Severity: {{inspectionInfo.DefaultSeverity}} -

{{severityTitle}}

-
-
-
-
-
-
-
-

 This inspection only runs when hosted in {{inspectionInfo.HostApp}}.

-
-
-
-
-
-

 This inspection only runs when the {{inspectionInfo.References[0]}} library is referenced.

-
-
-
-
-
-

 This inspection only runs when any of the following libraries is referenced:

-
    -
  • {{library}}
  • -
-
-
-
-
-
-
-
-
-  QuickFixes -
-

This inspection offers the following fixes:

- -
-
-
-
-
-
-
-
-
-
Reasoning
-

-
-
-
-
-
-
-
-
-
Remarks
-

-
-
-
-
-
-
-
- -
-
+
+
Discontinued
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-

View | Edit this content on  GitHub

+
+  {{item.tagName}}
+
diff --git a/rubberduckvba.client/src/app/components/feature-item-box/feature-item-box.component.ts b/rubberduckvba.client/src/app/components/feature-item-box/feature-item-box.component.ts index 82c98ef..dd10e12 100644 --- a/rubberduckvba.client/src/app/components/feature-item-box/feature-item-box.component.ts +++ b/rubberduckvba.client/src/app/components/feature-item-box/feature-item-box.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnChanges, OnInit, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; -import { AnnotationFeatureItem, AnnotationInfo, FeatureItem, FeatureItemViewModel, InspectionInfo, QuickFixInfo, QuickFixViewModel } from '../../model/feature.model'; +import { AnnotationViewModel, InspectionViewModel, QuickFixViewModel, XmlDocItemViewModel } from '../../model/feature.model'; import { BehaviorSubject } from 'rxjs'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; @@ -11,13 +11,15 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; }) export class FeatureItemBoxComponent implements OnInit, OnChanges { - private readonly _info: BehaviorSubject = new BehaviorSubject(null!); - private readonly _inspectionInfo: BehaviorSubject = new BehaviorSubject(null!); - private readonly _quickfixInfo: BehaviorSubject = new BehaviorSubject(null!); - private readonly _annotationInfo: BehaviorSubject = new BehaviorSubject(null!); + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _inspectionInfo: BehaviorSubject = new BehaviorSubject(null!); + private readonly _quickfixInfo: BehaviorSubject = new BehaviorSubject(null!); + private readonly _annotationInfo: BehaviorSubject = new BehaviorSubject(null!); - private readonly _quickfixes: BehaviorSubject = new BehaviorSubject(null!); - private _quickfixMap: Map = null!; + private readonly _quickfixes: BehaviorSubject = new BehaviorSubject(null!); + private _quickfixMap: Map = null!; + + public readonly _iconClass: BehaviorSubject = new BehaviorSubject(null!); constructor(private fa: FaIconLibrary) { fa.addIconPacks(fas); @@ -42,46 +44,44 @@ export class FeatureItemBoxComponent implements OnInit, OnChanges { } } - private getQuickFixItem(item: FeatureItem): FeatureItem { + private getQuickFixItem(item: QuickFixViewModel): QuickFixViewModel { item.title = item.name.replace('QuickFix', ''); return item; } - public get quickFixes(): FeatureItem[] { + public get quickFixes(): QuickFixViewModel[] { return this._quickfixes.value; } @Input() - public set item(value: FeatureItemViewModel) { + public set item(value: XmlDocItemViewModel) { if (value != null) { this._info.next(value); if (value.featureName == 'Inspections') { - this._inspectionInfo.next(JSON.parse(value.serialized)); + this._inspectionInfo.next(value as InspectionViewModel); + this._iconClass.next(`icon icon-severity-${this.inspectionInfo?.defaultSeverity.toLowerCase()}`); this.isInspectionInfo = true; - if (this.item.isNew) { - console.log(this.item); - } } if (value.featureName == 'QuickFixes') { - this._quickfixInfo.next(value.info as QuickFixInfo); + this._quickfixInfo.next(value as QuickFixViewModel); this.isQuickfixInfo = true; } if (value.featureName == 'Annotations') { - this._annotationInfo.next(value.info as AnnotationInfo); + this._annotationInfo.next(value as AnnotationViewModel); this.isAnnotationInfo = true; } } } - public get item(): FeatureItemViewModel { + public get item(): XmlDocItemViewModel { return this._info.value; } - public getQuickFix(name: string): FeatureItem { + public getQuickFix(name: string): QuickFixViewModel { if (this._quickfixMap) { return this._quickfixMap.get(name)!; } @@ -89,7 +89,7 @@ export class FeatureItemBoxComponent implements OnInit, OnChanges { } public showQuickFixModal(name: string): void { - this.quickFixVM = new QuickFixViewModel(this.getQuickFix(name)!); + this.quickFixVM = this.getQuickFix(name); this.modal.open(this.content) } @@ -98,7 +98,7 @@ export class FeatureItemBoxComponent implements OnInit, OnChanges { } isInspectionInfo: boolean = false; - public get inspectionInfo(): InspectionInfo | undefined { + public get inspectionInfo(): InspectionViewModel | undefined { if (!this.isInspectionInfo) { return undefined; } @@ -106,7 +106,7 @@ export class FeatureItemBoxComponent implements OnInit, OnChanges { } public get severityTitle(): string { - switch (this.inspectionInfo?.DefaultSeverity) { + switch (this.inspectionInfo?.defaultSeverity) { case 'DoNotShow': return 'Inspections at this severity level are disabled until/unless configured differently.'; case 'Hint': @@ -122,8 +122,12 @@ export class FeatureItemBoxComponent implements OnInit, OnChanges { } } + public get severityIconClass(): string { + return this._iconClass.value; + } + isQuickfixInfo: boolean = false; - public get quickfixInfo(): QuickFixInfo | undefined { + public get quickfixInfo(): QuickFixViewModel | undefined { if (!this.isQuickfixInfo) { return undefined; } @@ -131,7 +135,7 @@ export class FeatureItemBoxComponent implements OnInit, OnChanges { } isAnnotationInfo: boolean = false; - public get annotationInfo(): AnnotationInfo | undefined { + public get annotationInfo(): AnnotationViewModel | undefined { if (!this.isAnnotationInfo) { return undefined; } diff --git a/rubberduckvba.client/src/app/components/feature-item-box/inspection-item-box/inspection-item-box.component.html b/rubberduckvba.client/src/app/components/feature-item-box/inspection-item-box/inspection-item-box.component.html new file mode 100644 index 0000000..b9abb1e --- /dev/null +++ b/rubberduckvba.client/src/app/components/feature-item-box/inspection-item-box/inspection-item-box.component.html @@ -0,0 +1,138 @@ +
+
+ +
+
+ + + + + + diff --git a/rubberduckvba.client/src/app/components/feature-item-box/inspection-item-box/inspection-item-box.component.ts b/rubberduckvba.client/src/app/components/feature-item-box/inspection-item-box/inspection-item-box.component.ts new file mode 100644 index 0000000..50a4fb5 --- /dev/null +++ b/rubberduckvba.client/src/app/components/feature-item-box/inspection-item-box/inspection-item-box.component.ts @@ -0,0 +1,99 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; +import { AnnotationViewModel, InspectionViewModel, QuickFixViewModel, XmlDocItemViewModel } from '../../../model/feature.model'; +import { BehaviorSubject } from 'rxjs'; +import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; +import { fas } from '@fortawesome/free-solid-svg-icons'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ApiClientService } from '../../../services/api-client.service'; + +@Component({ + selector: 'inspection-item-box', + templateUrl: './inspection-item-box.component.html' +}) +export class InspectionItemBoxComponent implements OnInit, OnChanges { + + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _inspectionInfo: BehaviorSubject = new BehaviorSubject(null!); + + private quickFixVM: QuickFixViewModel = null!; + + constructor(private fa: FaIconLibrary, private api: ApiClientService) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + } + + ngOnChanges(changes: SimpleChanges): void { + } + + @ViewChild('inspectionDetails', { read: TemplateRef }) inspectionDetails: TemplateRef | undefined; + + @ViewChild('content', { read: TemplateRef }) content: TemplateRef | undefined; + public modal = inject(NgbModal); + + @Input() + public set item(value: XmlDocItemViewModel) { + if (value != null) { + this._info.next(value); + this._inspectionInfo.next(value as InspectionViewModel); + } + } + + public get item(): XmlDocItemViewModel { + return this._info.value; + } + + public showDetailsModal(): void { + this.api.getInspection(this.inspectionInfo.name).subscribe((inspection: InspectionViewModel) => { + this._inspectionInfo.next(inspection); + this.modal.open(this.inspectionDetails, { modalDialogClass: 'modal-xl'}); + }); + } + + public showQuickFixModal(name: string): void { + this.quickFixVM = this.inspectionInfo.quickFixes.find(e => e.title == name)!; + this.modal.open(this.content) + } + + isInspectionInfo: boolean = false; + public get inspectionInfo(): InspectionViewModel { + return this._inspectionInfo.value; + } + + public get severityIconClass(): string { + return `icon icon-severity-${this.inspectionInfo.defaultSeverity.toLowerCase()}`; + } + + public get severityTitle(): string { + switch (this.inspectionInfo?.defaultSeverity) { + case 'DoNotShow': + return 'Inspections at this severity level are disabled until/unless configured differently.'; + case 'Hint': + return 'Inspections at this severity level are merely making an observation about the code.'; + case 'Suggestion': + return 'Inspections at this severity level are making an actionnable observation about the code.'; + case 'Warning': + return 'Inspections at this severity level are flagging a potential issue that is usually more serious than a simple observation.'; + case 'Error': + return 'Inspections at this severity level are flagging a potential bug, or a possible run-time or compile-time error.'; + default: + return ''; + } + } + + public get inspectionTypeTitle(): string { + switch (this.inspectionInfo?.inspectionType) { + case 'Code Quality Issues': + return 'Inspections of this type indicate a problem (real or potential) with the code.'; + case 'Naming and Convention Issues': + return 'Inspections of this type point out issues that arise out of naming style and programming conventions.'; + case 'Language Opportunities': + return 'Inspections of this type indicate a probable misuse of a language feature that exists for backward compatibility.'; + case 'Rubberduck Opportunities': + return 'Inspections of this type relate specifically to Rubberduck features.'; + default: + return 'Uncategorized, I guess.'; + } + } +} diff --git a/rubberduckvba.client/src/app/components/feature-item-box/quickfix-item-box/quickfix-item-box.component.html b/rubberduckvba.client/src/app/components/feature-item-box/quickfix-item-box/quickfix-item-box.component.html new file mode 100644 index 0000000..e8bc387 --- /dev/null +++ b/rubberduckvba.client/src/app/components/feature-item-box/quickfix-item-box/quickfix-item-box.component.html @@ -0,0 +1,144 @@ +
+
+ +
+
+ + + + + diff --git a/rubberduckvba.client/src/app/components/feature-item-box/quickfix-item-box/quickfix-item-box.component.ts b/rubberduckvba.client/src/app/components/feature-item-box/quickfix-item-box/quickfix-item-box.component.ts new file mode 100644 index 0000000..de64884 --- /dev/null +++ b/rubberduckvba.client/src/app/components/feature-item-box/quickfix-item-box/quickfix-item-box.component.ts @@ -0,0 +1,53 @@ +import { Component, OnInit, OnChanges, ViewChild, TemplateRef, SimpleChanges, inject, Input } from "@angular/core"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { BehaviorSubject } from "rxjs"; +import { XmlDocItemViewModel, QuickFixViewModel, QuickFixViewModelClass } from "../../../model/feature.model"; + +@Component({ + selector: 'quickfix-item-box', + templateUrl: './quickfix-item-box.component.html' +}) +export class QuickFixItemBoxComponent implements OnInit, OnChanges { + + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + private readonly _quickFixInfo: BehaviorSubject = new BehaviorSubject(null!); + + @ViewChild('quickFixDetails', { read: TemplateRef }) QuickFixDetails: TemplateRef | undefined; + + constructor(private fa: FaIconLibrary) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + } + + ngOnChanges(changes: SimpleChanges): void { + } + + @ViewChild('content', { read: TemplateRef }) content: TemplateRef | undefined; + public modal = inject(NgbModal); + + @Input() + public set item(value: XmlDocItemViewModel) { + if (value != null) { + this._info.next(value); + + this._quickFixInfo.next(value as QuickFixViewModelClass); + } + } + + public get item(): XmlDocItemViewModel { + return this._info.value; + } + + public showDetailsModal(): void { + console.log(`Showing details for QuickFix: ${this.quickFixInfo.name}`); + this.modal.open(this.QuickFixDetails, { modalDialogClass: 'modal-xl' }); + } + + public get quickFixInfo(): QuickFixViewModelClass { + return this._quickFixInfo.value; + } +} diff --git a/rubberduckvba.client/src/app/components/loading-content/loading-content.component.html b/rubberduckvba.client/src/app/components/loading-content/loading-content.component.html index aa5d462..319c6ec 100644 --- a/rubberduckvba.client/src/app/components/loading-content/loading-content.component.html +++ b/rubberduckvba.client/src/app/components/loading-content/loading-content.component.html @@ -3,6 +3,6 @@ Rubberduck logo
-

loading...

+

{{label}}

diff --git a/rubberduckvba.client/src/app/components/loading-content/loading-content.component.ts b/rubberduckvba.client/src/app/components/loading-content/loading-content.component.ts index 84d0f17..6e20f5a 100644 --- a/rubberduckvba.client/src/app/components/loading-content/loading-content.component.ts +++ b/rubberduckvba.client/src/app/components/loading-content/loading-content.component.ts @@ -1,11 +1,4 @@ -import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChange, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; -import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; -import { fas } from '@fortawesome/free-solid-svg-icons'; -import { fab } from '@fortawesome/free-brands-svg-icons'; -import { Tag, TagDownloadInfo } from '../../model/tags.model'; -import { BehaviorSubject } from 'rxjs'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { AngularDeviceInformationService } from 'angular-device-information'; +import { Component, Input } from '@angular/core'; @Component({ //standalone: true, @@ -14,4 +7,5 @@ import { AngularDeviceInformationService } from 'angular-device-information'; }) export class LoadingContentComponent { @Input() public show: boolean = true; + @Input() public label: string = 'loading...'; } diff --git a/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.html b/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.html index 616dc96..c63520a 100644 --- a/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.html +++ b/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.html @@ -1,63 +1,73 @@
- +
+
+
+
+ + Windows desktop only :) + +
+
+ + +
+ + + + +
+
+
+ + + + + +
+
+ diff --git a/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.ts b/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.ts index 977f44f..b88cba2 100644 --- a/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.ts +++ b/rubberduckvba.client/src/app/components/nav-menu/nav-menu.component.ts @@ -1,4 +1,4 @@ -import { Component, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ApiClientService } from '../../services/api-client.service'; import { DownloadInfo } from '../../model/downloads.model'; import { fab } from '@fortawesome/free-brands-svg-icons'; @@ -18,6 +18,8 @@ export class NavMenuComponent implements OnInit { public isHomePage: boolean = true; public isFeaturesPage: boolean = false; + public isAboutPage: boolean = false; + public isIndenterPage: boolean = false; public isExpanded: boolean = false; public canDownload: boolean = false; @@ -33,14 +35,11 @@ export class NavMenuComponent implements OnInit { map(e => e) ) .subscribe(navEnd => { - if (navEnd.urlAfterRedirects.startsWith('/features')) { - this.isFeaturesPage = true; - this.isHomePage = false; - } - else { - this.isFeaturesPage = false; - this.isHomePage = true; - } + const url = navEnd.urlAfterRedirects; + this.isFeaturesPage = url.startsWith('/features'); + this.isAboutPage = url.startsWith('/about'); + this.isIndenterPage = url.startsWith('/indenter'); + this.isHomePage = !(this.isFeaturesPage || this.isAboutPage || this.isIndenterPage); }); } diff --git a/rubberduckvba.client/src/app/components/quickfix-example.modal/quickfix-example.modal.component.ts b/rubberduckvba.client/src/app/components/quickfix-example.modal/quickfix-example.modal.component.ts index 801f2bb..9518514 100644 --- a/rubberduckvba.client/src/app/components/quickfix-example.modal/quickfix-example.modal.component.ts +++ b/rubberduckvba.client/src/app/components/quickfix-example.modal/quickfix-example.modal.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnChanges, OnInit, SimpleChanges, TemplateRef, ViewChild, inject } from '@angular/core'; -import { AnnotationFeatureItem, AnnotationInfo, FeatureItem, FeatureItemViewModel, InspectionInfo, QuickFixInfo, QuickFixViewModel } from '../../model/feature.model'; +import { QuickFixViewModel } from '../../model/feature.model'; import { BehaviorSubject } from 'rxjs'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; @@ -11,7 +11,7 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; }) export class FeatureItemExampleComponent implements OnInit, OnChanges { - private readonly _quickfix: BehaviorSubject = new BehaviorSubject(null!); + private readonly _quickfix: BehaviorSubject = new BehaviorSubject(null!); constructor(private fa: FaIconLibrary) { fa.addIconPacks(fas); @@ -29,13 +29,13 @@ export class FeatureItemExampleComponent implements OnInit, OnChanges { @Input() - public set quickFix(value: FeatureItem) { + public set quickFix(value: QuickFixViewModel) { if (value != null) { this._quickfix.next(value); } } - public get quickFix(): FeatureItem { + public get quickFix(): QuickFixViewModel { return this._quickfix.value!; } diff --git a/rubberduckvba.client/src/app/components/tag-download/tag-download.component.html b/rubberduckvba.client/src/app/components/tag-download/tag-download.component.html index cc2ac11..bfdc428 100644 --- a/rubberduckvba.client/src/app/components/tag-download/tag-download.component.html +++ b/rubberduckvba.client/src/app/components/tag-download/tag-download.component.html @@ -10,11 +10,12 @@

{{text}}
- -
diff --git a/rubberduckvba.client/src/app/model/feature.model.ts b/rubberduckvba.client/src/app/model/feature.model.ts index 2f654f7..97a0534 100644 --- a/rubberduckvba.client/src/app/model/feature.model.ts +++ b/rubberduckvba.client/src/app/model/feature.model.ts @@ -1,415 +1,432 @@ -export interface FeatureSummary { - /** - * The internal name of the feature. - */ +export interface ViewModel { + id: number; + dateInserted: string; + dateUpdated: string; name: string; - /** - * The display name of the feature. - */ - title: string; - /** - * A short (1-2 sentences) summary description of the feature. - */ - shortDescription: string; - /** - * Whether the feature should have a 'new!' marker, indicating it is currently only available in pre-release builds. - */ isNew: boolean; + isHidden: boolean; - hasImage: boolean; + isCollapsed: boolean; + isDetailsCollapsed: boolean; } -export interface XmlDocExampleModule { - sortOrder: number; - moduleName: string; - moduleType: number; // TODO enum - htmlContent: string; - properties: string; // JSON string ~> TODO deserialize -} +export interface SubFeatureViewModel extends ViewModel { + featureId?: number; + featureName?: string; + featureTitle?: string; -export interface XmlDocExample { - sortOrder: number; - properties: string; // JSON string ~> TODO deserialize - modules: XmlDocExampleModule[]; + title: string; + description: string; } -export interface FeatureItem { - id: number; - dateTimeInserted: Date; - dateTimeUpdated: Date | null; - - featureId: number; - featureName: string; - featureTitle: string; +export interface XmlDocViewModel extends SubFeatureViewModel { + tagAssetId: number; + tagName: string; + sourceUrl: string; + isDiscontinued: boolean; +} - name: string; - title: string; - summary: string; +export interface FeatureViewModel extends SubFeatureViewModel { + shortDescription: string; - isNew: boolean; - isDiscontinued: boolean; - isHidden: boolean; + hasImage: boolean; - tagAssetId: number; - sourceUrl: string; - serialized: string; + features: FeatureViewModel[]; + links: BlogLink[]; } -export interface InspectionInfo { - Reasoning: string; - Summary: string; - Remarks: string; - HostApp: string; - DefaultSeverity: string; - InspectionType: string; - QuickFixes: string[]; - References: string[]; - Examples: any[]; +export interface BlogLink { + name: string; + url: string; + author: string; + published: string; } -export class InspectionInfoViewModel { - id: number; - dateTimeInserted: Date; - dateTimeUpdated: Date | null; - featureId: number; - featureName: string; - featureTitle: string; +export class BlogLinkViewModelClass implements BlogLink { name: string; - title: string; - summary: string; - isNew: boolean; - isDiscontinued: boolean; - isHidden: boolean; - tagAssetId: number; - sourceUrl: string; - examples: InspectionExampleViewModel[] = []; - reasoning: string = ''; - remarks: string = ''; - defaultSeverity: string = ''; - inspectionType: string = ''; - hostApp: string = ''; - references: string[] = []; - quickFixes: string[] = []; - - constructor(model: any) { - this.id = model.id; - this.dateTimeInserted = model.dateTimeInserted; - this.dateTimeUpdated = model.dateTimeUpdated; - this.featureId = model.featureId; - this.featureName = model.featureName; - this.featureTitle = model.featureTitle; - this.name = model.Name; - this.title = model.Title; - this.summary = model.Summary; - this.isNew = model.IsNew; - this.isDiscontinued = model.IsDiscontinued; - this.isHidden = model.IsHidden; - this.tagAssetId = model.TagAssetId; - this.sourceUrl = model.SourceUrl; - - const info = JSON.parse(model.serialized); - if (info) { - this.reasoning = info.Reasoning; - this.remarks = info.Remarks; - this.defaultSeverity = info.DefaultSeverity; - this.inspectionType = info.InspectionType; - this.hostApp = info.HostApp; - this.references = info.References; - this.quickFixes = info.QuickFixes; - - if (info.Examples) { - this.examples = info.Examples.map((e: any) => new InspectionExampleViewModel(e)); - } - } + url: string; + author: string; + published: string; + + constructor(model: BlogLink) { + this.name = model.name; + this.url = model.url; + this.author = model.author; + this.published = model.published; } } -export class InspectionExampleViewModel { - hasResult: boolean; +export interface Example { + description: string; sortOrder: number; - modules: ExampleModuleViewModel[]; - isCollapsed: boolean = false; + isCollapsed: boolean | undefined; +} - constructor(model: any) { - this.sortOrder = model.SortOrder; - this.hasResult = model.Properties != undefined && (JSON.parse(model.Properties).HasResult == 'True'); - this.modules = model.Modules.map((module: any) => new ExampleModuleViewModel(module)); - this.isCollapsed = this.sortOrder > 1; - } +export interface BeforeAfterExample extends Example { + modulesBefore: ExampleModule[]; + modulesAfter: ExampleModule[]; } -export class ExampleModuleViewModel { - sortOrder: number; +export interface ExampleModule { moduleName: string; - moduleType: number; moduleTypeName: string; htmlContent: string; + description: string; +} - constructor(model: any) { - this.sortOrder = model.SortOrder; - this.htmlContent = model.HtmlContent; - this.moduleName = model.ModuleName; - this.moduleType = model.ModuleType; - this.moduleTypeName = model.ModuleTypeName; - } - - public get moduleTypeIconClass(): string { - switch (this.moduleType) { - case 2: - return 'icon-module-class'; - case 3: - return 'icon-module-document'; - case 4: - return 'icon-module-interface'; - case 5: - return 'icon-module-predeclared'; - case 6: - return 'icon-module-standard'; - case 7: - return 'icon-module-userform'; - default: - return 'icon-project'; - } - } +export interface InspectionExample extends Example { + hasResult: boolean; + modules: ExampleModule[]; } -export class QuickFixExampleViewModel { - isBefore: boolean; - sortOrder: number; - modules: ExampleModuleViewModel[]; +export interface QuickFixExample extends BeforeAfterExample { - constructor(model: any) { - console.log(model); - this.sortOrder = model.sortOrder; - this.isBefore = JSON.parse(model.properties).isBefore === true; - this.modules = model.modules.map((module: any) => new ExampleModuleViewModel(module)); - } } -export interface QuickFixInfo { - Remarks: string; - CanFixInProcedure: boolean; - CanFixInModule: boolean; - CanFixInProject: boolean; - Inspections: string[]; - Examples: any[]; +export interface AnnotationExample extends BeforeAfterExample { + modules: ExampleModule[]; } -export class QuickFixViewModel { - id: number; - dateTimeInserted: Date; - dateTimeUpdated: Date | null; - featureId: number; - featureName: string; - featureTitle: string; +export type XmlDocExample = InspectionExample | QuickFixExample | AnnotationExample; + +export interface AnnotationParameter { name: string; - title: string; - summary: string; - isNew: boolean; - isDiscontinued: boolean; - isHidden: boolean; - tagAssetId: number; - sourceUrl: string; + type: string; + required: boolean; + description: string; +} - examples: QuickFixExampleViewModel[]; +export interface InspectionViewModel extends XmlDocViewModel { + inspectionType: string; + defaultSeverity: string; - remarks: string; - canFixInProcedure: boolean; - canFixInModule: boolean; - canFixInProject: boolean; - inspections: string[]; + summary: string; + reasoning: string; - constructor(model: FeatureItem) { - const quickfix: QuickFixInfo = JSON.parse(model.serialized); + remarks?: string; + hostApp?: string; - this.id = model.id; - this.dateTimeInserted = model.dateTimeInserted; - this.dateTimeUpdated = model.dateTimeUpdated; - this.featureId = model.featureId; - this.featureName = model.featureName; - this.featureTitle = model.featureTitle; - this.name = model.name; - this.title = model.title; - this.summary = model.summary; - this.isNew = model.isNew; - this.isDiscontinued = model.isDiscontinued; - this.isHidden = model.isHidden; - this.tagAssetId = model.tagAssetId; - this.sourceUrl = model.sourceUrl; + references: string[]; + quickFixes: QuickFixViewModel[]; - this.remarks = quickfix.Remarks; - this.canFixInProcedure = quickfix.CanFixInProcedure; - this.canFixInModule = quickfix.CanFixInModule; - this.canFixInProject = quickfix.CanFixInProject; - this.inspections = quickfix.Inspections; + examples: InspectionExample[]; - console.log(model.serialized); - console.log(quickfix); - this.examples = quickfix.Examples.map(e => new QuickFixExampleViewModel(e)); - } + getGitHubViewLink(): string; + getGitHubEditLink(): string; } -export interface AnnotationInfo { - parameters: AnnotationParameterInfo[]; +export interface InspectionsFeatureViewModel extends SubFeatureViewModel { + inspections: InspectionViewModel[]; } -export interface AnnotationParameterInfo { - name: string; - type: string; - description: string; +export interface QuickFixesFeatureViewModel extends SubFeatureViewModel { + quickFixes: QuickFixViewModel[]; } -export interface AnnotationFeatureItem extends FeatureItem { - parameters: AnnotationParameterInfo[]; +export interface AnnotationsFeatureViewModel extends SubFeatureViewModel { + annotations: AnnotationViewModel[]; } -export interface Feature extends FeatureSummary { - /** - * A markdown document with the user documentation of the feature. - */ - description: string; +export type XmlDocOrFeatureViewModel = SubFeatureViewModel | InspectionsFeatureViewModel | QuickFixesFeatureViewModel | AnnotationsFeatureViewModel; - /** - * Summaries of child features, if any. - */ - features: Feature[]; - items: FeatureItem[]; +export interface QuickFixInspectionLinkViewModel { + name: string; + summary: string; + inspectionType: string; + defaultSeverity: string; +} - inspections: InspectionInfo[]; +export class QuickFixInspectionLinkViewModelClass implements QuickFixInspectionLinkViewModel { + name: string; + summary: string; + inspectionType: string; + defaultSeverity: string; - isHidden: boolean; - sortOrder: number; + constructor(model: QuickFixInspectionLinkViewModel) { + this.name = model.name; + this.summary = model.summary; + this.inspectionType = model.inspectionType; + this.defaultSeverity = model.defaultSeverity; + } + + public get getSeverityIconClass(): string { + return `icon icon-severity-${this.defaultSeverity.toLowerCase()}`; + } } -export interface PaginatedFeature { - feature: Feature; - pagination: unknown; +export interface QuickFixViewModel extends XmlDocViewModel { + summary: string; + remarks?: string; + canFixMultiple: boolean; + canFixProcedure: boolean; + canFixModule: boolean; + canFixProject: boolean; + canFixAll: boolean; + + inspections: QuickFixInspectionLinkViewModel[]; + examples: QuickFixExample[]; + + getGitHubViewLink(): string; + getGitHubEditLink(): string; } -export class FeatureViewModel { - parentId: number|null = null; +export interface AnnotationViewModel extends XmlDocViewModel { + summary: string; + remarks?: string; - description: string; - features: FeatureViewModel[] = []; + parameters: AnnotationParameter[]; + examples: AnnotationExample[]; - inspections: InspectionInfo[] = []; + getGitHubViewLink(): string; + getGitHubEditLink(): string; +} - items: FeatureItemViewModel[] = []; - isHidden: boolean; - sortOrder: number; +export type XmlDocItemViewModel = InspectionViewModel | QuickFixViewModel | AnnotationViewModel; + +export class ViewModelBase implements ViewModel { + id: number; + dateInserted: string; + dateUpdated: string; name: string; - title: string; - shortDescription: string; isNew: boolean; - hasImage: boolean; - + isHidden: boolean; isCollapsed: boolean; isDetailsCollapsed: boolean; - constructor(model: any) { - this.description = model.description; - this.isHidden = model.isHidden; - this.sortOrder = model.sortOrder; - - if (model.features) { - this.features = model.features.map((e: any) => new FeatureViewModel(e)); - } - if (model.inspections) { - this.inspections = model.inspections.map((e: any) => new InspectionInfoViewModel(e)); - } - if (model.items) { - this.items = model.items.map((e: any) => new FeatureItemViewModel(e)); - } - + constructor(model: ViewModel) { + this.id = model.id; + this.dateInserted = model.dateInserted; + this.dateUpdated = model.dateUpdated; this.name = model.name; + this.isNew = model.isNew; + this.isHidden = model.isHidden; + + this.isCollapsed = model.isCollapsed; + this.isDetailsCollapsed = true; + } + + protected depascalize(name: string): string { + const lWords = ["The", "Is", "As", "Of", "In", "On", "Not"]; + const uWords = ["Udf", "Id"]; + const nsWords = ["By Ref", "By Val", "Def Type", "I If", "U D T", "UD T", "Is Missing", "Param Array", "Predeclared ID"]; + + const words = name.split(/(?=[A-Z])/) + + .map(e => uWords.find(w => w == e) ? e.toUpperCase() : lWords.find(w => w == e) ? e.toLowerCase() : e); + + let depascalized = words.join(' '); + + nsWords.forEach(w => { + depascalized = depascalized.replace(w, w.replace(' ', '')); + }); + + depascalized = depascalized.replace("is Missing", "IsMissing"); + let [first, ...rest] = depascalized; + return first.toUpperCase() + rest.join(''); + } +} + +export class FeatureViewModelClass extends ViewModelBase { + featureId?: number; + featureName?: string; + featureTitle?: string; + + title: string; + description: string; + + shortDescription: string; + + hasImage: boolean; + + features: FeatureViewModel[]; + links: BlogLink[]; + + constructor(model: FeatureViewModel) { + super(model); this.title = model.title; + this.description = model.description; this.shortDescription = model.shortDescription; - this.isNew = model.isNew; this.hasImage = model.hasImage; + this.features = model.features.map(e => new FeatureViewModelClass(e)); + this.links = model.links?.map(e => new BlogLinkViewModelClass(e)) ?? []; - this.isCollapsed = !this.hasImage || this.items.length > 0; - this.isDetailsCollapsed = true; + this.isCollapsed = !model.hasImage; } } -export class FeatureItemViewModel { - id: number; - dateInserted: Date; - dateUpdated: Date | undefined; - featureId: number; - featureName: string; - featureTitle: string; - name: string; +export class SubFeatureViewModelClass extends ViewModelBase implements SubFeatureViewModel { + featureId?: number | undefined; + featureName?: string | undefined; + featureTitle?: string | undefined; title: string; + description: string; + + constructor(model: SubFeatureViewModel) { + super(model); + this.title = model.title; + this.description = model.description; + this.isDetailsCollapsed = true; + this.featureId = model.featureId; + this.featureName = model.featureName; + } +} + +export class InspectionViewModelClass extends SubFeatureViewModelClass implements InspectionViewModel { + inspectionType: string; + defaultSeverity: string; summary: string; reasoning: string; - remarks: string; - isNew: boolean; - isDiscontinued: boolean; - isHidden: boolean; + remarks?: string | undefined; + hostApp?: string | undefined; + references: string[]; + quickFixes: QuickFixViewModel[]; + examples: InspectionExample[]; tagAssetId: number; - sourceUrl: string; - serialized: string; tagName: string; + sourceUrl: string; + isDiscontinued: boolean; - isCollapsed: boolean; - isDetailsCollapsed: boolean; + public getGitHubViewLink(): string { + return `https://github.com/rubberduck-vba/Rubberduck/blob/next/${this.sourceUrl}.cs` + } - info: any; // QuickFixInfo | AnnotationInfo | InspectionInfo; - examples: any[]; - - constructor(model: any) { - const info: any = JSON.parse(model.serialized); - info.Id = model.id; - info.FeatureName = model.featureName; - info.FeatureTitle = model.featureTitle; - - if (model.featureName == 'Inspections') { - this.info = new InspectionInfoViewModel(model); - } - else if (model.featureName == 'QuickFixes') { - //this.info = new QuickFixInfoViewModel(JSON.parse(model.serialized)); - } - else if (model.featureName == 'Annotations') { - //this.info = new AnnotationInfoViewModel(JSON.parse(model.serialized)); - } + public getGitHubEditLink(): string { + return `https://github.com/rubberduck-vba/Rubberduck/edit/next/${this.sourceUrl}.cs`; + } - this.id = model.id; - this.dateInserted = model.dateTimeInserted; - this.dateUpdated = model.dateTimeUpdated; - this.featureId = model.featureId; - this.featureName = model.featureName; - this.featureTitle = model.featureTitle; + constructor(model: InspectionViewModel) { + super(model); this.name = model.name; - this.title = model.title; + this.title = this.depascalize(model.name); + + this.inspectionType = model.inspectionType; + this.defaultSeverity = model.defaultSeverity; this.summary = model.summary; + this.reasoning = model.reasoning; + this.remarks = model.remarks; + this.hostApp = model.hostApp; + this.references = model.references; - this.reasoning = this.info.Reasoning; - this.remarks = this.info.Remarks; + this.quickFixes = model.quickFixes; + this.examples = model.examples; - this.isNew = model.isNew; + this.tagAssetId = model.tagAssetId; + this.tagName = model.tagName; + this.sourceUrl = model.sourceUrl; this.isDiscontinued = model.isDiscontinued; - this.isHidden = model.isHidden; + } +} + +export class QuickFixViewModelClass extends SubFeatureViewModelClass implements QuickFixViewModel { + summary: string; + remarks?: string | undefined; + canFixMultiple: boolean; + canFixProcedure: boolean; + canFixModule: boolean; + canFixProject: boolean; + canFixAll: boolean; + inspections: QuickFixInspectionLinkViewModelClass[]; + examples: QuickFixExample[]; + tagAssetId: number; + tagName: string; + sourceUrl: string; + isDiscontinued: boolean; + + public getGitHubViewLink(): string { + return `https://github.com/rubberduck-vba/Rubberduck/blob/next/${this.sourceUrl}.cs` + } + + public getGitHubEditLink(): string { + return `https://github.com/rubberduck-vba/Rubberduck/edit/next/${this.sourceUrl}.cs`; + } + constructor(model: QuickFixViewModel) { + super(model); + this.title = this.depascalize(model.name.replace('QuickFix','')); + + this.summary = model.summary; + this.remarks = model.remarks; + + this.examples = model.examples; + this.inspections = model.inspections.map(e => new QuickFixInspectionLinkViewModelClass(e)); this.tagAssetId = model.tagAssetId; this.tagName = model.tagName; this.sourceUrl = model.sourceUrl; - this.serialized = model.serialized; + this.isDiscontinued = model.isDiscontinued; - this.examples = this.info.examples; + this.canFixMultiple = model.canFixMultiple; + this.canFixAll = model.canFixAll; + this.canFixProject = model.canFixProject; + this.canFixModule = model.canFixModule; + this.canFixProcedure = model.canFixProcedure; + } +} - this.isCollapsed = true; - this.isDetailsCollapsed = true; +export class AnnotationViewModelClass extends SubFeatureViewModelClass implements AnnotationViewModel { + summary: string; + remarks?: string | undefined; + examples: AnnotationExample[]; + tagAssetId: number; + tagName: string; + sourceUrl: string; + isDiscontinued: boolean; + + parameters: AnnotationParameter[]; + + public getGitHubViewLink(): string { + return `https://github.com/rubberduck-vba/Rubberduck/blob/next/${this.sourceUrl}.cs` } public getGitHubEditLink(): string { - const url = this.sourceUrl; - return `https://github.com/rubberduck-vba/Rubberduck/edit/next/${url}.cs`; + return `https://github.com/rubberduck-vba/Rubberduck/edit/next/${this.sourceUrl}.cs`; } - public getGitHubViewLink(): string { - const url = this.sourceUrl; - return `https://github.com/rubberduck-vba/Rubberduck/tree/next/${url}.cs`; + constructor(model: AnnotationViewModel) { + super(model); + this.title = this.depascalize(model.name.replace('Annotation', '')); + + this.summary = model.summary; + this.remarks = model.remarks; + + this.examples = model.examples; + + this.tagAssetId = model.tagAssetId; + this.tagName = model.tagName; + this.sourceUrl = model.sourceUrl; + this.isDiscontinued = model.isDiscontinued; + + this.parameters = model.parameters; + } +} + +export class InspectionsFeatureViewModelClass extends SubFeatureViewModelClass implements InspectionsFeatureViewModel { + inspections: InspectionViewModel[]; + constructor(model: InspectionsFeatureViewModel) { + super(model); + this.inspections = model.inspections.map(e => new InspectionViewModelClass(e)); + } +} + +export class QuickFixesFeatureViewModelClass extends SubFeatureViewModelClass implements QuickFixesFeatureViewModel { + quickFixes: QuickFixViewModel[]; + + constructor(model: QuickFixesFeatureViewModel) { + super(model); + this.quickFixes = model.quickFixes.map(e => new QuickFixViewModelClass(e)); } } +export class AnnotationsFeatureViewModelClass extends SubFeatureViewModelClass implements AnnotationsFeatureViewModel { + annotations: AnnotationViewModel[]; + + constructor(model: AnnotationsFeatureViewModel) { + super(model); + this.annotations = model.annotations.map(e => new AnnotationViewModelClass(e)); + } +} + +export interface UserViewModel { + name: string; + isAuthenticated: boolean; + isAdmin: boolean; +} diff --git a/rubberduckvba.client/src/app/model/indenter.model.ts b/rubberduckvba.client/src/app/model/indenter.model.ts new file mode 100644 index 0000000..ec06a38 --- /dev/null +++ b/rubberduckvba.client/src/app/model/indenter.model.ts @@ -0,0 +1,122 @@ +export enum IndenterEmptyLineHandling { + ignore = 0, + remove = 1, + indent = 2 +} + +export enum IndenterEndOfLineCommentStyle { + absolute = 0, + sameGap = 1, + standardGap = 2, + alignInColumn = 3 +} + + +export interface IndenterViewModel { + indenterVersion: string; + code: string; + indentedCode: string; + + // indent + indentSpaces: number; + emptyLineHandlingMethod: IndenterEmptyLineHandling; + indentEntireProcedureBody: boolean; + indentFirstDeclarationBlock: boolean; + indentFirstCommentBlock: boolean; + ignoreEmptyLinesInFirstBlock: boolean; + indentEnumTypeAsProcedure: boolean; + indentCase: boolean; + + // outdent + //indentCompilerDirectives: boolean; + forceCompilerDirectivesInColumn1: boolean; + forceDebugPrintInColumn1: boolean; + forceDebugAssertInColumn1: boolean; + forceDebugStatementsInColumn1: boolean; + forceStopInColumn1: boolean; + + // alignment + alignContinuations: boolean; + ignoreOperatorsInContinuations: boolean; + alignDims: boolean; + alignDimColumn: number; + + + // comments + alignCommentsWithCode: boolean; + endOfLineCommentStyle: IndenterEndOfLineCommentStyle; + endOfLineCommentColumnSpaceAlignment: number; + + // vertical spacing + groupRelatedProperties: boolean; + verticallySpaceProcedures: boolean; + linesBetweenProcedures: number; +} + +export class IndenterVersionViewModelClass { + public version: string; + constructor(version: string){ + this.version = version; + } +} + +export class IndenterViewModelClass implements IndenterViewModel { + indenterVersion: string; + code: string; + indentedCode: string; + indentSpaces: number; + emptyLineHandlingMethod: IndenterEmptyLineHandling; + indentEntireProcedureBody: boolean; + indentFirstDeclarationBlock: boolean; + indentFirstCommentBlock: boolean; + ignoreEmptyLinesInFirstBlock: boolean; + indentEnumTypeAsProcedure: boolean; + indentCase: boolean; + //indentCompilerDirectives: boolean; + forceCompilerDirectivesInColumn1: boolean; + forceStopInColumn1: boolean; + forceDebugPrintInColumn1: boolean; + forceDebugAssertInColumn1: boolean; + forceDebugStatementsInColumn1: boolean; + alignContinuations: boolean; + ignoreOperatorsInContinuations: boolean; + alignDims: boolean; + alignDimColumn: number; + alignCommentsWithCode: boolean; + endOfLineCommentStyle: IndenterEndOfLineCommentStyle; + endOfLineCommentColumnSpaceAlignment: number; + groupRelatedProperties: boolean; + verticallySpaceProcedures: boolean; + linesBetweenProcedures: number; + + constructor(model: IndenterViewModel) { + this.indenterVersion = model.indenterVersion; + this.code = model.code; + this.indentedCode = model.indentedCode ?? model.code; + + this.indentSpaces = model.indentSpaces; + this.emptyLineHandlingMethod = model.emptyLineHandlingMethod; + this.indentEntireProcedureBody = model.indentEntireProcedureBody; + this.indentFirstDeclarationBlock = model.indentFirstDeclarationBlock; + this.indentFirstCommentBlock = model.indentFirstCommentBlock; + this.ignoreEmptyLinesInFirstBlock = model.ignoreEmptyLinesInFirstBlock; + this.indentEnumTypeAsProcedure = model.indentEnumTypeAsProcedure; + this.indentCase = model.indentCase; + //this.indentCompilerDirectives = model.indentCompilerDirectives; + this.forceCompilerDirectivesInColumn1 = model.forceCompilerDirectivesInColumn1; + this.forceStopInColumn1 = model.forceStopInColumn1; + this.forceDebugPrintInColumn1 = model.forceDebugPrintInColumn1; + this.forceDebugAssertInColumn1 = model.forceDebugAssertInColumn1; + this.forceDebugStatementsInColumn1 = model.forceDebugStatementsInColumn1; + this.alignContinuations = model.alignContinuations; + this.ignoreOperatorsInContinuations = model.ignoreOperatorsInContinuations; + this.alignDims = model.alignDims; + this.alignDimColumn = model.alignDimColumn; + this.alignCommentsWithCode = model.alignCommentsWithCode; + this.endOfLineCommentStyle = model.endOfLineCommentStyle; + this.endOfLineCommentColumnSpaceAlignment = model.endOfLineCommentColumnSpaceAlignment; + this.groupRelatedProperties = model.groupRelatedProperties; + this.verticallySpaceProcedures = model.verticallySpaceProcedures; + this.linesBetweenProcedures = model.linesBetweenProcedures; + } +} diff --git a/rubberduckvba.client/src/app/model/markdown-string.ts b/rubberduckvba.client/src/app/model/markdown-string.ts index a133772..e0bef23 100644 --- a/rubberduckvba.client/src/app/model/markdown-string.ts +++ b/rubberduckvba.client/src/app/model/markdown-string.ts @@ -1,26 +1,26 @@ import { ApiClientService } from "../services/api-client.service"; -export class MarkdownString extends String { +//export class MarkdownString extends String { - constructor(private service: ApiClientService, value: string) { - super(value); - this.rawText = value; - this.formattedText = value; - } +// constructor(private service: ApiClientService, value: string) { +// super(value); +// this.rawText = value; +// this.formattedText = value; +// } - /** - * The unformatted markdown content (text/html). - */ - public rawText: string; - /** - * The formatted markdown content (text/html). - */ - public formattedText: string | undefined; +// /** +// * The unformatted markdown content (text/html). +// */ +// public rawText: string; +// /** +// * The formatted markdown content (text/html). +// */ +// public formattedText: string | undefined; - /** - * Asynchronously fetches the formatted markdown content. - */ - public resolve(): void { - this.service.getMarkdown(this.rawText, true).forEach((result) => this.formattedText = result.markdownContent); - } -} +// /** +// * Asynchronously fetches the formatted markdown content. +// */ +// public resolve(): void { +// this.service.getMarkdown(this.rawText, true).forEach((result) => this.formattedText = result.markdownContent); +// } +//} diff --git a/rubberduckvba.client/src/app/routes/about/about.component.html b/rubberduckvba.client/src/app/routes/about/about.component.html new file mode 100644 index 0000000..905baf7 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/about/about.component.html @@ -0,0 +1,152 @@ +
+

About

+
+ +

+

Rubberduck is first and foremost a project aiming to bring the VBIDE functionality into this century, free of charge for all its users.

+

The project started in late 2014 as a C# port of a test runner tool written in VBA; it didn’t take too long to realize we could easily take the code in the editor, tokenize it, process it, analyze it, and then provide a wide array of developer tools to enhance the VBIDE experience.

+

Rubberduck is about learning, and paying forward: the project co-founder wrote their very first lines of C# for Rubberduck, and several contributors later did the same. Core contributors are always happy to guide new ducklings toward their first pull request!

+

Rubberduck is also about teaching: inspections will not just tell you that Option Explicit is missing, it will also explain why you want Option Explicit enabled everywhere. Many inspection ideas stemmed from questions frequently asked on Stack Overflow, and again explain what’s going on and why that might be a problem. If you’re new to VBA, Rubberduck inspections can teach you things about how VBA works, that many veterans took years to learn about!

+

The project’s blog presents new releases and upcoming features, and frequently proposes intermediate/advanced VBA topics revolving around object-oriented programming and clean code. A dedicated repository was created to host all VBA code examples from blog articles.

+ +
+ Rubberduck logo +
+
+ +

EULA

+

+ There is no end user license agreement (EULA) so to speak: everything is already covered by the GPLv3 license, and there is nothing in it that is relevant for the end user to "agree" to, except perhaps this excerpt from section 15: +

+
+

+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, + EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. + THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +

+
+

+ Basically: we did our best and it works for us, but we cannot promise it won't break anything on your computer so you use it at your own risk - standard stuff. +

+

+ The installer makes you agree to terms and conditions designed to fill up a typical amount of space, but anyone taking the time to actually read it is in for a ride. Nobody ever reads these anyway... right? +

+
+

+ You may reverse engineer, decompile, disassemble or otherwise attempt to discover the source code, underlying ideas, underlying user interface techniques or algorithms of the Application + by any means whatsoever, directly or indirectly, or disclose any of the foregoing to other parties. It'd be kinda pointless to do so though, + because the source code is publicy available under GPLv3 at https://github.com/rubberduck-vba/Rubberduck. Go nuts. +
+

+
+ +

Privacy Policy

+

Even though Rubberduck does not collect any data from its users, attention is given to avoid including any identifiable information in any data it produces, for example in log files.

+

+ Any data produced by the software belongs to its user. This ought to remain true whether or not Rubberduck ends up with any telemetry features, which it currently does not. +

+

+ Personal information provided on 3rd-party platforms such as GitHub or Ko-fi, is subject to the respective privacy policies of these platforms. + Personal information including full name, email, phone number, shipping address, is highly privileged and only ever disclosed to a single Rubberduck project administrator on a need-to-know basis; + such information is only collected to be shared with the parties that need it for their purpose, e.g. Canada Post/USPS/Purolator. +

+ +
+ Rubberduck logo +
+
+ +

Attributions

+ +

Rubberduck was made possible with the combined knowledge of many people, and depends on several libraries and projects.

+ +
+
    +
  • +
    +
    +  Rubberduck project contributors +

    70+ contributors and counting!

    +
    +
    +
  • +
  • +
    +
    +  Fugue icons by Yusuke Kamiyamane +

    Beautiful 16x16 PNG icons in a massive collection.

    +
    +
    +
  • +
  • +
    +
    +  Sharp Develop icons +

    All IDE-specific icons come from (or derived from icons taken from) the SharpDevelop open-source initiative.

    +
    +
    +
  • +
  • +
    +
    +  ANTLR +

    A powerful parser generator.

    +
    +
    +
  • +
  • +
    +
    +  Moq +

    The mocking library Rubberduck uses for its own tests, is also behind the Rubberduck Mocks API.

    +
    +
    +
  • +
+
    +
  • +
    +
    +  EasyHook +

    Used by the unit testing Fakes API to hook into the VBA runtime to intercept certain function calls.

    +
    +
    +
  • +
  • +
    +
    +  AvalonEdit +

    Used for syntax-highlighted code in refactoring previews, for example.

    +
    +
    +
  • +
  • +
    +
    +  WPF Localization for RESX +

    A piece of code specifically sublicensed for use in Rubberduck and its GPL license.

    +
    +
    +
  • +
  • +
    +
    +  Smart Indenter +

    The original Smart Indenter VBIDE add-in VB6 source code that served as a reference.

    +
    +
    +
  • +
+
+ +

Trademarks

+ +

Microsoft, Windows, Microsoft/Office 365, Excel, Access, Word, PowerPoint, Visual Basic for Applications, Visual Studio, Most Valuable Professional (MVP) Award logo, as well as other Microsoft products, services, and registered trademarks, are the property of Microsoft Corporation. Microsoft is a registered trademark of Microsoft Corporation in the United States and/or other countries.

+

The WordPress 'W logo with circle' is a trademark of the WordPress Foundation.

+

All other trademarks are the property of their respective owner.

+ +
+ Rubberduck logo +
+ +
diff --git a/rubberduckvba.client/src/app/routes/about/about.component.ts b/rubberduckvba.client/src/app/routes/about/about.component.ts new file mode 100644 index 0000000..941b2c0 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/about/about.component.ts @@ -0,0 +1,14 @@ +import { Component, OnInit } from "@angular/core"; + +@Component({ + selector: 'app-about', + templateUrl: './about.component.html', +}) +export class AboutComponent implements OnInit { + + constructor() { + } + + ngOnInit(): void { + } +} diff --git a/rubberduckvba.client/src/app/routes/annotation/annotation.component.html b/rubberduckvba.client/src/app/routes/annotation/annotation.component.html new file mode 100644 index 0000000..ca69c39 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/annotation/annotation.component.html @@ -0,0 +1,68 @@ +
+

HomeFeaturesCommentAnnotationsAnnotations

+

{{info.title}}

+
+
+

+
+
+ +
+
+
+
Remarks
+

+
+
+
+ +
+
Parameters
+
+ + + + + + + + + + + + + +
NameDescription
{{parameter.name}}

{{parameter.type}}

{{parameter.description}}
+
+
+ +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+ View | Edit this content on  GitHub +
+
+
diff --git a/rubberduckvba.client/src/app/routes/annotation/annotation.component.ts b/rubberduckvba.client/src/app/routes/annotation/annotation.component.ts new file mode 100644 index 0000000..2936851 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/annotation/annotation.component.ts @@ -0,0 +1,37 @@ +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { BehaviorSubject, switchMap } from "rxjs"; +import { AnnotationViewModel } from "../../model/feature.model"; +import { ApiClientService } from "../../services/api-client.service"; + +@Component({ + selector: 'app-annotation', + templateUrl: './annotation.component.html', +}) +export class AnnotationComponent implements OnInit { + + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + + public set info(value: AnnotationViewModel) { + this._info.next(value); + } + public get info(): AnnotationViewModel { + return this._info.getValue(); + } + + constructor(private api: ApiClientService, private fa: FaIconLibrary, private route: ActivatedRoute) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + this.route.paramMap.pipe( + switchMap(params => { + const name = params.get('name')!; + return this.api.getAnnotation(name); + })).subscribe(e => { + this.info = e; + }); + } +} diff --git a/rubberduckvba.client/src/app/routes/auth/auth.component.html b/rubberduckvba.client/src/app/routes/auth/auth.component.html new file mode 100644 index 0000000..518cc7a --- /dev/null +++ b/rubberduckvba.client/src/app/routes/auth/auth.component.html @@ -0,0 +1 @@ + diff --git a/rubberduckvba.client/src/app/routes/auth/auth.component.ts b/rubberduckvba.client/src/app/routes/auth/auth.component.ts new file mode 100644 index 0000000..a0780bd --- /dev/null +++ b/rubberduckvba.client/src/app/routes/auth/auth.component.ts @@ -0,0 +1,16 @@ +import { Component, OnInit } from "@angular/core"; +import { AuthService } from "src/app/services/auth.service"; + +@Component({ + selector: 'app-auth', + templateUrl: './auth.component.html', +}) +export class AuthComponent implements OnInit { + + constructor(private service: AuthService) { + } + + ngOnInit(): void { + this.service.onGithubCallback(); + } +} diff --git a/rubberduckvba.client/src/app/routes/feature/feature.component.ts b/rubberduckvba.client/src/app/routes/feature/feature.component.ts index 016a864..f21a64e 100644 --- a/rubberduckvba.client/src/app/routes/feature/feature.component.ts +++ b/rubberduckvba.client/src/app/routes/feature/feature.component.ts @@ -3,7 +3,7 @@ import { ApiClientService } from "../../services/api-client.service"; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { BehaviorSubject, switchMap } from 'rxjs'; -import { Feature, FeatureViewModel } from '../../model/feature.model'; +import { XmlDocOrFeatureViewModel } from '../../model/feature.model'; import { ActivatedRoute } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @@ -15,11 +15,11 @@ export class FeatureComponent implements OnInit { public modal = inject(NgbModal); - private readonly _feature: BehaviorSubject = new BehaviorSubject(null!); - public set feature(value: FeatureViewModel){ + private readonly _feature: BehaviorSubject = new BehaviorSubject(null!); + public set feature(value: XmlDocOrFeatureViewModel){ this._feature.next(value); } - public get feature(): FeatureViewModel { + public get feature(): XmlDocOrFeatureViewModel { return this._feature.getValue(); } constructor(private api: ApiClientService, private fa: FaIconLibrary, private route: ActivatedRoute) { @@ -32,7 +32,8 @@ export class FeatureComponent implements OnInit { const name = params.get('name')!; return this.api.getFeature(name); })).subscribe(e => { - this.feature = new FeatureViewModel(e); + this.feature = e; + console.log(this.feature); }); } } diff --git a/rubberduckvba.client/src/app/routes/features/features.component.html b/rubberduckvba.client/src/app/routes/features/features.component.html index 4f53c4a..2366bed 100644 --- a/rubberduckvba.client/src/app/routes/features/features.component.html +++ b/rubberduckvba.client/src/app/routes/features/features.component.html @@ -13,7 +13,7 @@

Features

- Rubberduck logo + Rubberduck logo
diff --git a/rubberduckvba.client/src/app/routes/features/features.component.ts b/rubberduckvba.client/src/app/routes/features/features.component.ts index 6cc328b..cf7c8e6 100644 --- a/rubberduckvba.client/src/app/routes/features/features.component.ts +++ b/rubberduckvba.client/src/app/routes/features/features.component.ts @@ -3,7 +3,7 @@ import { ApiClientService } from "../../services/api-client.service"; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { BehaviorSubject } from 'rxjs'; -import { Feature, FeatureItem, FeatureViewModel, QuickFixViewModel } from '../../model/feature.model'; +import { FeatureViewModel, QuickFixViewModel } from '../../model/feature.model'; @Component({ selector: 'app-features', @@ -19,8 +19,8 @@ export class FeaturesComponent implements OnInit, OnChanges { return this._features.getValue(); } - private readonly _quickFixes: BehaviorSubject = new BehaviorSubject(null!); - public get quickFixes(): FeatureItem[] { + private readonly _quickFixes: BehaviorSubject = new BehaviorSubject(null!); + public get quickFixes(): QuickFixViewModel[] { return this._quickFixes.value; } @@ -35,7 +35,7 @@ export class FeaturesComponent implements OnInit, OnChanges { ngOnInit(): void { this.api.getFeatureSummaries().subscribe(result => { if (result) { - this._features.next(result.map(feature => new FeatureViewModel(feature)).filter(e => !e.isHidden)); + this._features.next(result.filter(e => !e.isHidden)); } }); } diff --git a/rubberduckvba.client/src/app/routes/home/home.component.html b/rubberduckvba.client/src/app/routes/home/home.component.html index 3c4790e..fb5610e 100644 --- a/rubberduckvba.client/src/app/routes/home/home.component.html +++ b/rubberduckvba.client/src/app/routes/home/home.component.html @@ -45,7 +45,7 @@

The open-source Visual Basic Editor add-in

- Rubberduck + Rubberduck
Free and open-source since 2014 @@ -96,7 +96,7 @@

The open-source Visual Basic Editor add-in

Reddit @@ -115,14 +115,14 @@

The open-source Visual Basic Editor add-in

-

Style Guide

+

Features

- Enhance your VBA programming with this comprehensive VBA coding style guide. 70+ pages covering everything from naming and parameterization to design patterns and object-oriented programming principles. + Discover how Rubberduck changes your life as a VBA developer with what can only ever be a quick summary.

@@ -130,14 +130,14 @@

Style Guide

-

Rubberduck3

+

Style Guide

- A completely separate project is under way, to rewrite Rubberduck into a client/server architecture and its own standalone editor - introducing a proper language server for VBA, this project will ultimately become the next major Rubberduck version (3.x). + Enhance your VBA programming with this comprehensive VBA coding style guide. 70+ pages covering everything from naming and parameterization to design patterns and object-oriented programming principles.

@@ -145,14 +145,14 @@

Rubberduck3

-

Blog

+

Resources

+

+ Featured articles from the project blog: +

+
+
+
-

- Your source for intermediate/advanced VBA programming content. The blog is also where releases and upcoming features get announced, so make sure to subscribe! -

-
-
@@ -161,4 +161,3 @@

Blog

- diff --git a/rubberduckvba.client/src/app/routes/home/home.component.ts b/rubberduckvba.client/src/app/routes/home/home.component.ts index 944d3cd..dbea0d3 100644 --- a/rubberduckvba.client/src/app/routes/home/home.component.ts +++ b/rubberduckvba.client/src/app/routes/home/home.component.ts @@ -4,7 +4,7 @@ import { Tag } from '../../model/tags.model'; import { FaIconLibrary } from '@fortawesome/angular-fontawesome'; import { fas } from '@fortawesome/free-solid-svg-icons'; import { BehaviorSubject } from 'rxjs'; -import { Feature } from '../../model/feature.model'; +import { BlogLink, BlogLinkViewModelClass } from '../../model/feature.model'; @Component({ selector: 'app-home', @@ -29,6 +29,14 @@ export class HomeComponent implements OnInit, OnChanges { return this._next.getValue(); } + public get blogPosts(): BlogLink[] { + return [ + { name: 'Pre-release: v2.5.92.x', author: 'Mathieu Guindon', published: '2025-01-22', url: 'https://rubberduckvba.blog/2025/01/22/pre-release-2-5-92-x/' }, + { name: 'About Class Modules', author: 'Ben Clothier', published: '2019-07-08', url: 'https://rubberduckvba.blog/2019/07/08/about-class-modules/' }, + { name: 'Rubberduck Style Guide', author: 'Mathieu Guindon', published: '2021-05-29', url: 'https://rubberduckvba.blog/2021/05/29/rubberduck-style-guide/' } + ] + } + constructor(private api: ApiClientService, private fa: FaIconLibrary) { fa.addIconPacks(fas); } diff --git a/rubberduckvba.client/src/app/routes/indenter/indenter.component.css b/rubberduckvba.client/src/app/routes/indenter/indenter.component.css new file mode 100644 index 0000000..e69de29 diff --git a/rubberduckvba.client/src/app/routes/indenter/indenter.component.html b/rubberduckvba.client/src/app/routes/indenter/indenter.component.html new file mode 100644 index 0000000..d2ec415 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/indenter/indenter.component.html @@ -0,0 +1,472 @@ +
+
+

Online Indenter

+

+ Consistent identation is one of the most fundemntal qualities of code that is clean and clear. + Rubberduck makes indenting a procedure, module, or even an entire project, quick and simple. +

+

+ With all the functionalities of the legendary 32-bit Smart Indenter add-in, and then some, + Rubberduck lets you configure many indenting options: feel free to use this page to experiment with them. +

+
+
+ +
+
+ + +
+
+ +
+
+
+ + + + +
+ The size (in spaces) of an indentation level +
+
+
+
+ + + + +
+ Specifies whether and how to handle empty lines +
+
+
+
+
+ +
+ If checked, procedure scopes add an indentation level +
+
+
+
+
+
+ + If checked, a block of declarations at the top of a procedure adds an indentation level +
+
+
+
+
+ + If checked, a block of comments at the top of a procedure adds an indentation level +
+
+
+
+
+ + If checked, the first block of comments can include empty lines +
+
+
+
+
+ +
+ If checked, Enum members add an indentation level +
+
+
+
+
+
+ +
+ If checked, Case blocks add an indentation level +
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+ Forces precompiler directives into column 1 regardless of current indentation level +
+
+
+
+
+
+ +
+ Forces Debug.Print statements into column 1 regardless of current indentation level +
+
+
+
+
+
+ +
+ Forces Debug.Assert statements into column 1 regardless of current indentation level +
+
+
+
+
+
+ +
+ Forces Stop statements into column 1 regardless of current indentation level +
+
+
+
+
+ + +
+
+ +
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+ + +
+
+ +
+
+
+
+ +
+ If checked, comment lines will be made to follow indentation levels +
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ End-of-line comments will begin at that column whenever possible +
+
+
+ +
+
+ + +
+
+ +
+
+
+
+ +
+ Removes all spaces between Get, Let, and Set accessors of Property members +
+
+
+
+
+
+ +
+ Consistently keeps the specified number of empty lines between the members of a module +
+
+
+
+
+ +
+ The number of empty lines to maintain between procedures +
+
+
+
+
+ + +
+
+ +
+
+

Looking for a programmatic approach?

+
+ Try the API: +
    +
  1. Request (GET) {{apiBaseUrl}}indenter/defaults to get a JSON object for the model (or copy it from below);
  2. +
  3. Provide the code to be indented in the 'Code' property; use \n for newline characters;
  4. +
  5. Post (POST) the JSON object to {{apiBaseUrl}}indenter/indent to get the indented code back as an array of strings.
  6. +
+ +
+ + +
+
+ +
+ +
+
+
+

Try it right here!

+ Rubberduck.SmartIndenter.dll version: {{model.indenterVersion}} +
+
+ + +
+ +
+
+ +
+
+
+
 Reset to defaults
+
+
+
+ +
+
+
+
+ +
+
+ Rubberduck logo +
+
diff --git a/rubberduckvba.client/src/app/routes/indenter/indenter.component.ts b/rubberduckvba.client/src/app/routes/indenter/indenter.component.ts new file mode 100644 index 0000000..97c3229 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/indenter/indenter.component.ts @@ -0,0 +1,219 @@ +import { Component, OnInit } from "@angular/core"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { IndenterViewModel, IndenterViewModelClass } from "../../model/indenter.model"; +import { ApiClientService } from "../../services/api-client.service"; +import { environment } from "../../../environments/environment"; + +export interface IndenterOptionGroups { + isExpanded: boolean; + isIndentOptionsExpanded: boolean; + isOutdentOptionsExpanded: boolean; + isAlignmentOptionsExpanded: boolean; + isCommentOptionsExpanded: boolean; + isVerticalOptionsExpanded: boolean; + isApiAboutBoxExpanded: boolean; +} + +@Component({ + selector: 'app-indenter', + templateUrl: './indenter.component.html', +}) +export class IndenterComponent implements OnInit, IndenterOptionGroups { + private _model!: IndenterViewModel; + public wasCopied: boolean = false; + public wasTemplateCopied: boolean = false; + + + constructor(fa: FaIconLibrary, private service: ApiClientService) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + this.load(); + } + + private load(): void { + const localModel = localStorage.getItem('indenter.model'); + const localOptionGroups = localStorage.getItem('indenter.options'); + this.isLocalStorageOK = localModel != null || localOptionGroups != null; + + if (localModel) { + this.model = JSON.parse(localModel); + } + else { + this.getDefaults(); + } + + if (localOptionGroups) { + const optionGroups = JSON.parse(localOptionGroups); + this.isExpanded = optionGroups.isExpanded; + this.isIndentOptionsExpanded = optionGroups.isIndentOptionsExpanded; + this.isOutdentOptionsExpanded = optionGroups.isOutdentOptionsExpanded; + this.isAlignmentOptionsExpanded = optionGroups.isAlignmentOptionsExpanded; + this.isCommentOptionsExpanded = optionGroups.isCommentOptionsExpanded; + this.isVerticalOptionsExpanded = optionGroups.isVerticalOptionsExpanded; + this.isApiAboutBoxExpanded = optionGroups.isApiAboutBoxExpanded; + } + } + + private clearStorage(): void { + localStorage.removeItem('indenter.model'); + localStorage.removeItem('indenter.options'); + } + + public getDefaults(): void { + this.service.getIndenterDefaults().subscribe(model => { + this.model = model; + }); + } + + private _isExpanded: boolean = false; + private _isIndentOptionsExpanded: boolean = true; + private _isOutdentOptionsExpanded: boolean = false; + private _isAlignmentOptionsExpanded: boolean = false; + private _isCommentOptionsExpanded: boolean = false; + private _isVerticalOptionsExpanded: boolean = false; + private _isApiAboutBoxExpanded: boolean = false; + + public isIndenterBusy: boolean = false; + + private _isLocalStorageOK: boolean = false; + public get isLocalStorageOK(): boolean { + return this._isLocalStorageOK; + } + public set isLocalStorageOK(value: boolean) { + this._isLocalStorageOK = value; + if (!value) { + this.clearStorage(); + } + else { + this.saveModel(); + this.saveOptions(); + } + } + + public get model(): IndenterViewModel { + return this._model; + } + + private set model(value: IndenterViewModel) { + this._model = value; + this.invalidateClipboard(); + this.saveModel(); + } + + public get asJson(): string { + const copy = new IndenterViewModelClass(this._model); + copy.indentedCode = ''; + return JSON.stringify(copy); + } + + public get isExpanded(): boolean { + return this._isExpanded; + } + public set isExpanded(value: boolean) { + this._isExpanded = value; + this.saveOptions(); + } + public get isIndentOptionsExpanded(): boolean { + return this._isIndentOptionsExpanded; + } + public set isIndentOptionsExpanded(value: boolean) { + this._isIndentOptionsExpanded = value; + this.saveOptions(); + } + public get isCommentOptionsExpanded(): boolean { + return this._isCommentOptionsExpanded; + } + public set isCommentOptionsExpanded(value: boolean) { + this._isCommentOptionsExpanded = value; + this.saveOptions(); + } + public get isVerticalOptionsExpanded(): boolean { + return this._isVerticalOptionsExpanded; + } + public set isVerticalOptionsExpanded(value: boolean) { + this._isVerticalOptionsExpanded = value; + this.saveOptions(); + } + public get isApiAboutBoxExpanded(): boolean { + return this._isApiAboutBoxExpanded; + } + public set isApiAboutBoxExpanded(value: boolean) { + this._isApiAboutBoxExpanded = value; + this.saveOptions(); + } + public get isOutdentOptionsExpanded(): boolean { + return this._isOutdentOptionsExpanded; + } + public set isOutdentOptionsExpanded(value: boolean) { + this._isOutdentOptionsExpanded = value; + this.saveOptions(); + } + public get isAlignmentOptionsExpanded(): boolean { + return this._isAlignmentOptionsExpanded; + } + public set isAlignmentOptionsExpanded(value: boolean) { + this._isAlignmentOptionsExpanded = value; + this.saveOptions(); + } + + private get asOptionGroups(): IndenterOptionGroups { + return { + isExpanded: this.isExpanded, + isIndentOptionsExpanded: this.isIndentOptionsExpanded, + isAlignmentOptionsExpanded: this.isAlignmentOptionsExpanded, + isApiAboutBoxExpanded: this.isApiAboutBoxExpanded, + isCommentOptionsExpanded: this.isCommentOptionsExpanded, + isOutdentOptionsExpanded: this.isOutdentOptionsExpanded, + isVerticalOptionsExpanded: this.isVerticalOptionsExpanded + }; + } + + private saveModel(): void { + if (this.isLocalStorageOK) { + localStorage.setItem('indenter.model', JSON.stringify(this.model)); + } + } + private saveOptions(): void { + if (this.isLocalStorageOK) { + localStorage.setItem('indenter.options', JSON.stringify(this.asOptionGroups)); + } + } + + public indent(): void { + this.isIndenterBusy = true; + this.service.indent(this.model).subscribe(vm => { + this.model.indentedCode = vm.indentedCode; + this.model.code = vm.indentedCode; + this.isIndenterBusy = false; + + this.invalidateClipboard(); + this.saveModel(); + this.saveOptions(); + }); + } + + public copy(): void { + navigator.clipboard.writeText(this.model.code).then(e => this.wasCopied = true); + } + public copyTemplate(): void { + navigator.clipboard.writeText(this.asJson).then(e => this.wasTemplateCopied = true); + } + + private invalidateClipboard(): void { + this.wasCopied = false; + this.wasTemplateCopied = false; + } + + public get apiBaseUrl(): string { + return environment.apiBaseUrl.replace('https://', ''); + } + + public onModelChanged(code: string): void { + this.model.code = code; + this.invalidateClipboard(); + this.saveModel(); + } +} diff --git a/rubberduckvba.client/src/app/routes/inspection/inspection.component.html b/rubberduckvba.client/src/app/routes/inspection/inspection.component.html new file mode 100644 index 0000000..ec35b07 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/inspection/inspection.component.html @@ -0,0 +1,94 @@ +

HomeFeaturesCodeInspectionsInspections

+

{{info.title}}

+

+
+
+
+
+
+
Discontinued
+
+  {{info.tagName}} +
+
+
+
 New! 
+
+  {{info.tagName}} +
+
+
+  {{info.inspectionType}} +
+

{{inspectionTypeTitle}}

+
+
+
+
+ Default Severity: {{info.defaultSeverity}}  +

{{severityTitle}}

+
+
+
+
+
+

+
+
+
+
+

 This inspection only runs when hosted in {{info.hostApp}}.

+
+
+
+
+

 This inspection only runs when the {{info.references[0]}} library is referenced.

+
+
+
+
+

 This inspection only runs when any of the following libraries is referenced:

+
    +
  • {{library}}
  • +
+
+
+
+
+
Remarks
+
+
+
+
+
+
+
+  QuickFixes +
+

This inspection offers the following fixes:

+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ View | Edit this content on  GitHub +
+
diff --git a/rubberduckvba.client/src/app/routes/inspection/inspection.component.ts b/rubberduckvba.client/src/app/routes/inspection/inspection.component.ts new file mode 100644 index 0000000..796e195 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/inspection/inspection.component.ts @@ -0,0 +1,74 @@ +import { Component, OnInit } from "@angular/core"; +import { InspectionViewModel } from "../../model/feature.model"; +import { ActivatedRoute, Router } from "@angular/router"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { BehaviorSubject, switchMap } from "rxjs"; +import { ApiClientService } from "../../services/api-client.service"; + +@Component({ + selector: 'app-inspection', + templateUrl: './inspection.component.html', +}) +export class InspectionComponent implements OnInit { + + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + + public set info(value: InspectionViewModel) { + this._info.next(value); + } + public get info(): InspectionViewModel { + return this._info.getValue(); + } + + constructor(private api: ApiClientService, private fa: FaIconLibrary, private route: ActivatedRoute, private router: Router) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + this.route.paramMap.pipe( + switchMap(params => { + const name = params.get('name')!; + return this.api.getInspection(name); + })).subscribe(e => { + console.log(e); + this.info = e; + }); + } + + public get severityIconClass(): string { + return `icon icon-severity-${this.info.defaultSeverity.toLowerCase()}`; + } + + public get severityTitle(): string { + switch (this.info?.defaultSeverity) { + case 'DoNotShow': + return 'Inspections at this severity level are disabled until/unless configured differently.'; + case 'Hint': + return 'Inspections at this severity level are merely making an observation about the code.'; + case 'Suggestion': + return 'Inspections at this severity level are making an actionnable observation about the code.'; + case 'Warning': + return 'Inspections at this severity level are flagging a potential issue that is usually more serious than a simple observation.'; + case 'Error': + return 'Inspections at this severity level are flagging a potential bug, or a possible run-time or compile-time error.'; + default: + return ''; + } + } + + public get inspectionTypeTitle(): string { + switch (this.info?.inspectionType) { + case 'Code Quality Issues': + return 'Inspections of this type indicate a problem (real or potential) with the code.'; + case 'Naming and Convention Issues': + return 'Inspections of this type point out issues that arise out of naming style and programming conventions.'; + case 'Language Opportunities': + return 'Inspections of this type indicate a probable misuse of a language feature that exists for backward compatibility.'; + case 'Rubberduck Opportunities': + return 'Inspections of this type relate specifically to Rubberduck features.'; + default: + return 'Uncategorized, I guess.'; + } + } +} diff --git a/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.html b/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.html new file mode 100644 index 0000000..34b2038 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.html @@ -0,0 +1,50 @@ +
+

HomeFeaturesCodeInspectionsQuickFixes

+

{{info.title}}

+
+
+

+
+
+ +
+
+
+
Remarks
+

+
+
+
+ +
+
+
+
+  Inspections +
+

This action is offered as quickfix to the following inspections:

+ +
+
+ +
+
+ +
+
+ +
+
+ View | Edit this content on  GitHub +
+
+
diff --git a/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.ts b/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.ts new file mode 100644 index 0000000..4239302 --- /dev/null +++ b/rubberduckvba.client/src/app/routes/quickfixes/quickfix.component.ts @@ -0,0 +1,38 @@ +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { FaIconLibrary } from "@fortawesome/angular-fontawesome"; +import { fas } from "@fortawesome/free-solid-svg-icons"; +import { BehaviorSubject, switchMap } from "rxjs"; +import { QuickFixViewModel, QuickFixViewModelClass } from "../../model/feature.model"; +import { ApiClientService } from "../../services/api-client.service"; + +@Component({ + selector: 'app-quickfix', + templateUrl: './quickfix.component.html', +}) +export class QuickFixComponent implements OnInit { + + private readonly _info: BehaviorSubject = new BehaviorSubject(null!); + + public set info(value: QuickFixViewModelClass) { + this._info.next(value); + } + public get info(): QuickFixViewModelClass { + return this._info.getValue(); + } + + constructor(private api: ApiClientService, private fa: FaIconLibrary, private route: ActivatedRoute) { + fa.addIconPacks(fas); + } + + ngOnInit(): void { + this.route.paramMap.pipe( + switchMap(params => { + const name = params.get('name')!; + return this.api.getQuickFix(name); + })).subscribe(e => { + this.info = e; + console.log(this.info); + }); + } +} diff --git a/rubberduckvba.client/src/app/services/api-client.service.ts b/rubberduckvba.client/src/app/services/api-client.service.ts index 4e9f49a..5be402b 100644 --- a/rubberduckvba.client/src/app/services/api-client.service.ts +++ b/rubberduckvba.client/src/app/services/api-client.service.ts @@ -1,10 +1,11 @@ import { Injectable } from "@angular/core"; import { LatestTags, Tag } from "../model/tags.model"; -import { Feature, FeatureSummary, PaginatedFeature } from "../model/feature.model"; +import { AnnotationViewModel, AnnotationViewModelClass, AnnotationsFeatureViewModel, AnnotationsFeatureViewModelClass, FeatureViewModel, FeatureViewModelClass, InspectionViewModel, InspectionViewModelClass, InspectionsFeatureViewModel, InspectionsFeatureViewModelClass, QuickFixViewModel, QuickFixViewModelClass, QuickFixesFeatureViewModel, QuickFixesFeatureViewModelClass, SubFeatureViewModel, SubFeatureViewModelClass, UserViewModel, XmlDocOrFeatureViewModel } from "../model/feature.model"; import { DownloadInfo } from "../model/downloads.model"; import { DataService } from "./data.service"; import { environment } from "../../environments/environment.prod"; import { Observable, map } from "rxjs"; +import { IndenterVersionViewModelClass, IndenterViewModel, IndenterViewModelClass } from "../model/indenter.model"; @Injectable() export class ApiClientService { @@ -21,31 +22,64 @@ export class ApiClientService { return this.data.getAsync(`${environment.apiBaseUrl}tags/latest`); } - public getFeatureSummaries(): Observable { - return this.data.getAsync(`${environment.apiBaseUrl}features`); + public getFeatureSummaries(): Observable { + return this.data.getAsync(`${environment.apiBaseUrl}features`); } - public getFeature(name: string): Observable { - return this.data.getAsync(`${environment.apiBaseUrl}features/${name}`); + public getFeature(name: string): Observable { + const url = `${environment.apiBaseUrl}features/${name}`; + const featureName = name.toLowerCase(); + + switch (featureName) { + case "inspections": + return this.data.getAsync(url).pipe(map(e => new InspectionsFeatureViewModelClass(e))); + case "quickfixes": + return this.data.getAsync(url).pipe(map(e => new QuickFixesFeatureViewModelClass(e))); + case "annotations": + return this.data.getAsync(url).pipe(map(e => new AnnotationsFeatureViewModelClass(e))); + default: + return this.data.getAsync(url).pipe(map(e => new FeatureViewModelClass(e))); + } } - public getMarkdown(value: string, syntaxHighlighting: boolean): Observable { - const payload = new MarkdownFormattingViewModel(value, syntaxHighlighting); - return this.data.postAsync(`${environment.apiBaseUrl}features/markdown`, payload); + public getInspection(name: string): Observable { + const url = `${environment.apiBaseUrl}inspections/${name}` + return this.data.getAsync(url).pipe(map(e => new InspectionViewModelClass(e))); + } + public getAnnotation(name: string): Observable { + const url = `${environment.apiBaseUrl}annotations/${name}` + return this.data.getAsync(url).pipe(map(e => new AnnotationViewModelClass(e))); + } + public getQuickFix(name: string): Observable { + const url = `${environment.apiBaseUrl}quickfixes/${name}` + return this.data.getAsync(url).pipe(map(e => new QuickFixViewModelClass(e))); } -} -export interface MarkdownFormattingInfo { - markdownContent: string; - withVbeCodeBlocks: boolean; -} + public updateTagMetadata(): Observable { + const url = `${environment.apiBaseUrl}admin/update/tags`; + return this.data.postAsync(url); + } -export class MarkdownFormattingViewModel implements MarkdownFormattingInfo { - public markdownContent: string = ''; - public withVbeCodeBlocks: boolean = false; + public updateXmldocMetadata(): Observable { + const url = `${environment.apiBaseUrl}admin/update/xmldoc`; + return this.data.postAsync(url); + } + + public clearCache(): Observable { + const url = `${environment.apiBaseUrl}admin/cache/clear`; + return this.data.postAsync(url); + } + + public getIndenterDefaults(): Observable { + const url = `${environment.apiBaseUrl}indenter/defaults`; + return this.data.getAsync(url).pipe(map(model => new IndenterViewModelClass(model))); + } - constructor(content: string, syntax: boolean) { - this.markdownContent = content; - this.withVbeCodeBlocks = syntax; + public indent(model: IndenterViewModel): Observable { + const url = `${environment.apiBaseUrl}indenter/indent`; + return this.data.postAsync(url, model).pipe(map(lines => { + model.indentedCode = lines.join('\n'); + return model; + })); } } diff --git a/rubberduckvba.client/src/app/services/auth.service.ts b/rubberduckvba.client/src/app/services/auth.service.ts new file mode 100644 index 0000000..fc0c3da --- /dev/null +++ b/rubberduckvba.client/src/app/services/auth.service.ts @@ -0,0 +1,73 @@ +import { Injectable } from "@angular/core"; +import { Observable, map } from "rxjs"; +import { environment } from "../../environments/environment"; +import { UserViewModel } from "../model/feature.model"; +import { AuthViewModel, DataService } from "./data.service"; + + +@Injectable({ providedIn: 'root' }) +export class AuthService { + private timeout: number = 10000; + constructor(private data: DataService) { } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + private redirect(url: string = '/'): void { + console.log(`redirecting: ${url}`); + window.location.href = url; + } + + private writeStorage(key: string, value: string): void { + sessionStorage.setItem(key, value); + while (sessionStorage.getItem(key) != value) { + this.sleep(1000); + } + } + + public getUser(): Observable { + const url = `${environment.apiBaseUrl}auth`; + return this.data.getAsync(url); + } + + public signin(): void { + const vm = AuthViewModel.withRandomState(); + this.writeStorage('xsrf:state', vm.state); + + const url = `${environment.apiBaseUrl}auth/signin`; + this.data.postAsync(url, vm) + .subscribe((result: string) => this.redirect(result)); + } + + public signout(): void { + sessionStorage.clear(); + } + + public onGithubCallback(): void { + const urlParams = new URLSearchParams(location.search); + const code: string = urlParams.get('code')!; + const state: string = urlParams.get('state')!; + + if (state === sessionStorage.getItem('xsrf:state')) { + try { + const vm: AuthViewModel = { state, code }; + const url = `${environment.apiBaseUrl}auth/github`; + + this.data.postAsync(url, vm) + .subscribe(result => { + this.writeStorage('github:access_token', result.token!); + this.redirect(); + }); + } + catch (error) { + console.log(error); + this.redirect(); + } + } + else { + console.log('xsrf:state mismatched!'); + this.redirect(); + } + } +} diff --git a/rubberduckvba.client/src/app/services/data.service.ts b/rubberduckvba.client/src/app/services/data.service.ts index 457ed6e..c66a376 100644 --- a/rubberduckvba.client/src/app/services/data.service.ts +++ b/rubberduckvba.client/src/app/services/data.service.ts @@ -1,7 +1,6 @@ import { HttpClient, HttpHeaders } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { map, timeout, catchError, throwError, Observable } from "rxjs"; -import { environment } from "../../environments/environment"; @Injectable() export class DataService { @@ -12,10 +11,16 @@ export class DataService { } public getAsync(url: string): Observable { - const headers = new HttpHeaders() + let headers = new HttpHeaders() .append('accept', 'application/json'); + const token = sessionStorage.getItem('github:access_token'); + let withCreds = false; + if (token) { + headers = headers.append('X-ACCESS-TOKEN', token); + withCreds = true; + } - return this.http.get(url, { headers }) + return this.http.get(url, { headers, withCredentials: withCreds }) .pipe( map(result => result), timeout(this.timeout), @@ -27,13 +32,21 @@ export class DataService { } public postAsync(url: string, content?: TContent): Observable { - const headers = new HttpHeaders() + let headers = new HttpHeaders() + .append('Access-Control-Allow-Origin', '*') .append('accept', 'application/json') .append('Content-Type', 'application/json; charset=utf-8'); + const token = sessionStorage.getItem('github:access_token'); + let withCreds = false; + if (token) { + headers = headers.append('X-ACCESS-TOKEN', token); + withCreds = true; + } + return (content - ? this.http.post(url, content, { headers }) - : this.http.post(url, { headers })) + ? this.http.post(url, content, { headers, withCredentials: withCreds }) + : this.http.post(url, null, { headers, withCredentials: withCreds })) .pipe( map(result => result), timeout(this.timeout), @@ -42,23 +55,19 @@ export class DataService { } } -@Injectable({ providedIn: 'root' }) -export class AuthService { - constructor(private http: HttpClient) { } - - public signin(): Observable { - const headers = new HttpHeaders() - .append('accept', 'application/json') - .append('Content-Type', 'application/json; charset=utf-8'); +export class AuthViewModel { + state: string; + code?: string; + token?: string; - return this.http.post(`${environment.apiBaseUrl}auth/signin`, undefined, { headers }); + constructor(state: string, code?: string, token?: string) { + this.state = state; + this.code = code; + this.token = token; } - public signout(): Observable { - const headers = new HttpHeaders() - .append('accept', 'application/json') - .append('Content-Type', 'application/json; charset=utf-8'); - - return this.http.post(`${environment.apiBaseUrl}auth/signout`, undefined, { headers }); + public static withRandomState() { + const state = crypto.randomUUID(); + return new AuthViewModel(state); } } diff --git a/rubberduckvba.client/src/assets/rubberduck.png b/rubberduckvba.client/src/assets/rubberduck.png new file mode 100644 index 0000000..2470de9 Binary files /dev/null and b/rubberduckvba.client/src/assets/rubberduck.png differ diff --git a/rubberduckvba.client/src/environments/environment.prod.ts b/rubberduckvba.client/src/environments/environment.prod.ts index e95e3c7..d3de473 100644 --- a/rubberduckvba.client/src/environments/environment.prod.ts +++ b/rubberduckvba.client/src/environments/environment.prod.ts @@ -1,4 +1,4 @@ export const environment = { production: true, - apiBaseUrl: 'https://localhost:44314/' + apiBaseUrl: 'https://api.rubberduckvba.com/' }; diff --git a/rubberduckvba.client/src/environments/environment.test.ts b/rubberduckvba.client/src/environments/environment.test.ts index 26d101a..0b96c60 100644 --- a/rubberduckvba.client/src/environments/environment.test.ts +++ b/rubberduckvba.client/src/environments/environment.test.ts @@ -1,4 +1,4 @@ export const environment = { - production: true, - apiBaseUrl: 'https://test.api.rubberduckvba.com/' + production: false, + apiBaseUrl: 'https://localhost:44314/' }; diff --git a/rubberduckvba.client/src/environments/environment.ts b/rubberduckvba.client/src/environments/environment.ts index 0fd806b..fbede92 100644 --- a/rubberduckvba.client/src/environments/environment.ts +++ b/rubberduckvba.client/src/environments/environment.ts @@ -4,7 +4,7 @@ export const environment = { production: false, - apiBaseUrl: 'https://localhost:44314/' + apiBaseUrl: 'https://api.rubberduckvba.com/' }; /* diff --git a/rubberduckvba.client/src/styles.css b/rubberduckvba.client/src/styles.css index 978bc50..e0824eb 100644 --- a/rubberduckvba.client/src/styles.css +++ b/rubberduckvba.client/src/styles.css @@ -7,8 +7,33 @@ body { } h1, h2, h3, h4, h5, h6 { - font-family: 'Ink Free', 'Acme'; - font-weight: bold; + font-family: 'Segoe UI'; + word-wrap: anywhere; + transition: font-size 200ms ease-out; +} + +h1 { + font-size: xx-large; +} +h2 { + font-size: x-large; +} +h2:hover { + font-size: xx-large; + transition: font-size 400ms ease-in; +} + +h3,h4,h5 { + font-size: larger; +} + +h6 { + font-size: large; + font-weight: normal; +} + +p { + font-size: medium; } .document-img { @@ -20,8 +45,15 @@ h1, h2, h3, h4, h5, h6 { } .modal-header { - font-family: 'Ink Free', 'Acme'; - font-weight: bold; + font-family: 'Segoe UI'; +} + +.btn-expand { + color: black; + opacity: 0.5; +} +.btn-expand:hover{ + opacity: 0.75; } a { @@ -56,6 +88,12 @@ a { font-weight: bold; } +.btn-toggle { + background: #0ab1ff; + color: white; + font-weight: bold; +} + .card-highlight { border-width: 1px !important; border-color: lightsteelblue; @@ -72,3 +110,86 @@ a { background-color: #1e90ff1b; } +.hover-enlarge { + transition: transform 200ms ease-out; +} + +.hover-enlarge:hover { + transform: scale(1.2); + transition: transform 400ms ease-in; +} + +.card-columns { + display: inline-block; +} + +/*.modal { + width: 110%; +} +.modal-content { + width: 100%; +} +*/ +@media (min-width: 576px) { + .card-columns { + column-count: 2; + } +} + +@media (min-width: 768px) { + .card-columns { + column-count: 3; + } +} + + +span.icon { + display: inline-block; + position: relative; + top: 2px; + left: 0px; + width: 16px; + height: 16px; +} + +span.icon-severity-donotshow { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAWJJREFUeNrUU79PwkAUvv5IG6iAg8HEMIlxNLBCNxkkYah/gP6NmAChiYQUupjA4uCicbKJBQ0E24OWtvjdgCHGdmHyJV/efffuvrx37x232WzIPsaTPW1vAXGXaJpGRFGUOY67AD0FMsAX8IpSH4Mg8JrNZrwATI6i6KrRaGjVarWWTqePXde1TdO873Q67GYX8GIFwjAs1ev160qlckMp5QG2XQC/Xa1WnK7r7+APsQLr9fq8XC7XbNvmWXd832d7BFnxxWKxhnUvUQAXcqg/7zgOcRyKjIKfmCRJeRZPfETP89zJ5HO6XIYngiAThq1R6k5ZPLGNOPBsGMYglcpEsqyQLSRJicbj8YDFEzOwLGvUat21KfVFVb1Us9nc0WIx/xgOe2a/323PZrPRbwFud5RRvyQIwpmiKCX4AvgB4g6684Z2PsG/MJ4kIMAdsnn4Y+jYi85x3o8V+J+f6VuAAQDR57f+eJX9fgAAAABJRU5ErkJggg=='); +} +span.icon-severity-hint { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAmVJREFUeNqkU01rE2EQnt3sV2JiGslHTUMTCznVEqTYVtJDsBd7FH+AQYSCVwnUH+BB0ouIh6hIwZMeepMieAjStHgICLmIgaQtNVUSaTeJSbq72TjzrrsY8ebA8348M8+878y+y41GI/gfE2jI3CmB1+sDv38CFMWd4Hl+DelVRPJ3XBWxbZpmYTDo76vqKXS7HSi+TFsJbMOALA9n+cwVb/Da3DmIhix3o2mk9io/U8Vy/y7G5JDaHLuBLXaLg8L67biUuCiBVdqQ+aYjHMQnfbB0WQ4+fHFQUE0T7CQ8DbquJ0yjl1/PTkmxEE97MAwDS9tloDVx5KMYiiWNk6DT6a6tLPiDsbDLERPa7TaDvWdJMOb6VX+QNE4J/f5gdWUpzAJso/X7ZwswHJpQq9VxNhjvcgmwOOuB528G1OQHglU/n0xEPThr7CRVbUOv14eb978w0dZGEnjeahe1JhJwMY1zA0HwoIducobiDjUUTxItnvmlsW8/wsodHw2Kcr5aPeylLng0FEoIKxB5NouiMpbgqGlpnCa63YHtD2WdHhFIkuwAeYY/OVlWYK9iaZwEsuwrFMtcq6lSAjc7kaAofgZ7Tzj+IcNuRWyRxkmA9e5rhpLbeGVo309kFigICrx9EmGgNXENFD9+DRrFkoa0HL24mfkccJwLAtHlbGxmOZ+Zl4LplAhTYZ4196g5hNInHYplvfW1vpOrlR9tnjR22GtlCTiOsxsaCcVvzE7P3bs1MbmY5njxEuu6qddPv30sHVaebjUP3n1G6hih/Z2AyvEiPHZp/zD6Qfr0eOlHIe0vAQYA3M8ZunBCGsoAAAAASUVORK5CYII='); +} +span.icon-severity-suggestion { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAopJREFUeNqkk89rE0EUx9/sj2R/JatptU0pUk1tKrXUoogHFYUiFQMFT/4PrQevevUqQsGTd69FUfReBSkoaCn0lz+wsdGmaZtNdzfdnZ113uRXPXlw4O2+eTOf77z3dpbEcQz/MxTxGH0JQCQASeYz0guEFLhzkVt/c1+R2wLEMd8Y/wIW8RcDulhoCBwak4auTF84158fynX3WGbSxAT39w/clS/lax8//5zy/PAJ3/fmrwxacMbW7t8ujI6ziFl1L4DfpbJYsG07PXQyk84NZLIvXi/Zu3setESkBhtnDU2emZo8M+5Ua5bj7EO5XIa5R3lh6DuOC77rWTeuD47rSWkGmY4AiwpjZ7N5x6lZruuB7/sQBEE7NfQxhmsHvm8N5TJ5ZNolxBG91Ndr9VT3nDbEGIP8zTmIIgaKqkBEIxGXFZmXdLQHGT592ugBo/2yBEYURUD5xiovwXVdePbwBP8gBO48KIKkJBvFcgtDaiDTaSKjhFJK/AMK1Vqdn05AUpMC1nUd5IQGkqR2siMKQaYjENFiZcf33EC25IQJsgjVBWxZFigJg6eutQXCeuAhc6iJdGHjR2VLM9KQ0FLCFNUQsGmawm/F0fYqtTIyHYGIPt9Y/bbGc3NVLQ1qMiVORRjLQB9jaDQEd3ujuIpMp4SYlQLfm12cn7dHrk6M6qkjpqJZMHH3q1hOHTsl3l511115/26R1r1Zfu1LGCP4M+EpJDEIoF65pRrD9/qGx04fHxzpMuyM3gB3/K31pcrm8qe10Ft+DOH8qzhYB8E2BTCTLm7dIA30gnT+MpC+Ea6YbbatBPHmErAPb4F93+IBvOPbnKUtAeyF0TTlH38w3igXE+Ms+yPAAAHpKViFf4n+AAAAAElFTkSuQmCC'); +} + +span.icon-severity-warning { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAnRJREFUeNqkk8tu00AUhk/GTgpVUidpmrYqoVQtd1KVInUBKOIqLgvgHaiQ2CBYsKAbxBoWrLpjxROAUDYgYIEQCoKEiwKElksi4jRNolhx7NozHjPjOCZhwwJLnzzHPv8/c2bm+Gzbhv95fNwg8wABQizwOd/ijJOMOca4myczcozHjCqfk1KAhfMUxL8Mj4iB8MWRyQvJaHxha2BgKAQs2TSarcbaq+J66dFpYjbvsbznXUGvwdHAYOLazP4bqYCtSTapAmW4SZHR0dlIZGR+28q7O5Khlflan/F/yBXHkRhenJ69nkJmWcJGDQjZgKXbqgMfY6MBiNSlqb2XU0gILbqldgxYPadiW84kAVclbLaYQHdQFMWhG2OsAqKqFB07lOQarwTLggND0V0JbMhePdgyIZN5A5VKBa5cmgPKkpwZBQE2DYQTXMPC+12DCVEQQtiwgFAMTVaChtuQy+U6ZsIGgNAxtgCz49JDXNO7AkSZUCdNUIw1oLYFIvqzu35E+o4K0baj8QyIBbLSWlV1UpQEZHcng3r1lvOuVdN9BsTQVK7pXcHbmpw7HhkPSjY1vcTh+E3n/S0/32fQrMsy13inwIL0WrFQsLHVCggE/AJ28EpwYw411FajXCtwTe8xVnTNWM6/fp/FuqHymv0Ig1pfcuBjDtba6ufsj6yu42Wu8XrBx5pgz3aAc8fgbHKncDUxJe2YSARjwZB/M09SW1j/VVJrpe9K4cMX6+7Dp5DOfwVwtK4B34thRmz3NIydOAiHZyZhn1/sNBMmIK/8hI9PXsKLT6vA7/c631umJV0DXsqgi/iPDuY3qs3QmJb+FmAA3WlJUh7WYcUAAAAASUVORK5CYII='); +} + +span.icon-severity-error { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAlNJREFUeNqkk0trGlEUx888nMxoFWO1BEss6KR2QkkIJCmkFMwmm9CN7bZfwk+QT+CqH0AoZFWMkEV2xUIfgaaLEuiQRukLEhW16piZcZwZe+71gaZ0lQv/mfv6/c+5L2YwGMBNCk8+rxgGOPwTMQBRVBqrW6jYaN4v1AcMlUddONggeoHB+WuGTz2SlImvrMjRpaWQFAiIJEOj01m7OD9/8v309JllGFmcdziTwRiWgsG9R7u7yoDnpZ5tQ6/dHo95FxTFG4nHb58cHe1dDfupCUs+mFaUE8XM2s6O0tZ1aSGXg263OyPaZ5rSw1RKYefmMoSZGOB60rHlZfmPpkn383ka8kGhAJqmUZE6KcmDA9DQ5E4iITvDfRouwQZ47I9EQvVmE17LMjwvlcA0TYjt74Ou61DCNsuy8GZ9HTweD4Tn50OEQfQlOzJYdFlWtHHdfp8PcuEwqKoK1WqVGjF4Sm8RFsgp9ftg6rpImOkMwMYBC9PVMQuf6wLHcTSaIAhUt6Z2m8O59qhOM+gD/NZqNXNQr4PkOJAsFmdgnuchdXYGXnIcBLAskzATA3R7X6tUGn6cuDgFf9nYgE+rq9SAaKtcpgYdw2gSZtog/6PRKONOGT+3tyn8FeG7OEZ0nEzSdN8lEuC6rlHp9UqEmRi4eD2vHCf7sV5XQwDGt81NuIf90kikfoxwEOGTblfVXTdLmJmbiB2Hl3gKhVYrowiCHBCEUJDjRDLWchzz0rKaqmWV+ggH/nOVqUnddT8XTTON+ucx4WPL+0eRx4W56XP+K8AAhm8R+uHmzJwAAAAASUVORK5CYII='); +} + +span.icon-class-module { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAAAXVJREFUOE9jYEADy9o5lDdOEv4MotHlCPJBmrZOE///42bU/yWtbD9IMgSkeNNkkf/vzgf8f37C8/+z4x4gQ/4SZQhI0YaJQv8fHXb5f3OX9f9z6w3A+PERV7AheJ0O0ry2j///te0W/0+v0f2/f4EiGB9brgHSfAvmAv15Z5R5a1b9B9EoBgIVPDy+QvP/kaVq/0GuAOF98xUwNBsvuvC/+NH//xiGwFwAshGkefsMSbDmmpqa/zCsMvng/8wb/8EYZAhf7er/6K5QXt3DC3Y6UPNNmLNBBgjUr/3vsvkhWLPD+rsgF5zF8AbINJCm5R2cv5BDHWQASDHIRpPFF//zVK88h1UzrlAGGQCSgwbg2aKGLY4LrMv/H4qdSVwigxkAMgSk6Vji3P//F979T7QhyAG5Pajv/5/pl8EYZMhCmwrUgMSXWBbbVf2/XrQOrPly3iqQC84S7Q2Y80E2Hk+a93++Vdk5kjTDXAbSRLLNBLMvUAEAlVALfhoEMEwAAAAASUVORK5CYII='); +} + +span.icon-predeclared-class { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAB3RJTUUH4gwXAxg1SYhwLQAAAnhJREFUOMuFk09Ik3Ecxj/v6+yVbelFGPNQbq+u6KID0YvpLmN46NY8SCEYvN2KCALp8oKCtyDo4kShAqmMThVBMJhEhxD/DMIt372iMnROl5NNfY3et0PbHEb6Pf+eh8/3eb4/gVMzPVYnOxyOxWKx2D4wfJTinBFPixsaGrRQKOQ0TfP79FidfJ6BUC12Op1ad3c3hmFgWRbRaNQURdF3FolYFtvtdq29vZ1sNsvm5iZbW1v09vaKpmn+OIvANj1WJ0uSpPl8PjKZDIVCAQBJkojFYiuiKPYBtE3Nybqua16vt2VpqCNVTRBtamoil8uRTqfJ5/NYloWu6yuiKPYNDB+l2qbmZJvNpilKGF3XtbapuUo2QpnA7Xazvb2NJEns7u6uJA4ftZYfvXIFCQZ7ALDbYWLiLfsjYQFAHBg+ShmG0bKwsUFMEBjJZPJ3fh3nRm2jn7JkeVbjp7m5GYDlZZ1IZGbe4/G0/NPCBZX5Y/A3IjzdwboP4LJcFOrcSs3B44jP5yORSCzIshw+nQGoXDkGP8AOVhyYBMiTpzi8OOHxeFqSyeS8Uut4+GByRpu9PS6fGKh4gUbgFrCOyhTwGrjXZXaBSn5pvWP/w9rP/ptrmeigoqDresVEBIaAL8ATVC6XiD4DL2I1MYB6v+nfLhaLWmdnJ78PDhhUFFZXV7WygVKisVAJVS5EJV9axdo5SFcFuczzSKQSZA0B9oAbwAhwRIBWAiQJ8BH4BixdFOyxq19d1w3DIB6PL8iy3N/z8m7qbwsq9aWdLwHXgEMgDLyvutjW2dS4pev6G6/XWxGf1KgiAPtALbAHBIFFwALeodL/v7/wBwTrC1CT0SVqAAAAAElFTkSuQmCC'); +} + +span.icon-standard-module { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QAAAAAAAD5Q7t/AAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAB3RJTUUH4AcCEh4u1YzgtwAAApVJREFUOMudk11IU2EYx//v8ZxNHSRjeMxRic7IKD9a2Y2BRRFMiq40g7BBVFhoBOnAHZNwmUY3UklZWRSoK/u4MLwIrGMZmKATM6Zusw060fRi7syPnbmdLmKyahn03L0Pz/t7nx9/3gSsUa/a0sxlB5XrsjRLjnfj8WeoeM2SbaB6b7FcQUGBRaPR9Obk0Ef+9sgfgPoKUJWVqQ0ZGRmNFEVBp9PJWq32RefVREN0ZuqYtTouoKs5idbr1ddZlr2kUCjgdrvh8XgIy7LzhJBCAHBVPH+QmZnZ6jze0wEAdCyAEHJSrVZfIITITqeTMAyDcDgcsNvt1caGyKOBfa1nEpXKE8FgECzLGodL2tf/DhiSJOkbISRdkiSshEJ+XwAXr71ufAyYIQgCUlJSsLy8DFEUCc/zBirW62jtgm3AbUwXRRFKpTIw7/dXTS1Wtn9+b5YB4FMpc9vhcDz0+XwYHByEJEn3EPUK1X2Uo17dLcl7u1uSTVE4x3Eyx3Fy9Ny9xXS+HoeqAICO51VoOl0C4C0AKMdBYP01qfLJltbVFARBwOzsLLxeL1wuF+F5fjWuPQ69vcbKRZKqEhQWi4VEt6h+Iha/EcOKVeKz7VzHyOH7clvqKbmJKb0LAMVfdo1WeMoiB5xF8o7v2bl1EzU0AJj7lmxNA2G55qVfDwAk1mticiLSiN4bAKB7qpW35ucgWaXC6MsxTJ/zkPKb0815eZtqaVpBQiFg6EO/gY7nBQBEpmwzCzP5wb4VsuBZBAAIwleoVCpkZaWB54dgszl2k7U+0+aeDWPz/Yt5SXLiTuNZ4/jl3KaQ4crwWCAQyJ2bmyMroaUi/Ku0RnY/N2JiYnvZxk77xvI7+q5pL4P/Lavz5+UfbCoaBjVjrV0AAAAASUVORK5CYII='); +} + +span.icon-interface-module { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAB3RJTUUH4wENACcNN3r8OQAAAl9JREFUOMuNk1FIU2EUx3/fds2V405T72VMFwob6khhoIHDEMGegkV7C4p66aUX8yEi26Mg+BaElL33sF6E3kIrTRZLxsIZpMFyepP1YN5V3Otm3h6W0xy0/k/fd/7f/3zn/A9HUAUD01/4aezRINcwe91dwduqJdgv7jJ8rpHM523OP1qv4MXBoWdimeLeL4rFfYQ4ZBy1Ndy4HGAtu83z2QxKs5Ol0c7Kn9yj8xWxu29+cGf+OzWhe4iGMwLAVtco/NG38LS5VEE0GkUIwYOtPnamLwrA+qvEjjBWW9rNhe0JnLZuTol97OIjFmNARkSj0TYhxDjgtyxr1TTN25OTk18BC2GHJ/VeLGuh92xvratFVoVNoGv5XGL53S4O26C9v79/qru7eygUCvkkSVJyuVyH2+1+FggECHR18qFnfaqvq9dX3+pShSgZ45BrnU0nm0zt21a7ZJqmT1EUVdd1FEVRTdP0ATePdOF3eWQVIJlMAhAMBnF5ZJVP+KuOsRokwzDWUqlUi9frVbPZbM4wjLVYLPa45KAdIqcHdS3f4vLIajAYLAt1LZ8DVu0+ny+5sbHRvrKyUtjc3FwqFApj6XR6p/TMgkhdUnufudrUpJoOudZ5IE7MLe4iS9fEyMgI4XC4nHlmZgZN08r3WOQVLOht7FnjgP9PeBVJjDHgykj/0+fw/UsZ4MpREwFevH6JZBgGqVQKr9dLNpvFMAxisdihOtKMruVxeWSOeVAyUdd14vE48Xi8tF22Y4OxIDG3SN9QCJdHLosTc4vQ6jhcpn/i1gnYs47NT8DDAr8B53nldozKQcoAAAAASUVORK5CYII='); +} + +span.icon-userform-module { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAadEVYdFNvZnR3YXJlAFBhaW50Lk5FVCB2My41LjEwMPRyoQAAAYdJREFUOE+l079LAnEYx/HvnyBCQ1ubQ5uTS1JhhIShhEEgUfSTkiCKCBwaQoJGQ4oIgiCIMOrCzUw0zAg1SpCgCAVRUkzhrDPz+sRzcGLj6cGb7/d5uBfccoy1+wxsB2397mSsb+81pSQyZFmvKxFbvxXEradfOOMiNu9r2AhX4Qh+YdVfgd3HY+KKh/m6An1IQPfdNzpjP+i6+RTJsh5XMj17+YEZroip8wImz94xfpqF7SSDseM0Ro/eMHL4AvPBM4b3kxjaTcDofsTgzgM6Fi9SzGq1zqHFhyyzWCzzoigiGo1K+Xy+xr15lveZTAb1eh1kyDKTybRQq9VQrVYVRYYsMxqNdkEQEIlEpAKBQOPePMt7+SRDlhkMhiWe51EqlRRFhizT6/XL5XIZoVBIiuM4qeZZ3tMZj8dRKBRAhizT6XQrxWIR2WxWUWTIMq1Wu5bP5+HxeOD3+6XC4fC/Wd7T6fV6pXfIkGUajcZBn5TL5RRFhixTq9XTKpXK2Upk2/0X2/d/UwttUb0OuJsAAAAASUVORK5CYII='); +} + +span.icon-document-module { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAApJJREFUeNqMU09IFGEU/83s7Kqr69riLonE9E8jNxM6CR3c9CTRJbooeBMqunRN0e1QZESR0aEiEAShWPDUwRBDocQOxeyhAt0y1PDP7upqubOzOzPbe+OMrJfowfveN9/3fr/v9973jXBlcBBsgiBcolCHf9s70zQTpmGgWCwitbQEgSfRkRF8WVy8OhqNPsvTZoHdNGHQHu+zKYqC0cnJaxaJYSw460wQoRghgu4GWW7QdR1qoYDfuRzyNNeJiJPj8Ti6Oztxub//OuVP0dqCQ3A7caMjqn2dhcfrQU1rB2r6YsgxmJQYRPBH0/BmYmK/jidjYwMU7vBc4oHBR/pfwlN3FDvD3XC7XLj1fAPLyymsrWUw/vQc2tvbLSU+nw9vFWXLIRN54JPL6o6hPHwevgtd1kZLYzVe32uG3++FKAiQRJEbbe2RMoHVsVsK/CR7e7jLAhvzH7Hx4yfef0hD004iEKiCRIo8EqVSWUxi6LrgKGCC6dDAODeyjReM1U/IFjXc7D2M0/V+9F70I5vPo9zttgCuPQLxAAH76lR9W2VtJcRMPdA8jMezjyCXyUilUrh74r7VF4uAol5CsD/JJrOobpmHWfMLocBZyK0y+mr7kEwmwXq5D0zipl4QgcDXzS45BOqmiu+vDsEb9KKavvnknm89SKfTVt2i3UCrhELhQAls01rkc8+ZpqbjfFUm+YNTD4FGeol7Xd8n4LG0BKkhEkFlMMh9eKHEYkO8yCQMNO2nXLRLgB11+xZ2qTwxMTODzZUVqLu7kv1TWUlO3c63SLU765zLGMaylIrlubnQ9tZWmSNLKAG4KLLzW5DoLfAtcC5jGMunVpCHdtbXM1Xh8BD+w0xVzTCG2/FXgAEAjik0I5QgmIIAAAAASUVORK5CYII='); +} diff --git a/rubberduckvba.database/Tables/Annotations.sql b/rubberduckvba.database/Tables/Annotations.sql index 021a113..3a232a2 100644 --- a/rubberduckvba.database/Tables/Annotations.sql +++ b/rubberduckvba.database/Tables/Annotations.sql @@ -7,9 +7,13 @@ [TagAssetId] INT NOT NULL, [SourceUrl] NVARCHAR(1023) NOT NULL, [Name] NVARCHAR(255) NOT NULL, - [Remarks] NVARCHAR(MAX) NOT NULL, + [Summary] NVARCHAR(MAX) NOT NULL DEFAULT(''), + [Remarks] NVARCHAR(MAX) NULL, [JsonParameters] NVARCHAR(MAX) NULL, [JsonExamples] NVARCHAR(MAX) NULL, + [IsNew] BIT NOT NULL DEFAULT(0), + [IsDiscontinued] BIT NOT NULL DEFAULT(0), + [IsHidden] BIT NOT NULL DEFAULT(0), CONSTRAINT [PK_Annotations] PRIMARY KEY CLUSTERED ([Id]), CONSTRAINT [NK_Annotations] UNIQUE ([Name]), CONSTRAINT [FK_Annotations_Features] FOREIGN KEY ([FeatureId]) REFERENCES [dbo].[Features] ([Id]), diff --git a/rubberduckvba.database/Tables/Features.sql b/rubberduckvba.database/Tables/Features.sql index 4726fb2..93c4201 100644 --- a/rubberduckvba.database/Tables/Features.sql +++ b/rubberduckvba.database/Tables/Features.sql @@ -12,7 +12,8 @@ [IsHidden] BIT NOT NULL, [IsNew] BIT NOT NULL, [HasImage] BIT NOT NULL, - CONSTRAINT [PK_Features] PRIMARY KEY CLUSTERED ([Id]), + [Links] NVARCHAR(MAX) NULL, + CONSTRAINT [PK_Features] PRIMARY KEY CLUSTERED ([Id]), CONSTRAINT [FK_Features_Repositories] FOREIGN KEY ([RepositoryId]) REFERENCES [dbo].[Repositories] ([Id]), CONSTRAINT [FK_Features_Features] FOREIGN KEY ([ParentId]) REFERENCES [dbo].[Features] ([Id]), CONSTRAINT [UK_Features_Name] UNIQUE ([RepositoryId], [Name]) diff --git a/rubberduckvba.database/Tables/Inspections.sql b/rubberduckvba.database/Tables/Inspections.sql index 6aed520..cfe7061 100644 --- a/rubberduckvba.database/Tables/Inspections.sql +++ b/rubberduckvba.database/Tables/Inspections.sql @@ -16,6 +16,9 @@ [References] NVARCHAR(MAX) NULL, [QuickFixes] NVARCHAR(MAX) NULL, [JsonExamples] NVARCHAR(MAX) NULL, + [IsNew] BIT NOT NULL DEFAULT(0), + [IsDiscontinued] BIT NOT NULL DEFAULT(0), + [IsHidden] BIT NOT NULL DEFAULT(0), CONSTRAINT [PK_Inspections] PRIMARY KEY CLUSTERED ([Id]), CONSTRAINT [NK_Inspections] UNIQUE ([Name]), CONSTRAINT [FK_Inspections_Features] FOREIGN KEY ([FeatureId]) REFERENCES [dbo].[Features] ([Id]), diff --git a/rubberduckvba.database/Tables/QuickFixes.sql b/rubberduckvba.database/Tables/QuickFixes.sql index b1841e3..1c0dc2a 100644 --- a/rubberduckvba.database/Tables/QuickFixes.sql +++ b/rubberduckvba.database/Tables/QuickFixes.sql @@ -16,6 +16,9 @@ [CanFixAll] BIT NOT NULL, [Inspections] NVARCHAR(MAX) NOT NULL, [JsonExamples] NVARCHAR(MAX) NULL, + [IsNew] BIT NOT NULL DEFAULT(0), + [IsDiscontinued] BIT NOT NULL DEFAULT(0), + [IsHidden] BIT NOT NULL DEFAULT(0), CONSTRAINT [PK_QuickFixes] PRIMARY KEY CLUSTERED ([Id]), CONSTRAINT [NK_QuickFixes] UNIQUE ([Name]), CONSTRAINT [FK_QuickFixes_Features] FOREIGN KEY ([FeatureId]) REFERENCES [dbo].[Features] ([Id]), diff --git a/rubberduckvba.database/Views/HangfireJobState.sql b/rubberduckvba.database/Views/HangfireJobState.sql new file mode 100644 index 0000000..04930d7 --- /dev/null +++ b/rubberduckvba.database/Views/HangfireJobState.sql @@ -0,0 +1,43 @@ +CREATE VIEW [dbo].[HangfireJobState] +AS + +WITH meta_xmldoc AS ( + SELECT [Field], [Value] + FROM [hangfire].[Hash] + WHERE [Key] = 'recurring-job:update_xmldoc_content' +), +meta_tags AS ( + SELECT [Field], [Value] + FROM [hangfire].[Hash] + WHERE [Key] = 'recurring-job:update_installer_downloads' +), +jobState AS ( + SELECT s.[Id], [JobId] = j.[Id], j.[StateName], s.[CreatedAt] + FROM [hangfire].[Job] j + INNER JOIN [hangfire].[State] s ON j.StateId = s.Id AND j.Id = s.JobId + WHERE s.[Name] IN ('Succeeded', 'Failed') +), +pivoted AS ( +SELECT + [JobName] = 'update_installer_downloads' + ,[LastJobId] = src.[Value] + ,[CreatedAt] = (SELECT [Value] FROM meta_tags WHERE [Field]='CreatedAt') + ,[NextExecution] = (SELECT [Value] FROM meta_tags WHERE [Field]='NextExecution') +FROM (SELECT [Value] FROM meta_tags WHERE [Field]='LastJobId') src +UNION ALL +SELECT + [JobName] = 'update_xmldoc_content' + ,[LastJobId] = src.[Value] + ,[CreatedAt] = (SELECT [Value] FROM meta_xmldoc WHERE [Field]='CreatedAt') + ,[NextExecution] = (SELECT [Value] FROM meta_xmldoc WHERE [Field]='NextExecution') +FROM (SELECT [Value] FROM meta_xmldoc WHERE [Field]='LastJobId') src +) +SELECT + p.[JobName] + ,p.[LastJobId] + ,p.[CreatedAt] + ,p.[NextExecution] + ,s.[StateName] + ,[StateTimestamp]=s.[CreatedAt] +FROM pivoted p +LEFT JOIN jobState s ON p.[LastJobId]=s.[JobId]; \ No newline at end of file diff --git a/rubberduckvba.database/rubberduckvba.database.localdb.publish.xml b/rubberduckvba.database/rubberduckvba.database.localdb.publish.xml new file mode 100644 index 0000000..cb2c37b --- /dev/null +++ b/rubberduckvba.database/rubberduckvba.database.localdb.publish.xml @@ -0,0 +1,10 @@ + + + + True + rubberduckdb + rubberduckvba.database.sql + Data Source=(localdb)\MSSQLLocalDB;Integrated Security=True;Persist Security Info=False;Pooling=False;Multiple Active Result Sets=False;Connect Timeout=60;Encrypt=True;Trust Server Certificate=True;Command Timeout=0 + 1 + + \ No newline at end of file diff --git a/rubberduckvba.database/rubberduckvba.database.sqlproj b/rubberduckvba.database/rubberduckvba.database.sqlproj index 6887309..0753b36 100644 --- a/rubberduckvba.database/rubberduckvba.database.sqlproj +++ b/rubberduckvba.database/rubberduckvba.database.sqlproj @@ -58,14 +58,16 @@ + - + + diff --git a/rubberduckvba.sln b/rubberduckvba.sln index ee460c7..0818f14 100644 --- a/rubberduckvba.sln +++ b/rubberduckvba.sln @@ -13,6 +13,7 @@ Project("{00D1A9C2-B5F0-4AF3-8072-F6C62B433612}") = "rubberduckvba.database", "r EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{4F5F292F-9F99-47E7-9D88-0DC0D886424F}" ProjectSection(SolutionItems) = preProject + .github\workflows\dotnet-cd-prod.yml = .github\workflows\dotnet-cd-prod.yml .github\workflows\dotnet-cd.yml = .github\workflows\dotnet-cd.yml .github\workflows\dotnet-ci.yml = .github\workflows\dotnet-ci.yml README.md = README.md