From c917d6b04c4b3e8cf0ab8a20c9e13445c3ebb588 Mon Sep 17 00:00:00 2001 From: "DESKTOP-72TV0V4\\caoxiaozhu" Date: Sat, 7 Mar 2026 09:11:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0Neo4j=E5=9B=BE?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E6=94=AF=E6=8C=81=E5=8F=8A=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=BB=A3=E7=A0=81=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Neo4j 图数据库 handler、service、model - 后端添加 SaveGraph API 接口 - 前端 Database.vue 重构,拆分为独立组件 - 新增 web/src/views/database/ 组件目录 - 删除临时文件 (temp_*.go) - 添加 Neo4j 相关 API 需求文档 Co-Authored-By: Claude Opus 4.6 --- server/Dockerfile | 32 - server/cmd/api/main.go | 12 + server/internal/handler/database_handler.go | 17 + server/internal/handler/neo4j_handler.go | 86 ++ server/internal/model/database_info.go | 144 +++- server/internal/model/neo4j_info.go | 39 + server/internal/repository/database_repo.go | 5 + server/internal/service/database_service.go | 107 +++ server/internal/service/neo4j_service.go | 850 ++++++++++++++++++++ server/server.exe | Bin 0 -> 18427904 bytes server/temp_add_data2.go | 122 --- server/temp_check.go | 27 - server/temp_native.go | 32 - server/temp_newuser.go | 41 - server/temp_regrant.go | 32 - server/temp_reset.go | 35 - team-require/api/README.md | 4 + team-require/api/database-check.md | 113 ++- team-require/api/neo4j-check.md | 265 ++++++ team-require/api/neo4j-graph-save.md | 75 ++ team-require/web/neo4j-api-requirement.md | 110 +++ team-require/web/neo4j-check-return-id.md | 99 +++ team-require/web/neo4j-graphs.md | 196 +++++ team-require/web/neo4j-support.md | 42 + team-require/web/todo-2026-3-6.md | 25 + web/src/assets/styles/base/reset.css | 69 ++ web/src/assets/styles/base/variables.css | 65 ++ web/src/assets/styles/components/button.css | 127 +++ web/src/assets/styles/components/form.css | 102 +++ web/src/assets/styles/components/modal.css | 88 ++ web/src/assets/styles/components/table.css | 91 +++ web/src/assets/styles/element/index.css | 198 +++++ web/src/assets/styles/index.css | 295 +++++++ web/src/composables/useApi.ts | 125 +++ web/src/main.ts | 2 +- web/src/utils/format.ts | 95 +++ web/src/utils/validate.ts | 128 +++ web/src/views/Database.vue | 776 +++--------------- web/src/views/database/database.css | 75 ++ web/src/views/database/types.ts | 45 ++ web/src/views/database/useDatabase.ts | 683 ++++++++++++++++ 41 files changed, 4453 insertions(+), 1021 deletions(-) delete mode 100644 server/Dockerfile create mode 100644 server/internal/handler/neo4j_handler.go create mode 100644 server/internal/model/neo4j_info.go create mode 100644 server/internal/service/neo4j_service.go create mode 100644 server/server.exe delete mode 100644 server/temp_add_data2.go delete mode 100644 server/temp_check.go delete mode 100644 server/temp_native.go delete mode 100644 server/temp_newuser.go delete mode 100644 server/temp_regrant.go delete mode 100644 server/temp_reset.go create mode 100644 team-require/api/neo4j-check.md create mode 100644 team-require/api/neo4j-graph-save.md create mode 100644 team-require/web/neo4j-api-requirement.md create mode 100644 team-require/web/neo4j-check-return-id.md create mode 100644 team-require/web/neo4j-graphs.md create mode 100644 team-require/web/neo4j-support.md create mode 100644 web/src/assets/styles/base/reset.css create mode 100644 web/src/assets/styles/base/variables.css create mode 100644 web/src/assets/styles/components/button.css create mode 100644 web/src/assets/styles/components/form.css create mode 100644 web/src/assets/styles/components/modal.css create mode 100644 web/src/assets/styles/components/table.css create mode 100644 web/src/assets/styles/element/index.css create mode 100644 web/src/assets/styles/index.css create mode 100644 web/src/composables/useApi.ts create mode 100644 web/src/utils/format.ts create mode 100644 web/src/utils/validate.ts create mode 100644 web/src/views/database/database.css create mode 100644 web/src/views/database/types.ts create mode 100644 web/src/views/database/useDatabase.ts diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index 9f14488..0000000 --- a/server/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# 构建阶段 -FROM golang:1.21-alpine AS builder - -WORKDIR /app - -# 安装依赖 -RUN apk add --no-cache git - -# 复制 go.mod 和 go.sum -COPY go.mod go.sum ./ -RUN go mod download - -# 复制源代码 -COPY . . - -# 构建 -RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/api - -# 运行阶段 -FROM alpine:latest - -RUN apk --no-cache add ca-certificates - -WORKDIR /app - -# 复制构建产物 -COPY --from=builder /server . -COPY config/ ./config/ - -EXPOSE 8080 - -CMD ["./server"] diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 7caef09..963f4b0 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -79,10 +79,12 @@ func main() { // 5. 初始化 Service dbService := service.NewDatabaseService(dbRepo, subTableRepo) subTableService := service.NewSubTableService(subTableRepo, dbRepo) + neo4jService := service.NewNeo4jService(dbRepo) // 6. 初始化 Handler dbHandler := handler.NewDatabaseHandler(dbService) subTableHandler := handler.NewSubTableHandler(subTableService) + neo4jHandler := handler.NewNeo4jHandler(neo4jService) systemHandler := handler.NewSystemHandler() // 7. 设置路由 @@ -137,6 +139,7 @@ func main() { databaseGroup.POST("/add", dbHandler.Create) databaseGroup.PUT("/:id", dbHandler.Update) databaseGroup.DELETE("/:id", dbHandler.Delete) + databaseGroup.POST("/graph/save", dbHandler.SaveGraph) } // 子表映射管理模块 @@ -151,6 +154,15 @@ func main() { subTableGroup.DELETE("/:id", subTableHandler.Delete) } + // Neo4j 连接管理模块 + neo4jGroup := r.Group("/neo4j") + { + neo4jGroup.POST("/check", neo4jHandler.Check) + neo4jGroup.POST("/graphs", neo4jHandler.GetGraphs) + neo4jGroup.POST("/nodes", neo4jHandler.GetNodes) + neo4jGroup.POST("/relationships", neo4jHandler.GetRelationships) + } + // 系统信息模块 r.GET("/system/info", systemHandler.GetSystemInfo) diff --git a/server/internal/handler/database_handler.go b/server/internal/handler/database_handler.go index 5290016..eafc401 100644 --- a/server/internal/handler/database_handler.go +++ b/server/internal/handler/database_handler.go @@ -98,6 +98,23 @@ func (h *DatabaseHandler) Update(c *gin.Context) { c.JSON(http.StatusOK, info) } +// SaveGraph 保存图谱信息 +func (h *DatabaseHandler) SaveGraph(c *gin.Context) { + var req model.SaveGraphRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.service.SaveGraph(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + // Delete 删除 func (h *DatabaseHandler) Delete(c *gin.Context) { id := c.Param("id") diff --git a/server/internal/handler/neo4j_handler.go b/server/internal/handler/neo4j_handler.go new file mode 100644 index 0000000..c74a45d --- /dev/null +++ b/server/internal/handler/neo4j_handler.go @@ -0,0 +1,86 @@ +package handler + +import ( + "net/http" + + "x-agents/server/internal/model" + "x-agents/server/internal/service" + + "github.com/gin-gonic/gin" +) + +type Neo4jHandler struct { + service *service.Neo4jService +} + +func NewNeo4jHandler(svc *service.Neo4jService) *Neo4jHandler { + return &Neo4jHandler{service: svc} +} + +// Check 检查 Neo4j 连接 +func (h *Neo4jHandler) Check(c *gin.Context) { + var req model.Neo4jCheckRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.service.Check(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GetGraphs 获取图谱概览数据 +func (h *Neo4jHandler) GetGraphs(c *gin.Context) { + var req model.Neo4jGraphRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.service.GetGraphs(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GetNodes 获取节点详情 +func (h *Neo4jHandler) GetNodes(c *gin.Context) { + var req model.Neo4jNodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.service.GetNodes(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GetRelationships 获取关系详情 +func (h *Neo4jHandler) GetRelationships(c *gin.Context) { + var req model.Neo4jRelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.service.GetRelationships(req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} diff --git a/server/internal/model/database_info.go b/server/internal/model/database_info.go index 61ad2c1..c614237 100644 --- a/server/internal/model/database_info.go +++ b/server/internal/model/database_info.go @@ -9,7 +9,7 @@ type DatabaseInfo struct { ID string `json:"id" gorm:"primaryKey;size:36"` // UUID Name string `json:"name" gorm:"size:100;not null"` // 数据库名称 Description string `json:"description" gorm:"size:500"` // 描述 - DBType string `json:"db_type" gorm:"size:20;not null"` // 数据库类型: mysql, postgres, mongodb等 + DBType string `json:"db_type" gorm:"size:20;not null"` // 数据库类型: mysql, postgres, mongodb, neo4j等 Host string `json:"host" gorm:"size:255;not null"` // 主机地址 Port int `json:"port" gorm:"not null"` // 端口 Username string `json:"username" gorm:"size:100;not null"` // 用户名 @@ -17,6 +17,12 @@ type DatabaseInfo struct { Database string `json:"database" gorm:"size:100"` // 数据库名 TableCount int `json:"table_count" gorm:"default:0"` // 子表数量 + // Neo4j 专用字段 + URI string `json:"uri" gorm:"size:255"` // Neo4j 连接地址 (bolt://host:7687) + GraphLabels string `json:"graph_labels" gorm:"size:1000"` // Neo4j 标签列表 (JSON 格式) + GraphRelationship string `json:"graph_relationship" gorm:"size:1000"` // Neo4j 关系类型列表 (JSON 格式) + SelectedLabel string `json:"selected_label" gorm:"size:100"` // 当前选中的标签 + // 连接选项 Charset string `json:"charset" gorm:"size:20;default:utf8mb4"` // 字符集 SSLMode string `json:"ssl_mode" gorm:"size:20"` // SSL模式 @@ -48,17 +54,38 @@ type CreateDatabaseRequest struct { // UpdateRequest 更新数据库信息请求 type UpdateDatabaseRequest struct { - Name string `json:"name"` - Description string `json:"description"` - DBType string `json:"db_type"` - Host string `json:"host"` - Port int `json:"port"` - Username string `json:"username"` - Password string `json:"password"` - Database string `json:"database"` - TableCount int `json:"table_count"` - Charset string `json:"charset"` - SSLMode string `json:"ssl_mode"` + Name string `json:"name"` + Description string `json:"description"` + DBType string `json:"db_type"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Database string `json:"database"` + TableCount int `json:"table_count"` + Charset string `json:"charset"` + SSLMode string `json:"ssl_mode"` + URI string `json:"uri"` // Neo4j 连接地址 + GraphLabels string `json:"graph_labels"` // Neo4j 标签列表 (JSON) + GraphRelationship string `json:"graph_relationship"` // Neo4j 关系类型列表 (JSON) + SelectedLabel string `json:"selected_label"` // 当前选中的标签 +} + +// SaveGraphRequest 保存图谱信息请求 +type SaveGraphRequest struct { + DatabaseID string `json:"databaseId" binding:"required"` + DatabaseName string `json:"databaseName" binding:"required"` + URI string `json:"uri" binding:"required"` + Username string `json:"username" binding:"required"` + Labels []string `json:"labels" binding:"required"` + RelationshipTypes []string `json:"relationshipTypes" binding:"required"` + SelectedLabel string `json:"selectedLabel"` +} + +// SaveGraphResponse 保存图谱信息响应 +type SaveGraphResponse struct { + Success bool `json:"success"` + Message string `json:"message"` } // CheckRequest 检查连接请求 @@ -72,12 +99,97 @@ type CheckRequest struct { Charset string `json:"charset"` SSLMode string `json:"ssl_mode"` DatabaseID string `json:"database_id"` // 可选,用于获取已保存的字段映射 + URI string `json:"uri"` // Neo4j 连接地址,如 bolt://localhost:7687 } // CheckResponse 检查连接响应 type CheckResponse struct { - Success bool `json:"success"` // 是否连接成功 - Message string `json:"message"` // 消息 - Tables []TableDDLInfo `json:"tables,omitempty"` // 表列表(连接成功时返回) - Database string `json:"database"` // 数据库名 + Success bool `json:"success"` // 是否连接成功 + Message string `json:"message"` // 消息 + Tables []TableDDLInfo `json:"tables,omitempty"` // 表列表(关系型数据库) + Database string `json:"database"` // 数据库名 + Graphs *GraphOverview `json:"graphs,omitempty"` // 图谱概览(Neo4j) +} + +// GraphOverview 图谱概览数据 +type GraphOverview struct { + Labels []LabelCount `json:"labels"` // 标签列表 + RelationshipTypes []RelTypeCount `json:"relationshipTypes"` // 关系类型列表 + Nodes []NodeProperty `json:"nodes"` // 节点属性定义 + Relationships []RelProperty `json:"relationships"` // 关系属性定义 +} + +// LabelCount 标签数量统计 +type LabelCount struct { + Name string `json:"name"` // 标签名 + Count int `json:"count"` // 节点数量 +} + +// RelTypeCount 关系类型数量统计 +type RelTypeCount struct { + Name string `json:"name"` // 关系类型名 + Count int `json:"count"` // 关系数量 +} + +// NodeProperty 节点属性定义 +type NodeProperty struct { + Label string `json:"label"` // 节点标签名 + Properties []PropertyInfo `json:"properties"` // 属性列表 +} + +// PropertyInfo 属性信息 +type PropertyInfo struct { + Name string `json:"name"` // 属性名 + Type string `json:"type"` // 属性类型 +} + +// RelProperty 关系属性定义 +type RelProperty struct { + Type string `json:"type"` // 关系类型名 + StartLabel string `json:"startLabel"` // 起始节点标签 + EndLabel string `json:"endLabel"` // 目标节点标签 + Properties []PropertyInfo `json:"properties"` // 属性列表 +} + +// Neo4jNodeRequest 获取节点详情请求 +type Neo4jNodeRequest struct { + URI string `json:"uri" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Database string `json:"database"` + Label string `json:"label" binding:"required"` + Limit int `json:"limit"` // 默认 10 +} + +// Neo4jNodeResponse 获取节点详情响应 +type Neo4jNodeResponse struct { + Success bool `json:"success"` // 是否成功 + Message string `json:"message"` // 消息 + Nodes []map[string]interface{} `json:"nodes"` // 节点数据 + Properties []PropertyInfo `json:"properties"` // 属性定义列表 +} + +// Neo4jRelRequest 获取关系详情请求 +type Neo4jRelRequest struct { + URI string `json:"uri" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Database string `json:"database"` + RelationshipType string `json:"relationship_type" binding:"required"` + Limit int `json:"limit"` // 默认 10 +} + +// Neo4jRelResponse 获取关系详情响应 +type Neo4jRelResponse struct { + Success bool `json:"success"` // 是否成功 + Message string `json:"message"` // 消息 + Relationships []Neo4jRelationship `json:"relationships"` // 关系数据 +} + +// Neo4jRelationship 关系数据 +type Neo4jRelationship struct { + ID string `json:"id"` // 关系唯一标识 + Source string `json:"source"` // 起始节点ID + Target string `json:"target"` // 目标节点ID + Properties map[string]interface{} `json:"properties"` // 关系属性 } diff --git a/server/internal/model/neo4j_info.go b/server/internal/model/neo4j_info.go new file mode 100644 index 0000000..f1c8888 --- /dev/null +++ b/server/internal/model/neo4j_info.go @@ -0,0 +1,39 @@ +package model + +// Neo4jCheckRequest Neo4j 连接测试请求 +type Neo4jCheckRequest struct { + Name string `json:"name"` // 数据库名称 + Host string `json:"host" binding:"required"` + Port int `json:"port" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Database string `json:"database"` // 可选,默认 neo4j + URI string `json:"uri"` // 可选,Neo4j 连接地址 (bolt://host:7687) + Description string `json:"description"` // 可选,数据库描述 +} + +// Neo4jCheckResponse Neo4j 连接测试响应 +type Neo4jCheckResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Version string `json:"version,omitempty"` + Databases []string `json:"databases,omitempty"` + DatabaseID string `json:"databaseId,omitempty"` // 数据库记录 ID + Name string `json:"name,omitempty"` // 数据库名称 + Description string `json:"description,omitempty"` // 数据库描述 +} + +// Neo4jGraphRequest 获取图谱概览请求 +type Neo4jGraphRequest struct { + URI string `json:"uri" binding:"required"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + Database string `json:"database"` // 可选,默认 neo4j +} + +// Neo4jGraphResponse 获取图谱概览响应 +type Neo4jGraphResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Graphs *GraphOverview `json:"graphs,omitempty"` +} diff --git a/server/internal/repository/database_repo.go b/server/internal/repository/database_repo.go index 552b305..3c865ba 100644 --- a/server/internal/repository/database_repo.go +++ b/server/internal/repository/database_repo.go @@ -41,6 +41,11 @@ func (r *DatabaseRepository) Update(id string, info *model.DatabaseInfo) error { return r.db.Model(&model.DatabaseInfo{}).Where("id = ?", id).Updates(info).Error } +// UpdateFields 更新指定字段 +func (r *DatabaseRepository) UpdateFields(id string, fields map[string]interface{}) error { + return r.db.Model(&model.DatabaseInfo{}).Where("id = ?", id).Updates(fields).Error +} + // Delete 删除 func (r *DatabaseRepository) Delete(id string) error { return r.db.Delete(&model.DatabaseInfo{}, "id = ?", id).Error diff --git a/server/internal/service/database_service.go b/server/internal/service/database_service.go index 18ee629..aa4a390 100644 --- a/server/internal/service/database_service.go +++ b/server/internal/service/database_service.go @@ -259,6 +259,27 @@ func (s *DatabaseService) Check(req model.CheckRequest) (*model.CheckResponse, e var db *sql.DB var err error + // Neo4j 处理 + if dbType == "neo4j" { + log.Printf("[Check] 检测到 Neo4j 类型,使用图数据库连接...") + neo4jService := NewNeo4jService(s.repo) + graph, err := neo4jService.GetGraphOverview(req) + if err != nil { + log.Printf("[Check] Neo4j 连接失败: %v", err) + return &model.CheckResponse{ + Success: false, + Message: fmt.Sprintf("neo4j connection failed: %v", err), + }, nil + } + log.Printf("[Check] Neo4j 连接成功,获取到 %d 个标签", len(graph.Labels)) + return &model.CheckResponse{ + Success: true, + Message: "connection successful", + Graphs: graph, + Database: req.Database, + }, nil + } + switch dbType { case "mysql": db, err = sql.Open("mysql", dsn) @@ -684,6 +705,92 @@ func (s *DatabaseService) Update(id string, req model.UpdateDatabaseRequest) (*m return s.repo.FindByID(id) } +// SaveGraph 保存图谱信息 +func (s *DatabaseService) SaveGraph(req model.SaveGraphRequest) (*model.SaveGraphResponse, error) { + log.Printf("[SaveGraph] 保存图谱信息, databaseId=%s, databaseName=%s", req.DatabaseID, req.DatabaseName) + + // 检查数据库是否存在 + _, err := s.repo.FindByID(req.DatabaseID) + if err != nil { + // 如果不存在,创建一个新的 + if err == ErrDatabaseNotFound { + // 创建新的数据库记录 + dbType := "neo4j" + // 从 URI 解析 host 和 port + host := "localhost" + port := 7687 + if req.URI != "" { + uri := strings.TrimPrefix(req.URI, "bolt://") + uri = strings.TrimPrefix(uri, "neo4j://") + if idx := strings.Index(uri, ":"); idx > 0 { + host = uri[:idx] + fmt.Sscanf(uri[idx+1:], "%d", &port) + } + } + + // 将 labels 和 relationshipTypes 转为 JSON 字符串 + labelsJSON, _ := json.Marshal(req.Labels) + relJSON, _ := json.Marshal(req.RelationshipTypes) + + info := &model.DatabaseInfo{ + ID: req.DatabaseID, + Name: req.DatabaseName, + DBType: dbType, + Host: host, + Port: port, + Username: req.Username, + URI: req.URI, + GraphLabels: string(labelsJSON), + GraphRelationship: string(relJSON), + SelectedLabel: req.SelectedLabel, + } + + if err := s.repo.Create(info); err != nil { + log.Printf("[SaveGraph] 创建失败: %v", err) + return &model.SaveGraphResponse{ + Success: false, + Message: fmt.Sprintf("创建失败: %v", err), + }, err + } + + return &model.SaveGraphResponse{ + Success: true, + Message: "保存成功", + }, nil + } + log.Printf("[SaveGraph] 查询失败: %v", err) + return &model.SaveGraphResponse{ + Success: false, + Message: fmt.Sprintf("查询失败: %v", err), + }, err + } + + // 更新现有记录 + labelsJSON, _ := json.Marshal(req.Labels) + relJSON, _ := json.Marshal(req.RelationshipTypes) + + updates := map[string]interface{}{ + "uri": req.URI, + "username": req.Username, + "graph_labels": string(labelsJSON), + "graph_relationship": string(relJSON), + "selected_label": req.SelectedLabel, + } + + if err := s.repo.UpdateFields(req.DatabaseID, updates); err != nil { + log.Printf("[SaveGraph] 更新失败: %v", err) + return &model.SaveGraphResponse{ + Success: false, + Message: fmt.Sprintf("更新失败: %v", err), + }, err + } + + return &model.SaveGraphResponse{ + Success: true, + Message: "保存成功", + }, nil +} + // fillFieldMappings 填充字段映射到表结构中 func (s *DatabaseService) fillFieldMappings(databaseID string, tables []model.TableDDLInfo) { // 从数据库中获取该数据库下所有子表的字段映射 diff --git a/server/internal/service/neo4j_service.go b/server/internal/service/neo4j_service.go new file mode 100644 index 0000000..c8ee70d --- /dev/null +++ b/server/internal/service/neo4j_service.go @@ -0,0 +1,850 @@ +package service + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + + "x-agents/server/internal/model" + "x-agents/server/internal/repository" + + "github.com/google/uuid" +) + +// Neo4jService Neo4j 服务 +type Neo4jService struct { + client *http.Client + databaseRepo *repository.DatabaseRepository +} + +func NewNeo4jService(dbRepo *repository.DatabaseRepository) *Neo4jService { + return &Neo4jService{ + client: &http.Client{}, + databaseRepo: dbRepo, + } +} + +// GetGraphs 获取图谱概览数据(新增接口) +func (s *Neo4jService) GetGraphs(req model.Neo4jGraphRequest) (*model.Neo4jGraphResponse, error) { + host := "localhost" + port := 7687 + if req.URI != "" { + // 解析 URI + uri := strings.TrimPrefix(req.URI, "bolt://") + uri = strings.TrimPrefix(uri, "neo4j://") + if idx := strings.Index(uri, ":"); idx > 0 { + host = uri[:idx] + fmt.Sscanf(uri[idx+1:], "%d", &port) + } + } + + db := req.Database + if db == "" { + db = "neo4j" + } + + auth := fmt.Sprintf("%s:%s", req.Username, req.Password) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + + // 尝试多个端口 + ports := []int{port - 1000, 7474, port} + checkedPorts := make(map[int]bool) + + for _, p := range ports { + if checkedPorts[p] { + continue + } + checkedPorts[p] = true + + url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db) + graph, err := s.getGraphOverviewWithURL(url, encodedAuth, db) + if err == nil && graph != nil { + return &model.Neo4jGraphResponse{ + Success: true, + Message: "success", + Graphs: graph, + }, nil + } + } + + return &model.Neo4jGraphResponse{ + Success: false, + Message: "failed to connect to Neo4j", + }, nil +} + +// Check 测试 Neo4j 连接 +func (s *Neo4jService) Check(req model.Neo4jCheckRequest) (*model.Neo4jCheckResponse, error) { + db := req.Database + if db == "" { + db = "neo4j" + } + + auth := fmt.Sprintf("%s:%s", req.Username, req.Password) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + + httpPort := req.Port - 1000 + if httpPort <= 0 { + httpPort = 7474 + } + + ports := []int{httpPort, 7474, req.Port} + checkedPorts := make(map[int]bool) + + var version string + for _, port := range ports { + if checkedPorts[port] { + continue + } + checkedPorts[port] = true + + url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", req.Host, port, db) + resp, err := s.checkWithURL(url, encodedAuth, db) + if err == nil && resp.Success { + version = resp.Version + // 连接成功,检查或创建数据库记录 + log.Printf("[Check] Neo4j 连接成功,准备获取/创建数据库记录, host=%s, port=%d, db=%s", req.Host, req.Port, db) + dbInfo, err := s.ensureNeo4jDatabase(req, db) + if err != nil { + log.Printf("[Check] 确保数据库记录失败: %v", err) + } + log.Printf("[Check] 数据库记录ID: %s, Name: %s", dbInfo.ID, dbInfo.Name) + return &model.Neo4jCheckResponse{ + Success: true, + Message: "connection successful", + Version: version, + Databases: []string{db}, + DatabaseID: dbInfo.ID, + Name: dbInfo.Name, + Description: dbInfo.Description, + }, nil + } + } + + return &model.Neo4jCheckResponse{ + Success: false, + Message: fmt.Sprintf("connection failed on all ports (7474, %d)", req.Port), + }, nil +} + +// Neo4jDatabaseInfo Neo4j 数据库记录信息 +type Neo4jDatabaseInfo struct { + ID string + Name string + Description string +} + +// ensureNeo4jDatabase 确保 Neo4j 数据库记录存在 +func (s *Neo4jService) ensureNeo4jDatabase(req model.Neo4jCheckRequest, dbName string) (*Neo4jDatabaseInfo, error) { + log.Printf("[ensureNeo4jDatabase] 开始处理, host=%s, port=%d, username=%s, dbName=%s, uri=%s", req.Host, req.Port, req.Username, dbName, req.URI) + + // 根据 host, port, username, database 查找是否已存在 + databases, err := s.databaseRepo.FindAll() + if err != nil { + log.Printf("[ensureNeo4jDatabase] FindAll 失败: %v", err) + return nil, err + } + log.Printf("[ensureNeo4jDatabase] 找到 %d 条数据库记录", len(databases)) + + // 构建 URI + uri := req.URI + if uri == "" { + uri = fmt.Sprintf("bolt://%s:%d", req.Host, req.Port) + } + log.Printf("[ensureNeo4jDatabase] 使用 URI: %s", uri) + + // 查找已存在的记录 + for _, d := range databases { + log.Printf("[ensureNeo4jDatabase] 对比: URI=%s, Username=%s, Database=%s", d.URI, d.Username, d.Database) + if d.URI == uri && d.Username == req.Username && d.Database == dbName { + log.Printf("[ensureNeo4jDatabase] 找到已存在的记录, id=%s, name=%s", d.ID, d.Name) + return &Neo4jDatabaseInfo{ + ID: d.ID, + Name: d.Name, + Description: d.Description, + }, nil + } + } + + // 不存在,创建新记录 + log.Printf("[ensureNeo4jDatabase] 未找到匹配记录,创建新记录") + dbType := "neo4j" + name := req.Name + if name == "" { + name = fmt.Sprintf("Neo4j-%s", dbName) + } + description := req.Description + if description == "" { + description = fmt.Sprintf("Neo4j %s@%s:%d", dbName, req.Host, req.Port) + } + + newDB := &model.DatabaseInfo{ + ID: uuid.New().String(), + Name: name, + DBType: dbType, + Host: req.Host, + Port: req.Port, + Username: req.Username, + Password: req.Password, + Database: dbName, + URI: uri, + Description: description, + } + + log.Printf("[ensureNeo4jDatabase] 创建新数据库: ID=%s, Name=%s, Host=%s, Port=%d, URI=%s", newDB.ID, newDB.Name, newDB.Host, newDB.Port, newDB.URI) + + if err := s.databaseRepo.Create(newDB); err != nil { + log.Printf("[ensureNeo4jDatabase] Create 失败: %v", err) + return nil, err + } + + log.Printf("[ensureNeo4jDatabase] 创建成功, 返回 ID=%s", newDB.ID) + return &Neo4jDatabaseInfo{ + ID: newDB.ID, + Name: newDB.Name, + Description: newDB.Description, + }, nil +} + +// GetGraphOverview 获取图谱概览数据 +func (s *Neo4jService) GetGraphOverview(req model.CheckRequest) (*model.GraphOverview, error) { + // 从 CheckRequest 获取连接信息 + // URI 可能是 bolt://host:7687 格式 + host := req.Host + port := req.Port + if req.URI != "" { + // 解析 URI + uri := strings.TrimPrefix(req.URI, "bolt://") + uri = strings.TrimPrefix(uri, "neo4j://") + if idx := strings.Index(uri, ":"); idx > 0 { + host = uri[:idx] + fmt.Sscanf(uri[idx+1:], "%d", &port) + } + } + + db := req.Database + if db == "" { + db = "neo4j" + } + + auth := fmt.Sprintf("%s:%s", req.Username, req.Password) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + + // 尝试多个端口 + ports := []int{port - 1000, 7474, port} + checkedPorts := make(map[int]bool) + + var graph *model.GraphOverview + for _, p := range ports { + if checkedPorts[p] { + continue + } + checkedPorts[p] = true + + url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db) + var err error + graph, err = s.getGraphOverviewWithURL(url, encodedAuth, db) + if err == nil && graph != nil { + return graph, nil + } + } + + return nil, fmt.Errorf("failed to connect to Neo4j") +} + +func (s *Neo4jService) getGraphOverviewWithURL(url, encodedAuth, db string) (*model.GraphOverview, error) { + // 查询所有标签及其数量 + labelsQuery := `CALL db.labels() YIELD label RETURN label` + relTypesQuery := `CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType` + + // 构建复合查询 + query := fmt.Sprintf(`{"statements": [ + {"statement": "%s", "resultDataContents": ["row"]}, + {"statement": "%s", "resultDataContents": ["row"]}, + {"statement": "MATCH (n) RETURN labels(n) as nodeLabels, count(*) as count", "resultDataContents": ["row"]}, + {"statement": "MATCH ()-[r]->() RETURN type(r) as relType, count(*) as count", "resultDataContents": ["row"]} + ]}`, labelsQuery, relTypesQuery) + + reqBody := strings.NewReader(query) + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + // 检查错误 + if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { + if errMap, ok := errors[0].(map[string]interface{}); ok { + if msg, ok := errMap["message"].(string); ok { + return nil, fmt.Errorf("%s", msg) + } + } + return nil, fmt.Errorf("query error") + } + + // 解析结果 + results, ok := result["results"].([]interface{}) + if !ok || len(results) < 4 { + return nil, fmt.Errorf("invalid response format") + } + + graph := &model.GraphOverview{ + Labels: []model.LabelCount{}, + RelationshipTypes: []model.RelTypeCount{}, + Nodes: []model.NodeProperty{}, + Relationships: []model.RelProperty{}, + } + + // 解析标签 + if len(results) > 0 { + if res0, ok := results[0].(map[string]interface{}); ok { + if data, ok := res0["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if label, ok := row["row"].([]interface{}); ok && len(label) > 0 { + graph.Labels = append(graph.Labels, model.LabelCount{ + Name: fmt.Sprintf("%v", label[0]), + Count: 0, + }) + } + } + } + } + } + } + + // 解析关系类型 + if len(results) > 1 { + if res1, ok := results[1].(map[string]interface{}); ok { + if data, ok := res1["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if relType, ok := row["row"].([]interface{}); ok && len(relType) > 0 { + graph.RelationshipTypes = append(graph.RelationshipTypes, model.RelTypeCount{ + Name: fmt.Sprintf("%v", relType[0]), + Count: 0, + }) + } + } + } + } + } + } + + // 解析节点统计 + if len(results) > 2 { + if res2, ok := results[2].(map[string]interface{}); ok { + if data, ok := res2["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 2 { + if labels, ok := rowData[0].([]interface{}); ok && len(labels) > 0 { + labelName := fmt.Sprintf("%v", labels[0]) + count := int(rowData[1].(float64)) + + // 更新标签数量 + for i := range graph.Labels { + if graph.Labels[i].Name == labelName { + graph.Labels[i].Count = count + break + } + } + } + } + } + } + } + } + } + + // 解析关系统计 + if len(results) > 3 { + if res3, ok := results[3].(map[string]interface{}); ok { + if data, ok := res3["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 2 { + relTypeName := fmt.Sprintf("%v", rowData[0]) + count := int(rowData[1].(float64)) + + // 更新关系类型数量 + for i := range graph.RelationshipTypes { + if graph.RelationshipTypes[i].Name == relTypeName { + graph.RelationshipTypes[i].Count = count + break + } + } + } + } + } + } + } + } + + // 获取节点属性定义 + s.fillNodeProperties(url, encodedAuth, db, graph) + // 获取关系属性定义 + s.fillRelProperties(url, encodedAuth, db, graph) + + return graph, nil +} + +func (s *Neo4jService) fillNodeProperties(url, encodedAuth, db string, graph *model.GraphOverview) { + // 获取每个标签的属性 + for i, label := range graph.Labels { + query := fmt.Sprintf(`MATCH (n:%s) RETURN properties(n) as props LIMIT 1`, label.Name) + body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query) + + reqBody := strings.NewReader(body) + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + continue + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + continue + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + continue + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + continue + } + + results, ok := result["results"].([]interface{}) + if !ok || len(results) == 0 { + continue + } + + if res0, ok := results[0].(map[string]interface{}); ok { + if data, ok := res0["data"].([]interface{}); ok && len(data) > 0 { + if item, ok := data[0].(map[string]interface{}); ok { + if row, ok := item["row"].([]interface{}); ok && len(row) > 0 { + if props, ok := row[0].(map[string]interface{}); ok { + nodeProp := model.NodeProperty{ + Label: label.Name, + Properties: []model.PropertyInfo{}, + } + for name, value := range props { + nodeProp.Properties = append(nodeProp.Properties, model.PropertyInfo{ + Name: name, + Type: fmt.Sprintf("%T", value), + }) + } + graph.Nodes = append(graph.Nodes, nodeProp) + graph.Labels[i].Count = label.Count + } + } + } + } + } + } +} + +func (s *Neo4jService) fillRelProperties(url, encodedAuth, db string, graph *model.GraphOverview) { + // 获取每个关系类型的属性 + for _, relType := range graph.RelationshipTypes { + query := fmt.Sprintf(`MATCH ()-[r:%s]->() RETURN properties(r) as props LIMIT 1`, relType.Name) + body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query) + + reqBody := strings.NewReader(body) + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + continue + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + continue + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + continue + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + continue + } + + results, ok := result["results"].([]interface{}) + if !ok || len(results) == 0 { + continue + } + + if res0, ok := results[0].(map[string]interface{}); ok { + if data, ok := res0["data"].([]interface{}); ok && len(data) > 0 { + if item, ok := data[0].(map[string]interface{}); ok { + if row, ok := item["row"].([]interface{}); ok && len(row) > 0 { + if props, ok := row[0].(map[string]interface{}); ok { + relProp := model.RelProperty{ + Type: relType.Name, + Properties: []model.PropertyInfo{}, + } + for name, value := range props { + relProp.Properties = append(relProp.Properties, model.PropertyInfo{ + Name: name, + Type: fmt.Sprintf("%T", value), + }) + } + graph.Relationships = append(graph.Relationships, relProp) + } + } + } + } + } + } +} + +// GetNodes 获取节点详情 +func (s *Neo4jService) GetNodes(req model.Neo4jNodeRequest) (*model.Neo4jNodeResponse, error) { + host := "localhost" + port := 7687 + if req.URI != "" { + uri := strings.TrimPrefix(req.URI, "bolt://") + uri = strings.TrimPrefix(uri, "neo4j://") + if idx := strings.Index(uri, ":"); idx > 0 { + host = uri[:idx] + fmt.Sscanf(uri[idx+1:], "%d", &port) + } + } + + db := req.Database + if db == "" { + db = "neo4j" + } + + limit := req.Limit + if limit <= 0 { + limit = 10 + } + + auth := fmt.Sprintf("%s:%s", req.Username, req.Password) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + + ports := []int{port - 1000, 7474, port} + checkedPorts := make(map[int]bool) + + for _, p := range ports { + if checkedPorts[p] { + continue + } + checkedPorts[p] = true + + url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db) + nodes, props, err := s.getNodesWithURL(url, encodedAuth, req.Label, limit) + if err == nil { + return &model.Neo4jNodeResponse{ + Success: true, + Message: "success", + Nodes: nodes, + Properties: props, + }, nil + } + } + + return &model.Neo4jNodeResponse{ + Success: false, + Message: "failed to connect to Neo4j", + }, nil +} + +func (s *Neo4jService) getNodesWithURL(url, encodedAuth, label string, limit int) ([]map[string]interface{}, []model.PropertyInfo, error) { + query := fmt.Sprintf(`MATCH (n:%s) RETURN properties(n) as props, elementId(n) as id LIMIT %d`, label, limit) + body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query) + + reqBody := strings.NewReader(body) + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return nil, nil, err + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, nil, err + } + + if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { + if errMap, ok := errors[0].(map[string]interface{}); ok { + if msg, ok := errMap["message"].(string); ok { + return nil, nil, fmt.Errorf("%s", msg) + } + } + return nil, nil, fmt.Errorf("query error") + } + + results, ok := result["results"].([]interface{}) + if !ok || len(results) == 0 { + return []map[string]interface{}{}, []model.PropertyInfo{}, nil + } + + var nodes []map[string]interface{} + propsMap := make(map[string]string) // 用于去重属性 + + if res0, ok := results[0].(map[string]interface{}); ok { + if data, ok := res0["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 2 { + node := map[string]interface{}{ + "id": rowData[1], + } + if props, ok := rowData[0].(map[string]interface{}); ok { + for k, v := range props { + node[k] = v + // 收集属性类型 + if _, exists := propsMap[k]; !exists { + propsMap[k] = fmt.Sprintf("%T", v) + } + } + } + nodes = append(nodes, node) + } + } + } + } + } + + // 构建属性列表 + var properties []model.PropertyInfo + for name, propType := range propsMap { + properties = append(properties, model.PropertyInfo{ + Name: name, + Type: propType, + }) + } + + return nodes, properties, nil +} + +// GetRelationships 获取关系详情 +func (s *Neo4jService) GetRelationships(req model.Neo4jRelRequest) (*model.Neo4jRelResponse, error) { + host := "localhost" + port := 7687 + if req.URI != "" { + uri := strings.TrimPrefix(req.URI, "bolt://") + uri = strings.TrimPrefix(uri, "neo4j://") + if idx := strings.Index(uri, ":"); idx > 0 { + host = uri[:idx] + fmt.Sscanf(uri[idx+1:], "%d", &port) + } + } + + db := req.Database + if db == "" { + db = "neo4j" + } + + limit := req.Limit + if limit <= 0 { + limit = 10 + } + + auth := fmt.Sprintf("%s:%s", req.Username, req.Password) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + + ports := []int{port - 1000, 7474, port} + checkedPorts := make(map[int]bool) + + for _, p := range ports { + if checkedPorts[p] { + continue + } + checkedPorts[p] = true + + url := fmt.Sprintf("http://%s:%d/db/%s/tx/commit", host, p, db) + rels, err := s.getRelationshipsWithURL(url, encodedAuth, req.RelationshipType, limit) + if err == nil { + return &model.Neo4jRelResponse{ + Success: true, + Message: "success", + Relationships: rels, + }, nil + } + } + + return &model.Neo4jRelResponse{ + Success: false, + Message: "failed to connect to Neo4j", + }, nil +} + +func (s *Neo4jService) getRelationshipsWithURL(url, encodedAuth, relType string, limit int) ([]model.Neo4jRelationship, error) { + query := fmt.Sprintf(`MATCH (a)-[r:%s]->(b) RETURN properties(r) as props, elementId(a) as startId, elementId(b) as endId, labels(a) as startLabels, labels(b) as endLabels, elementId(r) as relId LIMIT %d`, relType, limit) + body := fmt.Sprintf(`{"statements": [{"statement": "%s", "resultDataContents": ["row"]}]}`, query) + + reqBody := strings.NewReader(body) + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return nil, err + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, err + } + + if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { + if errMap, ok := errors[0].(map[string]interface{}); ok { + if msg, ok := errMap["message"].(string); ok { + return nil, fmt.Errorf("%s", msg) + } + } + return nil, fmt.Errorf("query error") + } + + results, ok := result["results"].([]interface{}) + if !ok || len(results) == 0 { + return []model.Neo4jRelationship{}, nil + } + + var rels []model.Neo4jRelationship + if res0, ok := results[0].(map[string]interface{}); ok { + if data, ok := res0["data"].([]interface{}); ok { + for _, item := range data { + if row, ok := item.(map[string]interface{}); ok { + if rowData, ok := row["row"].([]interface{}); ok && len(rowData) >= 6 { + rel := model.Neo4jRelationship{ + ID: fmt.Sprintf("%v", rowData[5]), + Source: fmt.Sprintf("%v", rowData[1]), + Target: fmt.Sprintf("%v", rowData[2]), + } + if props, ok := rowData[0].(map[string]interface{}); ok { + rel.Properties = props + } + rels = append(rels, rel) + } + } + } + } + } + + return rels, nil +} + +func (s *Neo4jService) checkWithURL(url, encodedAuth, db string) (*model.Neo4jCheckResponse, error) { + query := `{"statements": [{"statement": "RETURN 1 AS num"}]}` + reqBody := strings.NewReader(query) + + httpReq, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return &model.Neo4jCheckResponse{ + Success: false, + Message: fmt.Sprintf("failed to create request: %v", err), + }, nil + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Basic "+encodedAuth) + + resp, err := s.client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return &model.Neo4jCheckResponse{ + Success: false, + Message: fmt.Sprintf("failed to read response: %v", err), + }, nil + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return &model.Neo4jCheckResponse{ + Success: false, + Message: fmt.Sprintf("failed to parse response: %v", err), + }, nil + } + + if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { + msg := "connection failed" + if errMap, ok := errors[0].(map[string]interface{}); ok { + if m, ok := errMap["message"].(string); ok { + msg = m + } + } + return &model.Neo4jCheckResponse{ + Success: false, + Message: msg, + }, nil + } + + version := "unknown" + if resp.Header.Get("X-Neo4j-Version") != "" { + version = resp.Header.Get("X-Neo4j-Version") + } + + return &model.Neo4jCheckResponse{ + Success: true, + Message: "connection successful", + Version: version, + Databases: []string{db}, + }, nil +} diff --git a/server/server.exe b/server/server.exe new file mode 100644 index 0000000000000000000000000000000000000000..c9ab2af2715514a354fe24b79a9390f52d9b7554 GIT binary patch literal 18427904 zcmeFa34B!5`8Ph3b%2CBKmuW(;LwIDm?&Tdh)f{CGh_lm5Tjs?h%r`$kjy}Y00Wa? zrkBwvwXLmPw6(3aw)*Q5HkX6|VJjp=S)?l9!X3vYNDBd!dB4v&_s)_9wLgFV|NDO4 z7tPGM_nv!}XFt#LoHK=Y`gE~6oh}Z4!Jtkj0=MvKcOH6sQ=b9c~y7<0v$;rLVYSTse zI^E(8MsfAeUi#nV>2&S7QMx`c7}e=J(Sdhr(mxzu+|N6%IhDBb79$dB1 zkhxt~#|PehwQ-L7svLOKdJ=P{0nyymd1!B#bh?sp6^rk2+@sTF4k*Fx1v*^{z7^Ds z_-6(B#))dWl9!WXbf)bBj-PLfsKs&IFs@uwcT?wj)^Sy$-rwj+`@R^0 zy{`YRF7>V-FK*4_bKNiMrF5=moOXSdg;(6PM#6ki&xP-S-PF7B241hi1bXnj8KWD8 z?;pS3sb91=ej>k@NuLdGiGH^n?OJc*1b*)q&LKKo3+gS$_qJ~8-IUGi$vTXyr~~cc zJM*)y?O8;714aT_C5f>*6TWZIM`7Bkqxoj>iHx}qO}LU{b*1>eHL***aSsSMa~cFK zavIpuxt^|JT*ZA4Exi{Y;k4K{RyP6&d9_QuY2zv%dDwB!BHiM-7{AFFtDA>!@LAME zbh>GsuK(ZV|0NIznr>NYTI77-p~WU!-Xzng8y+c}l{b^kcxb+5NpAMNH$9NM#CiP# zwwvZoTyoF6vgr?IKJwt*xup+2eCPEu?tNg+ggZ(L?kg{zcyB@Gyc_Smamn@b%4e{m zhh|zHdU(czSr0CoU$}gwWmIod@IX)hY?Dik(uM{=`!?ozpSeFWGS3dy*fEuP8rhb| z-_3rE{%4*xcHu+joiczm24mTnL(H2JWMkTx=OS*zNZU^7DhDyoKGt|C)_q3rJ}uci zdu@#u6MX^6eP*P3!916;f;P`STkEA>pj?_H<=O^ELe^dk&5+e_SCh@l^vpZVhQB+cdWT-mYEM*8 z^jvU%u+|0w^BiYwA29btD;s=*rN7PepIOg0yPSIgH@&l$t19Mh>50ZbOx9k{p1YcS z%88Y6oDf1YiY^HLs@C``G?Lx~6e8pTjAcB=GJcs2RM}%RKv;=PYYG zBL;JxrC$J8bI&)poO^uC8yCyG))>IQI+b|!!f|G4t%wJwf;xZaOVlaP-~;fZ@d>yY zXKB1q%6WrED#mwM9iLQhJijAG1=dxDC~f>1-uTjC?qvEm0rwMuIGcAS&9gT|2(`2J>TeZzD5&Hz=UH7Sp&s{ zIsI9VSHxh(e|sD=evv+AJ`$%>^6{b0dPt!*Yh!5EVI0}Li_OOhxeyG%vf<>i?xUwM zJLah|zr?>cnj7eQ-DP~5B=36EE0spp;EL|TXs5f^;Vx^=Gw0LYQWOfs^WLt2D&2t%27%jYu7J`l>oBI;AAmb$;l_mEo{yx?APU*+IYhz}4 zhxL|5!q%rQ`_J;42h8@GhZO=CvF?L0fmoa8bLRQExekM89(DenYI8C3JYjb6n*um? zNWLn&XP&vl?y;N8@@pC#Xux2tbC_q_J&VodkkpU#FVWdO&>_c_KVvn)WV^XM48PjF z(uOveLj>+8(Xv!O&pf-%$h>pF_D9$S0O!(FQUm%OOs+PU*}b>uZI*VMWN&BQh30%_ zX>$zcvgWKJEurQ`JG>kI2J_w?1KyC<-_N@_$i`e@?%h|I=K{O%Hfy{xm8E~iTmPGw zn&k?U>{polbAf(Kn=_foPY77l9u8c?JG-d-`eEuUbU&(JsYW3n$qqowa#mU|uBy#d zY|KUGedLPG)4?vh1EitvD@?ze##v(b&ej)N8Vfx;3MG3ZO>T0jk6F$+;|ndVfx8I6 zMT86&nkJA}G0)%3KI+HQZuc}Q{rG_9p%VJ7VwTUsLlQ6XUTmILn+K27fy<(a6?rr= zcjFh#b4VSTJ^c{VUx6wFH1-iR8mKGFIfoJbDbGF>0qS)-pT;wA;U{GVo#LmD_wYgd zshpszBlOWq9|!2886Q~<(B98E$}##wuhR0@?u}pNS)$9`3P#dx9f}YB{(?IbbjtD_ ziKs_v{{^W;rzC!WOYiV0wdK04!%&fVnzbgfLrr8g@b`z(`x8U&KSb|0zpvQ~LURMM zX$<6TBD&4UtIIQwQQl=VK^PLy_@vya3b15?#y}N_FY_2`-@pAbnHfF))AY~HQaxHq zy>B2lGUn5lW#1Y}m=!57E9`v@fh4jyBqk4J@+<=qI{xbW7n^X%!Q#cOnaOUG3p z@MZSNH_-pq3;+N2Z~5=^FB>dhC zNMjb3cJ&eP{&Up+AsO<_Ch9LknI4ZpfPYNHKN$+SB|!g$1Dm+X2!l5!*n&ZlF&`e} zlKiMa{`$ZH($5`2KQ}^1h>mu;NL!HT^92&15s6i5Pe?U)K+QqD#Lh5QtRR$VCfOgBk*el}f<+0VR{P(Ynj zg!^J#_2cWZ?=5$Zb3O{Y20b+~%l_3#QvLWetLsXR^AqZ-iFXBo0(m_TIuK*q$d!*} zzx1dQX+HRv`*NJL{ty`t-pbEyo_19yxj%@r!#1>gQ!?$Az0&&UP{R&DF|KUcnB#Wu z9YNB*rb5p@Z5K{p)ikv*{dl2wn4azy!-R@;4#<}_C+SOb*Iit?#JQ4g1-^~XIOh^P zxK2;<3lG0oTI#%OhE#7i#N@gzmOAH9#atM<^c>?$bbc#O+B_^1RWIN3kaI93U}g*^ z`;Zf&WX z!wpbJF;Ry}nMjx3_{U!@*KIe^^U><_5_-;*f#Uh9wM%r{(UDGx#|??>7v%&sEfbU> zos0aRd_l_vyE%iXstoIg1)}S`7BrnsukPC_x-Juhxt%V(@upDIf4~i;HoDa(sZ}{% zqKV1LMtlLy?gkSJ?gd3jk3EWpT~%p1$A?&D4Q6t&vF5O2iEFaX=PaYqfDZDYlEG`Z zz(}nq0GFUYW&59b|Az`OzQHuU@2KNLHJbWRF}?%W;rSqX-kZkvKD`C_p-r^zF9*TZ z4e&oe6z=}WMQfbtKri6KEbWr_RlH}HhZkox>Fkzck~jx}fkvH%4_aU1 z%PyymNB8-4_L#vouN86tKzkN7?Q;Azcwo0QRF1ZJ8U(;n4Y-xNJ1-_pj~8QcQD|wY z*sh$t8?AmsA7|*}G(OP&2`V3npv~eK;{xw^AE3I0;V&d^oQ1Fl$a3@Gt`H3`9o&HQ&~LC#8U=IN3ZVRNUt(0 zitEMGB0McqpZ=G4T7swdiKpeP*+naZUgGtWGneA^I+wW0=XL5h%9~x!;U;;}@$$h> zsF%8@#g$1^dx@@^U3>7Q4)u@gc(n#m?NSTPrLIL(BQqM1pw`mw;GLcnS9?UYcDibg ze`s8j&LD~ps6}_qwB5}*onrvl*M@t_vv(T60$Ar1fED8>(ygmMNe zKNSNkdy?Nn4R_8fyJ!%D@WxJDtM;inkhwIFKT$)T9pEcF^9=tzuI%3Fmu+Cwnaz1I z1_EX2G?c)P?wqB(weQW6lVWDc^J0v%FLo2SxP=(#to(DNWv$f^uLBGcwskUdM@7~?`Wv*agY2#LzdJ3{Z+ zy~%w)UZR`jNs2*n;h|;}?J-6gS_V-I9~%7jc)0faRL>q`LV2QEF_S7X0uQfP%qt%G zm8fV%MM0?I6{~o~zo->UQ1Q$`(RKOE=9-u~G^p0CiFpziym}q4F6M$a{>tCj z$fxA)hz;S=ejUeU4t)t+eus*{Wqt%M&yRfo{0pK2CWqj;JLeoIgp*V1rE5Vuw-rZX z@eFQ-v3LqsQCK8O4`cCp-qP2>;&bTi>tgY5w};*VG;C`^SX}*&t6=e!x-b^!s}%(n zL+BP*d{V7QSe&9(6j%&Z6j-cOD-sq{)QTZ2s(^{W;%^|&XL&+ceE$FqR$vh>ggkIT zN1WvSv*1hDcQ`JW(HF;M{CBAcTu#=Qa_n_Lq#;PF$>ceB=+61h4oxm(;ab@^KTk5hYr1|4f{z%&O^T#PzK388d)~Upj77ssx}Y9@uvP%tp~}UXTU;G)C2nP>^8NY z73B)G9&|vSp-ig>?BUr2wO$Fz1!_HLgBk?Z@JlCNiF(Ns|GRB~2bu>> zaQ%yy)%IVCs%n(BozzL2;-B2ttV2=1VoEVq^cWx9#g69}fBzX1~#o;Ip<;7FIFxC1A=l1XkoQLr_5ADQ> zYDTNHX*XQs<{9Z1taUkuQOQ~plxlWh4(J%*h<^?DP#l5c-$k(>ihtzAQ+@GdB!E~K z|CG34pa#*tS6n9HvKbA#wdz2)DC%?ZT3%iJ{o*nKmyLOK$#;lLjMRYJDdEpYS)Y^x_wSj-aAJOFBo0}n}rh)i$P6O`Gt8ECt!xZ0`&;;z4u|h5dIK1}lvmaENxutREj~{s(>>)otiP z*#Bd|tLhj`aBv`uGBZx-KdjXal6M`Zz{9W5PqJe$^iZ{4$WLauBGsOt$G8vGRINAi zTYGQ|OgtDPuGXhnvr+5s>MbnrtM!AWkuCxqfASQ)@2$|Y#t+bbb%FtkZG>pwZfTZk zX^oHjBaFD8VC1*Tcw^Ng(s-mNEiW6@C?^GNk01|1!eB9eYxV`HcCu(crlk7IlgKuqyviRzo13))X_M3R~R1$Z(-#DxMy z(pnX^?KbrvOK)N6`xTeeXXZoyeB9mK*T(p0+EpIX_`jsW)!+W|`~cw}B_F6ZqUi50eNKXh z$DUK+BQ(G>368Q#Pwciz_2&^=hWBvo2S9!g&5QhktixI719*Rm5maH#K7{w1@qUoU zXWHof_MYB%<@|!1r^#3s$Ni;QHx*tleN~VB8tHGp~kFl(V?f_Bf@PVCW94brU;q>2NY>50mz?tVSlivX`|f%zYB!XZ7Wo-lSa^ybd9AM*;kp z9V@_P69a~svLm5`wS9u{ztt7wcc;?bYBbvRDaGNXgG@TavMxAM(C#6s>i&S*b@D-B zqzoYv_>h0q0PiXD=H&*Px(9+(n~qoTY=#jZvyIUpl4>tQpwLqjJvDWH+NM6uq^Fsk zpZ*a~0YEE2X$6S!*I|`5Jb=6YrRa3mm8ApmHxhrPCVVV4?Rwv_W7ii)8-Si*(0R?S zHyr=MR90~L4))?L;W`3JDN546+^7iv)ljoPGToi6AzIVgczWI zQ@2%0nY5&#R4P&b${Qf$E*q;?*_wB99Ku8IzHF7;ZxBMgc_Z2b=1^>z+ht+orJ*FlK znIlnVhOzYdcFA#l9lb}=7cbe|AL{X9-_9?3j7>I}Nch*(b9eR;?cKcR%K0ZOH@#Fl z0@&fuuzMGdXs~;)8?p2B>MC%Snu*Z0K=TKN2jTC2)2@#l$9KKu@B_7N`1=P<2!DG4 z5S#u05sTgO&YBjx_Qva#Cn|j(GnKFf~cZ(IVPx{5~Q_c5j|J!+)t;y3ks2<-Cr?OSkW*(fcOVYsLYl73!`U9AaE9U{;W+lakh`PA2175i}!}omR+PmqalsI%LeJm$#Tua>8h`%=c zy^p^OrP7HV`1=5VJ0S!S@)DfM+yU#lOGIvtpF?moX;`HGDslk*1pG<(n@>!7w`tcG zj#FUN4|lzBg1GZY=XQcY$4){Gn!;Cz7AU@CSU!}=2-P6_Yd1t|GyWiAEv=4ssW!}s z5go8NMnZD7lAgmGItS9hlHLL+NJvJANZ<#R+dheqkFcBYD&*ss3lNcA6eY79a~fE4 z12G69ePH%?kk{m|)ucst&*eJ}{>5qqW@(Wgt%doE^edy}uH&VSm=!ma=EK7F&KndA z&6WELO68kM^DMii8VX2Zvhd{}t@zMCUAz~&!djXu7wff`uc*}#G*_#B;!i`>Qh9~G z)bh4e1yX+$T#uM7DiP2$bUpsOI$IP2B>FacI3(nb)VxMUOn7d zyZ?`d&FN=nkkxf4`4tr_G-(+A*%kI+qL4M~$5YMGpcIw}Nm#W9xts*qk-x5wE@N z|Bz_J-7#`SiPk;6o~LW)t#s2scs!^5f5OYCvM`$j-!b%ZF4PMJvNfD+_1B1AOe@Ch zY+NG-cM5DVhzqqZj-I2jfdk64H{K20stt!;$kUzV?{)J^pYSWB5*KEl1ecuzhy6=! zGexVg1AJv`j-`%>#R3nya_-*3CG&%jFn~dl4n|xl2mgm9;ncC3_5EQ zipDku0z#A7zEWw@JyH@vyvx(<`a{feq2gn@H7iK>Ry@wUYhdLavX41x^GxnehJyV_ zV$L(u+KYni5Scd5CFlrqw*NIm&NM;U!*}%iY4pBHeLus$_RHVzK|do=j1;p;dh`c` zIR?b(#waPqXAyh#uYGPn?Va9ol6f5P?{XxBwX|WK{i*u{*dw=j5k8c7H>EX zTHdVOXM?)Mg9v@7N6RdP&$pjSJy%wi_JxQE5gETNI1DLY&nwR;`^q&|?bWv3PKv~13@5S5Bs`V#p4xCi`Jn~~hI7v3&!eA~1Kb;>ySkjSHeUaU&v zKg2|=%#&*%`YhqV+VBBM1lnY9GM47O<%ee5CiViYSaBYC%}Py3c}MH!nA|%WF1%ScCmj>~?AZq~ zY)WWX&b%w=jT}AKQ@?_6uU3dV!YBnA`90`26QVGz6DW;DvD6F2@~F+9q>9F5*arfRFMBC%SWH@wXBQ!PY=3 z|6=kTM&q!A@yPMAMM%17W8vHOdCy7 zN%eCRvWrYo?O33SC==9A&M~4H>W9I#itPqT{YB5nAN~yie!sHpG|>}5Pp*KXsy3%( z?e$S=xeZ3%S;_0=R5GlWU7lw270PjYIR9BLOYT3QC+tC?WJ`CB<#zz3V}M;w0fXw4 zrzTNgG?qf*c2AsPwl}3$q4!oK&ydZ5*DRML_kDQZoud;k&XR|<*g*t~#EX+VcPdbYHoKX2_Xzr`?0eaNPj?=&i@ zXpE;L5Jsi}BP)TC78PRz9ZVO1p&WrZ1~{3~M=~Ngj?>QUA7uAf9lwAvQSQH=kvEv&qEP=Gz9ib^$ab~O&HU<4TA(9o#t&~ z>%kEgi#9wf&^cAPJ)(-}7keezs9)xp0&Z+X)O9t|g>FZ`NFFXnzm3oGemSvzi+4Ji z6YD{tij==hL5|Fy=;Jwj_&UifL4mV|_dtO^>WKmc{T;((H2P!iY*?`r7wANMuBuTw z$2H(|%OBKSF@n$bSBUDk3zrBK!X3z%3tYevTMUTo2_7Ldfhd=yVe^I~pSiE-opDGn zglipwgD^zv9yp<}vjk#~M8Gm&Jq7P^x^E=pg7jOIL&$m-&`|r~_f^TJ%%LH>$-@gr zlcx!;CVtTgP|A0Pb%BxpeD}gVB;P3^Oob2UMn>Q+CcKeUgv6QXKDCzYB$%!x4WCJH z!_d`1nC-Z8wA~74W&mj|4w#|d89|>teK*YjP@-mP&r3Bw=J3XIq?HzvV$H~t3(9;x zI|MGJG^W|47xy81(NO4JV8HIMQ!$nC$Y;@mDM>cV1pqLll-wKg+wM6LY=xuzagqk; zqXHv^G=Q4Cz+x*(=psYDwajPt#@$@#wIU{UI<^v-Zh1Ew2$Lqer)euEZ}-m3NMPck z6EFqU9UIaGqSS|p32cE=ow`l~L3}uKbnjnB53XNTz%W6 z7k3wWQ;d+{f{1*TAZri_rOswBd3vU@|H(wi^6^hY7zusK2$&fG_((W0adNMQSw-Z>y-c^AHcBfWA)?EJk@Nt=z}z&mP!M}yQ1`^{zoAgTNZ|fH=<^U-GjDbsWxQlg>R( zbiMEwA-cvKVbEe5rwlsbQ-+=KaTZ}0_idgV$#SuIrW;`f!q~{ zNElBgx*cCnWJI+n_Q7R2uXf9M(no3Je2oqLTwVCKaDq#Yjw8D^x`_h9v*kVlI@W^RklX)N_A z+ZzP^V|uv;*CV5J&ViU%_1oIS7W`d(F4gCba<}X2az_E`8Fw{7LPHSikxhi1qSzV) zi9=Sfj2uva(24wW)FSz4H7Ms_>bjvxNl%Hy6^$y*PmyYkbt3fBFT?l&2FN`CTojW) zDAV*41pC#EfIU+E-oP;G2AlVm1$`_XD|l)OV1nSMrjROMBdmU^Bv*1n0CE&)4Os)N z#2_YE1Be?r%xRH)BuoNiA7F6cglMHCOad46U&MmF=(|{+Lm-T)yvdz2{bwP@;M|o) z<_hOTbb`@A;4d3T(seS@-UcGLjxgyECg)}{ca;ITEi{V^<&&obvM?WVB|xJ>*26R= zR{}(Fo}5eCY}h0wIR9j5)f~)Ep zonu~@`8eMV19`r6LM2bo?_WZU2Xcq?KgxYpU&p{**cwFpclUxfz_e*RNIg&4PjgBT zBrF& zM;Es9#=rLh7A&LCi<10b7>YN3Mi!hg&-{j*G2ktCPX14b0f_C*LH4V% z3Lyv;c2E*|lJSN-sb40(D~u?Vx$Oy-RRI`WXLsk{h01?1KY2zNuatjeO zyKqW!Q7RFkfzke5ir_p;H;78m_47Xz>-lF6W2_jTRb@mh>d8i>5fVq5S=NbCU*LYa zh7`(GZ3^WN*w#+3K^pSeU=-Ntqf`chLFm69t8C6S1$+g>#JtRm%1hrNRO2Skqb-2G z#)O24#J6!t-dFg{!0A|}z9alkwI|l(!DN1?16y;QlaR+9q#(?OgQyAn?ifNT=Oi~J z5V>>S`4K@Gj`*ZwN*?;+ZiNDftGHu<5WH$3&Q+aR*fo68LDpkUs<{4l@5uWEIq&Uq0HMbDJ0mOm3sHO>2NA zOs<6%e(nl73d{l%j^k?wlBN*ciScl5d?|>KWW2namRHr#bI(?Op|JUg)8_D21~U9$z&sRMl0N$VtqvGM-s`qyK4#Br;*Du>@b26_y>KW5I(4rPz;MF?jK%$ou*rqv7aaEw$e5>M01$uZLBoB!;=S<>y&>D zO(0mouHkg0)Mz~fUWsU}Yq1;sMPi5pK)&HCcqI&>Wil+makOUSPKSe7@;2tncB3KJ za?yDL) z4L)C8(f}3&NZTVI$T^b_giHZteD6q%ae+WqfbXmMCz8Va3o`n~;9E%WwS5KnP7lME zlzKFPZ**z^zMe;N2ooUe2mq6|xH^Pq{ZMI48x z+H>y?67OyLi*^JuY>f)k!#+E_(3F1cfOqd?V#N$Q4gjJ43oUaDj(9p!W+Lt0ttyn$ z3+3y~g_dg_3ApPEjD#OBrDL`{ZvZTy*G1JnxjZ}&h((Qj>~xlBV$#Obq*^@t0<|{J zVbLYxkCYopkFm~>H}WnaZXFrHMu0 zpug&;HFfpV?o&PDEmA#Bo3eYSkuQ7)x0;Uw+9Cfd*FlG&gLcfOEo0o*YQs@4|KOUO z`-o^+jI}X&JILkzF3y5d{`@8f%QXZfC<%Dg2qqu~@*UwQ7Z>^<%BTv|aH3=?mZ?G} zn(jmKADK)|ERb8t)Ksoy<@6x_qfEkUE zgA|Z}Ax6RhaLYgzrRql!^z$qRsMU0OlxGAN|~h~-fU?z;;#N!zEL~N;#K)Sd{Jopc2V% zmJ2;X>~Dlv?u$@31Ri4C@TXChgKGMx+2w*FFvMPaEHjqc)GTQpn&U#FQ0_%{_i}-h zCmr3BPyV?~ivDC25=9lJLve*8N7Ss~M0kg}3?Wr-ECjf3rv|Yko(q57LLB%Y-;3B$ zq4cuf?s>?Z4I%c%Z10pIh2FIkb9s-4j;aS>*;`E_A=*oKS%i+3gQx=IX)Fxmw1u1| zG~yjyhmAoCu$bs7Ia3MidL51N+-YdwRw|z32e6F>^H!aaM)^(Ei#hO z&nT3K(Da*1fw5z>e#J?c4jcmrF9{I@P&8MKc>(?>$Pr3`)3!pdxxW^>L*(?^?wq-` z#7cu;f%gX{jw^o~9J0u4p8YV&`Wpf_5D_yHcO~Rq<~ru0GP71p9k`>X5O*cq=p)=i z1kNp{4q5D6yn{F^_uJx>nn_wPB$j%AqlWhm{+@*1Pbd?4?`p&WXF)n98Hh2bXY&}^ zS>;HL+Gn^YIua?kx75Spq|Jt4V<}=npvMei4WG|9qb`mtfK1M`Pd0I;UDp8){RqCe z2o4Qf1461Nf6+Z&g;c-36LLfL{y zMBYks!EmIScL039`r?Ma{Zk&0iB@j+>=ajDpkx>lSK?_18 z8j!C~D`T!ViToZo^Wlhtza1HU_!k(-qtx81pTbF8N#oSV59UD+nsBjd!iB(A#KX8V zy~E&((t>*M?0>wD=sbC*JP(VWEc67=;>>33E#y6648fs)*a~%sU^+TSaNo`lku=Bm!Z)k-ml> zq})hMTZF(o7RrpY!5(|8&8VvHM&gk@ zKGMSJHY8RyD)C67jFa4d1-SC$c{Rp->5u#JJSYU7M}@$%G!gYIKPBWh+AJv zVD}t^{ereGqAdzI$@Yy#={IL-Z-36TD(v4zPEY}%_8^1F!eF0ZOpVBZKZ?*{jtMnz zcxfVS9pYJ)#L1wCOt@@s-$l^oi$kI%VsgCk$%HVi^Wh|D^98-+{Z^K1e_QqjS@$HQecy)2^#@l zzQ|%>+V4&Ti&n)2n@c@aadciU@?nn!ej>Vq|NaCb=E@QJXr+$>_~@*klojW@@TlJ{ za_(e9|G}VUWCTk!w41}nEnz6iz#wi1OONt!x;F*3aMGMo7~axmV=2~RNu?GR z=cKq?wh$N4`wPuQWWf$(?p4L66}_?D80VxGrBzV!S|$Dp3I1uFL6V_`y-uwWf-M68 z30ZKAL^izzx&|vSGKaYr6n^lcw8}q~LSX7htE(dcE;F$h_9SpdKyrWEpIB=QS*a@F{7#2ugx767G>*3E&+OhL-^94qqQsc9#IPdLqk(uC&} zDYfWbn304`^B?aPaH^IO^u03-KP-l{ z^&?FS!z=;`k}7E736$rQF-PD8*(=r75a2*RnAIAXH8av|mZ}UV(lTZN@HUW*$QPt3 zt8(0t9Jc&vy$3?Y%g6+h*T4vZUm4Q3*&aKi1Z#;Rd3hO5OGYlkS|p}@fE*s1Tx~RP z2;#^|ErAftL=-#eVaQMAsn5DXgyL^e^S=v89V-1;`GjGgEpx3Lh4TaFQk#H%xs?>$ zMjZa9lj^G=|7LRhTC*=Y*5K&gwhz?o-@!pj*7cyeT$F(XUY3sCwI~6MQvI!_I-Ku5 z3xt1WCql-EMCz0`H(`K0$H1L4{1KvCWJX|~MKGTzK8)BOr?t5TsBZWvC?^4>Duy%S ziqQaYP=wCPfu%KR*lj00wt({V-BnEIgqzWtVaLhM!9y$^PQUp8b;>1x9DXB&@q1&3 zjBN~@Th5DU1QW?c2v<%S z$kV=VqnraZ>uWbTiw6Z`V@baY%R( zq~%2?ATHHhgnp*Fm}8MLAJI3SR8VQ~pTq;6lL>^wzlw~g*%Vr&ydd~-w8aCC99Y;7 z$3a+&-8sLf79B`H>&oJH#4jON|+C7~s_+vf-b$u^}Z1bG6dw@c1)HGGVtC)uh+%q^DkyE1o%zYY~ zr<}O%2F`WJgoKu)Sd8@Ir962$PJB;78aH2jp2Xr43)q`MFYv6mSElKt$9_&Ch^zrx zkiwzRV)yJ-;#-JZoYkr|9M%trwKN1U^e#Fgo9hT9;PF>r^CQ!|9qXe!vMS{n7!kwx z$+AGAI?TfoNV6i@S_ZTtWV`@6;v_&RunK5rBT0ZPamv3e;6TSuroflS^O8l)d0Bw& zZaXCowz1$00QQ%a1Xz$Da>C|A{KEgLOh(jE^@D2Ez+f$Tu+&hgp^U#3qK174mUR)> zTrnxqN2)IJ)A`_B1Cjn+o~h~{6PeB4Dg8_FqXuy+B;tQ%0m)f6Ih2)5f;2C7(sN3E zpo@|V{5I6SwBaz&t@csQaCEba<1GC90(8P1v|0i=q{juD=?nX+j&0{VxWviL$|ePf z5W29&T!$ATpOVA+dS$6vg^_E0oV&5{!g0+@E;gxPBMZ_B`9eYjODGISZDEDL(||3r zu$no~M+Yyxx*&s#b(WE+t3*5U8Jp@WTga1A0{2DA;N0^&T+TB#l9JMkm-FQ!%J?28 zg~XgHPdMA>2!XY1^0pw*_DMeQWqdEir625N(~@plF-Z6!bKVc{AMC_>~BLg z=)z_O$nb*)C+(vKR4+{PGy|$pLQQ!WuY>ZEzY7)k{FlFqe<8wu3QZE_G>|sKZlGXE z6^Q;ASVi%CKm&psxYWH8zaQ3@Uchu5=5ko>JuEzbT1*BZ)e%3a zkX74Z4Y2VdmW3-Jt+6!Z`D-96I#R(<*-F@<(~{#x^51Yd1Cy2V$(!=zSv;RSTXUg8 z$RVFR8`6PiqJ3A*C&%-U_CP*)HYfrsIP&6IAufR5uonVSkZ6TFUO+3{@5J2#fRtkM z6Z|;Lcm@%7sG}ziLVqDAgD9)M6q7M2htG7uuCy5s{FX1b?+1kWAGAe52MiseGyWF? z5f$rLDH0Vi0&o=V6Zp{*!ogsOWYl?t>b$;Qq$!FzNJ`9t4N7T>%S4(Ylm?W|LWpNI zJ2R7W8v!4UmQntoQeK7`pm%_auaLk(PzKojNwx+N6bw_sU^_u96AS2@1n9iOZpIQ9 z`?Dl0%?NT>-8QJ_qcIBrS@|-x$fymp&6h6>;jBXs(=14H7?tq|Cw5IcM9&<{M#Vut zX@OS_gf*jomj+X@KJrBRpXsFg3&ibioG&ibe8ildmAaYjp-Oc#n{sx8=%PIA zCgvVuvSF~9jdmWYo7t+=&1`GFi$tjKZnkCE&9)D=A%p|%X2Z5Ngrhom+L@e$D;}c9 zE;#N-LnXMr0JrpZ{#!<%6z>1Bt62arfmaC%m0vcy_#Ct(`m(*Gyd6llI|e~R?oDs} z_>Ik^dvF2i%Jbr}g*@woj}qgNREI3wilaad662v{;}f*nYJU8Yb(p$04t`4F-ek1v|!H{9+JEW?bqL4w11Ul$EV{*FR zNo_(06mzG0alTxp`rFCXjwsqS6h*U>@Zhd?*vE-T5V*z=zy{UJHkqLpzN&@Qz<0s< zXd@P#{!Y;}G1?xy?c9^dBM02u{s{;yfRS>)^qdmeRz}ER2$bWPPuh^rk~Fu!o_bx7HSMv#KH?SH_d)vUGWov#cOdP%zaD9S31MsduZg}N{152cPxL)D zn!agIm3PWOjlQQ0g!GEW$-zg%I3b!8!dZ~{;gem+9Pt0L_zC@f$sY0hCG?Y`+z4Lx z(Mup2vA+yK9jRs-o{(AGz{TLt&3pLwMso|kIU|e#@@;?6whU#EG=q7@t3#YpH8KJD zYus?u#CfR?8_O_{A`nMQTWEXKCp)SAC-_jvy;bUSj-iP^P3?Q*OP|-KnC}u(vj?Z$pURxCegVh(40|Y4(-71NoOqslZ^*#c-RJhD@s=2zwjV*R!5=u zNjf#?E`I!qa^`40NP5dUk)QQmV1UgV7bkQ|hb_3176h=z7Q=P`%%NSL;J;|b4SKu$ zAZZ!J9=oJVw#EzG&@Pl8)=_-)A6mwSg0;oRfg-z1NfzGK6z2UxSQjcX36f6|^8DnUJQ1x~1W$W2Wdm*EyMmwI4g~N_HRsOlzg`313gji+PVj??H z6In{nexH8|)E8lxg;FCx^#Y>U-){Gs2k~VdU)lkZ z$j(?yD^OU#FPop^eNWF+CN8ASo4>jj_97h}{Dk@hsTY(c5pfBt0z1{tTu2l?fza0E zXaco)cB%;-Nc&VCxi@lB6(#b@GNO;NS>B`sY1^6D$~;1I2@h8PX#ovnARh=UE12Wl zFs8`5RtDenR_@bAKn~hCtf#Qb5QIea0SgEHnA&)Fk`ScjA#4Qm=OC4%AH}k9TR;%o zl=+s(adxQvI)a8b{+{2eLO{ru$<;J^QJsjDYT;moQSQ(5b#q3M_ar0?aWJOeHM+kKN32Iq0xoMIu^ip z!TqX1CE$BvrMe-U0wUh{1FmMwfwJKs+kg(Tms@PX2D?0) z0GMxf35JGq+tif3A)yh&ndoD!0m_|7v-EbhOWWSnRep%&R|yOx3IK~K`qyex46Ar< z$hIGgBnYAh`2W6=|Kdz%`glD|AA7r@53`_;@Sz45j(|R@imh}gFcAoyIHM|&{{aHo z9VUHbmy25OH&OKh-{Wt0UgBVU3%Gj_c~fN1IZZT%Voc$wUumY?%H?7L#iSW9vXBu+ zD<7=S;a6D^>5;0UF@34LBm0d`(5ZmD3ws(6k5ZitNK6dFtDN>J2$Ky7n3~Df&P_JW zEc;%U#s5Q;ytbO#WYMNdpuf$#Ht4gZ!|qfwg0f+mIEGW!5jUcO5S7i-(Oq}7Bz9(H@$93nzVUk zT<}n-XC?IeBua#t1ify<0U6W~*Y6+;vI@NMHNViv;$8=l1*LG2!GPneM!C=F>+Tpu zaelrg#t&_+UC|Jw&R5Vi)Oliglsf0a`^L7;e8}~if0f9iiBjqj7lJ>hg!!|G^Jk*U zpCzQA%gA0R=L<37&W4|d3>3sio~ji6me6F%iA#8OuvZj&W()Srpju@;vS*3%!ZyL4 zqN2*4-|x(xB}&}($a;YXsbdVGnk0(qM-%#e6`W}V;<7M*5+W`?r6JX8|!M=-&?hJQ!H~CCoXZ6LaEc znus}R{cnrpPiuGlDQ%z)g59tfkXBiYHJ9KqgqDAKJ2{Mp;>Xh{_$)2(jH-Y4b+*sCwcnrRZ1z>`_qkE#8O<5P%c9 zd?PkIEvFz*R3m;o5cuN-npbq_(kS~q38?`BM&G3UJ~Y~X$9Ab`)Wug=6LpD0C;eK+5ihksGKitVa?~QbT zu(|%t+2_Ne?Q>28r9PYGbLa7|vU=k*FhHY$X#4v1iijZ#%beTgkgr4yA936K1EPlL z_eMs(*NG^u+255YK8-NURU6I~2{Hj*c_Jh~pL$UA16>w2IID;-p~9o2LB?0K z(4YT{(6-=sGLPtN3#sN=fP;Z;3!?gKEEGsR3EJb;T#O525)Na z!jNP?ee?FYNt*+=PcGjtx`lE4P1xs2FA7!wb5mM@V9svr^Tkp1u412`ctx;hC;NPX zPea5cbpots`}`)p;-ip*HmLvh`uYFa=LSMrw0(Z1psRhp0Aayz+&;hSv5Vcl5Ti0VP_X!yqbM}lV+a_OSeY@UHUUiEq=Qi9}+mmd2q*M}m}k9mI1 zqcaqKd79rBt3`3350JQu&cwez6 zNn7gVV|mZl?{qX2?b@m0-uZD4G?T?FE;h~9)&PSL;o&D?hzEvDrbwvE$nG#nICgy12(9o%Jm3U2+^VG<6(Q5Fvk!P?+}69+kB zSwvndMy@H?M>T3VG@yL&6C_16I3Es$GdU_i;D8y85$hc&My?YD+9;Np?O%m;1eIuj zU%F{r&P2nJEB5ohzN)sxjzKgXsyLJ4Jt<)y3j2j9qF$HU2V!pG8Xh( zd3E~jN2>{}c%_;|lmz{rCgS`*i+;Zsrr+8v5%k+fv)To)A1GC=eQ~+pu90yOM3ow9 z5Raxt)%*rY8AtpJQpWo9tI%@GRcLw0k2o#g|21iO<=3HQPQ{mqim|3s}t1o757I4x=Rstw)JyAt1!s3QHIf z$?xJ})zVyOfXXeu6-HdBx@yP$iQ1+x2)xjHC^>-F{%Pmh{#rf;E#c;6F-xRspQQVt zh1WBisTYwl!kw=?)#W*=%0_=Pe>fC#F&GK{ML0*}5WMDjv5t58A31_)<4X3=orGF1uE@|Ne z7lAA zlTQX+_^q6kwfPCBbTfIBJ4z{oMXG54=YCndr&x{mJb}6t?@1xRK8fDCb16H=6S#|L zZN7jn?*-60jX!GWwAq)(d^Y?AeD3EFKVX}{NJM&)e>*66F|RMO;0NTw)Wseca3Jw; z0KZa<{VK}CZxFGHdpIjuY-+9UYaqTOWN!TZYtPJeC)|eFg zOT`9W-eT#G*p9qddfb=zric@~(4b3N{?v&A5gFFBc*xISp@FfnHDYf)*PUpMo zEd?=73mrUDYAJ|wUXOEY6S4~uocJ9ZhGXgq3{LEzV@8yWPVCjj*~gZGH2l&pe%^hr zXAe#lY6`cjmB{{E9`XC}*eYw8Yjs{vDvbOSxMEHuqhW)lWo{AT^t46JGPl^7yw!r) zA-`ZFw0?KLA3v==5=B@4{diJrVHh&}$et?xuz#fhNA(JYHWw2rN^>GDzsQ6!n4HTH z70$%HOy~XBG@hM3H{1DL6fD_uElxB?4hO!Zkg<1Br}V=}aZ>&izEGuKHKIZW(#lYn znWlUu^T#k?`2MRq@kBgEVP>Y%hR5B`^Ul9V&eMM_gslc#e7ka*X3(PC?8@1~3qxHw zukiwYm-^4Vfb%Efe}fXWUKf9QLUZJAUUU2`QDKuw(}8Y`Lz*l0!job#5ZjAGKa6*uCG?wm;9Sgrwtsk%%tV1onK4u^C#oKWKIq7z5S{X6G3PkyAwya6x5&9t zt7%*mkVwt&7e!BKw4b-a{(Kr5xjOjPd;&%RGBnHGJv znt69FI`;)T*r;3d*~85H`l53mvxBX;xQ}`F@Xy1M-H7(jYpo8yPHT1aBCRy`Nv)LJ zSL-P?Pb+2eK6mpzo6yZ&%=^Kjb8oSO@8aS%=KW;Rxi{Fs6S!E-oCoN+vn^r(qW|Tg z9$wH&si(El@HDM2(|F}u(MrinwEo9F ztCc3asgA8Do3QPZ`V9@A!J`cL)4 zz))KowbJOAJhlDgEn54jA^1hf&p~(xkN{{@`(lH*5tg6-7B>Kz?(K_#T3@LfwbJmI ze6|14RazOGs`{x2-QX1se_Xtr>4ZNn zUedlc{w8WONPR&o4Iec_o8OaKDfuyNR%3T+r3sg`QWSk&BL*9u-zIS*JU=~J#dN~+ zD;GCH^P8%>^2ou?^J@s|H)MV7`OVj6F#Ld48a?e6jee@M8Kj2r7ezm}i-CpdXScW! zrk@FDOQ5EE`kAlwm3lxc4WGs|_-@fk$xF16jD1!sO?Xo)MZtHv=rIi6zla-Q_@<-P zDERusjSzf;LD$;s&hXXg*1DR%HhhvU(ia!N6pmW`=nM%4)s4a)c=HS+DM|{dyVKX4Bt)SMi{<& zv>FB9a&aR9zCr!3y24j?rQ~bFcYbIF8?{pE4y`miTbqID>&sX2lN<8jC@`wNde4FU zF|X&kKQ~lQ-TK>SI!7OzQgE!$b5pLnNsrok)Tn+t5AKq* zT^%cWUWas9n5Z|n=VjeT{15uUKZ;ZT`m(BEBmpD|P zgf4c7A+b+v2Mu}0u2Rq(XvmB*&~H?Y+I4Xg2J;15+Lyz2AMY>aD4y53sD1nlwtKgg zxf^i)=&_1D@H1zlcfbt$7iso)bq_n?E20n^?Wjd(u8X!6cGtyg9ld#efa~H|=U5iR zDQ<7lZ>=?6n(98SXZjZ07v~a&e=|V*W=A?2a2D`qS%+~xSwGjsS&mVziw`){)SGl# z&8r3)vt&Pqw1jHc#U;+cxEs4QR@~i>!#f5@w>MC*wn_Wu2Y7;7r26qO-ZigTUBSUC zV%@t<%$u|G)WaD1ltI?)3yx!S@*QAtDb~4*@g8Avir<#@ZK3vgI1^{Ib+kUF%E~Fimw`8td%4n^(|_TQT%=h*|i30vuBUj~|`#+bTWwXS`1*01d!Q?soVw zJ`~0|M_6mlOSLi{+GUpsz1Tgj41Dp!ivl}PC$nNXJ}oQCsq7rW+*_y`?Vs<6q>4?T%>a*4$ zezgf*VTg`+Od^2IyDw3b%<~fUi(6|}N*j)&3NSyweb8ieUAfPBKhEdV6Z|b_*W8NJ zxrXRL5Z5}5xE&)PG@RoXcgMn+j=SowTLj|yV|s7k@-@Kx2vo_e=#5Xyiny#`z(fPn z{@}lErC`8aOg!3B#P;!GFgDrOcK8m@W3g>S%>Z3IWcxi+nReZ{$aE~CD(O1(|I$l78)RK z_nUBCLnLCqigotHWv|BFuYprTP||9>3hM zGtdj2NI%+<+pEnM+$EaOrVt)n3ZISW#{Ur_yEq-&=)A|h&x8U0irUAZ<5v_>2mNi0 zr{df@p2y~CwTpyYE{MKr`y0_dhi;$`{}O}p9HEPp8f>5*2$%ts942;bGvx6n8c?#W z@%}r;81Gq%UzyVc^#6qf+fJ@YIGMm%{#85bGl|fD@xWml#~a z?HEqgfnj2P#NTw2iF5n{@%$;hmjU{gHv9^s!cbF;xqxUz3O+3>`r^hsbAQNBznPVN8o!W>yHdTwJWybQGS^IW zN)wJPtH)abL`rc?2Sq4R4aZ~?Wv9{$gUG7OjGQI5KWX#F@NxwRIhQFbw?a0Nera1{ zMZ;47N65+zY9Sv_hx6YuHQnXNX0nfq+I5#E0?E?j zzXnj*x=Z!wvAQ4eBV;M!aanu)6#Azv^AM5e0|u?cWF#Q$VV;Wu9=5hq?Ap0Fu$RvB zZB==v?Oo;$S_=oiSD1dP(EC8JFnt&50Zv?5gF3ALi?ufaud2HG{}Uh(An`H8Ms3v0cLP0X}Y6v35EexeHQ<3N-g_{@kk zX3lQY0T`kREQcH2-CMnp$EpFwoTm_?>G+hYd33!SV1a`@$IBjw>2=`zam{H9`Ybz| zW+#@qv60hwcG>TocuIw0licWic#JJEPh^(k79L!p=|7^R%GbOIG_6-?gqkzO~?KVBxw;cnp@ zZu|#5)V#|KFdZ#|@J;&YW>dt(%peS-5LP*)Ih^0>w!dNcPw`GC{%dhYQzVw;_7z^A zpg8uIZ}l+;o}dZj-1VR65Tms zm(5m(JB{}hM(WhD@v8?rjg4L1?tPRHVdTr$!0$ztrEz|OV(OXTdWNnTfk>O{oJQjx z+Sop~pY#1SJ6-4GH*sc-?u~qs?yhfh`|kDMuG#5KS|c$nYp0Vl@X@-NgqYe+Kws7l zt-@G*qNbmMS+^q%51LPGtn72j=;!z)pGr1e^hYzsvXAB+`5)LKYx7R$^0hAw;)6=u zVoE%H?X1J}w9!7Tuj}LFv`7G>yiZCgulTj<8QgE=o^-voP)182oW&<{*udUF)x@XD zUZ%uawQO~Zx73a1^a0jp*^lXM;d*i&zSH@^TFUnG`n9CI(E0;6k~^@Od$qdE;L;(P zSrN_PH}Vf$5H7-=53^XGqy{2)Loj*abVEs%Lq4m_|CFUJ2U-2-0Ov)-$3TmwOW4|f zY-N0IL60!JA-dn)xZRR56qFX?<6pG;*zZQ)b0e>%S2V0~*~Xv92}j>_*KgW10SfOG!ZfAy%wuhr1 zxX}Y0umDG;Q~p|I@$2(WGQyX;@sn^q+Jh|c5rS~`m9iec_t}{c%*Y8B&rD+-pm)*T zI{P~96B;b(yD+{-T;&-%%^WU^ZyM5miy>I+Dod3Do_W)cL zypecdVf`|wXkG2#X~c;7(z$}rLafbUmD&rM!=gk%8oRtp%Hv&c!to~xb};&^3#%5e zh9VaJW3gGnJXY|y`Nd64vi-d~@KM?7G+wKhC>``texOR^9sbYuc6!%oIEg0{1B+jc zH_oBPF_4Fljyk`P8;(9Mi|*CQG|^RMawYYPif4xxEKBolfMx7-u4py~8ZdAg3hI?< z;YtB@)r$I(-%ds3F#O4B@ZiY`hDL? zQ8{^a;%HJz9T06L)`{D96RGs!jW1yoh%#AUGLGA4B>6;bKf~?X%;wnmC6IS1^3%Q? z=dOD7Ze)MI`3&5F`6bN|LG>qC2(Xur160wFMZ_gFX0=@wD@S!(0E7n{DgWO zm`AoA?llkV>vYn{jm&RvjG;(Miu5uija|M#3Zh+UiLo#$*75uLRga>o|4@eL zyYB9Hm2J^#gB8>toL~|gIa?nKH&MW#FX16pwqZez3~cvNO4OHVQOb(uEUy@w5PnP3 zByyg~X{PR%&f(<+XN$YDNNyJ85tm0P9h-6tC5H>nc1~Z-`umJKPJ<@}eW-O%vLCyB zX%2p+n^BpX9svu-lX^o_)7c2c%329ef`jcB%dNqtuTY0-c)g{qA?L@n7rm1Q*et z_o$v8e*kLJ_}6IF_@5~BD~TEZIm&zR_*eO0=jJ&Y|3qK!82c&)gb5&x{b!~u zW9&D)rd|vP)rC;c5fc~%6R17TYu&9*ll(6KWhssfwTeoKS&UHWJpmNLAB26q2zwNS zJz=K^yEK)sQBRV^t0Ofj&t=*rNcc1;JB6+VpWjUuM)mnYFU>wCQtAMiZa%vZWYxb( z*-6ws?NfGDz>`JUSE$dLYtA<{KxzH%0K<(0h15KeO4jS2>P*%PCw7o^r#|q>+PsSo zdqmmd+0Q9;&`U|Oo@YNknEEjlS4}ItcbW8GBH+uq+j?Q9GUK0E?n^O9 zeSy!Vcb(~Uxl$Ids~Iei;~Cq3BT$EB3K~B`#@mvayF|+{UWZy2AS{+iI>E&H7& zRzia^IVg8WXce|`)HY7}j&OW@dT6(lQqT$uJ0rXAaaow`{s2wF!fov;Gd`4KiYKR2 zksk4FjSs-#cYPg^5p6IX)=ZxeUd}D%{JDo(nGSY^(_0aK7Jh87+x}{O+0w=u{I1g;b9UoP z4lpih61P8Y*X_as2bjrb_V&wc*><+eenb6p(%k$lZeLTZv#jkrE6uLgP+D#~#pVgR z@6XBpV@}L?Zu+rG zH$f3zqpQ(v(49oPf?v~}tLV=5aQbG>1_*VO$dd9qMt?+)jtF{mxNUt$G7%Deo7448 zM=zh4+ROVodUD$2zKWJ}Xkk-+u#|E8xs6I`0 zE;pPu*kb{0*pps!r0#Ksk7q2|bW7Sxhf$3pMjjZi5k~{jL@yPN5u*_}Kwp3AOAwk0WW*3A2P2-sH|MTPn#{(rI)#rl$7V zKChN4TW@KiX4Qovd-~PVh!&CIvPXVq{tZc-!~2|$_hit&oW!w}jm=KOT{KQXR(-}6 z-7sQ9X!j1pv|ZM zQca(o?MDq~@!45o>_>yUvGuZu%9jQ5sBXR`7eCUkO~C5$*r@3our^ zv8In%uM-~@1*BOT75SnDe0A8Qv7C;0cSywwWx3y1rpob%)JpoBWKz?ATYqk7;-AU4UBnaNCC~fHoJN1}9;~Le z$!G;r8Df`vtKXKe?)P7w$}bXrFR|XvSlt#=^qY?xTzI7hnWpH~?(Qwn4!zUjBh$PNBslc1CfIy{nYJt@ z*Hjc(Y|`31d!CV0R@1~Db6q%e?PxYurAOA}2|x9FudV4< zzxP(?#G+p*;?k5TkXiJVA8?--(s6rO$8Aqz{CH>H<+6PkTXApMit7d; zRSqd5An2P|q}#8VIgsnLS&k_-@fsftf0yC6HY;+VsP@QLu2v<_L=IeC)9;mh-Tw;d zjsLuHe%jsUE)Ze+e^bhMAYv==|K!!aemAr!2hlkeh zQRI5JiTJF2RUayhCM0aT8#2fnlqIdX>z%2D*b++N2hJiL&><4SnzZK>r{O8U;O;c1 z;V}VZ!5tJp_t1Etdv=_lBflU>3&+P|{x8LTxWc(pii8km;llOUx7$Oz-wENTVzlra z$zo~5&F9+yX0t7%sw`e!GeUTrBFHDnvQ`D}K$9T*TH!D3dSZ%&7A8a>3R1N8$~h&$ zk$D{DrOM)$^f2?w`s!0mDR2ii|Gp~H()yv|YO7z^KbF#>?vh;Dq5ISqrQZGXE!-)y z16{&`%B;FE0V+9W2X+;cyzLie&PYl{@vH(AKgImro(U&lFYTP7(#2!4n3jDm*|@dt zG;znJuloa3tAgDhvM}h0GMi(O$Gqwm?q~IuJ9Bv(ItDx%>hqZV^bhPN2HTn7JT;}eGIJmt{3-l@7=9SBNwCU7kqb@;ja@t@lgbh-)Y|J-`A$BGSHe-#X-(aBSH8L{IN!?ewV}ygdlQoA z)McQ<@1O0DboAASDfgRG?qAb=`YzyKV!B5*2%YGhpo7|;kO|H&>=z_#3hZOApV%xp z#bGL;`SAghQS&~dch=o09P70yaB#ZoS(3mIHZjgF-Gs@Q1v6u$c>TO#Zt>oE7}(EF zXR;j7*Yf|-J8Ryr>ltcaO+YjRn_0-roGI1*hE5iU62nI4q|Uvaxdr{}s&!%$&=R(h z_{B`XBBrh3{P)7?Z%R5(j=YepzMp91aR9^-7sd;o2p4`5vZ$PQxD)BAR?s`+7SHY9 zp17aBskx`Db)oUVO5AC}ol^B$J3i_-hkHdw*=AIKkUFZd1hu<`dxM_OaUu_iNFpso zi^IpXC!VImYMyG1*wTODk5`T)buAINmbT_<7>daB7fv@L^8Yb?y!mhkeXK~ie<9`m zDek@h=3*^*c7B~ldy<^c_kFA3oLXta8n-UEcrKb36DCwb{Yzs=?R~f(QZizAWCdH2 zl`^t_<=nr?T)m2K3F1a16F(?%%1^v9NIYi2-=;NrbNH0_xfOGi!CO0 z2t=vE5%&h_#;tQ_1Z=YSwRuyM+y(pH!u`Ux6Il zFo$=w%vwZ&7O{SY(triM@tNG3IF3wG;joH0`)K}_F!p*d^rWRh$#aeo_twrE+KGQI z*pMK$tkMrPYz3#}9hScXH}*M;iQ={M$7%TEp*WH)`0?A*@qgG?n;&5Lcv?B%0n6z= zmhq4651#udgGDsF1jN3bbk>!9VK&fO`{MH%KArj=&4_dIHAWiDs~sqF$24dD@^CzR z{B_IH5=V=FOHgzgr;@J;C7bye;-l+FRdKOU8brOyb-0%m^Gipj4 zOM;ribZj|%d*GPnAEhO-f~PZiLO0hRd9Qk05Z6s@TCIX*J$(~Vt$57UJgoOF!ceDL z^${fCYA8?fhgERQVo4!WY^xjW2|D0^kG`j)2H)uI5Gf-sn^$BpQz=C#3#xtVgr1{GZ)`WG^AL{P z!>=lw!_VVyL5?mQ9<$3twAqRnTE6&+0x@b9`DtmF0Qg8T`fsF9;J92>WmIuEQ;TW1Ar z&FR|KJlYquRrjfF1=-%hVP=VSk0FAx`n?lo9l^DtM%M{7RSCi$mD25duSc=Ns?Sq# zIHWZ0lI$+${GEvs^ly8o)pACZ|NWHb3wG#ZYNA}y+@MfCHjVt}Y%AR8ws73`4~ROG zhMdXoOqrZ#3UxDcbw>mW+aDcWVTX*N22d)G#UFTQ!NQVwGM#bwxnO>dI%19}s?>RG zl|iiE2R%|V9I6k9f1>6DgY!_yypaP_Qbjk1qo1QCrZ)5!_(XT)k7wL;j#+y>Tk?>3-(Va`r{@C(U5_t;8eebv*dSMFJbYfb6mpEGb3 zaO1FCs)RDkT2#bxJ0$X$X$Vq^b58{WgyDIQX_9Dp)=^w5YIL1Yn?ahy#a!C{M9+$T zugAWk*&bND*Hs0RjIe@K+)93jmoWJw0A?kt)n8FnC0-;Ic|8agLlL7-%-y4SCsyFV)b>lUH z;Q4<6!Sg-@&l?B=`@K|9f}nE`o&=sNYIL1Ydzhkbse=ep)|8l>@|5@i39he2H!&Zn zA(<#~KCh?M=7kGe;R9uceT86ODM>WF6p-qMT3qsy8TB@`wlLN#BW?!ml+^*s|Jp<N~0sEAj3a6|Dt0#;Kl--zqsOXkf?0Dgf%D|q*mJIvHS>@33h$+I=$_$Zzf3u zXaXiH-wNZi4(D1?qw9p4YuipIF}K&(j*|k6v;3wK{w{WN!E9l!o}6JwX0d*wy$ZBx zBNNp}bW>&YeP%cBDNTKmm6NWr`>QBun$s}Y7I}lysAWh{j@nmQyl%b|E`~b!upoo= zungH7Dx=GTS}Nn$wWE$N`y&OXaeC2PNS0sE>w|4Ifqkg5c-w+f?!(T7TP!_-afOsunhNB->M!iajw;R0lB&4YsiJM+o{I-g#EPNZ=SlO#oOTA$x zmtF-y5z7B_%y71)Ze&Fd=~s^{U+hm^OzI4tk?)Q`ak=;OK9<@g2co<@Z~{o{DfRj! zollj5W9klY6SUq|YSHVR(NQCGgzoQ38B_46QS)5UQAkrsl{#gTx6qHfkJ3-+AQF~1 zq!Z*faVA+<%`FGl8rg7e<*wo;JS- z{`!r5KgTFy@-_~x{l6}!2w&T)o-T0ZE^8UTXjk1j+ zzjwx`f|bpLNq|ooB1?%1_m1!zqE5otvjHFP6#Z-x;qeLSlHclubR0_xcet@=VBJp^72FKAtOw z<|X=%5{>DC(I79ux=_e!YWpwVqK_as#Eqf(i_hr&j()V|kbiCtc^3>$q>QQ{ z6>3R!zzKc_RG-BLv|gEGimN|vZyvk27e3#d&uQyRtZhw42V?36{VWw0iYyY(1v;3E z8=0trg5Bu-0@96{^Z+p*2dJu~jy})mhJ;_~=~00;*RG^&6{vqn#SZ$R{jzxn`rnlM z4^!?pbKh0QHg;F?&$+()`LpdfGfOJ|@cWjzeZPabJq!?HXyVusbl&Dyg3J5n7g5jr zK48{D5}x<)EW>ihjbDOdZ9RKoBKz_d3`PIOa(|!(;0#13>v8*H9;p+Yn7A>pZ@q8| z)5M1102+O#wY*3BX~wJBjl66sNUOm>VgnVko$X0*Bw0!zF;qnUOGW-}{0eH`Qax0X z#Q31(t5ouAN;Z}UNhoN7&bKNV=gl}^j8st2loY1FtuE6qh$nti&#OEDk zmjy0dyei~exJMpj3;G$R*!DU@Snvr&IoAR~|5q#F>I3U@6sx|GWW@iapoP>)nwb)a4i*pAZ zRJN2C93ox54s_1zhyTDUr|}|@@yaPmNwcc_;`1~~Cwu4BLYnr@i2_h(FETHW=etCM zef{Q*kW_*Ga7*=|z^Hhi(*Gt%FHxc>vb;!8wGB}hM)pmece4>j@R~>(3yd@3Hft>C zg|VPleYkLy%EfMpe5a0?f{8Dv^&shiJ->15>5UX~y{g0X!S+X3K4se+3vm5x#wLe@GXUWq9wZKeLn#Pp_QtfKI zmBjB#;;)j#KdEh0ip1Ypf)ju9EPs0$Di4G=A%l@)kOF^O&9nI1I`iv~O_qx9n`hSE z{Qe96Mpqn?zimDdB<(z&R;BW{fJ-m~EjTG)U&-)DEQoFu(-=eNs)_{8yv%6&;^VvH^rl$nJloxQAAI!zi_jO#Uen8 z9>iWI5%vPVORul-YHbRgh@w~L)O15A8_i@3`9?p|}G8_`*y+e6_s=R(G!=>5B zS}w9T*;2`VPzu=#zjH>WP`)Q{nuf;>jW! zM8dj@^q|h*)Y)F?$$80+nx`~sSeTR-Ob7sPDY%;W!&~z^AQykuqJ%_kvoLxHzWsBt z*X`nB22qX%5hIChtX;MewL1aK`hBue6xV6ed9@8VF%BPYpuA zRyy8|Zd>1-CtC{tsYWjE!has8385H{zspxLX@<=9+sf#l*+E^|_)*!*d}ZZb{ujH< zVh{CNoLtj4gqV7XVBg1emTenTjn759Q+>Jdi2i`>i){JZ7J6A0KK?nOd~&x?`j*hf zHcZ~yFIn05&mliNOIl`S@dwqt!}0Ue<<+yEnnTXA4@#=DLhCttbvgUXc#C;%QZ4JX z>=W1Al7<6HJo~hjw=h<%X-nGGW+2tdSAF7(F|4eiU$!0A%#c(lN7N}sDm+>;;ULL` z9Xzw_r95&b&O<1~5)^dcf92DG%Z`x%;#WcUOV$v=Cy8{Yz)^*^BsnF8$M_XJHGV_I zTdRvzq(6s-RpbrdT4bm4dVYf!P)*MRpf1$XHb&^Hh?fzT0BSN@B^Ys&Ai;SP)g|Mg zL)ADUr4jGsQR7#`L_;JET4I3jTvPk$ZJz@rh0VXfFULR*JJ;<0W0mvUuH|SzSd18< zFa;Bcs^GuvXo`&PRM?o&J8S#AI74X8c(F=lwiACw(4K(0WPA$5!&TEbY6^gqnq<6m zHfSX0^~1mHMJwLDXv90)h5aWH7pzaz}NW*98%KsAJ!UMRa-)!#w&{!9gTHDj#BNJ{+= z#4kfo>fhe>-=G-p5f4;3@uw{;$aN_|gmxPNvI|u9ot%XR{1~uCU5ISiA=x2!DqG=! z%0hNRY^^@mNOl5qiV=2vSbf5L?igTw1D7{LCgLqqgnl33qOSE9C)vn}xTR zfj22t8hCqmFNAh#MK>*D?@s5I*=RoQ(lPH{7=P%NBO;;bU&vNo*oxOpW#L*iu0n#n zkywMqXS!8dgz#Ww8#zkTf@M%CW4!HlII_S@1|d5fQr$iz#T?hI{7`p- zFFWKBJuI&_%j1j+&A)k9t-ycQkgx8$0NODZdoNsi5KtX6aN@CpDouG|<(DI<(1!t{ z)JoYHN!b5kyxtn+l86Z7G|uG4y>WftU-U(nv;2efuVM20&|f@jJm+@5+k`{W0d1D6 z%N)@@LjD0K^Fmu*oXF$QIZ-`&xi{{6?J{Hicf#?EN7uicc7E*a-fsIU`2w_jJ}Ml~ za@h0#&(Q8IP3ZZFrJ>`e?zvLgaixr`LFM!s?y;ebXzDkHT0R|Fnf^`)XB76_lsP|L ziPqyI>l(MFGJkbt`ummnC{a_G6_cnxs;SB>HxW;(Z-PtVwR!PZNE%Hk{tp|Rd;Y3v z$0&C3a`AHL?=TP`9`y{_MH0;Z{ykxqra^qRJ5QQiaoj+aR{k$Xjjq9xw#Xg)Up8bWK6hXBczvDm> z-Ter+HDVW<%EXu?>_$Ehu%D?mfc@}|tAu?v4uSpbVe4K_yC62;uu!aK1b&*$?(X+0 zapG6V1(dd#h)(AOJJj6t1EwFO%M|=GBxTPQ&$jkyBUVcy94;1)!-E3N#CxGzmyQsh zhBa(|#K|!S98=Fb%IW&Z)O{(zZ*Z^1Vb=RZEyt61y=VF@V5>>b!%Rp>lM!=AlVK;w z1kOD}1(?C)?kyVad6n#O+UU`SQnMpzPZ_&9u4&v=J5mBxk*1It=49s+jFx@3qAm?zpiBi_+@9} zo&uehz4%>JGDISiuy;b)P3x!9#G|cmaj(P6ipI4r&t8$ewh+B#-_h*W)lOY;DQ)5%5{m95cttS(n<4*#;eY5e1J;WtyJfw( zhh)9s{5Q(eKZf;ILo!NzaRg#$_g_QNub3Y5Dx%vNMReWXyow=f$_Z!V z{C;=H`8_l7_B@z0+5(d0Cx_}AcBp9xQn;J7vvbc1(HU$vm*ol^MIK8VKeJAm z8!GePDl7h`&V_%leKfa%%aLZxWtX+Dd37r6t*wO%c*Xw0p+rI~ULRc$Yh2|`FF1{g z;8_lGV^8s1nI}BT?D`x#{~`xLq@ z)bbA~^wfe0@tc2}R*}AD0@~;a@iAEy#c$Q#q$Z?*uk-(Yzf;U?x<<{$&spFAcqxFt zZQ;W?|6QrX#t}0Keg|(t2_3^ofR2>^B(#nsrJ0@jVX@*pGsT)HZGy}B|XO=O2XdB^|ULsrcqPP*U znVHzXig(OHRgX!W^STOAZqCe97=kEI1md% z%R`Ogwj}szWwfn8lyQE)h4%1@4I0ounGs#{xLpqq%{xDy#aD^FYkn5`Y^D~ME{fevIZfZV>wnEnF_=Ui1_=_wElDZVtyEn#_j4F492{?kmOe+w1vuZ+)uH_V1?4UU*K(P1)!RyO{H6-^5(nl<6RYeSK5 z1~`!i!~)~lD{fr|Dh9N%w<}z{X(krZ&MO>u_s39Ta{o`rXpb(>Bho7+uAs!N;l5kt zU#^T2yLYm`avHwZvD@+_Jx@WI4}L0(|Kv1&E*^w*z(S|*JM8f?A-;F=7~+5NhT5Y- z@$bnf33T_9bYkq%wmoipSswYN^}B*H@iF*n)~#X-aOaz)9Hzkuvpa6Hw^>_15$w!9 z89tdcL|JYEoz3L{R0a#V2s+2}rugkTd}h)y0rY4eQ&dBj=|DSg*Hnl+p5bH}-`dJPkPupjLFr%_;>|BAfz<&;FvfQ>-UZd4CO&u*+DMQx{bwNY69{CWp} zc3CF(a~h@dG3&z!r?;I&%#42X5T>*LW6!N=a4cg3O2BOf{`m*D0c+M+jZ0_%&b%6& z%Q6s$hhx*Te)d|$@|k7sDy@@xvw&lim-8;NCwJCFH}0~OTdP$c`?1~p)o%RCUPbH) zXIsU-%QG~=Q6!