diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc78471 --- /dev/null +++ b/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/Flow.ConfluenceSearch.Test/ConfluenceQueryBuilderTest.cs b/Flow.ConfluenceSearch.Test/ConfluenceQueryBuilderTest.cs new file mode 100644 index 0000000..6ad9234 --- /dev/null +++ b/Flow.ConfluenceSearch.Test/ConfluenceQueryBuilderTest.cs @@ -0,0 +1,138 @@ +using Flow.ConfluenceSearch.ConfluenceClient; +using Shouldly; + +namespace Flow.ConfluenceSearch.Test; + +public class ConfluenceQueryBuilderTest +{ + [Theory] + [InlineData( + "test", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test*", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test example", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test* example*\" OR text~\"test* example*\") order by lastmodified DESC" + )] + [InlineData( + "test* example*", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"test* example*\" OR text~\"test* example*\") order by lastmodified DESC" + )] + [InlineData( + "*test", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"\\*test*\" OR text~\"\\*test*\") order by lastmodified DESC" + )] + [InlineData( + "\\*+-!():^[]{}~?|&/\"'", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND (title~\"\\\\\\*\\+\\-\\!\\(\\)\\:\\^\\[\\]\\{\\}\\~\\?\\|\\&\\/\\\"\\\'*\" OR text~\"\\\\\\*\\+\\-\\!\\(\\)\\:\\^\\[\\]\\{\\}\\~\\?\\|\\&\\/\\\"\\\'*\") order by lastmodified DESC" + )] + [InlineData( + "test #myspace", + "space IN (myspace) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test #myspace #yourspace", + "space IN (myspace,yourspace) AND type IN(page,blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test @me", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND (contributor = currentUser()) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test @john", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND (contributor.fullname ~ john) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test @john @me", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND (contributor = currentUser() OR contributor.fullname ~ john) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test +label1", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND label IN (label1) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test +label1 +label2", + "space IN (AAA,BBB) AND type IN(page,blogpost) AND label IN (label1,label2) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test /", + "space IN (AAA,BBB) AND type IN(folder) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test .", + "space IN (AAA,BBB) AND type IN(page) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test \"", + "space IN (AAA,BBB) AND type IN(blogpost) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test / . \"", + "space IN (AAA,BBB) AND type IN(folder,blogpost,page) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test *", + "space IN (AAA,BBB) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + [InlineData( + "test / +label1 @me #myspace @john . +label2 \" #yourspace", + "space IN (myspace,yourspace) AND type IN(folder,blogpost,page) AND label IN (label1,label2) AND (contributor = currentUser() OR contributor.fullname ~ john) AND (title~\"test*\" OR text~\"test*\") order by lastmodified DESC" + )] + public async Task BuildTextCqlTest(string input, string expectedOutput) + { + // Act + var queryBuilder = new ConfluenceQueryBuilder(); + + var output = await queryBuilder.BuildTextCql( + input, + ["AAA", "BBB"], + TestContext.Current.CancellationToken + ); + + output.ShouldBe(expectedOutput); + } + + [Theory] + [InlineData("test", "spaces=AAA,BBB&text=test")] + [InlineData("test*", "spaces=AAA,BBB&text=test*")] + [InlineData("test example", "spaces=AAA,BBB&text=test example")] + [InlineData("test* example*", "spaces=AAA,BBB&text=test* example*")] + [InlineData("*test", "spaces=AAA,BBB&text=*test")] + [InlineData( + "spaces=AAA,BBB&text=\\*+-!():^[]{}~?|&/\"'", + "spaces=AAA,BBB&text=spaces=AAA,BBB&text=\\*+-!():^[]{}~?|&/\"'" + )] + [InlineData("test #myspace", "space=myspace&text=test")] + [InlineData("test #myspace #yourspace", "space=myspace,yourspace&text=test")] + [InlineData("test @me", "spaces=AAA,BBB&text=test")] + [InlineData("test @john", "spaces=AAA,BBB&text=test")] + [InlineData("test @john @me", "spaces=AAA,BBB&text=test")] + [InlineData("test +label1", "spaces=AAA,BBB&labels=label1&text=test")] + [InlineData("test +label1 +label2", "spaces=AAA,BBB&labels=label1,label2&text=test")] + [InlineData("test /", "spaces=AAA,BBB&type=folder&text=test")] + [InlineData("test .", "spaces=AAA,BBB&type=page&text=test")] + [InlineData("test \"", "spaces=AAA,BBB&type=blogpost&text=test")] + [InlineData("test / . \"", "spaces=AAA,BBB&type=folder&type=blogpost&type=page&text=test")] + [InlineData("test *", "spaces=AAA,BBB&text=test")] + [InlineData( + "test / +label1 @me #myspace @john . +label2 : #yourspace", + "space=myspace,yourspace&type=folder&type=page&labels=label1,label2&text=test" + )] + public async Task BuildQueryForOpenInBrowserTest(string input, string expectedOutput) + { + // Act + var queryBuilder = new ConfluenceQueryBuilder(); + + var output = await queryBuilder.BuildQueryForOpenInBrowser( + input, + ["AAA", "BBB"], + TestContext.Current.CancellationToken + ); + + output.ShouldBe(expectedOutput); + } +} diff --git a/Flow.ConfluenceSearch.Test/ConfluenceSearchClientTests.cs b/Flow.ConfluenceSearch.Test/ConfluenceSearchClientTests.cs new file mode 100644 index 0000000..bb5900f --- /dev/null +++ b/Flow.ConfluenceSearch.Test/ConfluenceSearchClientTests.cs @@ -0,0 +1,186 @@ +using System.Linq; +using System.Net; +using System.Text.Json; +using Flow.ConfluenceSearch.ConfluenceClient; +using Shouldly; + +namespace Flow.ConfluenceSearch.Test; + +public class ConfluenceSearchClientTests : IDisposable +{ + private readonly TestHttpMessageHandler _httpMessageHandler; + private readonly HttpClient _httpClient; + private readonly ConfluenceSearchClient _searchClient; + + public ConfluenceSearchClientTests() + { + _httpMessageHandler = new TestHttpMessageHandler(); + _httpClient = new HttpClient(_httpMessageHandler) + { + BaseAddress = new Uri("https://test.atlassian.net/"), + }; + var httpFactory = () => _httpClient; + _searchClient = new ConfluenceSearchClient(httpFactory); + } + + [Fact] + public async Task SearchCqlAsync_WithValidCql_ReturnsConfluenceResponse() + { + // Arrange + const string cql = "project = TEST"; + const int maxResults = 50; + var cancellationToken = CancellationToken.None; + + var expectedResponse = new ContentSearchResponse + { + Results = new List + { + new() + { + Excerpt = "TEST-1", + Content = new ConfluenceContentDto + { + Id = "Test Issue Summary", + History = new ConfluenceHistoryDto + { + LastUpdated = new ConfluenceLastUpdatedDto + { + By = new ConfluenceUserDto + { + DisplayName = "John Doe", + ProfilePicture = new ConfluenceProfilePictureDto + { + Path = "High", + }, + }, + }, + }, + }, + Title = "TEST", + Url = "Bug", + ResultGlobalContainer = new ResultGlobalContainerDto + { + DisplayUrl = "/spaces/John Doe/pages", + }, + }, + }, + }; + _httpMessageHandler.SetResponse( + HttpStatusCode.OK, + JsonSerializer.Serialize(expectedResponse) + ); + + // Act + var result = await _searchClient.SearchCqlAsync(cql, maxResults, cancellationToken); + + // Assert + result.ShouldNotBeNull(); + result.Results.ShouldNotBeEmpty(); + result.Results.Count.ShouldBe(1); + + // Test issue details + var issue = result.Results[0]; + issue.Excerpt.ShouldBe("TEST-1"); + issue.Content.Id.ShouldBe("Test Issue Summary"); + issue.LastUpdatedByAvatarPath.ShouldBe("High"); + issue.SpaceKey.ShouldBe("John Doe"); + issue.Title.ShouldBe("TEST"); + issue.Url.ShouldBe("Bug"); + } + + [Fact] + public async Task SearchCqlAsync_WhenApiReturnsError_ThrowsApplicationException() + { + // Arrange + var errorContent = "server error details"; + _httpMessageHandler.SetResponse(HttpStatusCode.InternalServerError, errorContent); + + // Act & Assert + var ex = await Should.ThrowAsync(async () => + await _searchClient.SearchCqlAsync("invalid cql", 10, CancellationToken.None) + ); + ex.Message.ShouldContain(errorContent); + } + + [Fact] + public async Task SearchCqlAsync_WithEmptyResults_ReturnsEmptyList() + { + // Arrange + var expected = new ContentSearchResponse { Results = new List() }; + _httpMessageHandler.SetResponse(HttpStatusCode.OK, JsonSerializer.Serialize(expected)); + + // Act + var result = await _searchClient.SearchCqlAsync("no results", 10, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Results.ShouldBeEmpty(); + } + + [Fact] + public async Task SearchCqlAsync_WithSpecialCharacters_PreservesFieldsAndBuildsBrowseUrl() + { + // Arrange + var special = "\\*+-!():^[]{}~?|&/\\\"'"; + var expected = new ContentSearchResponse + { + Results = new List + { + new() + { + Excerpt = special, + Title = special, + Url = "/pages/viewpage.action?pageId=123", + Content = new ConfluenceContentDto + { + Id = special, + History = new ConfluenceHistoryDto + { + LastUpdated = new ConfluenceLastUpdatedDto + { + By = new ConfluenceUserDto + { + DisplayName = special, + ProfilePicture = new ConfluenceProfilePictureDto + { + Path = "/avatars/1", + }, + }, + }, + }, + }, + ResultGlobalContainer = new ResultGlobalContainerDto + { + DisplayUrl = "/spaces/KEY/pages", + }, + }, + }, + }; + _httpMessageHandler.SetResponse(HttpStatusCode.OK, JsonSerializer.Serialize(expected)); + + // Act + var result = await _searchClient.SearchCqlAsync("special", 10, CancellationToken.None); + + // Assert + result.ShouldNotBeNull(); + result.Results.Count.ShouldBe(1); + var item = result.Results.Single(); + item.Title.ShouldBe(special); + item.Excerpt.ShouldBe(special); + item.Content.Id.ShouldBe(special); + item.LastUpdatedByName.ShouldBe(special); + item.LastUpdatedByAvatarPath.ShouldBe("/avatars/1"); + + // BrowseUrl should contain base + /wiki + url + var browse = item.BrowseUrl(_httpClient.BaseAddress!.ToString()); + browse.ShouldContain("/wiki"); + browse.ShouldContain("viewpage.action"); + } + + public void Dispose() + { + _httpClient?.Dispose(); + _httpMessageHandler?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/Flow.ConfluenceSearch.Test/Flow.ConfluenceSearch.Test.csproj b/Flow.ConfluenceSearch.Test/Flow.ConfluenceSearch.Test.csproj new file mode 100644 index 0000000..7c2c67a --- /dev/null +++ b/Flow.ConfluenceSearch.Test/Flow.ConfluenceSearch.Test.csproj @@ -0,0 +1,25 @@ + + + net9.0-windows + enable + enable + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/Flow.ConfluenceSearch.Test/TestHttpMessageHandler.cs b/Flow.ConfluenceSearch.Test/TestHttpMessageHandler.cs new file mode 100644 index 0000000..7f04458 --- /dev/null +++ b/Flow.ConfluenceSearch.Test/TestHttpMessageHandler.cs @@ -0,0 +1,42 @@ +using System.Net; +using System.Text; + +namespace Flow.ConfluenceSearch.Test; + +public class TestHttpMessageHandler : HttpMessageHandler +{ + private HttpStatusCode _statusCode = HttpStatusCode.OK; + private string _responseContent = ""; + + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestBody { get; private set; } + public int RequestCount { get; private set; } + + public void SetResponse(HttpStatusCode statusCode, string content) + { + _statusCode = statusCode; + _responseContent = content; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + RequestCount++; + LastRequest = request; + + // Request Body lesen (falls vorhanden) + if (request.Content != null) + { + LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); + } + + var response = new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_responseContent, Encoding.UTF8, "application/json"), + }; + + return response; + } +} diff --git a/Flow.ConfluenceSearch.Tests/Search/ResultContextTests.cs b/Flow.ConfluenceSearch.Tests/Search/ResultContextTests.cs new file mode 100644 index 0000000..e02abfc --- /dev/null +++ b/Flow.ConfluenceSearch.Tests/Search/ResultContextTests.cs @@ -0,0 +1 @@ + diff --git a/Flow.ConfluenceSearch.sln b/Flow.ConfluenceSearch.sln new file mode 100644 index 0000000..ccd002c --- /dev/null +++ b/Flow.ConfluenceSearch.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flow.ConfluenceSearch", "Flow.ConfluenceSearch\Flow.ConfluenceSearch.csproj", "{11E99889-21C1-4BBC-807A-EEB752D51F87}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flow.ConfluenceSearch.Test", "Flow.ConfluenceSearch.Test\Flow.ConfluenceSearch.Test.csproj", "{A7739BB0-27AB-4D8A-80D9-20734EDE3700}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {11E99889-21C1-4BBC-807A-EEB752D51F87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11E99889-21C1-4BBC-807A-EEB752D51F87}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11E99889-21C1-4BBC-807A-EEB752D51F87}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11E99889-21C1-4BBC-807A-EEB752D51F87}.Release|Any CPU.Build.0 = Release|Any CPU + {A7739BB0-27AB-4D8A-80D9-20734EDE3700}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7739BB0-27AB-4D8A-80D9-20734EDE3700}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7739BB0-27AB-4D8A-80D9-20734EDE3700}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7739BB0-27AB-4D8A-80D9-20734EDE3700}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Flow.ConfluenceSearch/Build-Plugin.ps1 b/Flow.ConfluenceSearch/Build-Plugin.ps1 new file mode 100644 index 0000000..e6998bd --- /dev/null +++ b/Flow.ConfluenceSearch/Build-Plugin.ps1 @@ -0,0 +1,127 @@ +# Build and package Flow Launcher plugin +# This script builds the plugin and creates a ZIP file ready for Flow Launcher installation + +param( + [string]$Configuration = "Release", + [string]$OutputPath = ".\dist" +) + +$ErrorActionPreference = "Stop" + +# Plugin information from plugin.json +$PluginId = "BD32A62C-6F98-4541-AE8E-17B46458595F" +$PluginName = "Flow.ConfluenceSearch" + +Write-Host "Building Flow Launcher Plugin: $PluginName" -ForegroundColor Green + +# Resolve to absolute paths +$OutputPath = Resolve-Path $OutputPath -ErrorAction SilentlyContinue +if (-not $OutputPath) { + $OutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath(".\dist") +} + +# Clean and create output directory +if (Test-Path $OutputPath) { + Remove-Item $OutputPath -Recurse -Force +} +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null + +# Build the project +Write-Host "Building project in $Configuration configuration..." -ForegroundColor Yellow +dotnet build "Flow.ConfluenceSearch.csproj" --configuration $Configuration --no-restore + +if ($LASTEXITCODE -ne 0) { + Write-Error "Build failed!" + exit 1 +} + +# Define source and target paths +$BinPath = "bin\$Configuration\net9.0-windows" +$TempPluginDir = Join-Path $OutputPath "temp\$PluginName" + +# Create temporary plugin directory structure +New-Item -ItemType Directory -Path $TempPluginDir -Force | Out-Null + +# Copy required files to temp directory +Write-Host "Copying plugin files..." -ForegroundColor Yellow + +# Copy main DLL and dependencies +Copy-Item "$BinPath\Flow.ConfluenceSearch.dll" -Destination $TempPluginDir +Copy-Item "$BinPath\Flow.ConfluenceSearch.deps.json" -Destination $TempPluginDir -ErrorAction SilentlyContinue + +# Copy plugin.json +Copy-Item "plugin.json" -Destination $TempPluginDir + +# Copy Images directory +if (Test-Path "Images") { + Copy-Item "Images" -Destination $TempPluginDir -Recurse +} + +# Copy required NuGet packages (exclude system assemblies) +$ExcludePatterns = @( + "Microsoft.WindowsDesktop.App.*", + "Microsoft.NETCore.App.*", + "System.*", + "netstandard.*", + "mscorlib.*", + "WindowsBase.*", + "PresentationCore.*", + "PresentationFramework.*" +) + +Get-ChildItem "$BinPath\*.dll" | Where-Object { + $fileName = $_.Name + $exclude = $false + foreach ($pattern in $ExcludePatterns) { + if ($fileName -like $pattern) { + $exclude = $true + break + } + } + return !$exclude -and $fileName -ne "Flow.ConfluenceSearch.dll" +} | ForEach-Object { + Copy-Item $_.FullName -Destination $TempPluginDir + Write-Host " Copied dependency: $($_.Name)" -ForegroundColor Gray +} + +# Create ZIP file +$ZipFileName = "$PluginName-v1.0.0.zip" +$ZipPath = Join-Path $OutputPath $ZipFileName + +Write-Host "Creating ZIP package: $ZipFileName" -ForegroundColor Yellow +Write-Host "Source directory: $TempPluginDir" -ForegroundColor Gray +Write-Host "Target ZIP path: $ZipPath" -ForegroundColor Gray + +# Ensure the output directory exists +$ZipDir = Split-Path $ZipPath -Parent +if (-not (Test-Path $ZipDir)) { + New-Item -ItemType Directory -Path $ZipDir -Force | Out-Null +} + +# Use .NET compression to create ZIP +Add-Type -AssemblyName System.IO.Compression.FileSystem +[System.IO.Compression.ZipFile]::CreateFromDirectory($TempPluginDir, $ZipPath) + +# Clean up temp directory +Remove-Item (Join-Path $OutputPath "temp") -Recurse -Force + +# Verify ZIP contents +Write-Host "ZIP package created successfully!" -ForegroundColor Green +Write-Host "Package location: $ZipPath" -ForegroundColor Cyan +Write-Host "" +Write-Host "Package contents:" -ForegroundColor Yellow + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) +$zip.Entries | ForEach-Object { + Write-Host " $($_.FullName)" -ForegroundColor Gray +} +$zip.Dispose() + +Write-Host "" +Write-Host "Installation instructions:" -ForegroundColor Green +Write-Host "1. Open Flow Launcher" -ForegroundColor White +Write-Host "2. Go to Settings > Plugins" -ForegroundColor White +Write-Host "3. Click 'Install Plugin'" -ForegroundColor White +Write-Host "4. Select the ZIP file: $ZipPath" -ForegroundColor White +Write-Host "5. Restart Flow Launcher" -ForegroundColor White diff --git a/Flow.ConfluenceSearch/ConfluenceClient/ConfluenceQueryBuilder.cs b/Flow.ConfluenceSearch/ConfluenceClient/ConfluenceQueryBuilder.cs new file mode 100644 index 0000000..0099e90 --- /dev/null +++ b/Flow.ConfluenceSearch/ConfluenceClient/ConfluenceQueryBuilder.cs @@ -0,0 +1,126 @@ +namespace Flow.ConfluenceSearch.ConfluenceClient; + +internal interface IConfluenceQueryBuilder +{ + Task BuildTextCql( + string text, + IReadOnlyList spaces, + CancellationToken cancellationToken + ); + + Task BuildQueryForOpenInBrowser( + string text, + IReadOnlyList spaces, + CancellationToken cancellationToken + ); +} + +internal class ConfluenceQueryBuilder : IConfluenceQueryBuilder +{ + public async Task BuildTextCql( + string text, + IReadOnlyList spaces, + CancellationToken cancellationToken + ) + { + var tokens = text.Tokenize(); + + return await tokens + .When("#all") + .ThenDoNothing() + .When("#([a-zA-Z]{2,})") + .ThenRemember() + .Aggregate(mem => $"space IN ({string.Join(",", mem)})") + .Else(spaces.Count > 0 ? $"space IN ({string.Join(",", spaces)})" : string.Empty) + .When(@"\*") + .ThenDoNothing() + .When(@"\/") + .ThenRemember("folder") + .When("\"") + .ThenRemember("blogpost") + .When(".") + .ThenRemember("page") + .Aggregate(mem => $"type IN({string.Join(",", mem)})") + .Else("type IN(page,blogpost)") + .When(@"\+([a-zA-Z0-9]{2,})") + .ThenRemember() + .Aggregate(mem => $"label IN ({string.Join(",", mem)})") + .When("@me") + .ThenRemember("contributor = currentUser()") + .When(@"@([\p{L}-]{2,})") + .ThenRemember(async input => [$"contributor.fullname ~ {input}"]) + .Aggregate(mem => $"({string.Join(" OR ", mem)})") + .When(".*") + .ThenRemember() + .Aggregate(CreateSearchCql) + .BuildCql(" AND ") + " order by lastmodified DESC"; + } + + public async Task BuildQueryForOpenInBrowser( + string text, + IReadOnlyList spaces, + CancellationToken cancellationToken + ) + { + var tokens = text.Tokenize(); + + return await tokens + .When("#all") + .ThenDoNothing() + .When("#([a-zA-Z]{2,})") + .ThenRemember() + .Aggregate(mem => $"space={string.Join(",", mem)}") + .Else(spaces.Count > 0 ? $"spaces={string.Join(",", spaces)}" : string.Empty) + .When(@"\*") + .ThenDoNothing() + .When(@"\/") + .Then("type=folder") + .When("\"") + .Then("type=blogpost") + .When(".") + .Then("type=page") + .When(@"\+([a-zA-Z0-9]{2,})") + .ThenRemember() + .Aggregate(mem => $"labels={string.Join(",", mem)}") + .When("@me") + .ThenDoNothing() + .When(@"@([\p{L}-]{2,})") + .ThenDoNothing() + .When(".*") + .ThenRemember() + .Aggregate(mem => $"text={string.Join(" ", mem)}") + .BuildCql("&"); + } + + private static string EscapeForCqlToken(string s) + { + if (string.IsNullOrWhiteSpace(s)) + return string.Empty; + s = s.Trim().TrimEnd('*'); + + var specialChars = new HashSet("\\*+-!():^[]{}~?|&/\"'"); + var sb = new System.Text.StringBuilder(); + + foreach (var c in s) + { + if (specialChars.Contains(c)) + sb.Append('\\'); + sb.Append(c); + } + + sb.Append('*'); + return sb.ToString(); + } + + private string CreateSearchCql(IEnumerable mem) + { + var tokens = mem.Select(EscapeForCqlToken).Where(t => !string.IsNullOrEmpty(t)).ToArray(); + + if (tokens.Length == 0) + return "(title~\"\" OR text~\"\")"; + + var searchText = string.Join(" ", tokens); + + return $"(title~\"{searchText}\" OR text~\"{searchText}\")"; + } +} diff --git a/Flow.ConfluenceSearch/ConfluenceClient/ConfluenceSearchClient.cs b/Flow.ConfluenceSearch/ConfluenceClient/ConfluenceSearchClient.cs new file mode 100644 index 0000000..0651923 --- /dev/null +++ b/Flow.ConfluenceSearch/ConfluenceClient/ConfluenceSearchClient.cs @@ -0,0 +1,36 @@ +using System.Net.Http; +using System.Net.Http.Json; +using Flow.Launcher.Plugin; + +namespace Flow.ConfluenceSearch.ConfluenceClient; + +internal interface IConfluenceSearchClient +{ + Task SearchCqlAsync(string cql, int maxResults, CancellationToken ct); +} + +internal sealed class ConfluenceSearchClient(Func httpFactory) : IConfluenceSearchClient +{ + public async Task SearchCqlAsync( + string cql, + int maxResults, + CancellationToken ct + ) + { + var url = + $"/wiki/rest/api/search?cql={Uri.EscapeDataString(cql)}&limit={maxResults}&expand=content.history.lastUpdated"; + + using var http = httpFactory(); + using var req = new HttpRequestMessage(HttpMethod.Get, url); + using var resp = await http.SendAsync(req, ct).ConfigureAwait(false); + + if (!resp.IsSuccessStatusCode) + throw new ApplicationException( + $"Error in API Call: {await resp.Content.ReadAsStringAsync(ct)}" + ); + + return await resp + .Content.ReadFromJsonAsync(cancellationToken: ct) + .ConfigureAwait(false); + } +} diff --git a/Flow.ConfluenceSearch/ConfluenceClient/ConfluenceSearchPage.cs b/Flow.ConfluenceSearch/ConfluenceClient/ConfluenceSearchPage.cs new file mode 100644 index 0000000..7f0310d --- /dev/null +++ b/Flow.ConfluenceSearch/ConfluenceClient/ConfluenceSearchPage.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Serialization; + +public sealed class ContentSearchResponse +{ + [JsonPropertyName("results")] + public List Results { get; set; } = new(); +} + +public sealed class ConfluenceSearchItemDto +{ + [JsonPropertyName("content")] + public required ConfluenceContentDto Content { get; set; } + + [JsonPropertyName("title")] + public required string Title { get; set; } + + [JsonPropertyName("excerpt")] + public required string Excerpt { get; set; } + + [JsonPropertyName("url")] + public string? Url { get; set; } + + [JsonPropertyName("resultGlobalContainer")] + public ResultGlobalContainerDto? ResultGlobalContainer { get; set; } + + public string BrowseUrl(string baseUrl) => $"{baseUrl}/wiki{Url}"; + + public string SpaceKey + { + get + { + var spaceUrl = ResultGlobalContainer?.DisplayUrl; + if (string.IsNullOrWhiteSpace(spaceUrl)) + return string.Empty; + + const string prefix = "/spaces/"; + int i = spaceUrl.IndexOf(prefix, StringComparison.OrdinalIgnoreCase); + if (i < 0) + return string.Empty; + + int start = i + prefix.Length; + int end = spaceUrl.IndexOf('/', start); + return end < 0 ? spaceUrl[start..] : spaceUrl.Substring(start, end - start); + } + } + + // --- NEU: Komfort-Getter für "letztes Update" --- + [JsonIgnore] + public string? LastUpdatedByName => Content?.History?.LastUpdated?.By?.DisplayName; + + /// + /// Relativer Avatar-Pfad (z. B. "/wiki/aa-avatar/712020:..."). + /// + [JsonIgnore] + public string? LastUpdatedByAvatarPath => + Content?.History?.LastUpdated?.By?.ProfilePicture?.Path; + + /// + /// Absoluter Avatar-Link bauen (optional zu verwenden). + /// + public string? LastUpdatedByAvatarUrl(string baseUrl) => + string.IsNullOrWhiteSpace(LastUpdatedByAvatarPath) + ? null + : $"{baseUrl}{LastUpdatedByAvatarPath}"; +} + +public sealed class ConfluenceContentDto +{ + [JsonPropertyName("id")] + public required string Id { get; set; } + + // --- NEU: History mit "lastUpdated" --- + [JsonPropertyName("history")] + public ConfluenceHistoryDto? History { get; set; } +} + +// --- NEU: unterstützende DTOs für "history.lastUpdated.by" --- +public sealed class ConfluenceHistoryDto +{ + [JsonPropertyName("lastUpdated")] + public ConfluenceLastUpdatedDto? LastUpdated { get; set; } +} + +public sealed class ConfluenceLastUpdatedDto +{ + [JsonPropertyName("by")] + public ConfluenceUserDto? By { get; set; } +} + +public sealed class ConfluenceUserDto +{ + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("profilePicture")] + public ConfluenceProfilePictureDto? ProfilePicture { get; set; } +} + +public sealed class ConfluenceProfilePictureDto +{ + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("width")] + public int? Width { get; set; } + + [JsonPropertyName("height")] + public int? Height { get; set; } +} + +public sealed class ResultGlobalContainerDto +{ + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("displayUrl")] + public string? DisplayUrl { get; set; } +} diff --git a/Flow.ConfluenceSearch/ConfluenceClient/QueryBuilderExtensions.cs b/Flow.ConfluenceSearch/ConfluenceClient/QueryBuilderExtensions.cs new file mode 100644 index 0000000..e82b154 --- /dev/null +++ b/Flow.ConfluenceSearch/ConfluenceClient/QueryBuilderExtensions.cs @@ -0,0 +1,167 @@ +using System.Collections.Immutable; +using System.Text.RegularExpressions; + +namespace Flow.ConfluenceSearch.ConfluenceClient; + +public static class QueryBuilderExtensions +{ + public record Query( + IImmutableList Tokens, + IImmutableList QueryParts, + IImmutableList Captures, + IImmutableList Memory, + bool HadMatches = false + ); + + // Start der Fluent API + public static Task Tokenize(this string input) + { + var textTokens = input + .Split([' '], StringSplitOptions.RemoveEmptyEntries) + .ToImmutableList(); + return Task.FromResult( + new Query( + Tokens: textTokens, + QueryParts: ImmutableList.Empty, + Captures: ImmutableList.Empty, + Memory: ImmutableList.Empty, + HadMatches: false + ) + ); + } + + public static async Task When(this Task queryTask, string regexPattern) + { + var query = await queryTask; + var regex = new Regex( + $"^{regexPattern}$", + RegexOptions.Compiled | RegexOptions.CultureInvariant + ); + var matches = query + .Tokens.Select(token => regex.Match(token)) + .Where(match => match.Success) + .ToList(); + + var tokens = query.Tokens.RemoveAll(token => matches.Any(m => m.Value == token)); + var captures = matches + .Select(match => match.Groups.Count > 1 ? match.Groups[1].Value : match.Value) // Fix: Groups[1] für erste Capture-Gruppe + .Distinct() + .ToImmutableList(); + + return query with + { + Tokens = tokens, + Captures = captures, + }; + } + + // ThenRemember-Methoden + public static async Task ThenRemember(this Task queryTask, string tokenToRemember) + { + var query = await queryTask; + if (!query.Captures.Any()) + return query; + + return query with + { + Captures = ImmutableList.Empty, + Memory = query.Memory.Add(tokenToRemember), + }; + } + + public static async Task ThenRemember(this Task queryTask) + { + var query = await queryTask; + return query with + { + Captures = ImmutableList.Empty, + Memory = query.Memory.AddRange(query.Captures), + }; + } + + public static async Task ThenRemember( + this Task queryTask, + Func>> convertCapture + ) + { + var query = await queryTask; + if (!query.Captures.Any()) + return query; + + var convertedTokens = await Task.WhenAll(query.Captures.Select(convertCapture)); + var tokens = convertedTokens + .SelectMany(x => x) + .Distinct() + .ToImmutableList(); + return query with + { + Captures = ImmutableList.Empty, + Memory = query.Memory.AddRange(tokens), + }; + } + + // Then-Methoden + public static async Task Then(this Task queryTask, string cqlPart) + { + var query = await queryTask; + if (!query.Captures.Any()) + return query; + + var parts = query.QueryParts.Add(cqlPart); + return query with { QueryParts = parts, HadMatches = true }; + } + + public static async Task ThenDoNothing(this Task queryTask) + { + var query = await queryTask; + if (!query.Captures.Any()) + return query; + + return query with + { + HadMatches = true, + Captures = ImmutableList.Empty, + }; + } + + // Else-Methode + public static async Task Else(this Task queryTask, string cqlPart) + { + var query = await queryTask; + if (query.HadMatches) + return query with { HadMatches = false }; + + return query with + { + HadMatches = false, + QueryParts = query.QueryParts.Add(cqlPart), + }; + } + + public static async Task Aggregate( + this Task queryTask, + Func, string> aggregator + ) + { + var query = await queryTask; + if (!query.Memory.Any()) + return query with { Captures = ImmutableList.Empty }; + + var aggregated = aggregator(query.Memory); + return query with + { + QueryParts = query.QueryParts.Add(aggregated), + Memory = ImmutableList.Empty, + HadMatches = true, + }; + } + + public static async Task BuildCql(this Task queryTask, string separator) + { + var query = await queryTask; + return string.Join( + separator, + query.QueryParts.Where(part => !string.IsNullOrWhiteSpace(part)) + ); + } +} diff --git a/Flow.ConfluenceSearch/Flow.ConfluenceSearch.csproj b/Flow.ConfluenceSearch/Flow.ConfluenceSearch.csproj new file mode 100644 index 0000000..01e83f3 --- /dev/null +++ b/Flow.ConfluenceSearch/Flow.ConfluenceSearch.csproj @@ -0,0 +1,49 @@ + + + net9.0-windows + enable + enable + true + + + + <_Parameter1>Flow.ConfluenceSearch.Test + + + <_Parameter1>Castle.Core + + + <_Parameter1>DynamicProxyGenAssembly2 + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + + + diff --git a/Flow.ConfluenceSearch/Images/done.png b/Flow.ConfluenceSearch/Images/done.png new file mode 100644 index 0000000..29a9065 Binary files /dev/null and b/Flow.ConfluenceSearch/Images/done.png differ diff --git a/Flow.ConfluenceSearch/Images/gray.png b/Flow.ConfluenceSearch/Images/gray.png new file mode 100644 index 0000000..65f89fe Binary files /dev/null and b/Flow.ConfluenceSearch/Images/gray.png differ diff --git a/Flow.ConfluenceSearch/Images/icon.png b/Flow.ConfluenceSearch/Images/icon.png new file mode 100644 index 0000000..7d42cf4 Binary files /dev/null and b/Flow.ConfluenceSearch/Images/icon.png differ diff --git a/Flow.ConfluenceSearch/Images/open.png b/Flow.ConfluenceSearch/Images/open.png new file mode 100644 index 0000000..f87abf8 Binary files /dev/null and b/Flow.ConfluenceSearch/Images/open.png differ diff --git a/Flow.ConfluenceSearch/Images/progress.png b/Flow.ConfluenceSearch/Images/progress.png new file mode 100644 index 0000000..43f7109 Binary files /dev/null and b/Flow.ConfluenceSearch/Images/progress.png differ diff --git a/Flow.ConfluenceSearch/Main.cs b/Flow.ConfluenceSearch/Main.cs new file mode 100644 index 0000000..0cfd026 --- /dev/null +++ b/Flow.ConfluenceSearch/Main.cs @@ -0,0 +1,34 @@ +using System.Windows.Controls; +using Flow.ConfluenceSearch.Search; +using Flow.ConfluenceSearch.Settings; +using Flow.Launcher.Plugin; +using Microsoft.Extensions.DependencyInjection; + +namespace Flow.ConfluenceSearch; + +public class Main : IAsyncPlugin, ISettingProvider +{ + private ISearcher _search = null!; + private IConfigurator _configuration = null!; + + public Task InitAsync(PluginInitContext context) + { + var serviceCollection = new ServiceCollection(); + var config = context.API.LoadSettingJsonStorage(); + context.API.LogDebug(nameof(Main), config.BaseUrl); + serviceCollection.ConfigureServices(context, config); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + _search = serviceProvider.GetRequiredService(); + _configuration = serviceProvider.GetRequiredService(); + return Task.CompletedTask; + } + + public Task> QueryAsync(Query query, CancellationToken token) => + _search.QueryAsync(query, token); + + public Control CreateSettingPanel() + { + return _configuration.CreateSettingPanel(); + } +} diff --git a/Flow.ConfluenceSearch/Search/ResultContext.cs b/Flow.ConfluenceSearch/Search/ResultContext.cs new file mode 100644 index 0000000..42340ab --- /dev/null +++ b/Flow.ConfluenceSearch/Search/ResultContext.cs @@ -0,0 +1,3 @@ +namespace Flow.ConfluenceSearch.Search; + +public record ResultContext(string Key, string Url, string Cql); diff --git a/Flow.ConfluenceSearch/Search/ResultCreator.cs b/Flow.ConfluenceSearch/Search/ResultCreator.cs new file mode 100644 index 0000000..13996f9 --- /dev/null +++ b/Flow.ConfluenceSearch/Search/ResultCreator.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Flow.ConfluenceSearch.Settings; +using Flow.Launcher.Plugin; + +namespace Flow.ConfluenceSearch.Search; + +internal interface IResultCreator +{ + Result CreateResult( + string title, + string subtitle, + string? icon, + string badgeIcon, + string key, + string url, + string cql, + string copyText + ); + + Result CreateHint(string title, string sub); + + Result CreateOpenInBrowserAction( + string title, + string originalQuery, + string searchTextInBrowser + ); +} + +internal class ResultCreator(PluginSettings settings) : IResultCreator +{ + public Result CreateResult( + string title, + string subtitle, + string? icon, + string badgeIcon, + string key, + string url, + string cql, + string copyText + ) => + new() + { + Title = title, + SubTitle = subtitle, + BadgeIcoPath = badgeIcon, + IcoPath = icon, + ShowBadge = true, + Action = _ => Open(url), + ContextData = new ResultContext(key, url, cql), + CopyText = copyText, + }; + + public Result CreateHint(string title, string sub) => + new() + { + Title = title, + SubTitle = sub, + IcoPath = "Images/gray.png", + Action = _ => false, + }; + + public Result CreateOpenInBrowserAction( + string title, + string originalQuery, + string searchTextInBrowser + ) => + new() + { + Title = title, + SubTitle = originalQuery, + IcoPath = "Images/icon.png", + Action = _ => + { + var url = $"{settings.BaseUrl}/wiki/search?{searchTextInBrowser}"; + return Open(url); + }, + CopyText = $"{settings.BaseUrl}/wiki/search?{searchTextInBrowser}", + }; + + private bool Open(string url) + { + Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + return true; + } +} diff --git a/Flow.ConfluenceSearch/Search/Searcher.cs b/Flow.ConfluenceSearch/Search/Searcher.cs new file mode 100644 index 0000000..9a0fb23 --- /dev/null +++ b/Flow.ConfluenceSearch/Search/Searcher.cs @@ -0,0 +1,143 @@ +using Flow.ConfluenceSearch.ConfluenceClient; +using Flow.ConfluenceSearch.Settings; +using Flow.Launcher.Plugin; + +namespace Flow.ConfluenceSearch.Search; + +internal interface ISearcher +{ + Task> QueryAsync(Query query, CancellationToken cancellationToken); +} + +internal sealed class Searcher( + IConfluenceSearchClient confluenceSearch, + IConfluenceQueryBuilder confluenceQueryBuilder, + IResultCreator resultCreator, + PluginSettings settings, + PluginInitContext context +) : ISearcher +{ + public async Task> QueryAsync(Query query, CancellationToken cancellationToken) + { + context.API.LogDebug(nameof(Searcher), $"Query: {query.Search}"); + + try + { + await Task.Delay(300, cancellationToken); + } + catch (TaskCanceledException) + { + // Typing continued → skip this request + return new List(); + } + + using var timeoutCts = new CancellationTokenSource( + TimeSpan.FromSeconds(Math.Max(3, settings.Timeout.TotalSeconds)) + ); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); + + if (string.IsNullOrWhiteSpace(query.Search)) + return CreateHints(); + + var cql = await confluenceQueryBuilder.BuildTextCql( + query.Search, + settings.DefaultSpaces, + linkedCts.Token + ); + var searchTextInBrowser = await confluenceQueryBuilder.BuildQueryForOpenInBrowser( + query.Search, + settings.DefaultSpaces, + linkedCts.Token + ); + context.API.LogDebug(nameof(Searcher), $"CQL: {cql}"); + context.API.LogDebug(nameof(Searcher), $"Text in Browser: {searchTextInBrowser}"); + + return await SearchAsync(cql, searchTextInBrowser, query.Search, linkedCts.Token); + } + + private List CreateHints() + { + return + [ + resultCreator.CreateHint( + "@me @name", + "Search pages by contributor: me (@me) or specific person (@name)" + ), + resultCreator.CreateHint("/ \" . ", "Search for folders (/), blogs (\") or pages (.)"), + resultCreator.CreateHint( + "*", + "Search for all types (otherwise defaults to pages & blogs)" + ), + resultCreator.CreateHint("+Label1", "Pages with label 'Label1'"), + ]; + } + + private async Task> SearchAsync( + string cql, + string searchTextInBrowser, + string originalQuery, + CancellationToken externalCt + ) + { + using var timeoutCts = new CancellationTokenSource( + TimeSpan.FromSeconds(Math.Max(3, settings.Timeout.TotalSeconds)) + ); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + externalCt, + timeoutCts.Token + ); + + var data = await confluenceSearch + .SearchCqlAsync(cql, settings.MaxResults, linkedCts.Token) + .ConfigureAwait(false); + + var results = new List(); + + foreach (var page in data.Results.Take(settings.MaxResults)) + { + var subtitle = page + .Excerpt.Replace("\r\n", " · ") + .Replace("\n", " · ") + .Replace("\r", " · "); + + subtitle = + $"{page.LastUpdatedByName} · {page.ResultGlobalContainer?.Title} | {subtitle}"; + + var url = page.BrowseUrl(settings.BaseUrl); + results.Add( + resultCreator.CreateResult( + title: System.Net.WebUtility.HtmlDecode(page.Title), + subtitle: System.Net.WebUtility.HtmlDecode(subtitle), + icon: page.LastUpdatedByAvatarUrl(settings.BaseUrl) ?? "Images/icon.png", + badgeIcon: "Images/icon.png", + key: page.Content.Id, + url: url, + cql: cql, + copyText: cql + ) + ); + } + + if (data.Results.Count == 0) + results.Add( + resultCreator.CreateOpenInBrowserAction( + "No results. Open search in browser ...", + originalQuery, + searchTextInBrowser + ) + ); + else + results.Add( + resultCreator.CreateOpenInBrowserAction( + "More results in browser ...", + originalQuery, + searchTextInBrowser + ) + ); + + return results; + } +} diff --git a/Flow.ConfluenceSearch/ServiceProvider.cs b/Flow.ConfluenceSearch/ServiceProvider.cs new file mode 100644 index 0000000..beb1076 --- /dev/null +++ b/Flow.ConfluenceSearch/ServiceProvider.cs @@ -0,0 +1,45 @@ +using System.Net.Http; +using System.Text; +using Flow.ConfluenceSearch.ConfluenceClient; +using Flow.ConfluenceSearch.Search; +using Flow.ConfluenceSearch.Settings; +using Flow.Launcher.Plugin; +using Microsoft.Extensions.DependencyInjection; + +namespace Flow.ConfluenceSearch; + +public static class ServiceProvider +{ + public static void ConfigureServices( + this ServiceCollection serviceCollection, + PluginInitContext context, + PluginSettings settings + ) + { + serviceCollection.AddSingleton(context); + serviceCollection.AddSingleton(settings); + serviceCollection.AddSingleton>(_ => + { + return () => + { + var config = settings; + var httpClient = new HttpClient + { + BaseAddress = new Uri($"{config.BaseUrl}/"), + Timeout = TimeSpan.FromSeconds(Math.Clamp(config.Timeout.TotalSeconds, 3, 30)), + }; + var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes(config.ApiToken)); + httpClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", basic); + httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + return httpClient; + }; + }); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + } +} diff --git a/Flow.ConfluenceSearch/Settings/Configurator.cs b/Flow.ConfluenceSearch/Settings/Configurator.cs new file mode 100644 index 0000000..f2e0aa7 --- /dev/null +++ b/Flow.ConfluenceSearch/Settings/Configurator.cs @@ -0,0 +1,13 @@ +using System.Windows.Controls; + +namespace Flow.ConfluenceSearch.Settings; + +internal interface IConfigurator +{ + Control CreateSettingPanel(); +} + +public class Configurator(SettingsViewModel viewModel) : IConfigurator +{ + public Control CreateSettingPanel() => new SettingsView { DataContext = viewModel }; +} diff --git a/Flow.ConfluenceSearch/Settings/PluginSettings.cs b/Flow.ConfluenceSearch/Settings/PluginSettings.cs new file mode 100644 index 0000000..178f7f1 --- /dev/null +++ b/Flow.ConfluenceSearch/Settings/PluginSettings.cs @@ -0,0 +1,19 @@ +namespace Flow.ConfluenceSearch.Settings; + +public record PluginSettings +{ + public PluginSettings() + { + BaseUrl = "https://www.example.com"; + Timeout = TimeSpan.FromSeconds(10); + ApiToken = string.Empty; + MaxResults = 10; + DefaultSpaces = new List(); + } + + public string BaseUrl { get; set; } + public TimeSpan Timeout { get; set; } + public string ApiToken { get; set; } + public List DefaultSpaces { get; set; } + public int MaxResults { get; set; } +} diff --git a/Flow.ConfluenceSearch/Settings/SettingsView.xaml b/Flow.ConfluenceSearch/Settings/SettingsView.xaml new file mode 100644 index 0000000..b804dcc --- /dev/null +++ b/Flow.ConfluenceSearch/Settings/SettingsView.xaml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Flow.ConfluenceSearch/Settings/SettingsView.xaml.cs b/Flow.ConfluenceSearch/Settings/SettingsView.xaml.cs new file mode 100644 index 0000000..06cf5c5 --- /dev/null +++ b/Flow.ConfluenceSearch/Settings/SettingsView.xaml.cs @@ -0,0 +1,24 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; + +namespace Flow.ConfluenceSearch.Settings; + +public partial class SettingsView +{ + public SettingsView() + { + InitializeComponent(); + } + + private void TokenBox_PasswordChanged(object sender, RoutedEventArgs e) + { + if (DataContext is SettingsViewModel vm) + vm.Settings.ApiToken = ((PasswordBox)sender).Password; + } + + private void NumberValidationTextBox(object sender, TextCompositionEventArgs e) + { + e.Handled = !int.TryParse(e.Text, out _); + } +} diff --git a/Flow.ConfluenceSearch/Settings/SettingsViewModel.cs b/Flow.ConfluenceSearch/Settings/SettingsViewModel.cs new file mode 100644 index 0000000..b5ccf30 --- /dev/null +++ b/Flow.ConfluenceSearch/Settings/SettingsViewModel.cs @@ -0,0 +1,12 @@ +namespace Flow.ConfluenceSearch.Settings; + +public sealed class SettingsViewModel(PluginSettings settings) +{ + public PluginSettings Settings { get; } = settings; + + public string DefaultSpaces + { + get => string.Join(",", Settings.DefaultSpaces); + set => Settings.DefaultSpaces = value.Split(",", StringSplitOptions.RemoveEmptyEntries).ToList(); + } +} diff --git a/Flow.ConfluenceSearch/Start.ps1 b/Flow.ConfluenceSearch/Start.ps1 new file mode 100644 index 0000000..aefa3f0 --- /dev/null +++ b/Flow.ConfluenceSearch/Start.ps1 @@ -0,0 +1,49 @@ +# =============================== +# Build-And-Restart-FlowLauncher.ps1 +# =============================== +# Stops Flow Launcher, builds the plugin and restarts Flow Launcher +# =============================== +param ( + [string]$SolutionPath = ".\Flow.ConfluenceSearch.csproj", # Path to solution or csproj file + [string]$BuildConfig = "Debug", # Or "Release" + [string]$PluginFolderName = "Confluence Search-1.0.0", # Plugin name (folder name under Plugins) + [string]$FlowLauncherPath = "$env:LOCALAPPDATA\FlowLauncher\Flow.Launcher.exe" # Default path +) + +Write-Host "----------------------------------------" +Write-Host "🔧 Flow Launcher Plugin Build Script" +Write-Host "----------------------------------------" + +# 1️⃣ Stop Flow Launcher +Write-Host "🛑 Stopping Flow Launcher..." +Get-Process "Flow.Launcher" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +Start-Sleep -Seconds 2 + +# 2️⃣ Build solution/project +Write-Host "⚙️ Building solution: $SolutionPath ($BuildConfig)" +dotnet build $SolutionPath -c $BuildConfig + +if ($LASTEXITCODE -ne 0) { + Write-Host "❌ Build failed!" -ForegroundColor Red + exit 1 +} + +# 3️⃣ Copy build output +Write-Host "📂 Copying build output to Flow Launcher plugins folder..." +$buildOutput = Join-Path -Path (Split-Path $SolutionPath) -ChildPath "bin\$BuildConfig\net9.0-windows" +$pluginTarget = Join-Path "$env:APPDATA\FlowLauncher\Plugins" $PluginFolderName + +if (-not (Test-Path $pluginTarget)) { + New-Item -ItemType Directory -Path $pluginTarget | Out-Null +} + +Copy-Item -Path "$buildOutput\*" -Destination $pluginTarget -Recurse -Force +Write-Host "✅ Files copied to:`n$pluginTarget" + +# 4️⃣ Restart Flow Launcher +Write-Host "🚀 Starting Flow Launcher..." +Start-Process $FlowLauncherPath + +Write-Host "----------------------------------------" +Write-Host "🎉 Done! Flow Launcher has been restarted." +Write-Host "----------------------------------------" \ No newline at end of file diff --git a/Flow.ConfluenceSearch/plugin.json b/Flow.ConfluenceSearch/plugin.json new file mode 100644 index 0000000..e6cb099 --- /dev/null +++ b/Flow.ConfluenceSearch/plugin.json @@ -0,0 +1,15 @@ +{ + "ID": "EE691863-50D1-4E0F-9A36-83314C2B2B27", + "ActionKeywords": [ + "confluence" + ], + "Name": "Confluence Search", + "Description": "Search for and open Confluence pages directly from Flow Launcher.", + "Author": "Jan Haug", + "Version": "1.0.0", + "Language": "csharp", + "Website": "https://github.com/haugjan/Flow.ConfluenceSearch", + "IcoPath": "Images/icon.png", + "ExecuteFileName": "Flow.ConfluenceSearch.dll", + "MinFlowLauncherVersion": "2.0.0" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..26b91f5 --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# Flow.ConfluenceSearch + +Search for and open Confluence pages directly from Flow Launcher. + +## Features + +- 🔍 **Fast Content Search**: Search Confluence pages by title, excerpt, or content +- 🎯 **Space Filtering**: Configure default spaces for focused searches +- 🚀 **Direct Navigation**: Open pages directly in your browser +- ⚡ **Real-time Results**: Get instant search results as you type +- 🔧 **Configurable**: Customize timeout, result limits, and default spaces +- 👤 **User-based Search**: Find pages updated by specific users +- 🏷️ **Space Key Extraction**: Extract and display space keys from results +- 📊 **Browse URL Generation**: Generate URLs for direct access to pages + +## Requirements + +- Flow Launcher 2.0.0 or higher +- .NET 9.0 Runtime +- Valid Confluence account with API access + +## Installation + +### From Release +1. Download the latest release from the [Releases](../../releases) page. +2. Extract the zip file to your Flow Launcher plugins directory. +3. Restart Flow Launcher. + +### From Source +1. Clone this repository: + ```bash + git clone https://github.com/yourusername/Flow.ConfluenceSearch.git + ``` +2. Build the project: + ```bash + cd Flow.ConfluenceSearch + dotnet build --configuration Release + ``` +3. Copy the built files to Flow Launcher's plugin directory. + +## Configuration + +The plugin is configured through Flow Launcher's settings interface: + +1. **Open Flow Launcher Settings**: + - Press `Alt + Space` to open Flow Launcher. + - Type `settings` and select "Flow Launcher Settings". + +2. **Navigate to Plugin Settings**: + - Go to the "Plugins" tab. + - Find "Confluence Search" in the plugin list. + - Click the settings icon next to it. + +3. **Configure the following settings**: + + | Setting | Description | Example | + |---------|-------------|---------| + | **Base URL** | Your Confluence instance URL | `https://yourcompany.atlassian.net` | + | **API Token** | Personal API token from Confluence | `ATATT3xFfGF0...` | + | **Timeout** | Request timeout in seconds | `10` | + | **Max Results** | Maximum number of results to display | `10` | + | **Default Spaces** | Space keys to search by default | `["SPACE1", "SPACE2"]` | + +### Creating a Confluence API Token + +1. Go to [Atlassian Account Settings](https://id.atlassian.com/manage-profile/security/api-tokens). +2. Click "Create API token". +3. Give it a descriptive name (e.g., "Flow Launcher Plugin"). +4. Copy the generated token and paste it in the plugin settings. + +## Usage + +### User-based Search +Open Flow Launcher (`Alt + Space`) and use the `confluence` keyword with these patterns: + +**Updated by User:** +``` +confluence @me +``` +Find pages updated by me. + +``` +confluence @john +``` +Find pages updated by John. + +### Space-based Search + +``` +confluence #docs +``` +Find pages from the "docs" space. + +``` +confluence #all +``` +Search all spaces (ignores default space filter from settings). + +### Text Search + +``` +confluence project plan +``` +Search for pages containing "project" and "plan" in the title or content. + +### Combined Search Examples + +``` +confluence @me #marketing +``` +Find pages updated by me in the "marketing" space. + +``` +confluence @john #docs +``` +Find pages updated by John in the "docs" space. + +``` +confluence #all meeting notes +``` +Search all spaces for pages containing "meeting notes". + +## Default Spaces + +If you configure default spaces in the settings, searches will automatically be limited to those spaces unless you use `#all` or specify a different space with `#spacekey`. + +## Search Operators Reference + +| Operator | Description | Example | +|----------|-------------|---------| +| `@me` | Pages updated by current user | `confluence @me` | +| `@username` | Pages updated by specific user | `confluence @john` | +| `#spacekey` | Pages from specific space | `confluence #docs` | +| `#all` | Search all spaces | `confluence #all` | + +## Troubleshooting + +### No Results Appearing +- Verify your Confluence URL is correct and accessible. +- Check that your API token is valid and hasn't expired. +- Ensure you have permission to view the spaces you're searching. + +### Timeout Issues +- Increase the timeout setting in plugin configuration. +- Check your internet connection to the Confluence instance. +- Verify the Confluence instance is responsive. + +### Authentication Errors +- Regenerate your API token in Atlassian Account Settings. +- Ensure you're using your email address as the username (for Atlassian Cloud). +- Check that your account has appropriate permissions. + +## Development + +### Building from Source +```bash +git clone https://github.com/yourusername/Flow.ConfluenceSearch.git +cd Flow.ConfluenceSearch +dotnet restore +dotnet build --configuration Release +``` + +### Running Tests +```bash +dotnet test +``` + +## Contributing + +1. Fork the repository. +2. Create a feature branch. +3. Make your changes. +4. Add tests if applicable. +5. Submit a pull request. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Support + +- **Issues**: Report bugs or request features on [GitHub Issues](../../issues). +- **Discussions**: Ask questions in [GitHub Discussions](../../discussions). +- **Documentation**: Check the [Wiki](../../wiki) for additional help. + +## Acknowledgments + +- Built for [Flow Launcher](https://flowlauncher.com/). +- Uses the Atlassian Confluence REST API. +- Icons from the Confluence design system.