Add project variants and UI updates

This commit is contained in:
Mikhail Chusavitin
2026-02-13 19:27:48 +03:00
parent 4e1a46bd71
commit 9b5d57902d
23 changed files with 1113 additions and 147 deletions

View File

@@ -163,7 +163,7 @@ func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string
}
for i := range projects {
p := projects[i]
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p
existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
}
}
@@ -253,12 +253,13 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
for _, action := range actions {
key := projectKey(action.OwnerUsername, action.TargetProjectName)
project := projectCache[key]
project := projectCache[key]
if project == nil {
project = &models.Project{
UUID: uuid.NewString(),
OwnerUsername: action.OwnerUsername,
Name: action.TargetProjectName,
Code: action.TargetProjectName,
Name: ptrString(action.TargetProjectName),
IsActive: true,
IsSystem: false,
}
@@ -268,7 +269,7 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
projectCache[key] = project
} else if !project.IsActive {
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err)
return fmt.Errorf("reactivate project %s (%s): %w", derefString(project.Name), project.UUID, err)
}
project.IsActive = true
}
@@ -294,3 +295,14 @@ func setKeys(set map[string]struct{}) []string {
func projectKey(owner, name string) string {
return owner + "||" + name
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func ptrString(value string) *string {
return &value
}

View File

@@ -50,6 +50,7 @@ const onDemandPullCooldown = 30 * time.Second
func main() {
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
migrate := flag.Bool("migrate", false, "run database migrations")
version := flag.Bool("version", false, "show version information")
flag.Parse()
@@ -100,6 +101,13 @@ func main() {
}
}
if shouldResetLocalDB(*resetLocalDB) {
if err := localdb.ResetData(resolvedLocalDBPath); err != nil {
slog.Error("failed to reset local database", "error", err)
os.Exit(1)
}
}
// Initialize local SQLite database (always used)
local, err := localdb.New(resolvedLocalDBPath)
if err != nil {
@@ -295,6 +303,31 @@ func main() {
}
}
func shouldResetLocalDB(flagValue bool) bool {
if flagValue {
return true
}
value := strings.TrimSpace(os.Getenv("QFS_RESET_LOCAL_DB"))
if value == "" {
return false
}
switch strings.ToLower(value) {
case "1", "true", "yes", "y":
return true
default:
return false
}
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "127.0.0.1"
@@ -1306,7 +1339,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if status == "archived" && p.IsActive {
continue
}
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
if search != "" &&
!strings.Contains(strings.ToLower(derefString(p.Name)), search) &&
!strings.Contains(strings.ToLower(p.Code), search) &&
!strings.Contains(strings.ToLower(p.Variant), search) {
continue
}
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
@@ -1319,8 +1355,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
left := filtered[i]
right := filtered[j]
if sortField == "name" {
leftName := strings.ToLower(strings.TrimSpace(left.Name))
rightName := strings.ToLower(strings.TrimSpace(right.Name))
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
if leftName == rightName {
if sortDir == "asc" {
return left.CreatedAt.Before(right.CreatedAt)
@@ -1333,8 +1369,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return leftName > rightName
}
if left.CreatedAt.Equal(right.CreatedAt) {
leftName := strings.ToLower(strings.TrimSpace(left.Name))
rightName := strings.ToLower(strings.TrimSpace(right.Name))
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
if sortDir == "asc" {
return leftName < rightName
}
@@ -1393,6 +1429,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
"id": p.ID,
"uuid": p.UUID,
"owner_username": p.OwnerUsername,
"code": p.Code,
"variant": p.Variant,
"name": p.Name,
"tracker_url": p.TrackerURL,
"is_active": p.IsActive,
@@ -1429,6 +1467,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Return simplified list of all projects (UUID + Name only)
type ProjectSimple struct {
UUID string `json:"uuid"`
Code string `json:"code"`
Variant string `json:"variant"`
Name string `json:"name"`
IsActive bool `json:"is_active"`
}
@@ -1437,7 +1477,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
for _, p := range allProjects {
simplified = append(simplified, ProjectSimple{
UUID: p.UUID,
Name: p.Name,
Code: p.Code,
Variant: p.Variant,
Name: derefString(p.Name),
IsActive: p.IsActive,
})
}
@@ -1451,14 +1493,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(req.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
if strings.TrimSpace(req.Code) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
return
}
project, err := projectService.Create(dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNameExists):
case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -1490,14 +1532,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(req.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
return
}
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNameExists):
case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})

View File

@@ -149,7 +149,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
t.Fatalf("setup router: %v", err)
}
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`)))
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1","code":"P1"}`)))
createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq)
@@ -243,7 +243,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
t.Fatalf("setup router: %v", err)
}
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`)))
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project","code":"MOVE"}`)))
createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq)