inst/plumber.R

# Framework GUI API
# This file defines all API endpoints for the Framework GUI using plumber

#* @apiTitle Framework GUI API
#* @apiDescription RESTful API for Framework GUI application
#* @plumber
function(pr) {
  # Configure JSON serializer to auto-unbox single-element arrays and properly handle NULL
  pr$registerHooks(list(
    preroute = function() {
      pr$setSerializer(plumber::serializer_json(auto_unbox = TRUE, null = "null"))
    }
  ))
}

# Helper function to read project metadata from settings file
.read_project_metadata <- function(project_path) {
  default_metadata <- list(
    name = basename(project_path),
    type = "project",
    author = NULL,
    author_email = NULL
  )

  settings_file <- NULL
  if (file.exists(file.path(project_path, "settings.yml"))) {
    settings_file <- file.path(project_path, "settings.yml")
  } else if (file.exists(file.path(project_path, "config.yml"))) {
    settings_file <- file.path(project_path, "config.yml")
  } else {
    return(default_metadata)
  }

  tryCatch({
    # Use yaml::read_yaml instead of config::get to avoid S3 class serialization issues
    settings_raw <- yaml::read_yaml(settings_file)
    settings <- settings_raw$default %||% settings_raw

    # Handle author field - could be a string (file reference) or an object
    author_name <- NULL
    author_email <- NULL

    if (!is.null(settings$author)) {
      if (is.character(settings$author) && length(settings$author) == 1 && grepl("\\.yml$", settings$author)) {
        # It's a file reference - try to read it
        author_file <- file.path(project_path, settings$author)
        if (file.exists(author_file)) {
          author_data <- tryCatch({
            yaml::read_yaml(author_file)
          }, error = function(e) NULL)

          if (!is.null(author_data) && !is.null(author_data$author)) {
            author_name <- author_data$author$name
            author_email <- author_data$author$email
          }
        }
      } else if (is.list(settings$author)) {
        # It's an object directly
        author_name <- settings$author$name
        author_email <- settings$author$email
      } else if (is.character(settings$author)) {
        # It's just a string name
        author_name <- settings$author
      }
    }

    list(
      name = as.character(settings$project_name %||% basename(project_path))[1],
      type = as.character(settings$project_type %||% "project")[1],
      author = if (!is.null(author_name)) as.character(author_name)[1] else NULL,
      author_email = if (!is.null(author_email)) as.character(author_email)[1] else NULL
    )
  }, error = function(e) {
    default_metadata
  })
}

.sanitize_relative_path <- function(path_value) {
  if (is.null(path_value) || is.na(path_value) || path_value == "") {
    return(NULL)
  }

  cleaned <- gsub("\\\\", "/", as.character(path_value)[1])
  cleaned <- gsub("^\\./", "", cleaned)

  if (grepl("\\.\\.", cleaned, fixed = TRUE)) {
    stop("Invalid path")
  }

  cleaned
}

#* Get global settings (simple endpoint for new project wizard)
#* @get /api/settings
#* @serializer unboxedJSON
function() {
  cat("[DEBUG] Using plumber.R at:", getwd(), "\n", file = stderr())
  settings_path <- file.path(framework::fw_config_dir(), "settings.yml")
  first_run <- !file.exists(settings_path)

  settings <- framework::read_frameworkrc()
  cat("[DEBUG] Author object:", names(settings$author), "\n", file = stderr())

  # Expand ~ in projects_root for frontend display and provide home directory
  settings$global$home_dir <- path.expand("~")
  if (!is.null(settings$global$projects_root)) {
    settings$global$projects_root <- path.expand(settings$global$projects_root)
  }

  # Flatten nested v2 structure to v1 flat structure for UI compatibility
  # UI expects flat defaults.* fields, not nested defaults.scaffold.*, etc.
  if (!is.null(settings$defaults)) {
    defaults_flat <- list()

    # Basic fields
    defaults_flat$project_type <- settings$defaults$project_type %||% "project"

    # Scaffold fields - flatten to root of defaults
    # IMPORTANT: Use field names that UI expects (notebook_format not default_format)
    if (!is.null(settings$defaults$scaffold)) {
      defaults_flat$seed_on_scaffold <- isTRUE(settings$defaults$scaffold$seed_on_scaffold)
      defaults_flat$seed <- settings$defaults$scaffold$seed %||% settings$defaults$seed
      defaults_flat$ide <- settings$defaults$scaffold$ide %||% settings$defaults$ide %||% "vscode"
      defaults_flat$positron <- isTRUE(settings$defaults$scaffold$positron %||% settings$defaults$positron)
      defaults_flat$notebook_format <- settings$defaults$scaffold$notebook_format %||% settings$defaults$notebook_format %||% "quarto"
    } else {
      # Fallback to flat fields if no nested scaffold
      defaults_flat$seed_on_scaffold <- isTRUE(settings$defaults$seed_on_scaffold)
      defaults_flat$seed <- settings$defaults$seed
      defaults_flat$ide <- settings$defaults$ide %||% "vscode"
      defaults_flat$positron <- isTRUE(settings$defaults$positron)
      defaults_flat$notebook_format <- settings$defaults$notebook_format %||% "quarto"
    }

    # renv - flatten from packages.use_renv to use_renv
    defaults_flat$use_renv <- if (!is.null(settings$defaults$packages$use_renv)) {
      isTRUE(settings$defaults$packages$use_renv)
    } else {
      isTRUE(settings$defaults$use_renv)
    }

    # Packages - convert to flat array
    defaults_flat$default_packages <- if (!is.null(settings$defaults$packages$default_packages)) {
      # Already structured from v2
      pkg_list <- settings$defaults$packages$default_packages
    } else if (!is.null(settings$defaults$packages) && is.list(settings$defaults$packages)) {
      # v1 format - packages is the list itself
      pkg_list <- settings$defaults$packages
    } else {
      list()
    }

    # Normalize packages to array of objects
    if (is.list(pkg_list) && length(pkg_list) > 0) {
      result <- lapply(pkg_list, function(pkg) {
        if (is.character(pkg) && length(pkg) == 1) {
          # Single string like "dplyr"
          list(name = pkg, auto_attach = TRUE)
        } else if (is.list(pkg) && !is.null(pkg$name)) {
          # Already proper format
          list(
            name = as.character(pkg$name),
            auto_attach = isTRUE(pkg$auto_attach)
          )
        } else {
          NULL
        }
      })
      defaults_flat$default_packages <- I(Filter(Negate(is.null), result))
    } else {
      defaults_flat$default_packages <- I(list())
    }

    # AI assistants - ensure it's an array at root defaults.ai_assistants
    ai_assistants_value <- settings$defaults$ai$assistants %||%
                           settings$defaults$ai_assistants %||%
                           "claude"

    if (is.character(ai_assistants_value)) {
      # Split comma-separated string or wrap single value
      if (grepl(",", ai_assistants_value)) {
        defaults_flat$ai_assistants <- I(strsplit(ai_assistants_value, ",\\s*")[[1]])
      } else {
        defaults_flat$ai_assistants <- I(c(ai_assistants_value))
      }
    } else if (is.list(ai_assistants_value) || is.vector(ai_assistants_value)) {
      defaults_flat$ai_assistants <- I(as.character(ai_assistants_value))
    } else {
      defaults_flat$ai_assistants <- I(c("claude"))
    }

    # AI support boolean
    defaults_flat$ai_support <- isTRUE(settings$defaults$ai$enabled %||%
                                       settings$defaults$ai_support %||%
                                       TRUE)

    defaults_flat$ai_canonical_file <- settings$defaults$ai$canonical_file %||%
                                       settings$defaults$ai_canonical_file %||%
                                       "CLAUDE.md"

    # Git hooks - flatten
    if (!is.null(settings$defaults$git$hooks)) {
      defaults_flat$git_hooks <- list(
        ai_sync = isTRUE(settings$defaults$git$hooks$ai_sync),
        data_security = isTRUE(settings$defaults$git$hooks$data_security),
        check_sensitive_dirs = isTRUE(settings$defaults$git$hooks$check_sensitive_dirs)
      )
    } else if (!is.null(settings$defaults$git_hooks)) {
      defaults_flat$git_hooks <- list(
        ai_sync = isTRUE(settings$defaults$git_hooks$ai_sync),
        data_security = isTRUE(settings$defaults$git_hooks$data_security),
        check_sensitive_dirs = isTRUE(settings$defaults$git_hooks$check_sensitive_dirs)
      )
    } else {
      defaults_flat$git_hooks <- list(
        ai_sync = FALSE,
        data_security = FALSE,
        check_sensitive_dirs = FALSE
      )
    }

    # Author info - check both defaults and author object (author object takes precedence)
    defaults_flat$author_name <- if (!is.null(settings$defaults$author_name)) settings$defaults$author_name else settings$author$name
    defaults_flat$author_email <- if (!is.null(settings$defaults$author_email)) settings$defaults$author_email else settings$author$email
    defaults_flat$author_affiliation <- if (!is.null(settings$defaults$author_affiliation)) settings$defaults$author_affiliation else settings$author$affiliation

    # Directories - preserve as-is
    defaults_flat$directories <- settings$defaults$directories %||% list()

    # Quarto settings - preserve as-is
    defaults_flat$quarto <- settings$defaults$quarto %||% list()

    # .env defaults - preserve as-is
    defaults_flat$env <- settings$defaults$env %||% list()

    # Connections defaults - preserve as-is
    defaults_flat$connections <- settings$defaults$connections %||% list()

    # Add alias for backwards compatibility (tests/UI may expect default_format)
    defaults_flat$default_format <- defaults_flat$notebook_format

    # Scaffold object for compatibility with new UI
    defaults_flat$scaffold <- list(
      seed_on_scaffold = defaults_flat$seed_on_scaffold,
      seed = defaults_flat$seed,
      ide = defaults_flat$ide,
      ides = defaults_flat$ide,  # Alias for backwards compatibility
      positron = defaults_flat$positron
    )

    # Replace nested structure with flat
    settings$defaults <- defaults_flat
  }

  # Add metadata about settings state
  settings$meta$first_run <- first_run
  settings$meta$settings_path <- settings_path

  return(settings)
}

#* Get global settings and projects list (legacy endpoint)
#* @get /api/settings/get
#* @serializer unboxedJSON
function() {
  settings_path <- file.path(framework::fw_config_dir(), "settings.yml")
  first_run <- !file.exists(settings_path)

  settings <- framework::read_frameworkrc()

  # Check if v1 format (no meta.version or version < 2)
  needs_migration <- is.null(settings$meta$version) || settings$meta$version < 2

  if (needs_migration) {
    message("Auto-migrating settings from v1 to v2...")

    # Build v2 structure
    settings_v2 <- list(
      meta = list(version = 2, description = "Framework global settings"),
      author = settings$author %||% list(
        name = "Your Name",
        email = "your.email@example.com",
        affiliation = "Your Institution"
      ),
      global = list(
        projects_root = settings$projects_root %||% "~/projects"
      ),
      defaults = list(
        project_type = settings$defaults$project_type %||% "project",
        scaffold = list(
          seed_on_scaffold = settings$defaults$seed_on_scaffold %||% FALSE,
          seed = settings$defaults$seed %||% "123",
          set_theme_on_scaffold = settings$defaults$set_theme_on_scaffold %||% FALSE,
          ggplot_theme = settings$defaults$ggplot_theme %||% "theme_minimal",
          notebook_format = settings$defaults$notebook_format %||% "quarto",
          ide = settings$defaults$ide %||% "vscode"
        ),
        packages = list(
          use_renv = settings$defaults$use_renv %||% FALSE,
          default_packages = if (!is.null(settings$defaults$packages) && is.list(settings$defaults$packages)) {
            # Convert to proper structure if needed, filtering out malformed entries
            result <- lapply(settings$defaults$packages, function(pkg) {
              if (is.character(pkg) && length(pkg) == 1) {
                # Single string like "dplyr"
                list(name = pkg, auto_attach = TRUE)
              } else if (is.list(pkg) && !is.null(pkg$name)) {
                # Already proper format
                list(name = as.character(pkg$name), auto_attach = isTRUE(pkg$auto_attach))
              } else {
                # Skip arrays, malformed entries, etc.
                NULL
              }
            })
            # Filter out NULLs and unbox to force JSON array
            filtered <- Filter(Negate(is.null), result)
            if (length(filtered) == 0) character(0) else filtered
          } else {
            character(0)
          }
        ),
        ai = list(
          enabled = settings$defaults$ai_support %||% TRUE,
          canonical_file = settings$defaults$ai_canonical_file %||% "CLAUDE.md",
          preferred_assistant = settings$defaults$ai_assistants %||% "claude",
          assistants = I(if (is.character(settings$defaults$ai_assistants)) {
            strsplit(settings$defaults$ai_assistants, ",\\s*")[[1]]
          } else {
            c("claude")
          })
        ),
        git = list(
          initialize = settings$defaults$use_git %||% TRUE,
          gitignore_template = settings$privacy$gitignore_template %||% "gitignore-project",
          hooks = list(
            ai_sync = settings$defaults$git_hooks$ai_sync %||% FALSE,
            data_security = settings$defaults$git_hooks$data_security %||% FALSE
          )
        )
      ),
      templates = settings$templates %||% list(),
      project_types = settings$project_types %||% list(),
      projects = settings$projects %||% list()
    )

    settings <- settings_v2

    # Save migrated settings back to disk
    framework::write_frameworkrc(settings)
  }

  # Enrich projects with live metadata
  if (!is.null(settings$projects) && length(settings$projects) > 0) {
    settings$projects <- lapply(settings$projects, function(proj) {
      if (!is.null(proj$path) && dir.exists(proj$path)) {
        metadata <- .read_project_metadata(proj$path)
        modifyList(proj, metadata)
      } else {
        modifyList(proj, list(name = basename(proj$path), type = "unknown"))
      }
    })
  }

  # Add metadata about settings state
  settings$meta$first_run <- first_run
  settings$meta$settings_path <- settings_path

  # CRITICAL FIX: Empty packages list becomes {} in JSON instead of []
  # Force it to be an array by using I() wrapper
  if (!is.null(settings$defaults$packages) && is.list(settings$defaults$packages) && length(settings$defaults$packages) == 0) {
    settings$defaults$packages <- I(list())
  }

  return(settings)
}

#* Get settings catalog (simple endpoint for new project wizard)
#* @get /api/settings-catalog
function() {
  framework::load_settings_catalog()
}

#* Get settings catalog metadata and defaults (legacy endpoint)
#* @get /api/settings/catalog
function() {
  framework::load_settings_catalog()
}

#* Fetch template contents for editing
#* @get /api/templates/<name>
function(name) {
  contents <- framework::read_framework_template(name)
  list(success = TRUE, name = name, contents = contents)
}

#* Update a template's contents
#* @post /api/templates/<name>
#* @param req The request object
function(name, req) {
  body <- jsonlite::fromJSON(req$postBody)
  framework::write_framework_template(name, body$contents %||% "")
  list(success = TRUE)
}

#* Reset a template back to defaults
#* @delete /api/templates/<name>
function(name) {
  framework::reset_framework_template(name)
  list(success = TRUE)
}

#* Save global settings
#* @post /api/settings/save
#* @param req The request object
function(req) {
  body <- jsonlite::fromJSON(req$postBody, simplifyDataFrame = FALSE)

  # DEBUG: Log what we received
  message("[API /api/settings/save] Received body$global$projects_root: ",
          body$global$projects_root %||% "NULL")
  message("[API /api/settings/save] Received body$projects_root: ",
          body$projects_root %||% "NULL")

  tryCatch({
    # Use unified configure_global function for validation and persistence
    framework::configure_global(settings = body, validate = TRUE)

    # DEBUG: Verify what was saved
    saved <- framework::read_frameworkrc()
    message("[API /api/settings/save] After save, global.projects_root: ",
            saved$global$projects_root %||% "NULL")

    list(success = TRUE)
  }, error = function(e) {
    message("ERROR: ", e$message)
    message("Traceback: ", paste(as.character(sys.calls()), collapse = "\n"))
    list(success = FALSE, error = e$message)
  })
}

#* Get current project context
#* @get /api/context
function() {
  context <- list(
    inProject = file.exists("config.yml") || file.exists("settings.yml"),
    projectPath = NULL,
    projectName = NULL
  )

  if (context$inProject) {
    context$projectPath <- getwd()

    # Try to read project name from config
    config_file <- if (file.exists("config.yml")) "config.yml" else "settings.yml"
    tryCatch({
      cfg <- config::get(file = config_file)
      context$projectName <- cfg$project_name %||% basename(getwd())
    }, error = function(e) {
      context$projectName <- basename(getwd())
    })
  }

  return(context)
}

#* Get project by ID
#* @get /api/project/<id>
#* @param id Project ID
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        # Enrich with live metadata from settings file
        if (dir.exists(proj$path)) {
          metadata <- .read_project_metadata(proj$path)
          return(modifyList(proj, metadata))
        } else {
          return(proj)
        }
      }
    }
  }

  list(error = "Project not found")
}

#* Get data catalog for a project
#* @get /api/project/<id>/data
#* @param id Project ID
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  # Find settings file
  settings_file <- NULL
  if (file.exists(file.path(project$path, "settings.yml"))) {
    settings_file <- file.path(project$path, "settings.yml")
  } else if (file.exists(file.path(project$path, "config.yml"))) {
    settings_file <- file.path(project$path, "config.yml")
  } else {
    return(list(data = list()))
  }

  # Read settings and extract data catalog
  tryCatch({
    old_wd <- getwd()
    setwd(project$path)
    on.exit(setwd(old_wd))

    # Use yaml::read_yaml instead of config::get
    settings_raw <- yaml::read_yaml(settings_file)
    settings <- settings_raw$default %||% settings_raw

    # Get data section
    data_catalog <- settings$data

    # If data points to a file, read it
    if (is.character(data_catalog) && length(data_catalog) == 1 && grepl("\\.yml$", data_catalog)) {
      data_file <- file.path(project$path, data_catalog)
      if (file.exists(data_file)) {
        data_settings <- yaml::read_yaml(data_file)
        data_catalog <- data_settings$data
      }
    }

    list(data = data_catalog %||% list())
  }, error = function(e) {
    list(error = paste("Failed to read data catalog:", e$message))
  })
}

#* Save data catalog for a project
#* @post /api/project/<id>/data
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  body <- jsonlite::fromJSON(req$postBody, simplifyVector = FALSE)
  if (is.null(body$data)) {
    return(list(error = "Missing data payload"))
  }

  # Locate primary settings file
  settings_file <- NULL
  if (file.exists(file.path(project$path, "settings.yml"))) {
    settings_file <- file.path(project$path, "settings.yml")
  } else if (file.exists(file.path(project$path, "config.yml"))) {
    settings_file <- file.path(project$path, "config.yml")
  } else {
    return(list(error = "No settings file found"))
  }

  tryCatch({
    old_wd <- getwd()
    setwd(project$path)
    on.exit(setwd(old_wd))

    settings_raw <- yaml::read_yaml(settings_file)
    has_default <- !is.null(settings_raw$default)
    settings <- settings_raw$default %||% settings_raw

    data_ref <- settings$data
    if (is.character(data_ref) && length(data_ref) == 1 && grepl("\\.yml$", data_ref)) {
      data_file <- file.path(project$path, data_ref)
      dir.create(dirname(data_file), recursive = TRUE, showWarnings = FALSE)
      yaml::write_yaml(list(data = body$data), data_file)
    } else {
      settings$data <- body$data
      if (has_default) {
        settings_raw$default <- settings
        yaml::write_yaml(settings_raw, settings_file)
      } else {
        yaml::write_yaml(settings, settings_file)
      }
    }

    list(success = TRUE)
  }, error = function(e) {
    list(error = paste("Failed to save data catalog:", e$message))
  })
}

#* List input files for a project
#* @get /api/project/<id>/inputs
#* @param id Project ID
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  tryCatch({
    old_wd <- getwd()
    setwd(project$path)
    on.exit(setwd(old_wd))

    settings_file <- if (file.exists("settings.yml")) "settings.yml" else if (file.exists("config.yml")) "config.yml" else NULL
    if (is.null(settings_file)) {
      return(list(error = "No settings file found"))
    }

    settings_raw <- yaml::read_yaml(settings_file)
    settings <- settings_raw$default %||% settings_raw
    dirs <- settings$directories %||% list()

    # Collect unique input directories
    input_dirs <- unique(unlist(dirs[grepl("^inputs", names(dirs))]))
    input_dirs <- input_dirs[file.exists(input_dirs)]

    files <- list()
    if (length(input_dirs) > 0) {
      for (dir in input_dirs) {
        paths <- list.files(dir, recursive = TRUE, full.names = TRUE)
        for (p in paths) {
          if (file.info(p)$isdir) next
          rel <- file.path(dir, basename(p))
          rel <- normalizePath(p, winslash = "/", mustWork = FALSE)
          rel <- sub(paste0("^", normalizePath(project$path, winslash = "/", mustWork = FALSE), "/?"), "", rel)
          ext <- tolower(tools::file_ext(p))
          type <- switch(ext,
            "csv" = "csv",
            "tsv" = "tsv",
            "txt" = "tsv",
            "dat" = "tsv",
            "rds" = "rds",
            "xlsx" = "excel",
            "xls" = "excel",
            "dta" = "stata",
            "sav" = "spss",
            "zsav" = "spss",
            "por" = "spss_por",
            "sas7bdat" = "sas",
            "sas7bcat" = "sas",
            "xpt" = "sas_xpt",
            NULL
          )
          files[[length(files) + 1]] <- list(
            path = rel,
            type = type
          )
        }
      }
    }

    list(files = files)
  }, error = function(e) {
    list(error = paste("Failed to list input files:", e$message))
  })
}

#* Get all settings for a project
#* @get /api/project/<id>/settings
#* @param id Project ID
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  # Find settings file
  settings_file <- NULL
  if (file.exists(file.path(project$path, "settings.yml"))) {
    settings_file <- file.path(project$path, "settings.yml")
  } else if (file.exists(file.path(project$path, "config.yml"))) {
    settings_file <- file.path(project$path, "config.yml")
  } else {
    return(list(error = "No settings file found"))
  }

  # Read all settings including delegated files
  tryCatch({
    old_wd <- getwd()
    setwd(project$path)
    on.exit(setwd(old_wd))

    # Use yaml::read_yaml instead of config::get to avoid S3 class issues
    settings_raw <- yaml::read_yaml(settings_file)
    settings <- settings_raw$default %||% settings_raw

    # Recursively resolve any file references in settings
    resolve_file_refs <- function(obj, base_path, parent_key = NULL) {
      if (is.list(obj)) {
        obj_names <- names(obj)

        if (is.null(obj_names)) {
          # Unnamed list (array) - process each element by index
          result <- lapply(seq_along(obj), function(i) {
            item <- obj[[i]]
            if (is.list(item)) {
              return(resolve_file_refs(item, base_path, parent_key))
            }
            item
          })
        } else {
          # Named list (object) - process by key with file reference resolution
          result <- lapply(obj_names, function(key) {
            item <- obj[[key]]
            if (is.character(item) && length(item) == 1 && grepl("\\.yml$", item)) {
              # This might be a file reference
              file_path <- file.path(base_path, item)
              if (file.exists(file_path)) {
                tryCatch({
                  sub_yaml <- yaml::read_yaml(file_path)
                  # Extract the content under the matching key
                  # e.g., for author.yml containing "author: {...}", extract just {...}
                  if (!is.null(sub_yaml[[key]])) {
                    return(sub_yaml[[key]])
                  }
                  # Otherwise return the whole thing
                  return(sub_yaml)
                }, error = function(e) item)
              }
            }
            if (is.list(item)) {
              return(resolve_file_refs(item, base_path, key))
            }
            item
          })
          names(result) <- obj_names
        }
        return(result)
      } else {
        obj
      }
    }

    settings_resolved <- resolve_file_refs(settings, project$path)

    # Read .gitignore file if it exists
    gitignore_path <- file.path(project$path, ".gitignore")
    if (file.exists(gitignore_path)) {
      gitignore_content <- paste(readLines(gitignore_path, warn = FALSE), collapse = "\n")
      settings_resolved$gitignore <- gitignore_content
    } else {
      settings_resolved$gitignore <- ""
    }

    # CRITICAL: Ensure extra_directories is always an array (unnamed list)
    # YAML parser can convert single-element arrays to named lists (objects)
    if (!is.null(settings_resolved$extra_directories)) {
      # If it's a named list (has names), convert to unnamed list
      if (!is.null(names(settings_resolved$extra_directories)) && length(names(settings_resolved$extra_directories)) > 0) {
        # It's a named list (single object) - wrap in unnamed list
        settings_resolved$extra_directories <- list(settings_resolved$extra_directories)
        # Remove names to ensure it serializes as array
        names(settings_resolved$extra_directories) <- NULL
      } else {
        # It's already an unnamed list, just ensure no names
        names(settings_resolved$extra_directories) <- NULL
      }
    }

    # Return full settings
    list(settings = settings_resolved, project_path = project$path)
  }, error = function(e) {
    list(error = paste("Failed to read settings:", e$message))
  })
}

#* Save project settings
#* @post /api/project/<id>/settings
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  body <- jsonlite::fromJSON(req$postBody)

  # DEBUG: Log what we received
  message("[DEBUG] Received extra_directories: ", jsonlite::toJSON(body$extra_directories, auto_unbox = TRUE))
  message("[DEBUG] Received enabled: ", jsonlite::toJSON(body$enabled, auto_unbox = TRUE))
  message("[DEBUG] extra_directories length: ", length(body$extra_directories))
  message("[DEBUG] extra_directories class: ", class(body$extra_directories))

  # CRITICAL: jsonlite converts arrays of objects to data.frames or weird column-wise structures
  # Convert back to proper array of objects (list of lists)
  if (!is.null(body$extra_directories) && length(body$extra_directories) > 0) {
    if (is.data.frame(body$extra_directories)) {
      # Data frame: each row is an object
      body$extra_directories <- lapply(1:nrow(body$extra_directories), function(i) {
        as.list(body$extra_directories[i, , drop = FALSE][1, ])
      })
    } else if (is.list(body$extra_directories)) {
      # Check if it's column-wise (all values for each field)
      # Heuristic: if first element is a vector, it's column-wise
      if (length(body$extra_directories) > 0 && (is.vector(body$extra_directories[[1]]) || is.list(body$extra_directories[[1]]))) {
        # Check if the last element is a proper object (new directory added)
        last_elem <- body$extra_directories[[length(body$extra_directories)]]
        if (is.list(last_elem) && !is.null(names(last_elem)) && "key" %in% names(last_elem)) {
          # Last element is a proper object, rest are column-wise
          # Extract the proper object and reconstruct the rest
          new_dir <- last_elem

          # The previous elements are columns - reconstruct objects from columns
          num_fields <- length(body$extra_directories) - 1
          if (num_fields > 0 && length(body$extra_directories[[1]]) > 0) {
            num_objects <- length(body$extra_directories[[1]])
            field_names <- c("key", "label", "path", "type", "_id", "render_for")

            reconstructed <- lapply(1:num_objects, function(i) {
              obj <- list()
              for (j in 1:min(num_fields, length(field_names))) {
                field_name <- field_names[j]
                if (j <= length(body$extra_directories)) {
                  obj[[field_name]] <- body$extra_directories[[j]][i]
                }
              }
              obj
            })

            # Combine reconstructed objects with the new directory
            body$extra_directories <- c(reconstructed, list(new_dir))
          } else {
            # Only the new directory
            body$extra_directories <- list(new_dir)
          }
        }
      }
    }
  }

  message("[DEBUG] After conversion, extra_directories: ", jsonlite::toJSON(body$extra_directories, auto_unbox = TRUE))

  tryCatch({
    old_wd <- getwd()
    setwd(project$path)
    on.exit(setwd(old_wd))

    # Save author settings
    if (!is.null(body$author)) {
      author_file <- "settings/author.yml"
      if (file.exists(author_file)) {
        message("Saving author to: ", author_file)
        message("Author data: ", jsonlite::toJSON(body$author, auto_unbox = TRUE))
        yaml::write_yaml(list(author = body$author), author_file)
        message("Author file saved successfully")
      } else {
        message("Author file does not exist: ", author_file)
      }
    } else {
      message("No author data in request body")
    }

    # Save directories settings
    if (!is.null(body$directories)) {
      if (file.exists("settings/directories.yml")) {
        yaml::write_yaml(list(directories = body$directories), "settings/directories.yml")
      }
    }

    # Save options settings
    if (!is.null(body$options)) {
      if (file.exists("settings/options.yml")) {
        yaml::write_yaml(list(options = body$options), "settings/options.yml")
      }
    }

    # Save git settings
    if (!is.null(body$git)) {
      if (file.exists("settings/git.yml")) {
        yaml::write_yaml(list(git = body$git), "settings/git.yml")
      }
    }

    # Save ai settings
    if (!is.null(body$ai)) {
      if (file.exists("settings/ai.yml")) {
        yaml::write_yaml(list(ai = body$ai), "settings/ai.yml")
      }
    }

    # Save scaffold settings
    if (!is.null(body$scaffold)) {
      if (file.exists("settings/scaffold.yml")) {
        yaml::write_yaml(list(scaffold = body$scaffold), "settings/scaffold.yml")
      }
    }

    # Save quarto settings (to split file if exists)
    if (!is.null(body$quarto)) {
      if (file.exists("settings/quarto.yml")) {
        yaml::write_yaml(list(quarto = body$quarto), "settings/quarto.yml")
      }
    }

    # Update main settings.yml if needed
    # For split-file projects, directories/extra_directories/render_dirs stay in main file
    needs_main_update <- !is.null(body$project_name) || !is.null(body$project_type) ||
                         !is.null(body$extra_directories) || !is.null(body$enabled) ||
                         !is.null(body$directories) || !is.null(body$render_dirs)

    if (needs_main_update) {
      settings_file <- if (file.exists("settings.yml")) "settings.yml" else "config.yml"
      current_settings <- yaml::read_yaml(settings_file)

      if (!is.null(body$project_name)) {
        current_settings$default$project_name <- body$project_name
      }
      if (!is.null(body$project_type)) {
        current_settings$default$project_type <- body$project_type
      }
      if (!is.null(body$directories)) {
        # Directories stay inline in main settings.yml (not a split file)
        current_settings$default$directories <- body$directories
      }
      if (!is.null(body$render_dirs)) {
        # Render dirs stay inline in main settings.yml
        current_settings$default$render_dirs <- body$render_dirs
      }
      if (!is.null(body$extra_directories)) {
        # Ensure it's saved as an array (unnamed list) not an object
        extra_dirs <- body$extra_directories
        if (is.list(extra_dirs)) {
          names(extra_dirs) <- NULL  # Remove names to ensure array serialization
        }
        current_settings$default$extra_directories <- extra_dirs
      }
      if (!is.null(body$enabled)) {
        # Save enabled state for directories (includes extra directories)
        current_settings$default$enabled <- body$enabled
      }

      yaml::write_yaml(current_settings, settings_file)

      # DEBUG: Verify what was actually saved
      verification <- yaml::read_yaml(settings_file)
      message("[DEBUG] After save, file contains ", length(verification$default$extra_directories), " extra directories")
      message("[DEBUG] After save, extra_directories keys: ", paste(sapply(verification$default$extra_directories, function(d) d$key), collapse = ", "))
    }

    list(success = TRUE)
  }, error = function(e) {
    list(success = FALSE, error = e$message)
  })
}

#* Get project connections
#* @get /api/project/<id>/connections
#* @param id Project ID
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  # Check if connections file exists
  connections_file <- file.path(project$path, "settings/connections.yml")
  if (!file.exists(connections_file)) {
    return(list(
      default_database = NULL,
      default_storage_bucket = NULL,
      databases = list(),
      storage_buckets = list()
    ))
  }

  tryCatch({
    connections_data <- yaml::read_yaml(connections_file)
    list(
      default_database = connections_data$default_database,
      default_storage_bucket = connections_data$default_storage_bucket,
      databases = connections_data$databases %||% list(),
      storage_buckets = connections_data$storage_buckets %||% list()
    )
  }, error = function(e) {
    list(error = paste("Failed to read connections:", e$message))
  })
}

#* Save project connections
#* @post /api/project/<id>/connections
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  body <- jsonlite::fromJSON(req$postBody)

  tryCatch({
    # Check if using split file or inline approach
    split_info <- .uses_split_file(project$path, "connections")

    connections_data <- list(
      default_database = body$default_database,
      default_storage_bucket = body$default_storage_bucket,
      databases = body$databases %||% list(),
      storage_buckets = body$storage_buckets %||% list()
    )

    if (split_info$use_split) {
      # Write to split file: settings/connections.yml
      dir.create(dirname(split_info$split_file), recursive = TRUE, showWarnings = FALSE)
      yaml::write_yaml(list(connections = connections_data), split_info$split_file)
    } else {
      # Write inline to main settings file
      settings_raw <- yaml::read_yaml(split_info$main_file)
      has_default <- !is.null(settings_raw$default)
      settings <- settings_raw$default %||% settings_raw

      settings$connections <- connections_data

      if (has_default) {
        settings_raw$default <- settings
        yaml::write_yaml(settings_raw, split_info$main_file)
      } else {
        yaml::write_yaml(settings, split_info$main_file)
      }
    }

    list(success = TRUE)
  }, error = function(e) {
    list(success = FALSE, error = e$message)
  })
}

#* Get project packages
#* @get /api/project/<id>/packages
#* @param id Project ID
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  # Check if packages file exists
  packages_file <- file.path(project$path, "settings/packages.yml")
  if (!file.exists(packages_file)) {
    return(list(packages = list()))
  }

  tryCatch({
    packages_data <- yaml::read_yaml(packages_file)
    packages_obj <- packages_data$packages %||% list()

    # Extract use_renv flag (defaults to FALSE)
    use_renv <- if (!is.null(packages_obj$use_renv)) as.logical(packages_obj$use_renv)[1] else FALSE

    # Get the packages list - could be under default_packages or directly in packages
    raw_packages <- if (!is.null(packages_obj$default_packages)) {
      packages_obj$default_packages
    } else if (is.list(packages_obj) && (is.null(names(packages_obj)) || all(names(packages_obj) == ""))) {
      # Old format: packages was a direct array
      packages_obj
    } else {
      list()
    }

    # Handle legacy comma-separated string format
    if (is.character(raw_packages) && length(raw_packages) == 1) {
      # Split comma-separated string: "dplyr,ggplot2,readr" -> ["dplyr", "ggplot2", "readr"]
      raw_packages <- strsplit(raw_packages, "\\s*,\\s*")[[1]]
      raw_packages <- trimws(raw_packages)
      raw_packages <- raw_packages[nchar(raw_packages) > 0]
    }

    # Normalize packages - handle both string format and object format
    normalized_packages <- lapply(raw_packages, function(pkg) {
      if (is.character(pkg) && length(pkg) == 1) {
        # Simple string format: "dplyr" -> {name: dplyr, source: cran, auto_attach: true}
        list(
          name = unname(as.character(pkg))[1],
          source = "cran",
          auto_attach = TRUE
        )
      } else if (is.list(pkg) && !is.null(names(pkg))) {
        # Object format - ensure defaults and extract scalars
        list(
          name = if (!is.null(pkg$name)) unname(as.character(pkg$name))[1] else "",
          source = if (!is.null(pkg$source)) unname(as.character(pkg$source))[1] else "cran",
          auto_attach = if (!is.null(pkg$auto_attach)) as.logical(pkg$auto_attach)[1] else TRUE
        )
      } else {
        # Skip invalid entries
        NULL
      }
    })

    # Remove NULL entries
    normalized_packages <- Filter(Negate(is.null), normalized_packages)

    # Ensure unnamed list for JSON array serialization
    names(normalized_packages) <- NULL

    list(
      use_renv = use_renv,
      packages = normalized_packages
    )
  }, error = function(e) {
    list(error = paste("Failed to read packages:", e$message))
  })
}

#* Get project AI settings
#* @get /api/project/<id>/ai
#* @param id Project ID
#* @param canonical_file Optional canonical file to preview content for
function(id, canonical_file = "") {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)
  # Convert empty string to NULL for internal logic

  if (canonical_file == "") canonical_file <- NULL

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  settings_file <- NULL
  if (file.exists(file.path(project$path, "settings.yml"))) {
    settings_file <- file.path(project$path, "settings.yml")
  } else if (file.exists(file.path(project$path, "config.yml"))) {
    settings_file <- file.path(project$path, "config.yml")
  } else {
    return(list(error = "No settings file found"))
  }

  tryCatch({
    settings_raw <- yaml::read_yaml(settings_file)
    settings <- settings_raw$default %||% settings_raw

    ai_config <- settings$ai %||% list()
    ai_ref <- NULL

    if (is.character(ai_config) && length(ai_config) == 1 && grepl("\\.yml$", ai_config)) {
      ai_ref <- ai_config
      ai_file <- file.path(project$path, ai_config)
      if (file.exists(ai_file)) {
        ai_yaml <- yaml::read_yaml(ai_file)
        if (!is.null(ai_yaml$ai)) {
          ai_config <- ai_yaml$ai
        } else {
          ai_config <- ai_yaml
        }
      } else {
        ai_config <- list()
      }
    }

    enabled <- as.logical(ai_config$enabled %||% FALSE)[1]
    canonical <- as.character(ai_config$canonical_file %||% "CLAUDE.md")[1]
    assistants_raw <- ai_config$assistants %||% list()

    assistants <- if (is.list(assistants_raw) || is.vector(assistants_raw)) {
      as.list(unique(as.character(unlist(assistants_raw))))
    } else {
      list()
    }

    requested_file <- .sanitize_relative_path(canonical_file %||% canonical %||% "CLAUDE.md")
    if (is.null(requested_file) || requested_file == "") {
      requested_file <- "CLAUDE.md"
    }

    canonical_path <- file.path(project$path, requested_file)
    canonical_exists <- file.exists(canonical_path)
    canonical_content <- ""

    if (canonical_exists) {
      canonical_content <- paste(readLines(canonical_path, warn = FALSE), collapse = "\n")
    }

    list(
      success = TRUE,
      ai = list(
        enabled = enabled,
        canonical_file = canonical,
        assistants = assistants,
        canonical_content = canonical_content,
        content_file = requested_file,
        content_exists = canonical_exists,
        reference = ai_ref
      )
    )
  }, error = function(e) {
    list(error = paste("Failed to load AI settings:", e$message))
  })
}

#* Get project Git settings
#* @get /api/project/<id>/git
#* @param id Project ID
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  tryCatch({
    git_file <- file.path(project$path, "settings/git.yml")
    settings_file <- NULL
    if (file.exists(file.path(project$path, "settings.yml"))) {
      settings_file <- file.path(project$path, "settings.yml")
    } else if (file.exists(file.path(project$path, "config.yml"))) {
      settings_file <- file.path(project$path, "config.yml")
    } else {
      return(list(error = "No settings file found"))
    }

    settings_raw <- yaml::read_yaml(settings_file)
    has_default <- !is.null(settings_raw$default)
    settings <- settings_raw$default %||% settings_raw
    use_split_file <- is.character(settings$git) && grepl("\\.yml$", settings$git)

    git_data <- list()
    base_git <- if (!use_split_file && is.list(settings$git)) settings$git else list()
    if (use_split_file && file.exists(git_file)) {
      git_yaml <- yaml::read_yaml(git_file)
      git_data <- git_yaml$git %||% git_yaml
    } else if (!use_split_file && is.list(settings$git)) {
      git_data <- settings$git
    }

    git_settings <- list(
      initialize = as.logical(git_data$initialize %||% git_data$enabled %||% TRUE)[1],
      user_name = as.character(git_data$user_name %||% base_git$user_name %||% "")[1],
      user_email = as.character(git_data$user_email %||% base_git$user_email %||% "")[1],
      hooks = list(
        ai_sync = as.logical(git_data$hooks$ai_sync %||% FALSE)[1],
        data_security = as.logical(git_data$hooks$data_security %||% FALSE)[1],
        check_sensitive_dirs = as.logical(git_data$hooks$check_sensitive_dirs %||% FALSE)[1]
      )
    )

    list(success = TRUE, git = git_settings)
  }, error = function(e) {
    list(error = paste("Failed to read git settings:", e$message))
  })
}

#* Save project packages
#* @post /api/project/<id>/packages
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  body <- jsonlite::fromJSON(req$postBody, simplifyDataFrame = FALSE)

  tryCatch({
    # Check if using split file or inline approach
    split_info <- .uses_split_file(project$path, "packages")

    # Extract use_renv flag and packages list
    # Accept both "packages" and "default_packages" for compatibility
    use_renv <- as.logical(body$use_renv %||% FALSE)[1]
    packages_list <- body$default_packages %||% body$packages %||% list()

    # Convert to proper unnamed list for YAML array serialization
    # Each package should be a named list (object) in an unnamed list (array)
    if (length(packages_list) > 0) {
      packages_list <- lapply(packages_list, function(pkg) {
        # Ensure it's a proper named list
        list(
          name = as.character(pkg$name %||% "")[1],
          source = as.character(pkg$source %||% "cran")[1],
          auto_attach = as.logical(pkg$auto_attach %||% TRUE)[1]
        )
      })
      # Remove names to force array serialization
      names(packages_list) <- NULL
    }

    packages_data <- list(
      use_renv = use_renv,
      default_packages = packages_list
    )

    if (split_info$use_split) {
      # Write to split file: settings/packages.yml
      dir.create(dirname(split_info$split_file), recursive = TRUE, showWarnings = FALSE)
      yaml::write_yaml(
        list(packages = packages_data),
        split_info$split_file,
        column.major = FALSE
      )
    } else {
      # Write inline to main settings file
      settings_raw <- yaml::read_yaml(split_info$main_file)
      has_default <- !is.null(settings_raw$default)
      settings <- settings_raw$default %||% settings_raw

      settings$packages <- packages_data

      if (has_default) {
        settings_raw$default <- settings
        yaml::write_yaml(settings_raw, split_info$main_file)
      } else {
        yaml::write_yaml(settings, split_info$main_file)
      }
    }

    list(success = TRUE)
  }, error = function(e) {
    list(success = FALSE, error = e$message)
  })
}

#* Save project AI settings
#* @post /api/project/<id>/ai
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  body <- jsonlite::fromJSON(req$postBody, simplifyDataFrame = FALSE)

  tryCatch({
    canonical_relative <- body$canonical_file %||% "CLAUDE.md"
    canonical_relative <- .sanitize_relative_path(canonical_relative) %||% "CLAUDE.md"
    assistants_raw <- body$assistants %||% list()
    assistant_values <- if (is.list(assistants_raw) || is.vector(assistants_raw)) {
      as.list(unique(as.character(unlist(assistants_raw))))
    } else {
      list()
    }

    new_ai_config <- list(
      enabled = as.logical(body$enabled %||% FALSE)[1],
      canonical_file = canonical_relative,
      assistants = assistant_values
    )

    canonical_path <- file.path(project$path, canonical_relative)

    # Check if using split file or inline approach
    split_info <- .uses_split_file(project$path, "ai")

    if (split_info$use_split) {
      # Write to split file
      dir.create(dirname(split_info$split_file), recursive = TRUE, showWarnings = FALSE)
      yaml::write_yaml(list(ai = new_ai_config), split_info$split_file)
    } else {
      # Write inline to main settings file
      settings_raw <- yaml::read_yaml(split_info$main_file)
      has_default <- !is.null(settings_raw$default)
      settings <- settings_raw$default %||% settings_raw

      settings$ai <- new_ai_config

      if (has_default) {
        settings_raw$default <- settings
        yaml::write_yaml(settings_raw, split_info$main_file)
      } else {
        yaml::write_yaml(settings, split_info$main_file)
      }
    }

    if (!is.null(body$canonical_content)) {
      dir.create(dirname(canonical_path), recursive = TRUE, showWarnings = FALSE)
      canonical_text <- as.character(body$canonical_content)[1]
      canonical_lines <- strsplit(canonical_text, "\r?\n", perl = TRUE)[[1]]
      writeLines(canonical_lines, canonical_path, sep = "\n")
    }

    list(success = TRUE)
  }, error = function(e) {
    list(success = FALSE, error = e$message)
  })
}

#* Regenerate AI context file for a project
#* Updates dynamic sections marked with <!-- @framework:regenerate -->
#* @post /api/project/<id>/ai/regenerate
#* @param id Project ID
#* @param req The request object (optional body with sections to regenerate)
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(success = FALSE, error = "Project not found"))
  }

  tryCatch({
    # Parse optional body for specific sections to regenerate
    sections <- NULL
    if (!is.null(req$postBody) && nchar(req$postBody) > 0) {
      body <- jsonlite::fromJSON(req$postBody, simplifyDataFrame = FALSE)
      sections <- body$sections  # NULL means all sections
    }

    # Get AI file name from project config
    settings_file <- file.path(project$path, "settings.yml")
    ai_file <- "CLAUDE.md"  # Default

    if (file.exists(settings_file)) {
      settings <- tryCatch(yaml::read_yaml(settings_file), error = function(e) list())
      settings <- settings$default %||% settings
      ai_file <- settings$ai$canonical_file %||% "CLAUDE.md"
    }

    # Call ai_regenerate_context
    framework::ai_regenerate_context(
      project_path = project$path,
      sections = sections,
      ai_file = ai_file
    )

    # Read regenerated content to return
    ai_path <- file.path(project$path, ai_file)
    content <- if (file.exists(ai_path)) {
      paste(readLines(ai_path, warn = FALSE), collapse = "\n")
    } else {
      NULL
    }

    list(
      success = TRUE,
      message = "AI context regenerated",
      ai_file = ai_file,
      content = content
    )
  }, error = function(e) {
    list(success = FALSE, error = e$message)
  })
}

#* Save project Git settings
#* @post /api/project/<id>/git
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  body <- jsonlite::fromJSON(req$postBody, simplifyDataFrame = FALSE)

  tryCatch({
    # Check if using split file or inline approach
    split_info <- .uses_split_file(project$path, "git")

    hooks_payload <- body$hooks %||% list()

    git_data <- list(
      initialize = as.logical(body$initialize %||% TRUE)[1],
      user_name = as.character(body$user_name %||% "")[1],
      user_email = as.character(body$user_email %||% "")[1],
      hooks = list(
        ai_sync = as.logical(hooks_payload$ai_sync %||% FALSE)[1],
        data_security = as.logical(hooks_payload$data_security %||% FALSE)[1],
        check_sensitive_dirs = as.logical(hooks_payload$check_sensitive_dirs %||% FALSE)[1]
      )
    )

    if (split_info$use_split) {
      # Write to split file: settings/git.yml
      dir.create(dirname(split_info$split_file), recursive = TRUE, showWarnings = FALSE)
      yaml::write_yaml(list(git = git_data), split_info$split_file)
    } else {
      # Write inline to main settings file
      settings_raw <- yaml::read_yaml(split_info$main_file)
      has_default <- !is.null(settings_raw$default)
      settings <- settings_raw$default %||% settings_raw

      settings$git <- git_data

      if (has_default) {
        settings_raw$default <- settings
        yaml::write_yaml(settings_raw, split_info$main_file)
      } else {
        yaml::write_yaml(settings, split_info$main_file)
      }
    }

    list(success = TRUE)
  }, error = function(e) {
    list(success = FALSE, error = e$message)
  })
}

#* Search CRAN packages
#* @get /api/packages/search
#* @param q Search query
#* @param source Package source (cran, bioconductor, github)
function(q = "", source = "cran") {
  if (q == "" || nchar(q) < 2) {
    return(list(packages = list()))
  }

  tryCatch({
    # Determine repository based on source
    if (source == "bioconductor") {
      # Bioconductor repositories
      repos <- c(
        "https://bioconductor.org/packages/release/bioc",
        "https://bioconductor.org/packages/release/data/annotation",
        "https://bioconductor.org/packages/release/data/experiment"
      )
    } else if (source == "cran") {
      repos <- "https://cloud.r-project.org"
    } else {
      # GitHub doesn't have a package listing API we can easily search
      return(list(packages = list()))
    }

    # Get available packages
    available <- available.packages(repos = repos)

    # Filter by search term (case-insensitive)
    matches <- grepl(tolower(q), tolower(available[, "Package"]))
    matching_rows <- available[matches, , drop = FALSE]

    # Limit to first 20 results
    matching_rows <- head(matching_rows, 20)

    # Return as unnamed list (array in JSON) with metadata
    results <- lapply(seq_len(nrow(matching_rows)), function(i) {
      row <- matching_rows[i, ]

      # Extract author from Maintainer field (format: "Name <email>")
      maintainer <- if (!is.na(row["Maintainer"])) as.character(row["Maintainer"]) else ""
      author <- gsub("\\s*<.*>\\s*", "", maintainer)  # Remove email part

      list(
        name = unname(as.character(row["Package"]))[1],
        version = unname(as.character(row["Version"]))[1],
        title = if (!is.na(row["Title"])) unname(as.character(row["Title"]))[1] else "",
        author = author,
        source = source
      )
    })
    names(results) <- NULL  # Ensure unnamed for JSON array

    list(packages = results)
  }, error = function(e) {
    list(packages = list(), error = e$message)
  })
}

#* Get project directories
#* @get /api/project/<id>/directories
#* @param id Project ID
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  # Check if directories file exists
  directories_file <- file.path(project$path, "settings/directories.yml")
  if (!file.exists(directories_file)) {
    return(list(directories = list()))
  }

  tryCatch({
    directories_data <- yaml::read_yaml(directories_file)
    list(
      directories = directories_data$directories %||% list()
    )
  }, error = function(e) {
    list(error = paste("Failed to read directories:", e$message))
  })
}

#* Save project directories
#* Add custom directories to a project
#* @post /api/project/<id>/directories
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  body <- jsonlite::fromJSON(req$postBody)

  # Get the directories array from the request body
  directories <- body$directories

  # Debug logging
  cat("[DEBUG] Received directories request\n")
  cat(sprintf("[DEBUG] directories class: %s\n", class(directories)))
  cat(sprintf("[DEBUG] directories length: %d\n", length(directories)))
  cat("[DEBUG] directories structure:\n")
  print(str(directories))

  if (is.null(directories) || length(directories) == 0) {
    return(list(success = FALSE, error = "No directories provided"))
  }

  # jsonlite converts arrays of objects to data.frames by default
  # Convert data.frame to list of lists (each row becomes a directory object)
  if (is.data.frame(directories)) {
    directories <- lapply(1:nrow(directories), function(i) {
      as.list(directories[i, , drop = FALSE][1, ])
    })
  }

  # Track results for each directory
  results <- list()
  errors <- list()

  # Process each directory
  for (i in seq_along(directories)) {
    dir_spec <- directories[[i]]

    # Debug logging
    cat(sprintf("[DEBUG] dir_spec class: %s\n", class(dir_spec)))
    cat(sprintf("[DEBUG] dir_spec structure:\n"))
    print(str(dir_spec))

    # Validate required fields
    if (is.null(dir_spec$key) || is.null(dir_spec$label) || is.null(dir_spec$path)) {
      errors[[length(errors) + 1]] <- sprintf(
        "Directory %d missing required fields (key, label, or path)", i
      )
      next
    }

    # Call the project_add_directory function
    result <- framework::project_add_directory(
      project_path = project$path,
      key = dir_spec$key,
      label = dir_spec$label,
      path = dir_spec$path
    )

    if (result$success) {
      results[[length(results) + 1]] <- result$directory
    } else {
      errors[[length(errors) + 1]] <- sprintf(
        "%s: %s", dir_spec$key, result$error
      )
    }
  }

  # Return summary
  if (length(errors) > 0 && length(results) == 0) {
    # All failed
    return(list(
      success = FALSE,
      error = paste(errors, collapse = "; ")
    ))
  } else if (length(errors) > 0) {
    # Partial success
    return(list(
      success = TRUE,
      created = results,
      errors = errors,
      message = sprintf(
        "Created %d director%s with %d error%s",
        length(results),
        if (length(results) == 1) "y" else "ies",
        length(errors),
        if (length(errors) == 1) "" else "s"
      )
    ))
  } else {
    # All succeeded
    return(list(
      success = TRUE,
      created = results,
      message = sprintf(
        "Successfully created %d director%s",
        length(results),
        if (length(results) == 1) "y" else "ies"
      )
    ))
  }
}

#* Get project security settings
#* @get /api/project/<id>/security
#* @param id Project ID
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  # Check if security file exists
  security_file <- file.path(project$path, "settings/security.yml")
  if (!file.exists(security_file)) {
    return(list(security = list()))
  }

  tryCatch({
    security_data <- yaml::read_yaml(security_file)
    list(
      security = security_data$security %||% list()
    )
  }, error = function(e) {
    list(error = paste("Failed to read security:", e$message))
  })
}

#* Save project security settings
#* @post /api/project/<id>/security
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  body <- jsonlite::fromJSON(req$postBody)

  tryCatch({
    security_file <- file.path(project$path, "settings/security.yml")

    # Save security
    yaml::write_yaml(list(
      security = body$security %||% list()
    ), security_file)

    list(success = TRUE)
  }, error = function(e) {
    list(success = FALSE, error = e$message)
  })
}

#* Get project .env file with usage analysis
#* @get /api/project/<id>/env
#* @param id Project ID
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  # Check for dotenv_location in settings
  dotenv_location <- "."
  settings_file <- NULL
  if (file.exists(file.path(project$path, "settings.yml"))) {
    settings_file <- file.path(project$path, "settings.yml")
  } else if (file.exists(file.path(project$path, "config.yml"))) {
    settings_file <- file.path(project$path, "config.yml")
  }

  if (!is.null(settings_file)) {
    tryCatch({
      settings_raw <- yaml::read_yaml(settings_file)
      settings <- settings_raw$default %||% settings_raw
      if (!is.null(settings$dotenv_location)) {
        dotenv_location <- settings$dotenv_location
      }
    }, error = function(e) {
      # Continue with default location
    })
  }

  env_file <- file.path(project$path, dotenv_location, ".env")

  # Read current .env variables
  variables <- list()
  if (file.exists(env_file)) {
    tryCatch({
      lines <- readLines(env_file, warn = FALSE)
      for (line in lines) {
        # Skip comments and empty lines
        if (grepl("^\\s*#", line) || grepl("^\\s*$", line)) {
          next
        }

        # Parse KEY=VALUE
        if (grepl("=", line)) {
          parts <- strsplit(line, "=", fixed = TRUE)[[1]]
          key <- trimws(parts[1])

          if (nzchar(key)) {
            value <- if (length(parts) > 1) trimws(paste(parts[-1], collapse = "=")) else ""

            # Remove quotes if present
            value <- gsub('^"(.*)"$', '\\1', value)
            value <- gsub("^'(.*)'$", '\\1', value)

            variables[[key]] <- value
          }
        }
      }
    }, error = function(e) {
      # Continue with empty variables if read fails
    })
  }

  # Scan project for env() references
  tryCatch({
    # Find all R and YAML files
    r_files <- list.files(project$path, pattern = "\\.(R|r)$", recursive = TRUE, full.names = TRUE)
    yaml_files <- list.files(project$path, pattern = "\\.(yml|yaml)$", recursive = TRUE, full.names = TRUE)
    all_files <- c(r_files, yaml_files)

    # Track env variable usage
    env_usage <- list()

    for (filepath in all_files) {
      # Skip node_modules, renv, etc
      if (grepl("node_modules|renv|\\.git", filepath)) next

      content <- tryCatch(readLines(filepath, warn = FALSE), error = function(e) NULL)
      if (is.null(content)) next

      # Find env("VARIABLE") patterns
      matches <- gregexpr('env\\(["\']([^"\']+)["\']', paste(content, collapse = "\n"), perl = TRUE)
      if (matches[[1]][1] != -1) {
        match_text <- regmatches(paste(content, collapse = "\n"), matches)[[1]]
        var_names <- gsub('env\\(["\']([^"\']+)["\'].*', '\\1', match_text)

        for (var_name in var_names) {
          relative_path <- gsub(paste0("^", project$path, "/?"), "", filepath)
          if (is.null(env_usage[[var_name]])) {
            env_usage[[var_name]] <- list()
          }
          env_usage[[var_name]] <- c(env_usage[[var_name]], relative_path)
        }
      }
    }

    # Group variables by prefix
    groups <- list()
    all_vars <- unique(c(names(variables), names(env_usage)))

    for (var_name in all_vars) {
      # Extract prefix (everything before first underscore)
      prefix <- "Other"
      if (grepl("_", var_name)) {
        prefix <- strsplit(var_name, "_", fixed = TRUE)[[1]][1]
      }

      if (is.null(groups[[prefix]])) {
        groups[[prefix]] <- list()
      }

      groups[[prefix]][[var_name]] <- list(
        value = variables[[var_name]] %||% "",
        defined = !is.null(variables[[var_name]]),
        used = !is.null(env_usage[[var_name]]),
        used_in = if (!is.null(env_usage[[var_name]])) unique(env_usage[[var_name]]) else list()
      )
    }

    # Also return raw content for raw editor mode
    raw_content <- ""
    if (file.exists(env_file)) {
      raw_content <- paste(readLines(env_file, warn = FALSE), collapse = "\n")
    }

    list(
      variables = variables,
      groups = groups,
      raw_content = raw_content,
      exists = file.exists(env_file)
    )
  }, error = function(e) {
    # Fallback to simple variables list
    list(
      variables = variables,
      groups = list(),
      exists = file.exists(env_file),
      error = paste("Failed to analyze usage:", e$message)
    )
  })
}

#* Save project .env file
#* @post /api/project/<id>/env
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  body <- jsonlite::fromJSON(req$postBody)

  tryCatch({
    # Check for dotenv_location in settings
    dotenv_location <- "."
    settings_file <- NULL
    if (file.exists(file.path(project$path, "settings.yml"))) {
      settings_file <- file.path(project$path, "settings.yml")
    } else if (file.exists(file.path(project$path, "config.yml"))) {
      settings_file <- file.path(project$path, "config.yml")
    }

    if (!is.null(settings_file)) {
      tryCatch({
        settings_raw <- yaml::read_yaml(settings_file)
        settings <- settings_raw$default %||% settings_raw
        if (!is.null(settings$dotenv_location)) {
          dotenv_location <- settings$dotenv_location
        }
      }, error = function(e) {
        # Continue with default location
      })
    }

    env_file <- file.path(project$path, dotenv_location, ".env")

    # If saving raw content, just write it directly
    if (!is.null(body$raw_content)) {
      writeLines(strsplit(body$raw_content, "\n")[[1]], env_file)
      return(list(success = TRUE))
    }

    # If regroup flag is set, rewrite file grouped by prefix
    if (!is.null(body$regroup) && body$regroup == TRUE) {
      # Group variables by prefix
      vars <- body$variables
      if (is.null(vars) || length(vars) == 0) {
        writeLines(c("# Environment Variables", ""), env_file)
        return(list(success = TRUE))
      }

      # Extract prefixes
      get_prefix <- function(key) {
        parts <- strsplit(key, "_")[[1]]
        if (length(parts) > 1) parts[1] else "OTHER"
      }

      prefixes <- sapply(names(vars), get_prefix)
      unique_prefixes <- unique(prefixes)
      # Sort prefixes, with OTHER last
      unique_prefixes <- c(sort(unique_prefixes[unique_prefixes != "OTHER"]), "OTHER")
      unique_prefixes <- unique_prefixes[unique_prefixes %in% prefixes]

      # Build lines
      lines <- c("# Environment Variables", "# Grouped by prefix", "")

      for (prefix in unique_prefixes) {
        keys_in_prefix <- names(vars)[prefixes == prefix]
        if (length(keys_in_prefix) > 0) {
          # Add section header
          if (prefix == "OTHER") {
            lines <- c(lines, "# Other Variables")
          } else {
            lines <- c(lines, sprintf("# %s Variables", toupper(prefix)))
          }

          # Add variables
          for (key in sort(keys_in_prefix)) {
            value <- vars[[key]]
            if (grepl(" ", value)) {
              lines <- c(lines, sprintf('%s="%s"', key, value))
            } else {
              lines <- c(lines, sprintf('%s=%s', key, value))
            }
          }
          lines <- c(lines, "")
        }
      }

      writeLines(lines, env_file)
      return(list(success = TRUE))
    }

    # Otherwise, preserve original file structure and only update values
    if (file.exists(env_file)) {
      # Read existing file
      lines <- readLines(env_file, warn = FALSE)
      updated_keys <- character(0)

      # Update existing keys while preserving structure
      for (i in seq_along(lines)) {
        line <- lines[i]

        # Skip comments and empty lines
        if (grepl("^\\s*#", line) || grepl("^\\s*$", line)) {
          next
        }

        # Parse KEY=VALUE (handles empty values like KEY=)
        if (grepl("=", line)) {
          # Split only on first = to handle values containing =
          eq_pos <- regexpr("=", line, fixed = TRUE)
          key <- trimws(substr(line, 1, eq_pos - 1))

          if (nzchar(key)) {
            # If this key is in our update, replace the value
            if (!is.null(body$variables[[key]])) {
              new_value <- body$variables[[key]]
              # Quote values that contain spaces
              if (grepl(" ", new_value)) {
                lines[i] <- sprintf('%s="%s"', key, new_value)
              } else {
                lines[i] <- sprintf('%s=%s', key, new_value)
              }
              updated_keys <- c(updated_keys, key)
            }
          }
        }
      }

      # Add any new keys that weren't in the original file
      new_keys <- setdiff(names(body$variables), updated_keys)
      if (length(new_keys) > 0) {
        lines <- c(lines, "", "# Added variables")
        for (key in new_keys) {
          value <- body$variables[[key]]
          if (grepl(" ", value)) {
            lines <- c(lines, sprintf('%s="%s"', key, value))
          } else {
            lines <- c(lines, sprintf('%s=%s', key, value))
          }
        }
      }

      writeLines(lines, env_file)
    } else {
      # File doesn't exist, create new one
      lines <- c(
        "# Environment Variables",
        "# WARNING: This file contains sensitive credentials - do not commit to version control",
        ""
      )

      if (!is.null(body$variables) && length(body$variables) > 0) {
        for (key in names(body$variables)) {
          value <- body$variables[[key]]
          if (grepl(" ", value)) {
            lines <- c(lines, sprintf('%s="%s"', key, value))
          } else {
            lines <- c(lines, sprintf('%s=%s', key, value))
          }
        }
      }

      writeLines(lines, env_file)
    }

    list(success = TRUE)
  }, error = function(e) {
    list(success = FALSE, error = e$message)
  })
}

#* List files in a project directory
#* @get /api/project/<id>/files/<dir>
#* @param id Project ID
#* @param dir Directory key (inputs_raw, inputs_intermediate, outputs_public, outputs_private, etc.)
function(id, dir, res) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    res$status <- 404
    return(list(error = "Project not found"))
  }

  # Get settings to map directory key to path
  settings_file <- NULL
  if (file.exists(file.path(project$path, "settings.yml"))) {
    settings_file <- file.path(project$path, "settings.yml")
  } else if (file.exists(file.path(project$path, "config.yml"))) {
    settings_file <- file.path(project$path, "config.yml")
  }

  if (is.null(settings_file)) {
    return(list(files = list(), directory = dir, error = "No settings file found"))
  }

  settings <- tryCatch({
    raw <- yaml::read_yaml(settings_file)
    raw$default %||% raw
  }, error = function(e) list())

  # Get directory path from settings
  dir_path <- settings$directories[[dir]]

  if (is.null(dir_path)) {
    # Try common directory mappings as fallbacks
    fallbacks <- list(
      inputs_raw = "inputs/raw",
      inputs_intermediate = "inputs/intermediate",
      inputs_final = "inputs/final",
      inputs_reference = "inputs/reference",
      outputs_public = "outputs/public",
      outputs_private = "outputs/private",
      outputs_tables = "outputs/tables",
      outputs_figures = "outputs/figures",
      outputs_models = "outputs/models",
      outputs_notebooks = "outputs/notebooks",
      outputs_reports = "outputs/reports"
    )
    dir_path <- fallbacks[[dir]]
  }

  if (is.null(dir_path)) {
    res$status <- 400
    return(list(error = paste("Unknown directory:", dir)))
  }

  full_path <- file.path(project$path, dir_path)
  if (!dir.exists(full_path)) {
    return(list(files = list(), directory = dir_path))
  }

  # List files recursively
  files <- list.files(full_path, recursive = TRUE, full.names = FALSE)
  file_info <- lapply(files, function(f) {
    fp <- file.path(full_path, f)
    info <- file.info(fp)
    list(
      name = f,
      path = file.path(dir_path, f),
      size = info$size,
      modified = format(info$mtime, "%Y-%m-%d %H:%M:%S"),
      is_dir = info$isdir
    )
  })

  list(files = file_info, directory = dir_path)
}

#* List saved results from project database
#* @get /api/project/<id>/results
#* @param id Project ID
#* @param type Optional type filter (table, figure, model, report, notebook)
function(id, type = NULL, res) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    res$status <- 404
    return(list(error = "Project not found"))
  }

  # Connect to project's framework.db
  db_path <- file.path(project$path, "framework.db")
  if (!file.exists(db_path)) {
    return(list(results = list()))
  }

  tryCatch({
    con <- DBI::dbConnect(RSQLite::SQLite(), db_path)
    on.exit(DBI::dbDisconnect(con), add = TRUE)

    query <- "SELECT name, type, public, comment, hash, created_at, updated_at FROM results WHERE deleted_at IS NULL"
    params <- list()

    if (!is.null(type) && type != "") {
      query <- paste(query, "AND type = ?")
      params <- c(params, type)
    }

    query <- paste(query, "ORDER BY updated_at DESC")

    results <- DBI::dbGetQuery(con, query, params)

    # Convert public column to logical for JSON
    if (nrow(results) > 0 && "public" %in% names(results)) {
      results$public <- as.logical(results$public)
    }

    # Convert to list of lists for proper JSON serialization
    results_list <- lapply(seq_len(nrow(results)), function(i) {
      as.list(results[i, ])
    })

    list(results = results_list)
  }, error = function(e) {
    list(results = list(), error = e$message)
  })
}

#* Create a new Framework project (new endpoint)
#* @post /api/projects/create
#* @param req The request object
function(req) {
  body <- jsonlite::fromJSON(req$postBody)

  # Convert extra_directories from data frame to list of lists if needed
  extra_dirs <- body$extra_directories %||% list()
  if (is.data.frame(extra_dirs)) {
    extra_dirs <- lapply(seq_len(nrow(extra_dirs)), function(i) {
      as.list(extra_dirs[i, , drop = FALSE])
    })
  }

  tryCatch({
    # Call new project_create() function with full configuration
    result <- framework::project_create(
      name = body$name,
      location = body$location,
      type = body$type %||% "project",
      author = body$author %||% list(name = "", email = "", affiliation = ""),
      packages = body$packages %||% list(use_renv = FALSE, default_packages = list()),
      directories = body$directories %||% list(),
      extra_directories = extra_dirs,
      ai = body$ai %||% list(enabled = FALSE, assistants = c(), canonical_content = ""),
      git = body$git %||% list(use_git = TRUE, hooks = list(), gitignore_content = ""),
      scaffold = body$scaffold %||% list(
        seed_on_scaffold = FALSE,
        seed = "123",
        set_theme_on_scaffold = FALSE,
        ggplot_theme = "theme_minimal"
      ),
      connections = body$connections %||% NULL,
      env = body$env %||% NULL,
      quarto = body$quarto %||% NULL,
      render_dirs = body$render_dirs %||% NULL
    )

    result
  }, error = function(e) {
    list(success = FALSE, error = e$message)
  })
}

#* Resolve project root directory
#* @post /api/project/resolve-root
#* @param req The request object
function(req) {
  body <- jsonlite::fromJSON(req$postBody)
  project_dir <- body$project_dir

  # Expand tilde in path
  if (grepl("^~", project_dir)) {
    project_dir <- path.expand(project_dir)
  }

  # Convert to absolute path
  project_dir <- normalizePath(project_dir, mustWork = FALSE)

  list(resolved_path = project_dir)
}

#* Remove project from Framework (untrack)
#* @post /api/projects/<id>/untrack
#* @param id The project ID
function(id) {
  tryCatch({
    # Get project path from registry
    projects <- framework::project_list()
    project <- projects[projects$id == as.integer(id), ]

    if (nrow(project) == 0) {
      return(list(success = FALSE, error = "Project not found"))
    }

    # Remove from registry
    framework::remove_project_from_config(id)

    list(success = TRUE, message = "Project removed from Framework")
  }, error = function(e) {
    list(success = FALSE, error = e$message)
  })
}

#* Delete project entirely (files and registry)
#* @post /api/projects/<id>/delete
#* @param id The project ID
function(id) {
  message("[DELETE] Called with id: ", id, " (type: ", typeof(id), ")")
  tryCatch({
    # Get project path from registry
    projects <- framework::project_list()
    message("[DELETE] Found ", nrow(projects), " projects in registry")
    message("[DELETE] Project IDs: ", paste(projects$id, collapse = ", "))

    target_id <- as.integer(id)
    message("[DELETE] Looking for target_id: ", target_id)

    project <- projects[projects$id == target_id, ]
    message("[DELETE] Matched ", nrow(project), " projects")

    if (nrow(project) == 0) {
      message("[DELETE] Project not found!")
      return(list(success = FALSE, error = "Project not found"))
    }

    project_path <- project$path[1]
    message("[DELETE] Project path: ", project_path)

    # Delete directory
    if (dir.exists(project_path)) {
      message("[DELETE] Deleting directory...")
      unlink(project_path, recursive = TRUE)
      message("[DELETE] Directory deleted")
    } else {
      message("[DELETE] Directory does not exist: ", project_path)
    }

    # Remove from registry
    message("[DELETE] Removing from registry...")
    framework::remove_project_from_config(target_id)
    message("[DELETE] Removed from registry")

    list(success = TRUE, message = "Project deleted")
  }, error = function(e) {
    message("[DELETE] ERROR: ", e$message)
    list(success = FALSE, error = e$message)
  })
}

#* Import an existing Framework project
#* @post /api/project/import
#* @param req The request object
function(req) {
  body <- jsonlite::fromJSON(req$postBody)
  project_dir <- body$project_dir

  # Validate project directory
  if (!dir.exists(project_dir)) {
    return(list(error = "Directory does not exist"))
  }

  # Check if it's a Framework project
  has_settings <- file.exists(file.path(project_dir, "settings.yml"))
  has_config <- file.exists(file.path(project_dir, "config.yml"))

  if (!has_settings && !has_config) {
    return(list(error = "Not a Framework project (no settings.yml or config.yml found)"))
  }

  # Add to registry
  tryCatch({
    project_id <- framework::add_project_to_config(project_dir)

    list(success = TRUE, id = project_id)
  }, error = function(e) {
    list(error = e$message)
  })
}

#* Regenerate Quarto configuration files for a project
#* @post /api/project/<id>/quarto/regenerate
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  # Find project
  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  # Parse request body for options
  body <- if (!is.null(req$postBody) && nchar(req$postBody) > 0) {
    jsonlite::fromJSON(req$postBody)
  } else {
    list()
  }

  backup <- if (!is.null(body$backup)) as.logical(body$backup) else TRUE

  # Regenerate Quarto configs
  tryCatch({
    result <- framework::quarto_regenerate(
      project_path = project$path,
      backup = backup
    )

    if (result$success) {
      list(
        success = TRUE,
        message = sprintf("Regenerated %d Quarto configuration file(s)", result$count),
        files = result$regenerated,
        backed_up = result$backed_up,
        backup_location = result$backup_location
      )
    } else {
      list(error = "Failed to regenerate Quarto configurations")
    }
  }, error = function(e) {
    list(error = paste("Error regenerating Quarto configs:", e$message))
  })
}

#* List Quarto configuration files for a project
#* @get /api/project/<id>/quarto/files
function(id) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  settings_path <- if (file.exists(file.path(project$path, "settings.yml"))) {
    file.path(project$path, "settings.yml")
  } else {
    file.path(project$path, "config.yml")
  }

  if (!file.exists(settings_path)) {
    return(list(error = "Project settings not found"))
  }

  cfg <- yaml::read_yaml(settings_path)
  defaults <- cfg$default %||% list()
  directories <- defaults$directories %||% list()
  render_dirs <- defaults$render_dirs %||% list()

  files <- list()

  # Root _quarto.yml
  root_path <- file.path(project$path, "_quarto.yml")
  files[[length(files) + 1]] <- list(
    key = "root",
    label = "Root _quarto.yml",
    path = root_path,
    exists = file.exists(root_path),
    contents = if (file.exists(root_path)) paste(readLines(root_path, warn = FALSE), collapse = "\n") else ""
  )

  # Per-render directory _quarto.yml (source dirs keyed by render_dirs)
  if (length(render_dirs) > 0) {
    for (render_key in names(render_dirs)) {
      source_dir <- directories[[render_key]] %||% render_key
      if (is.null(source_dir) || !nzchar(source_dir)) next
      file_path <- file.path(project$path, source_dir, "_quarto.yml")
      files[[length(files) + 1]] <- list(
        key = render_key,
        label = sprintf("%s/_quarto.yml", source_dir),
        path = file_path,
        exists = file.exists(file_path),
        contents = if (file.exists(file_path)) paste(readLines(file_path, warn = FALSE), collapse = "\n") else ""
      )
    }
  }

  list(success = TRUE, files = files)
}

#* Save Quarto configuration files for a project
#* @post /api/project/<id>/quarto/files
#* @param id Project ID
#* @param req The request object
function(id, req) {
  config <- framework::read_frameworkrc()
  project_id <- as.integer(id)

  project <- NULL
  if (!is.null(config$projects) && length(config$projects) > 0) {
    for (proj in config$projects) {
      if (!is.null(proj$id) && proj$id == project_id) {
        project <- proj
        break
      }
    }
  }

  if (is.null(project)) {
    return(list(error = "Project not found"))
  }

  if (is.null(req$postBody) || !nzchar(req$postBody)) {
    return(list(error = "No files provided"))
  }

  body <- jsonlite::fromJSON(req$postBody, simplifyDataFrame = FALSE)
  files <- body$files
  if (is.null(files) || length(files) == 0) {
    return(list(error = "No files provided"))
  }

  # Handle case where jsonlite converts single-element array differently
  if (is.data.frame(files)) {
    files <- lapply(1:nrow(files), function(i) as.list(files[i, ]))
  }

  written <- list()
  for (i in seq_along(files)) {
    file_spec <- files[[i]]
    target_path <- file_spec$path %||% ""
    if (!nzchar(target_path)) next

    # Safety: ensure path is inside project
    normalized <- normalizePath(target_path, winslash = "/", mustWork = FALSE)
    project_root <- normalizePath(project$path, winslash = "/", mustWork = TRUE)
    if (!startsWith(normalized, project_root)) {
      next
    }

    dir.create(dirname(normalized), recursive = TRUE, showWarnings = FALSE)
    writeLines(file_spec$contents %||% "", normalized)
    written[[length(written) + 1]] <- normalized
  }

  list(success = TRUE, written = written)
}

# =============================================================================
# Documentation API Endpoints
# =============================================================================

# Helper function to get documentation database path
.get_docs_db_path <- function() {
  # Try to find docs.db in multiple locations
  db_paths <- c(
    # Development: gui-dev directory
    file.path(getwd(), "gui-dev", "public", "docs.db"),
    # Production: inst/gui directory (served by plumber)
    system.file("gui", "docs.db", package = "framework"),
    # Fallback: inst/docs-export directory
    system.file("docs-export", "docs.db", package = "framework")
  )

  for (path in db_paths) {
    if (nzchar(path) && file.exists(path)) {
      return(path)
    }
  }
  return(NULL)
}

#* Get all documentation categories with function counts
#* @get /api/docs/categories
#* @serializer unboxedJSON
function() {
  tryCatch({
    db_path <- .get_docs_db_path()
    if (is.null(db_path)) {
      return(list(error = "Documentation database not found"))
    }

    con <- DBI::dbConnect(RSQLite::SQLite(), db_path, flags = RSQLite::SQLITE_RO)
    on.exit(DBI::dbDisconnect(con), add = TRUE)

    categories <- DBI::dbGetQuery(con, "
      SELECT
        c.id,
        c.name,
        c.description,
        c.position as display_order,
        COUNT(f.id) as function_count
      FROM categories c
      LEFT JOIN functions f ON f.category_id = c.id
      GROUP BY c.id
      ORDER BY c.position, c.name
    ")

    list(categories = categories)
  }, error = function(e) {
    list(error = paste("Database error:", conditionMessage(e)))
  })
}

#* Get functions list (optionally filtered by category)
#* @get /api/docs/functions
#* @param category_id Optional category ID to filter by
#* @serializer unboxedJSON
function(category_id = "") {
  tryCatch({
    db_path <- .get_docs_db_path()
    if (is.null(db_path)) {
      return(list(error = "Documentation database not found"))
    }

    con <- DBI::dbConnect(RSQLite::SQLite(), db_path, flags = RSQLite::SQLITE_RO)
    on.exit(DBI::dbDisconnect(con), add = TRUE)

    if (nzchar(category_id)) {
      functions <- DBI::dbGetQuery(con, "
        SELECT
          f.id,
          f.name,
          f.title,
          f.category_id,
          f.is_common,
          c.name as category_name
        FROM functions f
        LEFT JOIN categories c ON c.id = f.category_id
        WHERE f.category_id = ?
        ORDER BY f.is_common DESC, f.name
      ", params = list(as.integer(category_id)))
    } else {
      functions <- DBI::dbGetQuery(con, "
        SELECT
          f.id,
          f.name,
          f.title,
          f.category_id,
          f.is_common,
          c.name as category_name
        FROM functions f
        LEFT JOIN categories c ON c.id = f.category_id
        ORDER BY c.position, c.name, f.is_common DESC, f.name
      ")
    }

    list(functions = functions)
  }, error = function(e) {
    list(error = paste("Database error:", conditionMessage(e)))
  })
}

#* Get full documentation for a specific function
#* @get /api/docs/function/<name>
#* @param name Function name
#* @serializer unboxedJSON
function(name) {
  tryCatch({
    db_path <- .get_docs_db_path()
    if (is.null(db_path)) {
      return(list(error = "Documentation database not found"))
    }

    con <- DBI::dbConnect(RSQLite::SQLite(), db_path, flags = RSQLite::SQLITE_RO)
    on.exit(DBI::dbDisconnect(con), add = TRUE)

    # Get function details
    func <- DBI::dbGetQuery(con, "
      SELECT
        f.*,
        c.name as category_name,
        c.description as category_description
      FROM functions f
      LEFT JOIN categories c ON c.id = f.category_id
      WHERE f.name = ?
      LIMIT 1
    ", params = list(name))

    if (nrow(func) == 0) {
      # Try aliases
      alias_result <- DBI::dbGetQuery(con, "
        SELECT function_id FROM aliases WHERE alias = ? LIMIT 1
      ", params = list(name))

      if (nrow(alias_result) > 0) {
        func <- DBI::dbGetQuery(con, "
          SELECT
            f.*,
            c.name as category_name,
            c.description as category_description
          FROM functions f
          LEFT JOIN categories c ON c.id = f.category_id
          WHERE f.id = ?
          LIMIT 1
        ", params = list(alias_result$function_id[1]))
      }
    }

    if (nrow(func) == 0) {
      return(list(error = "Function not found"))
    }

    func_id <- func$id[1]

    # Get aliases
    aliases <- DBI::dbGetQuery(con, "
      SELECT alias FROM aliases WHERE function_id = ? ORDER BY alias
    ", params = list(func_id))

    # Get parameters
    params <- DBI::dbGetQuery(con, "
      SELECT name, description, position as param_order
      FROM parameters
      WHERE function_id = ?
      ORDER BY position
    ", params = list(func_id))

    # Get examples
    examples <- DBI::dbGetQuery(con, "
      SELECT code as content, position as example_order
      FROM examples
      WHERE function_id = ?
      ORDER BY position
    ", params = list(func_id))

    # Get seealso
    seealso <- DBI::dbGetQuery(con, "
      SELECT reference as target, reference as link_text
      FROM seealso
      WHERE function_id = ?
    ", params = list(func_id))

    # Get sections
    sections <- DBI::dbGetQuery(con, "
      SELECT id, title, content, position as section_order
      FROM sections
      WHERE function_id = ?
      ORDER BY position
    ", params = list(func_id))

    # Get subsections for each section
    section_list <- lapply(seq_len(nrow(sections)), function(i) {
      section <- as.list(sections[i, ])
      subsections <- DBI::dbGetQuery(con, "
        SELECT title, content, position as subsection_order
        FROM subsections
        WHERE section_id = ?
        ORDER BY position
      ", params = list(section$id))
      section$subsections <- if (nrow(subsections) > 0) {
        lapply(seq_len(nrow(subsections)), function(j) as.list(subsections[j, ]))
      } else {
        list()
      }
      section
    })

    # Build response
    result <- as.list(func[1, ])
    result$aliases <- as.list(aliases$alias)
    result$parameters <- if (nrow(params) > 0) {
      lapply(seq_len(nrow(params)), function(i) as.list(params[i, ]))
    } else {
      list()
    }
    result$examples <- if (nrow(examples) > 0) {
      lapply(seq_len(nrow(examples)), function(i) as.list(examples[i, ]))
    } else {
      list()
    }
    result$seealso <- if (nrow(seealso) > 0) {
      lapply(seq_len(nrow(seealso)), function(i) as.list(seealso[i, ]))
    } else {
      list()
    }
    result$sections <- section_list

    list(function_doc = result)
  }, error = function(e) {
    list(error = paste("Database error:", conditionMessage(e)))
  })
}

#* Search documentation using full-text search
#* @get /api/docs/search
#* @param q Search query
#* @param limit Maximum results (default 20)
#* @serializer unboxedJSON
function(q = "", limit = 20) {
  if (!nzchar(q)) {
    return(list(results = list()))
  }

  tryCatch({
    db_path <- .get_docs_db_path()
    if (is.null(db_path)) {
      return(list(error = "Documentation database not found"))
    }

    con <- DBI::dbConnect(RSQLite::SQLite(), db_path, flags = RSQLite::SQLITE_RO)
    on.exit(DBI::dbDisconnect(con), add = TRUE)

    # Check if FTS table exists
    tables <- DBI::dbGetQuery(con, "SELECT name FROM sqlite_master WHERE type='table' AND name='functions_fts'")
    if (nrow(tables) == 0) {
      # Fallback to LIKE search if FTS not available
      search_pattern <- paste0("%", q, "%")
      results <- DBI::dbGetQuery(con, "
        SELECT
          f.id,
          f.name,
          f.title,
          f.description,
          c.name as category_name
        FROM functions f
        LEFT JOIN categories c ON c.id = f.category_id
        WHERE f.name LIKE ? OR f.title LIKE ? OR f.description LIKE ?
        LIMIT ?
      ", params = list(search_pattern, search_pattern, search_pattern, as.integer(limit)))
    } else {
      # Use FTS5 for better search
      results <- DBI::dbGetQuery(con, "
        SELECT
          f.id,
          f.name,
          f.title,
          f.description,
          c.name as category_name,
          bm25(functions_fts) as relevance
        FROM functions_fts
        JOIN functions f ON f.id = functions_fts.rowid
        LEFT JOIN categories c ON c.id = f.category_id
        WHERE functions_fts MATCH ?
        ORDER BY relevance
        LIMIT ?
      ", params = list(q, as.integer(limit)))
    }

    list(results = if (nrow(results) > 0) {
      lapply(seq_len(nrow(results)), function(i) as.list(results[i, ]))
    } else {
      list()
    })
  }, error = function(e) {
    list(error = paste("Search error:", conditionMessage(e)))
  })
}

#* Get documentation metadata (package info, export date, etc.)
#* @get /api/docs/metadata
#* @serializer unboxedJSON
function() {
  tryCatch({
    db_path <- .get_docs_db_path()
    if (is.null(db_path)) {
      return(list(error = "Documentation database not found"))
    }

    con <- DBI::dbConnect(RSQLite::SQLite(), db_path, flags = RSQLite::SQLITE_RO)
    on.exit(DBI::dbDisconnect(con), add = TRUE)

    metadata <- DBI::dbGetQuery(con, "SELECT key, value FROM metadata")

    result <- list()
    for (i in seq_len(nrow(metadata))) {
      result[[metadata$key[i]]] <- metadata$value[i]
    }

    # Add counts
    result$total_functions <- DBI::dbGetQuery(con, "SELECT COUNT(*) as n FROM functions")$n[1]
    result$total_categories <- DBI::dbGetQuery(con, "SELECT COUNT(*) as n FROM categories")$n[1]

    list(metadata = result)
  }, error = function(e) {
    list(error = paste("Database error:", conditionMessage(e)))
  })
}

Try the framework package in your browser

Any scripts or data that you put into this service are public.

framework documentation built on Feb. 18, 2026, 1:07 a.m.