Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1a8985b29 | ||
|
|
9073c52b5c | ||
|
|
6981734bda | ||
|
|
5f4f417edc | ||
|
|
3736cc73ae | ||
|
|
29027d4515 | ||
|
|
444ce804ed | ||
|
|
f2ab2b31ed | ||
|
|
bb7bef18d9 | ||
|
|
1047e839f5 | ||
|
|
87108a9ebd | ||
|
|
1e0a5c0af1 | ||
|
|
8184db0c46 | ||
|
|
65ff109800 | ||
|
|
48d120fbff |
@@ -49,13 +49,15 @@ Package-by-feature layout. Server context path is `/api`. Main endpoints:
|
||||
- `GET /api/connections/status` — lists connected services for the current session
|
||||
- `DELETE /api/connections/{serviceType}` — removes a service from the session; invalidates the session if no connections remain
|
||||
- `POST /api/search` — paged search against the requested service; returns 401 if no active session
|
||||
- `POST /api/sync` — triggers a vector-store sync for the requested service; returns 401 if no active session. Not yet triggered on login or called from the frontend — manual/internal trigger only for now.
|
||||
|
||||
Six packages:
|
||||
Eight packages:
|
||||
|
||||
**`shared/`** — cross-cutting types used by more than one feature package
|
||||
|
||||
- `ServiceType` (enum): identifies each integrated app (e.g. `HOMEBOX`); used in both `connection/` and `search/`
|
||||
- `ServiceProvider` (interface): base for `ConnectionProvider` and `SearchProvider`; declares `getServiceType()`
|
||||
- `ServiceType` (enum): identifies each integrated app (e.g. `HOMEBOX`); used across `connection/`, `search/`, `sync/`
|
||||
- `ServiceProvider` (interface): base for `ConnectionProvider`, `SearchProvider`, `SyncProvider`; declares `getServiceType()`
|
||||
- `ServiceItem`: normalized item shape (`id`, `title`, `description`, `extraData`) returned by both search and sync fetches
|
||||
- `Endpoint` (enum): API path constants for all external service calls
|
||||
- `SessionKeys`: builds session attribute names of the form `{SERVICE_TYPE}_CONNECTION_ID`
|
||||
|
||||
@@ -67,29 +69,40 @@ Six packages:
|
||||
**`connection/`** — connecting to and persisting service credentials
|
||||
|
||||
- `ConnectionProvider` interface: extends `ServiceProvider`; each integrated app implements `login()` and credential checking
|
||||
- `ConnectionIdentifiable` interface: `appUrl()`/`username()`/`serviceType()`; implemented by `SearchRequest` and `SyncRequest` so `HomeboxItemClient` can resolve the underlying connection regardless of which feature is calling it
|
||||
- `ConnectionService`: auto-discovers providers via Spring injection, dispatches login by `ServiceType`
|
||||
- `ConnectionController`: stores `{serviceType}_CONNECTION_ID` in `HttpSession` after login; reads session attributes to build status responses
|
||||
- Entity (`ConnectionEntity`) uses **Single Table Inheritance** — one `connections` table with app-specific nullable columns
|
||||
- `HomeboxConnectionProvider` / `HomeboxEntity`: Homebox-specific implementation
|
||||
- `HomeboxConnectionProvider` / `HomeboxConnectionEntity`: Homebox-specific implementation
|
||||
|
||||
**`ai/`** — shared AI infrastructure used by multiple features (search, future image analysis)
|
||||
**`homebox/`** — shared Homebox API access
|
||||
|
||||
- `EmbeddingService`: wraps Spring AI's `EmbeddingModel`; reused by any feature that needs to generate vectors
|
||||
- `VectorStoreConfig`: Spring bean configuration for `PgVectorStore` (pgvector-backed `VectorStore`)
|
||||
- All classes here are provider-agnostic — the OpenAI starter is pointed at LiteLLM, so the underlying model is configurable without code changes
|
||||
- `HomeboxItemClient`: fetches a page of items from the Homebox entities API and maps them to `ServiceItem`; used by both `HomeboxSearchProvider` (keyword search) and `HomeboxSyncProvider` (vector-store sync) so the fetch/mapping logic isn't duplicated
|
||||
|
||||
**`vector/`** — vectorization
|
||||
|
||||
- `EmbeddingService`: embeds `ServiceItem`s into pgvector's `VectorStore` and prunes entries that no longer exist upstream. Document ID is `{connectionId}:{itemId}`, so re-syncing upserts existing items instead of duplicating them. Deliberately stateless (only field is the injected `VectorStore`) since it's a singleton bean — the per-sync ID accumulator used for pruning is owned by the caller (`HomeboxSyncProvider`), not held as instance state
|
||||
- `PgVectorStore` itself is auto-configured by `spring-ai-starter-vector-store-pgvector` from `application.yaml` (`spring.ai.vectorstore.pgvector.dimensions`) — no manual config class in this codebase
|
||||
|
||||
**`search/`** — querying connected services (keyword and AI)
|
||||
|
||||
- `SearchProvider` interface: extends `ServiceProvider`; each integrated app implements `getSearchResults()`
|
||||
- `SearchService`: maintains two provider maps — keyword providers and AI providers; routes based on `SearchRequest.aiSearch` flag
|
||||
- `SearchService`: dispatches by `ServiceType` via a single provider map — **does not yet route on `SearchRequest.aiSearch`**; `HomeboxSearchProvider` and `HomeboxAiSearchProvider` both currently register for `ServiceType.HOMEBOX`, so only one wins the registration (known gap, pending AI search work)
|
||||
- `SearchController`: guards with session check before delegating to `SearchService`
|
||||
- `SearchRequest`: includes `aiSearch: boolean` — when true, routes to AI provider instead of keyword provider
|
||||
- `SearchRequest`: includes `aiSearch: boolean` (not yet consumed, see above) and implements `ConnectionIdentifiable`
|
||||
- `PagedSearchResponse`: includes nullable `summary` field — populated only for AI search results; null for keyword search
|
||||
- `HomeboxSearchProvider`: keyword search via Homebox API; unchanged from original implementation
|
||||
- `HomeboxAiSearchProvider`: AI search via pgvector similarity; returns ranked items + generated summary
|
||||
- `HomeboxSyncService`: fetches all Homebox items page by page, embeds them via `EmbeddingService`, stores in `VectorStore`; triggered on connection login (background sync)
|
||||
- `HomeboxSearchProvider`: keyword search; delegates the remote fetch to `HomeboxItemClient`
|
||||
- `HomeboxAiSearchProvider`: **stub only** — `getSearchResults` throws `UnsupportedOperationException`; the actual similarity search + summary generation is pending
|
||||
|
||||
**AI search flow:** on login → background sync indexes all Homebox items into pgvector. On AI search → embed query → pgvector similarity search → top N results passed to `ChatClient` for summary generation → return list + summary. Sync is idempotent (delete-then-reindex per connection).
|
||||
**`sync/`** — indexing connected services into the vector store
|
||||
|
||||
- `SyncProvider` interface: extends `ServiceProvider`; each integrated app implements `syncVectorStore()`
|
||||
- `SyncService`: dispatches by `ServiceType` via a provider map, same pattern as `SearchService`
|
||||
- `SyncController`: session-gated `POST /sync`; not currently called by the frontend or triggered on login
|
||||
- `SyncRequest`: implements `ConnectionIdentifiable`
|
||||
- `HomeboxSyncProvider`: pages through the full Homebox catalog via `HomeboxItemClient`, embedding each page through `EmbeddingService.vectorizeData` and collecting every item ID seen along the way. Once the full page loop finishes, calls `EmbeddingService.deleteStaleVectorEntries` **once** with the complete ID set, removing any previously indexed item no longer present upstream. Order matters — pruning per page instead of once at the end would treat items on other pages as stale and delete them too.
|
||||
|
||||
**Pending work (not yet implemented):** `HomeboxAiSearchProvider`'s actual similarity search + summary generation; `SearchService` routing two provider maps by `aiSearch`; triggering `/sync` on login or from the frontend; a frontend sync button and AI search UI.
|
||||
|
||||
**`exception/`** — `GlobalExceptionHandler` via `@ControllerAdvice`
|
||||
|
||||
@@ -110,7 +123,7 @@ React 19 + TypeScript + SCSS, Vite 6 build. Package-by-feature under `components
|
||||
|
||||
- PostgreSQL + pgvector (semantic search via embeddings); also used as the Spring Session store (JDBC)
|
||||
- LiteLLM as a unified AI proxy; Spring AI OpenAI starter wired to it — `OPENAI_BASE_URL` points to LiteLLM, not OpenAI directly, keeping the underlying model provider configurable
|
||||
- `spring-ai-starter-vector-store-pgvector` provides `PgVectorStore`; configured in `ai/VectorStoreConfig`
|
||||
- `spring-ai-starter-vector-store-pgvector` provides `PgVectorStore`, auto-configured from `application.yaml` (no manual config class)
|
||||
- Embedding dimensions must stay consistent with the configured LiteLLM embedding model — changing models requires re-syncing all indexed items
|
||||
- Processing pipeline (Phase 2): stage in DB → LLM inference → refine via UI → export to target app
|
||||
|
||||
|
||||
+53
-56
@@ -1,83 +1,80 @@
|
||||
val wiremockVersion = "3.12.0"
|
||||
val postgresqlVersion = "42.7.11"
|
||||
val springAiVersion by extra("2.0.0")
|
||||
|
||||
plugins {
|
||||
java
|
||||
jacoco
|
||||
id("org.springframework.boot") version "4.1.0"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
id("org.sonarqube") version "7.3.0.8198"
|
||||
java
|
||||
jacoco
|
||||
id("org.springframework.boot") version "4.1.0"
|
||||
id("io.spring.dependency-management") version "1.1.7"
|
||||
id("org.sonarqube") version "7.3.0.8198"
|
||||
}
|
||||
|
||||
group = "com.vaessl"
|
||||
version = "0.0.1-SNAPSHOT"
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
}
|
||||
|
||||
sonar {
|
||||
properties {
|
||||
property("sonar.projectKey", "Vaessl")
|
||||
property("sonar.projectName", "Vaessl")
|
||||
property("sonar.coverage.jacoco.xmlReportPaths",
|
||||
"${layout.buildDirectory.get()}/reports/jacoco/test/jacocoTestReport.xml")
|
||||
}
|
||||
properties {
|
||||
property("sonar.projectKey", "Vaessl")
|
||||
property("sonar.projectName", "Vaessl")
|
||||
property("sonar.coverage.jacoco.xmlReportPaths",
|
||||
"${layout.buildDirectory.get()}/reports/jacoco/test/jacocoTestReport.xml")
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom(configurations.annotationProcessor.get())
|
||||
}
|
||||
compileOnly {
|
||||
extendsFrom(configurations.annotationProcessor.get())
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
extra["springAiVersion"] = "2.0.0"
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-session-jdbc")
|
||||
// implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.springframework.boot:spring-boot-starter-webmvc")
|
||||
implementation("org.springframework.ai:spring-ai-starter-model-openai")
|
||||
implementation("org.postgresql:postgresql:$postgresqlVersion")
|
||||
implementation("org.springframework.ai:spring-ai-starter-vector-store-pgvector")
|
||||
|
||||
|
||||
|
||||
compileOnly("org.projectlombok:lombok")
|
||||
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
|
||||
runtimeOnly("org.postgresql:postgresql")
|
||||
|
||||
annotationProcessor("org.projectlombok:lombok")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
|
||||
// testImplementation("org.springframework.boot:spring-boot-starter-security-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}")
|
||||
}
|
||||
imports {
|
||||
mavenBom("org.springframework.ai:spring-ai-bom:$springAiVersion")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Spring Boot Starters
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-session-jdbc")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.springframework.boot:spring-boot-starter-webmvc")
|
||||
|
||||
// Spring AI Starters (versions managed by spring-ai-bom)
|
||||
implementation("org.springframework.ai:spring-ai-starter-model-openai")
|
||||
implementation("org.springframework.ai:spring-ai-starter-vector-store-pgvector")
|
||||
|
||||
// Database
|
||||
implementation("org.postgresql:postgresql:$postgresqlVersion")
|
||||
|
||||
// Tooling & Code Generation
|
||||
compileOnly("org.projectlombok:lombok")
|
||||
annotationProcessor("org.projectlombok:lombok")
|
||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||
|
||||
// Testing
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-validation-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test")
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-session-jdbc-test")
|
||||
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
options.compilerArgs.add("-parameters")
|
||||
options.compilerArgs.add("-parameters")
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
@@ -97,4 +94,4 @@ tasks.jacocoTestReport {
|
||||
reports {
|
||||
xml.required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vaessl.app.connection;
|
||||
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
/**
|
||||
* Identifies the connection a request targets. Implemented by request records
|
||||
* (e.g. {@code SearchRequest}, {@code SyncRequest}) so a single {@code HomeboxItemClient} can
|
||||
* resolve the underlying connection regardless of which feature is calling it.
|
||||
*/
|
||||
public interface ConnectionIdentifiable {
|
||||
|
||||
String appUrl();
|
||||
|
||||
String username();
|
||||
|
||||
ServiceType serviceType();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.vaessl.app.homebox;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import com.vaessl.app.connection.ConnectionEntity;
|
||||
import com.vaessl.app.connection.ConnectionIdentifiable;
|
||||
import com.vaessl.app.connection.ConnectionRepository;
|
||||
import com.vaessl.app.connection.HomeboxConnectionEntity;
|
||||
import com.vaessl.app.exception.ConnectionNotFoundException;
|
||||
import com.vaessl.app.exception.RemoteApiException;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import static com.vaessl.app.shared.Endpoint.HOMEBOX_QUERY_ALL_ITEMS;
|
||||
|
||||
/**
|
||||
* Fetches items from the Homebox entities API. Shared by {@code HomeboxSearchProvider} and
|
||||
* {@code HomeboxSyncProvider} so both keyword search and vector-store sync page through the same
|
||||
* remote call and item mapping.
|
||||
*/
|
||||
@Component
|
||||
public class HomeboxItemClient {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
|
||||
public HomeboxItemClient(RestClient.Builder restClienBuilder,
|
||||
ConnectionRepository cRepository) {
|
||||
this.restClientBuilder = restClienBuilder;
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one page of items from Homebox for the given connection.
|
||||
*
|
||||
* @param connection identifies which stored connection (app URL, username) to query
|
||||
* @param query optional keyword filter; {@code null} returns all items for the page
|
||||
* @param pageable page number and size to request
|
||||
* @return the mapped page of items along with the resolved connection ID
|
||||
* @throws com.vaessl.app.exception.ConnectionNotFoundException if no matching connection is
|
||||
* stored
|
||||
* @throws com.vaessl.app.exception.RemoteApiException if Homebox returns an empty response body
|
||||
*/
|
||||
public HomeboxItemPage hbResponse(ConnectionIdentifiable connection, String query,
|
||||
Pageable pageable) {
|
||||
ConnectionEntity entity =
|
||||
cRepository.findByAppUrlAndUsername(connection.appUrl(), connection.username());
|
||||
|
||||
if (!(entity instanceof HomeboxConnectionEntity hbEntity)) {
|
||||
throw new ConnectionNotFoundException();
|
||||
}
|
||||
|
||||
HomeboxItemsResponse response = restClientBuilder.baseUrl(connection.appUrl()).build().get()
|
||||
.uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue()).queryParam("q", query)
|
||||
.queryParam("page", pageable.getPageNumber() + 1)
|
||||
.queryParam("pageSize", pageable.getPageSize()).build())
|
||||
.headers(h -> h.setBearerAuth(hbEntity.getToken())).retrieve()
|
||||
.body(HomeboxItemsResponse.class);
|
||||
|
||||
if (response == null) {
|
||||
throw new RemoteApiException(connection.appUrl(), HOMEBOX_QUERY_ALL_ITEMS.getValue());
|
||||
}
|
||||
|
||||
List<ServiceItem> items = response.items().stream().map(i -> {
|
||||
String id = i.id();
|
||||
String title = i.name();
|
||||
String description = i.description();
|
||||
|
||||
HomeboxParent parent = i.parent();
|
||||
Map<String, Map<String, Object>> extraData = new LinkedHashMap<>();
|
||||
Map<String, Object> locationData = new LinkedHashMap<>();
|
||||
|
||||
if (parent.name() != null && !parent.name().isBlank()) {
|
||||
locationData.put("name", parent.name());
|
||||
}
|
||||
if (parent.description() != null && !parent.description().isBlank()) {
|
||||
locationData.put("description", parent.description());
|
||||
}
|
||||
|
||||
if (locationData != null && !locationData.isEmpty()) {
|
||||
extraData.put("location", locationData);
|
||||
}
|
||||
|
||||
return new ServiceItem(id, title, description, extraData);
|
||||
}).toList();
|
||||
|
||||
return new HomeboxItemPage(new PageImpl<>(items, pageable, response.total()),
|
||||
hbEntity.getId());
|
||||
|
||||
}
|
||||
|
||||
public record HomeboxItemPage(Page<ServiceItem> page, Long connectionId) {
|
||||
}
|
||||
|
||||
private record HomeboxItemsResponse(int page, int pageSize, int total,
|
||||
List<HomeboxItem> items) {
|
||||
}
|
||||
|
||||
private record HomeboxItem(String id, String name, String description, HomeboxParent parent) {
|
||||
}
|
||||
|
||||
private record HomeboxParent(String name, String description) {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,36 +1,19 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import com.vaessl.app.connection.ConnectionEntity;
|
||||
import com.vaessl.app.connection.ConnectionRepository;
|
||||
import com.vaessl.app.connection.HomeboxConnectionEntity;
|
||||
import com.vaessl.app.exception.ConnectionNotFoundException;
|
||||
import com.vaessl.app.exception.RemoteApiException;
|
||||
import com.vaessl.app.homebox.HomeboxItemClient;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
import static com.vaessl.app.shared.Endpoint.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HomeboxSearchProvider implements SearchProvider {
|
||||
|
||||
private final RestClient.Builder restClientBuilder;
|
||||
|
||||
private final ConnectionRepository cRepository;
|
||||
|
||||
public HomeboxSearchProvider(RestClient.Builder restClientBuilder,
|
||||
ConnectionRepository cRepository) {
|
||||
this.restClientBuilder = restClientBuilder;
|
||||
this.cRepository = cRepository;
|
||||
}
|
||||
private final HomeboxItemClient client;
|
||||
|
||||
@Override
|
||||
public ServiceType getServiceType() {
|
||||
@@ -39,45 +22,6 @@ public class HomeboxSearchProvider implements SearchProvider {
|
||||
|
||||
@Override
|
||||
public Page<ServiceItem> getSearchResults(SearchRequest request, Pageable pageable) {
|
||||
|
||||
ConnectionEntity entity =
|
||||
cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
|
||||
|
||||
if (!(entity instanceof HomeboxConnectionEntity hbEntity)) {
|
||||
throw new ConnectionNotFoundException();
|
||||
}
|
||||
|
||||
HomeboxSearchResponse hbResponse = restClientBuilder.baseUrl(request.appUrl()).build().get()
|
||||
.uri(u -> u.path(HOMEBOX_QUERY_ALL_ITEMS.getValue())
|
||||
.queryParam("q", request.query())
|
||||
.queryParam("page", pageable.getPageNumber() + 1)
|
||||
.queryParam("pageSize", pageable.getPageSize()).build())
|
||||
.headers(h -> h.setBearerAuth(hbEntity.getToken())).retrieve()
|
||||
.body(HomeboxSearchResponse.class);
|
||||
|
||||
if (hbResponse == null) {
|
||||
throw new RemoteApiException(request.appUrl(), HOMEBOX_QUERY_ALL_ITEMS.getValue());
|
||||
}
|
||||
|
||||
List<ServiceItem> items = hbResponse.items().stream().map(i -> {
|
||||
String id = i.id();
|
||||
String title = i.name();
|
||||
String description = i.description();
|
||||
Map<String, Object> extraSearchResponseData = Map.of("parent", i.parent());
|
||||
return new ServiceItem(id, title, description, extraSearchResponseData);
|
||||
}).toList();
|
||||
|
||||
return new PageImpl<>(items, pageable, hbResponse.total());
|
||||
}
|
||||
|
||||
private record HomeboxSearchResponse(int page, int pageSize, int total,
|
||||
List<HomeboxItem> items) {
|
||||
}
|
||||
|
||||
private record HomeboxItem(String id, String name, String description,
|
||||
HomeboxExtraData parent) {
|
||||
}
|
||||
|
||||
private record HomeboxExtraData(String name, String description) {
|
||||
return client.hbResponse(request, request.query(), pageable).page();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.vaessl.app.search;
|
||||
|
||||
import com.vaessl.app.connection.ConnectionIdentifiable;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record SearchRequest(@NotBlank String appUrl, @NotBlank String username, String query,
|
||||
@NotNull ServiceType serviceType, boolean aiSearch) {
|
||||
@NotNull ServiceType serviceType, boolean aiSearch) implements ConnectionIdentifiable {
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ package com.vaessl.app.shared;
|
||||
|
||||
public enum Endpoint {
|
||||
HOMEBOX_LOGIN("/api/v1/users/login"), LOGIN("/login"), CONNECTION_STATUS(
|
||||
"/connections/status"), HOMEBOX_QUERY_ALL_ITEMS("/api/v1/entities"), SEARCH("/search");
|
||||
"/connections/status"), HOMEBOX_QUERY_ALL_ITEMS(
|
||||
"/api/v1/entities"), SEARCH("/search"), SYNC("/sync");
|
||||
|
||||
private final String value;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import java.util.Map;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record ServiceItem(String id, @NotNull String title, String description,
|
||||
Map<String, Object> extraData) {
|
||||
Map<String, Map<String, Object>> extraData) {
|
||||
|
||||
public String getExtra(String key) {
|
||||
if (extraData == null) {
|
||||
|
||||
@@ -1,27 +1,57 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import com.vaessl.app.homebox.HomeboxItemClient;
|
||||
import com.vaessl.app.homebox.HomeboxItemClient.HomeboxItemPage;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import com.vaessl.app.vector.EmbeddingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Service
|
||||
/**
|
||||
* Pages through the full Homebox catalog and re-indexes it into the vector store. Item IDs are
|
||||
* collected across all pages before {@link EmbeddingService#deleteStaleVectorEntries} is called
|
||||
* once at the end, rather than after each page: deleting per page would treat every item outside
|
||||
* the current page as stale, wiping out entries from pages already synced.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class HomeboxSyncProvider implements SyncProvider {
|
||||
|
||||
private final EmbeddingService embeddingService;
|
||||
|
||||
public HomeboxSyncProvider(EmbeddingService embeddingService) {
|
||||
this.embeddingService = embeddingService;
|
||||
}
|
||||
|
||||
private final HomeboxItemClient client;
|
||||
|
||||
@Override
|
||||
public ServiceType getServiceType() {
|
||||
return ServiceType.HOMEBOX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void syncVectorStore() {
|
||||
// TODO Auto-generated method stub
|
||||
throw new UnsupportedOperationException("Unimplemented method 'syncVectorStore'");
|
||||
}
|
||||
public void syncVectorStore(SyncRequest request) {
|
||||
|
||||
int batchSize = 100;
|
||||
int page = 0;
|
||||
Page<ServiceItem> current;
|
||||
Long connectionId = null;
|
||||
List<String> currentItemIds = new ArrayList<>();
|
||||
do {
|
||||
Pageable pageable = PageRequest.of(page, batchSize);
|
||||
HomeboxItemPage result = client.hbResponse(request, null, pageable);
|
||||
current = result.page();
|
||||
if (connectionId == null) {
|
||||
connectionId = result.connectionId();
|
||||
}
|
||||
embeddingService.vectorizeData(current.getContent(), request.serviceType(),
|
||||
result.connectionId(), currentItemIds);
|
||||
page++;
|
||||
} while (page < current.getTotalPages());
|
||||
|
||||
embeddingService.deleteStaleVectorEntries(connectionId, currentItemIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.vaessl.app.shared.SessionKeys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
@RestController
|
||||
public class SyncController {
|
||||
|
||||
private final SyncService syncService;
|
||||
|
||||
public SyncController(SyncService syncService) {
|
||||
this.syncService = syncService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a vector-store sync for the requested service. Returns {@code 401 Unauthorized}
|
||||
* if there is no active session.
|
||||
*/
|
||||
@PostMapping("/sync")
|
||||
public ResponseEntity<Object> syncVectorStore(@Valid @RequestBody SyncRequest request,
|
||||
HttpServletRequest httpReq) {
|
||||
|
||||
HttpSession session = httpReq.getSession(false);
|
||||
|
||||
if (session == null
|
||||
|| session.getAttribute(SessionKeys.connectionId(request.serviceType())) == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
syncService.syncServiceVectorStore(request);
|
||||
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,16 @@ package com.vaessl.app.sync;
|
||||
|
||||
import com.vaessl.app.shared.ServiceProvider;
|
||||
|
||||
public interface SyncProvider extends ServiceProvider{
|
||||
public void syncVectorStore();
|
||||
/**
|
||||
* Implemented by any service that supports indexing its items into the vector store.
|
||||
*/
|
||||
public interface SyncProvider extends ServiceProvider {
|
||||
|
||||
/**
|
||||
* Fetches all items from the remote service and indexes them into the vector store,
|
||||
* removing any previously indexed items that no longer exist upstream.
|
||||
*
|
||||
* @param request the sync request containing the app URL and user credentials
|
||||
*/
|
||||
public void syncVectorStore(SyncRequest request);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import com.vaessl.app.connection.ConnectionIdentifiable;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record SyncRequest(@NotBlank String appUrl, @NotBlank String username,
|
||||
@NotNull ServiceType serviceType) implements ConnectionIdentifiable {
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.vaessl.app.exception.WrongServiceTypeException;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
|
||||
@Service
|
||||
public class SyncService {
|
||||
|
||||
private final Map<ServiceType, SyncProvider> providerRegistry;
|
||||
|
||||
public SyncService(List<SyncProvider> providers) {
|
||||
Map<ServiceType, SyncProvider> registry = new EnumMap<>(ServiceType.class);
|
||||
for (SyncProvider provider : providers) {
|
||||
registry.put(provider.getServiceType(), provider);
|
||||
}
|
||||
this.providerRegistry = registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the vector-store sync request to the provider registered for
|
||||
* {@link SyncRequest#serviceType()}.
|
||||
*
|
||||
* @param request the sync request
|
||||
* @throws WrongServiceTypeException if no provider is registered for the given service type
|
||||
*/
|
||||
public void syncServiceVectorStore(SyncRequest request) {
|
||||
|
||||
SyncProvider provider = providerRegistry.get(request.serviceType());
|
||||
|
||||
if (provider == null) {
|
||||
throw new WrongServiceTypeException();
|
||||
}
|
||||
|
||||
provider.syncVectorStore(request);
|
||||
}
|
||||
}
|
||||
@@ -4,80 +4,116 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import org.springframework.ai.document.Document;
|
||||
import org.springframework.ai.vectorstore.VectorStore;
|
||||
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import com.vaessl.app.shared.ServiceItem;
|
||||
import com.vaessl.app.shared.ServiceType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Embeds {@link ServiceItem}s into the shared vector store and prunes entries that no longer exist
|
||||
* upstream. Kept stateless (no instance fields besides {@code vectorStore}) since this is a
|
||||
* singleton bean shared across concurrent sync runs; callers own the per-sync ID accumulator.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EmbeddingService {
|
||||
|
||||
private static final float THRESHOLD = 0.7f;
|
||||
private static final int LIMIT = 2;
|
||||
private final VectorStore vectorStore;
|
||||
|
||||
public EmbeddingService(VectorStore vectorStore) {
|
||||
this.vectorStore = vectorStore;
|
||||
}
|
||||
|
||||
public void vectorizeData(List<ServiceItem> items, ServiceType serviceType, Long connectionId) {
|
||||
/**
|
||||
* Embeds and upserts {@code items} into the vector store, appending each item's ID to
|
||||
* {@code currentItemIds} so the caller can later prune stale entries via
|
||||
* {@link #deleteStaleVectorEntries}. Safe to call repeatedly (e.g. once per page) against the
|
||||
* same accumulator across a single sync run.
|
||||
*
|
||||
* @param items the items to embed for this batch
|
||||
* @param serviceType the originating service, stored in each document's metadata
|
||||
* @param connectionId the connection these items belong to
|
||||
* @param currentItemIds accumulator collecting every item ID seen so far in this sync run
|
||||
*/
|
||||
public void vectorizeData(List<ServiceItem> items, ServiceType serviceType, Long connectionId,
|
||||
List<String> currentItemIds) {
|
||||
List<Document> documents = new ArrayList<>();
|
||||
|
||||
for (ServiceItem item : items) {
|
||||
documents.add(toDocument(item, serviceType, connectionId));
|
||||
currentItemIds.add(item.id());
|
||||
}
|
||||
vectorStore.add(documents);
|
||||
}
|
||||
|
||||
private Document toDocument(ServiceItem item, ServiceType serviceType, Long connectionId) {
|
||||
String extraData = buildExtraData(item.extraData());
|
||||
Map<String, Object> metadata = buildMetadata(item, serviceType, connectionId, extraData);
|
||||
Map<String, Object> metadata = buildMetadata(item, serviceType, connectionId);
|
||||
String content = buildContent(item, extraData);
|
||||
return new Document(connectionId + ":" + item.id(), content, metadata);
|
||||
}
|
||||
|
||||
private String buildExtraData(Map<String, Object> extraData) {
|
||||
private String buildExtraData(Map<String, Map<String, Object>> extraData) {
|
||||
if (extraData == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder data = new StringBuilder();
|
||||
for (Map.Entry<String, Object> entry : extraData.entrySet()) {
|
||||
|
||||
StringBuilder formattedData = new StringBuilder();
|
||||
|
||||
for (Entry<String, Map<String, Object>> entry : extraData.entrySet()) {
|
||||
StringBuilder data = new StringBuilder();
|
||||
if (entry.getValue() == null || entry.getValue().toString().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (!data.isEmpty()) {
|
||||
data.append(" \n");
|
||||
|
||||
if (entry.getValue().entrySet() != null
|
||||
&& !entry.getValue().entrySet().toString().isEmpty()) {
|
||||
for (Entry<String, Object> nestedEntry : entry.getValue().entrySet()) {
|
||||
data.append(" ").append(nestedEntry.getKey()).append(": ")
|
||||
.append(nestedEntry.getValue()).append("\n");
|
||||
}
|
||||
}
|
||||
data.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
|
||||
formattedData.append(entry.getKey()).append(": ").append("\n").append(data.toString());
|
||||
}
|
||||
return data.toString();
|
||||
return formattedData.toString();
|
||||
}
|
||||
|
||||
private Map<String, Object> buildMetadata(ServiceItem item, ServiceType serviceType,
|
||||
Long connectionId, String extraData) {
|
||||
Long connectionId) {
|
||||
Map<String, Object> metadata = new HashMap<>();
|
||||
metadata.put("connectionId", connectionId);
|
||||
metadata.put("serviceType", serviceType.name());
|
||||
metadata.put("itemId", item.id());
|
||||
metadata.put("title", item.title());
|
||||
if (item.description() != null && !item.description().isEmpty()) {
|
||||
metadata.put("description", item.description());
|
||||
}
|
||||
if (!extraData.isEmpty()) {
|
||||
metadata.put("extraData", extraData);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private String buildContent(ServiceItem item, String extraData) {
|
||||
StringBuilder content = new StringBuilder("Title: ").append(item.title());
|
||||
StringBuilder content = new StringBuilder("title: ").append(item.title());
|
||||
if (item.description() != null && !item.description().isEmpty()) {
|
||||
content.append("\n Description: ").append(item.description());
|
||||
content.append("\ndescription: ").append(item.description());
|
||||
}
|
||||
if (!extraData.isEmpty()) {
|
||||
content.append("\n Extradata: ").append(extraData);
|
||||
|
||||
content.append("\n").append(extraData);
|
||||
}
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every vector for {@code connectionId} whose item ID is not in {@code currentItemIds},
|
||||
* then clears the accumulator. Must be called once, after every page for this sync run has gone
|
||||
* through {@link #vectorizeData}, not per page — otherwise items from pages other than the most
|
||||
* recent one would look stale and get deleted too.
|
||||
*
|
||||
* @param connectionId the connection to prune stale vectors for
|
||||
* @param currentItemIds every item ID seen across the full sync run; cleared after this call
|
||||
*/
|
||||
public void deleteStaleVectorEntries(Long connectionId, List<String> currentItemIds) {
|
||||
FilterExpressionBuilder b = new FilterExpressionBuilder();
|
||||
vectorStore.delete(b.and(b.eq("connectionId", connectionId),
|
||||
b.nin("itemId", new ArrayList<>(currentItemIds))).build());
|
||||
|
||||
currentItemIds.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,43 @@ public final class Mockdata {
|
||||
public static final String MOCK_ID = "item-1";
|
||||
public static final String MOCK_TITLE = "title";
|
||||
public static final String MOCK_DESCRIPTION = "desc";
|
||||
public static final String VALID_HOMEBOX_LOGIN_RESPONSE = """
|
||||
{
|
||||
"token": "fake-bearer-token",
|
||||
"attachmentToken": "fake-attach-token",
|
||||
"expiresAt": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
public static final String VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE = """
|
||||
{
|
||||
"page": -1,
|
||||
"pageSize": -1,
|
||||
"total": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": "c643e7f9-93d0-4b5f-ae4d-e1c2d90389e0",
|
||||
"assetId": "000-001",
|
||||
"name": "MacBook Pro A1398",
|
||||
"description": "Running Linux (Fedora)",
|
||||
"quantity": 1,
|
||||
"insured": false,
|
||||
"archived": false,
|
||||
"createdAt": "2026-05-13T19:52:20.016176Z",
|
||||
"updatedAt": "2026-05-14T12:39:11.836403Z",
|
||||
"purchasePrice": 0,
|
||||
"parent": {
|
||||
"id": "b6f60ab8-3a2a-4a8d-a4bf-897d0555f636",
|
||||
"name": "Server Schrank Ikea weiß",
|
||||
"description": "Weißer Ikea Schrank, wo sich der Server befindet.",
|
||||
"createdAt": "2026-05-13T19:55:55.817576Z",
|
||||
"updatedAt": "2026-05-14T12:37:24.396651Z"
|
||||
},
|
||||
"tags": [],
|
||||
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
|
||||
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
|
||||
"soldTime": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import com.vaessl.app.connection.ConnectionRepository;
|
||||
import com.vaessl.app.exception.ConnectionNotFoundException;
|
||||
import com.vaessl.app.homebox.HomeboxItemClient;
|
||||
import static com.vaessl.app.shared.ServiceType.HOMEBOX;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import static com.vaessl.app.shared.ServiceType.HOMEBOX;
|
||||
class HomeboxSearchProviderTest {
|
||||
|
||||
@Mock
|
||||
private ConnectionRepository mockRepo;
|
||||
private HomeboxItemClient client;
|
||||
|
||||
@InjectMocks
|
||||
private HomeboxSearchProvider provider;
|
||||
@@ -27,11 +27,11 @@ class HomeboxSearchProviderTest {
|
||||
@Test
|
||||
void shouldReturnConnectionNotFoundException() {
|
||||
|
||||
when(mockRepo.findByAppUrlAndUsername(MOCK_URL, MOCK_USER)).thenReturn(null);
|
||||
|
||||
SearchRequest request =
|
||||
new SearchRequest(MOCK_URL, MOCK_USER, "test query", HOMEBOX, false);
|
||||
Pageable pageable = PageRequest.of(0, 10);
|
||||
SearchRequest request = new SearchRequest(MOCK_URL, MOCK_USER, "", HOMEBOX, false);
|
||||
|
||||
when(client.hbResponse(request, "", pageable)).thenThrow(new ConnectionNotFoundException());
|
||||
|
||||
assertThrows(ConnectionNotFoundException.class,
|
||||
() -> provider.getSearchResults(request, pageable));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ package com.vaessl.app.search;
|
||||
|
||||
import static com.vaessl.app.Mockdata.MOCK_PASS;
|
||||
import static com.vaessl.app.Mockdata.MOCK_USER;
|
||||
import static com.vaessl.app.shared.Endpoint.*;
|
||||
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE;
|
||||
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_LOGIN_RESPONSE;
|
||||
|
||||
import static com.vaessl.app.shared.Endpoint.LOGIN;
|
||||
import static com.vaessl.app.shared.Endpoint.SEARCH;
|
||||
import static com.vaessl.app.shared.Endpoint.HOMEBOX_QUERY_ALL_ITEMS;
|
||||
import static com.vaessl.app.shared.Endpoint.HOMEBOX_LOGIN;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@@ -18,6 +25,11 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
|
||||
/**
|
||||
* Integration tests for {@code POST /api/search}, verifying the session-gated contract and request
|
||||
* validation against a mocked Homebox backend (via WireMock) and a real Spring MVC dispatch chain
|
||||
* (via {@link MockMvc}).
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@WireMockTest
|
||||
@@ -32,47 +44,14 @@ class SearchControllerTest {
|
||||
|
||||
private static final String SEARCH_REQUEST = SEARCH.getValue();
|
||||
|
||||
private static final String VALID_HOMEBOX_LOGIN_RESPONSE = """
|
||||
{
|
||||
"token": "fake-bearer-token",
|
||||
"attachmentToken": "fake-attach-token",
|
||||
"expiresAt": "2099-01-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE = """
|
||||
{
|
||||
"page": -1,
|
||||
"pageSize": -1,
|
||||
"total": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": "c643e7f9-93d0-4b5f-ae4d-e1c2d90389e0",
|
||||
"assetId": "000-001",
|
||||
"name": "MacBook Pro A1398",
|
||||
"description": "Running Linux (Fedora)",
|
||||
"quantity": 1,
|
||||
"insured": false,
|
||||
"archived": false,
|
||||
"createdAt": "2026-05-13T19:52:20.016176Z",
|
||||
"updatedAt": "2026-05-14T12:39:11.836403Z",
|
||||
"purchasePrice": 0,
|
||||
"parent": {
|
||||
"id": "b6f60ab8-3a2a-4a8d-a4bf-897d0555f636",
|
||||
"name": "Server Schrank Ikea weiß",
|
||||
"description": "Weißer Ikea Schrank, wo sich der Server befindet.",
|
||||
"createdAt": "2026-05-13T19:55:55.817576Z",
|
||||
"updatedAt": "2026-05-14T12:37:24.396651Z"
|
||||
},
|
||||
"tags": [],
|
||||
"imageId": "cb3e44d5-ccd4-421e-9f5a-f52cd5f40ca6",
|
||||
"thumbnailId": "2bfd53fa-1bf1-483c-8d76-7720464532fa",
|
||||
"soldTime": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
/**
|
||||
* Logs in against a stubbed Homebox instance, then performs a keyword search using the
|
||||
* resulting session cookie. Expects {@code 200 OK} with the mocked item mapped into the paged
|
||||
* response body.
|
||||
*
|
||||
* @param wm WireMock runtime info, injected by {@link WireMockTest}, used to point the client
|
||||
* at the stub server
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnListOfQueriedHomeboxItems(WireMockRuntimeInfo wm) throws Exception {
|
||||
|
||||
@@ -93,9 +72,14 @@ class SearchControllerTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.content[0].title").value("MacBook Pro A1398"))
|
||||
.andExpect(jsonPath("$.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.content[0].extraData.parent.name").value("Server Schrank Ikea weiß"));
|
||||
.andExpect(jsonPath("$.content[0].extraData.location.name")
|
||||
.value("Server Schrank Ikea weiß"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls {@code /api/search} with no session cookie attached and expects
|
||||
* {@code 401 Unauthorized}, confirming the endpoint is session-gated.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnUnauthorizedWhenNoSession() throws Exception {
|
||||
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON).content("""
|
||||
@@ -109,6 +93,10 @@ class SearchControllerTest {
|
||||
""")).andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a search request with a {@code serviceType} that doesn't map to any known
|
||||
* {@link com.vaessl.app.shared.ServiceType} and expects {@code 400 Bad Request}.
|
||||
*/
|
||||
@Test
|
||||
void shouldReturnBadRequestWhenServiceTypeIsInvalid() throws Exception {
|
||||
mockMvc.perform(post(SEARCH_REQUEST).contentType(MediaType.APPLICATION_JSON)
|
||||
|
||||
@@ -18,7 +18,7 @@ class SearchResponseTest {
|
||||
@Test
|
||||
void shouldReturnNullWhenExtraDataKeyIsMissing() {
|
||||
|
||||
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", Map.of("key", "value")));
|
||||
|
||||
assertThat(response.getExtra("missing")).isNull();
|
||||
}
|
||||
@@ -26,7 +26,8 @@ class SearchResponseTest {
|
||||
@Test
|
||||
void shouldReturnExtraDataValue() {
|
||||
|
||||
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||
ServiceItem response = new ServiceItem(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key",
|
||||
Map.of("key", "value")));
|
||||
|
||||
assertThat(response.id()).isEqualTo(MOCK_ID);
|
||||
assertThat(response.getExtra("key")).contains("value");
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.vaessl.app.sync;
|
||||
|
||||
import static com.vaessl.app.shared.Endpoint.HOMEBOX_LOGIN;
|
||||
import static com.vaessl.app.shared.Endpoint.HOMEBOX_QUERY_ALL_ITEMS;
|
||||
import static com.vaessl.app.shared.Endpoint.LOGIN;
|
||||
import static com.vaessl.app.shared.Endpoint.SYNC;
|
||||
import static com.vaessl.app.Mockdata.MOCK_USER;
|
||||
import static com.vaessl.app.Mockdata.MOCK_SERVICE_TYPE;
|
||||
import static com.vaessl.app.Mockdata.MOCK_PASS;
|
||||
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE;
|
||||
import static com.vaessl.app.Mockdata.VALID_HOMEBOX_LOGIN_RESPONSE;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import com.github.tomakehurst.wiremock.client.WireMock;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
|
||||
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
/**
|
||||
* Integration tests for {@code POST /api/sync}, verifying the session-gated contract against a
|
||||
* mocked Homebox backend (via WireMock) and a real Spring MVC dispatch chain (via {@link MockMvc}).
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@WireMockTest
|
||||
class SyncControllerTest {
|
||||
|
||||
@Autowired
|
||||
MockMvc mockMvc;
|
||||
|
||||
private static final String SYNC_PATH = SYNC.getValue();
|
||||
|
||||
/**
|
||||
* Logs in against a stubbed Homebox instance, then performs a sync using the resulting session
|
||||
* cookie. Expects a {@code 204 No Content} response once the mocked item catalog has been paged
|
||||
* through and embedded.
|
||||
*
|
||||
* @param wm WireMock runtime info, injected by {@link WireMockTest}, used to point the client
|
||||
* at the stub server
|
||||
*/
|
||||
@Test
|
||||
void shouldReturn204NoContentOnSuccessfulSync(WireMockRuntimeInfo wm) throws Exception {
|
||||
|
||||
WireMock.stubFor(WireMock.post(HOMEBOX_LOGIN.getValue())
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_LOGIN_RESPONSE)));
|
||||
|
||||
WireMock.stubFor(WireMock.get(WireMock.urlPathEqualTo(HOMEBOX_QUERY_ALL_ITEMS.getValue()))
|
||||
.willReturn(WireMock.okJson(VALID_HOMEBOX_ALL_ITEMS_QUERY_RESPONSE)));
|
||||
|
||||
MvcResult loginResult =
|
||||
mockMvc.perform(post(LOGIN.getValue()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(connectionRequestBody(wm))).andExpect(status().isOk()).andReturn();
|
||||
|
||||
Cookie sessionCookie = loginResult.getResponse().getCookie("SESSION");
|
||||
|
||||
mockMvc.perform(post(SYNC_PATH).cookie(sessionCookie)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(syncRequestBody(wm)))
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls {@code /api/sync} with no session cookie attached and expects {@code 401 Unauthorized},
|
||||
* confirming the endpoint is session-gated.
|
||||
*
|
||||
* @param wm WireMock runtime info, injected by {@link WireMockTest}, used only to build a
|
||||
* well-formed request body
|
||||
*/
|
||||
@Test
|
||||
void shouldReturn401UnauthorizedWhenSessionIsNull(WireMockRuntimeInfo wm) throws Exception {
|
||||
mockMvc.perform(post(SYNC_PATH).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(syncRequestBody(wm))).andExpect(status().isUnauthorized());
|
||||
|
||||
}
|
||||
|
||||
private String connectionRequestBody(WireMockRuntimeInfo wm) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"serviceType": "%s",
|
||||
"username": "%s",
|
||||
"password": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), MOCK_SERVICE_TYPE, MOCK_USER, MOCK_PASS);
|
||||
}
|
||||
|
||||
private String syncRequestBody(WireMockRuntimeInfo wm) {
|
||||
return """
|
||||
{
|
||||
"appUrl": "%s",
|
||||
"serviceType": "%s",
|
||||
"username": "%s"
|
||||
}
|
||||
""".formatted(wm.getHttpBaseUrl(), MOCK_SERVICE_TYPE, MOCK_USER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Git Conventions
|
||||
|
||||
## Branch Naming
|
||||
|
||||
```
|
||||
<type>/<short-kebab-case-description>
|
||||
```
|
||||
|
||||
- All lowercase, hyphens only — no camelCase or Title-Case
|
||||
- Keep the description short (3–5 words); the branch name is not the place for detail
|
||||
- If using a ticket tracker, include the ID: `feature/VAE-123-ai-search`
|
||||
|
||||
| Type | Use for |
|
||||
|---|---|
|
||||
| `feature/` | New functionality |
|
||||
| `fix/` | Bug fixes |
|
||||
| `refactor/` | Code changes with no behavior change |
|
||||
| `chore/` | Tooling, deps, config, cleanup |
|
||||
| `docs/` | Documentation only |
|
||||
|
||||
**Example:** `feature/ai-search`
|
||||
|
||||
---
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <imperative, present-tense summary>
|
||||
|
||||
<optional body — explain WHY, not WHAT>
|
||||
```
|
||||
|
||||
- **Type** — same list as branch types above, plus `test`, `style`
|
||||
- **Scope** — the module/package touched (e.g. `search`, `sync`, `connection`, `vector`, `frontend`)
|
||||
- **Summary** — imperative mood ("add", not "added" or "adds"); no period at the end
|
||||
- **Body** — only when the reasoning isn't obvious from the diff (a constraint, a bug workaround, a decision). Skip it for simple/self-explanatory changes.
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
feat(search): route SearchRequest.aiSearch to AI provider map
|
||||
fix(sync): prune stale vectors only after full page loop completes
|
||||
style: reformat
|
||||
refactor(vector): nest extraData by section for clearer embedding
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ticket Titles
|
||||
|
||||
Frame as the outcome, not a task log:
|
||||
|
||||
```
|
||||
<Type>: <what changes for the user/system>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- `Feature: Add AI-powered search to Homebox connector`
|
||||
- `Bug: /api/sync not triggered on login`
|
||||
- `Chore: Consolidate SearchService provider maps`
|
||||
@@ -1,11 +1,19 @@
|
||||
import { apiFetch } from './client'
|
||||
import type { AuthResponse, ConnectionStatus, LoginRequest, ServiceType } from '../types/connection'
|
||||
import { apiFetch } from "./client";
|
||||
import type {
|
||||
AuthResponse,
|
||||
ConnectionStatus,
|
||||
LoginRequest,
|
||||
ServiceType,
|
||||
} from "../types/connection";
|
||||
|
||||
export const login = (req: LoginRequest) =>
|
||||
apiFetch<AuthResponse>('/login', { method: 'POST', body: JSON.stringify(req) })
|
||||
apiFetch<AuthResponse>("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
|
||||
export const getStatuses = () =>
|
||||
apiFetch<ConnectionStatus[]>('/connections/status')
|
||||
apiFetch<ConnectionStatus[]>("/connections/status");
|
||||
|
||||
export const logout = (serviceType: ServiceType) =>
|
||||
apiFetch<void>(`/connections/${serviceType}`, { method: 'DELETE' })
|
||||
apiFetch<void>(`/connections/${serviceType}`, { method: "DELETE" });
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
PagedSearchResponse,
|
||||
SearchRequest,
|
||||
ServiceItem,
|
||||
SyncRequest,
|
||||
} from "../types/search";
|
||||
|
||||
export const search = (req: SearchRequest) =>
|
||||
@@ -10,3 +11,6 @@ export const search = (req: SearchRequest) =>
|
||||
method: "POST",
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
|
||||
export const syncVectorData = (req: SyncRequest) =>
|
||||
apiFetch<Object>("/sync", { method: "POST", body: JSON.stringify(req) });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
||||
import { login } from '../../api/connections'
|
||||
import type { LoginRequest, ServiceType } from '../../types/connection'
|
||||
import { uiText } from '../../text/uiText'
|
||||
import '../ui/Modal.scss'
|
||||
|
||||
interface Props {
|
||||
@@ -44,7 +45,7 @@ export function ConnectModal({ serviceType, label, onClose, onSuccess }: Readonl
|
||||
await login(req)
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
setError(err instanceof Error ? err.message : uiText.connections.connectModal.loginFailed)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -53,31 +54,31 @@ export function ConnectModal({ serviceType, label, onClose, onSuccess }: Readonl
|
||||
return (
|
||||
<dialog className="modal" ref={dialogRef}>
|
||||
<div className="modal__header">
|
||||
<h2 className="modal__title" id="modal-title">Connect to {label}</h2>
|
||||
<button className="modal__close" onClick={onClose} aria-label="Close">×</button>
|
||||
<h2 className="modal__title" id="modal-title">{uiText.connections.connectModal.title(label)}</h2>
|
||||
<button className="modal__close" onClick={onClose} aria-label={uiText.modal.closeAriaLabel}>{uiText.modal.closeSymbol}</button>
|
||||
</div>
|
||||
<form className="modal__form" onSubmit={handleSubmit}>
|
||||
<div className="modal__field">
|
||||
<label className="modal__label" htmlFor="appUrl">App URL</label>
|
||||
<label className="modal__label" htmlFor="appUrl">{uiText.connections.connectModal.appUrlLabel}</label>
|
||||
<input id="appUrl" ref={firstInputRef} className="modal__input" type="url"
|
||||
placeholder="https://homebox.example.com"
|
||||
placeholder={uiText.connections.connectModal.appUrlPlaceholder}
|
||||
value={appUrl} onChange={e => setAppUrl(e.target.value)} required />
|
||||
</div>
|
||||
<div className="modal__field">
|
||||
<label className="modal__label" htmlFor="username">Username</label>
|
||||
<label className="modal__label" htmlFor="username">{uiText.connections.connectModal.usernameLabel}</label>
|
||||
<input id="username" className="modal__input" type="text"
|
||||
autoComplete="username"
|
||||
value={username} onChange={e => setUsername(e.target.value)} required />
|
||||
</div>
|
||||
<div className="modal__field">
|
||||
<label className="modal__label" htmlFor="password">Password</label>
|
||||
<label className="modal__label" htmlFor="password">{uiText.connections.connectModal.passwordLabel}</label>
|
||||
<input id="password" className="modal__input" type="password"
|
||||
autoComplete="current-password"
|
||||
value={password} onChange={e => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
{error && <p className="modal__error">{error}</p>}
|
||||
<button className="modal__submit" type="submit" disabled={loading}>
|
||||
{loading ? 'Connecting…' : 'Connect'}
|
||||
{loading ? uiText.connections.connectModal.connectingButton : uiText.connections.connectModal.connectButton}
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ServiceCard } from "./ServiceCard"
|
||||
import { ConnectModal } from "./ConnectModal"
|
||||
import "./Dashboard.scss"
|
||||
import { SearchModal } from "../search/SearchModal"
|
||||
import { uiText } from "../../text/uiText"
|
||||
|
||||
const SERVICES = [
|
||||
{ serviceType: ServiceType.HOMEBOX, label: 'Homebox', icon: '📦' },
|
||||
@@ -32,10 +33,10 @@ export function Dashboard() {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard__header">
|
||||
<h1 className="dashboard__title">Vaessl Dashboard</h1>
|
||||
<h1 className="dashboard__title">{uiText.dashboard.title}</h1>
|
||||
</div>
|
||||
|
||||
<p className="dashboard__section-label">Services</p>
|
||||
<p className="dashboard__section-label">{uiText.dashboard.servicesLabel}</p>
|
||||
<div className="dashboard__cards">
|
||||
{SERVICES.map(({ serviceType, label, icon }) => (
|
||||
<ServiceCard
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
color: var(--text-h);
|
||||
margin: 0 0 10px;
|
||||
text-align: left;
|
||||
|
||||
}
|
||||
|
||||
&__meta {
|
||||
@@ -54,7 +53,7 @@
|
||||
border-radius: 999px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
content: "";
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
@@ -81,4 +80,4 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ConnectionStatus, ServiceType } from '../../types/connection'
|
||||
import { ActionButton } from '../ui/ActionButton'
|
||||
import { uiText } from '../../text/uiText'
|
||||
import './ServiceCard.scss'
|
||||
|
||||
interface Props {
|
||||
@@ -29,20 +30,20 @@ export function ServiceCard({ serviceType: _serviceType, label, icon, status, on
|
||||
<p className="service-card__name">{label}</p>
|
||||
<p className="service-card__meta">
|
||||
<span className={`service-card__badge service-card__badge--${connected ? 'connected' : 'disconnected'}`}>
|
||||
{connected ? 'Connected' : 'Not connected'}
|
||||
{connected ? uiText.connections.serviceCard.connected : uiText.connections.serviceCard.notConnected}
|
||||
</span>
|
||||
{connected && status?.username && <span>{status.username}</span>}
|
||||
{connected && status?.expiresAt && (
|
||||
<span>· expires {formatExpiry(status.expiresAt)}</span>
|
||||
<span>{uiText.connections.serviceCard.expiresPrefix(formatExpiry(status.expiresAt) ?? '')}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="service-card__actions">
|
||||
<ActionButton variant={connected ? 'disconnect' : 'connect'} onClick={connected ? onDisconnect : onConnect}>
|
||||
{connected ? 'Disconnect' : 'Connect'}
|
||||
{connected ? uiText.connections.serviceCard.disconnectButton : uiText.connections.serviceCard.connectButton}
|
||||
</ActionButton>
|
||||
{connected && (<ActionButton variant='search' onClick={onSearch}>Search</ActionButton>)}
|
||||
{connected && (<ActionButton variant='search' onClick={onSearch}>{uiText.connections.serviceCard.searchButton}</ActionButton>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
||||
import '../ui/Modal.scss'
|
||||
import { type PagedSearchResponse, type ServiceItem, type SearchRequest } from '../../types/search'
|
||||
import { search } from '../../api/searches'
|
||||
import { type PagedSearchResponse, type ServiceItem, type SearchRequest, type SyncRequest } from '../../types/search'
|
||||
import { syncVectorData, search } from '../../api/searches'
|
||||
import type { ServiceType } from '../../types/connection'
|
||||
import { uiText } from '../../text/uiText'
|
||||
|
||||
interface Props {
|
||||
serviceType: ServiceType
|
||||
@@ -17,9 +18,10 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
|
||||
const [results, setResults] = useState<PagedSearchResponse<ServiceItem> | null>(null)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const [syncError, setSyncError] = useState<string | null>(null)
|
||||
//TODO: implement aiSearch
|
||||
const [aiSearch, setAiSearch] = useState(false);
|
||||
const [aiSearch, setAiSearch] = useState(false)
|
||||
const firstInputRef = useRef<HTMLInputElement>(null)
|
||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||
|
||||
@@ -40,16 +42,27 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
|
||||
|
||||
const handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSearchError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const req: SearchRequest = { appUrl, serviceType, username, query, aiSearch}
|
||||
const res = await search(req)
|
||||
console.log(req)
|
||||
setResults(res)
|
||||
console.log('results', results)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Search failed')
|
||||
setSearchError(err instanceof Error ? err.message : uiText.search.searchFailed)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const req: SyncRequest = { appUrl, serviceType, username}
|
||||
await syncVectorData(req)
|
||||
} catch (err) {
|
||||
setSyncError(err instanceof Error ? err.message: uiText.search.syncFailed)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -58,18 +71,20 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
|
||||
return (
|
||||
<dialog className='modal' ref={dialogRef}>
|
||||
<div className='modal__header'>
|
||||
<h2 className='modal__title' id='modal-title'>Search in {label}</h2>
|
||||
<button className='modal__close' onClick={onClose} aria-label='Close'>×</button>
|
||||
<h2 className='modal__title' id='modal-title'>{uiText.search.title(label)}</h2>
|
||||
<button className='modal__close' onClick={onClose} aria-label={uiText.modal.closeAriaLabel}>{uiText.modal.closeSymbol}</button>
|
||||
</div>
|
||||
<form className='modal__form' onSubmit={handleSubmit}>
|
||||
<div className='modal__field'>
|
||||
<input id='search' className='modal__input'
|
||||
value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
{error && <p className='modal__error'>{error}</p>}
|
||||
<button className='modal__submit' type='submit' disabled={loading}>
|
||||
Search
|
||||
</button>
|
||||
{searchError && <p className='modal__error'>{searchError}</p>}
|
||||
{syncError && <p className='modal__error'>{syncError}</p>}
|
||||
<div className='modal__actions'>
|
||||
<button className='modal__other' title={uiText.search.syncTooltip(label)} type='button' disabled={loading} onClick={handleSync}>{uiText.search.refreshDataButton}</button>
|
||||
<button className='modal__submit' type='submit' disabled={loading}>{uiText.search.searchButton}</button>
|
||||
</div>
|
||||
</form>
|
||||
{results && (
|
||||
<div className='modal__results'>
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, opacity 0.2s;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
|
||||
@@ -44,8 +44,13 @@
|
||||
line-height: 1;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
&:hover { color: var(--text-h); }
|
||||
&:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
&:hover {
|
||||
color: var(--text-h);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&__form {
|
||||
@@ -94,62 +99,81 @@
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
&__submit {
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__submit,
|
||||
&__other {
|
||||
padding: 10px 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
align-self: flex-end;
|
||||
min-width: 100px;
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) { opacity: 0.85; }
|
||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
&:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
&__results {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0; // required: flex children won't shrink without this
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__results-count {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
&__result-item {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
&__result-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--text-h);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__result-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
&__submit {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
&__results {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0; // required: flex children won't shrink without this
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__results-count {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
&__result-item {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
&__result-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--text-h);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__result-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
export const uiText = {
|
||||
modal: {
|
||||
closeAriaLabel: "Close",
|
||||
closeSymbol: "×",
|
||||
},
|
||||
dashboard: {
|
||||
title: "Vaessl Dashboard",
|
||||
servicesLabel: "Services",
|
||||
},
|
||||
connections: {
|
||||
connectModal: {
|
||||
title: (label: string) => `Connect to ${label}`,
|
||||
appUrlLabel: "App URL",
|
||||
appUrlPlaceholder: "https://homebox.example.com",
|
||||
usernameLabel: "Username",
|
||||
passwordLabel: "Password",
|
||||
loginFailed: "Login failed",
|
||||
connectButton: "Connect",
|
||||
connectingButton: "Connecting…",
|
||||
},
|
||||
serviceCard: {
|
||||
connected: "Connected",
|
||||
notConnected: "Not connected",
|
||||
expiresPrefix: (date: string) => `· expires ${date}`,
|
||||
disconnectButton: "Disconnect",
|
||||
connectButton: "Connect",
|
||||
searchButton: "Search",
|
||||
},
|
||||
},
|
||||
search: {
|
||||
title: (label: string) => `Search in ${label}`,
|
||||
searchFailed: "Search failed",
|
||||
syncFailed: "Refreshing data failed",
|
||||
syncTooltip: (label: string) =>
|
||||
`Syncs your ${label} database for vectorization`,
|
||||
refreshDataButton: "Refresh Data",
|
||||
searchButton: "Search",
|
||||
},
|
||||
} as const;
|
||||
@@ -24,3 +24,9 @@ export interface PagedSearchResponse<T> {
|
||||
last: boolean;
|
||||
sort: string;
|
||||
}
|
||||
|
||||
export interface SyncRequest {
|
||||
appUrl: string;
|
||||
username: string;
|
||||
serviceType: ServiceType;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user