Files
agent/backend/internal/logic/projects/service.go

872 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package projects
import (
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
type Service struct {
}
type Dashboard struct {
ProjectID uint `json:"project_id"`
PendingInboxCount int64 `json:"pending_inbox_count"`
OpenTaskCount int64 `json:"open_task_count"`
RecentNoteCount int64 `json:"recent_note_count"`
RecentSessionCount int64 `json:"recent_session_count"`
}
type WorkbenchProject struct {
ID uint `json:"id"`
Name string `json:"name"`
Identifier string `json:"identifier"`
Icon string `json:"icon"`
Background string `json:"background"`
Description string `json:"description"`
Initials string `json:"initials"`
UnreadCount int64 `json:"unreadCount"`
}
type WorkbenchChannel struct {
ID string `json:"id"`
ProjectID uint `json:"projectID"`
Type string `json:"type"`
Title string `json:"title"`
Icon string `json:"icon"`
Count *int64 `json:"count,omitempty"`
URL string `json:"url,omitempty"`
SortOrder int `json:"sortOrder"`
}
type InboxMessage struct {
ID string `json:"id"`
Source string `json:"source"`
Title string `json:"title"`
Summary string `json:"summary"`
Status string `json:"status"`
Tag string `json:"tag"`
Time string `json:"time"`
}
type WorkTask struct {
ID string `json:"id"`
Title string `json:"title"`
Summary string `json:"summary"`
Completed bool `json:"completed"`
Owner string `json:"owner"`
Due string `json:"due"`
CreatedAt string `json:"createdAt"`
CompletedAt string `json:"completedAt"`
Duration string `json:"duration"`
Tag string `json:"tag"`
}
type AISessionItem struct {
ID string `json:"id"`
Title string `json:"title"`
Summary string `json:"summary"`
UpdatedAt string `json:"updatedAt"`
References []string `json:"references"`
}
type NoteSourceItem struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
UpdatedAt string `json:"updatedAt"`
Tag string `json:"tag"`
Source string `json:"source"`
}
type CronPlan struct {
ID string `json:"id"`
Title string `json:"title"`
Schedule string `json:"schedule"`
NextRun string `json:"nextRun"`
Enabled bool `json:"enabled"`
LastResult string `json:"lastResult"`
Owner string `json:"owner"`
}
type ProjectWorkspace struct {
Project WorkbenchProject `json:"project"`
Channels []WorkbenchChannel `json:"channels"`
Tags []string `json:"tags"`
RecentSessions []AISessionItem `json:"recentSessions"`
Inbox []InboxMessage `json:"inbox"`
Tasks []WorkTask `json:"tasks"`
AISessions []AISessionItem `json:"aiSessions"`
NotesSources []NoteSourceItem `json:"notesSources"`
CronPlans []CronPlan `json:"cronPlans"`
}
type CreateTaskInput struct {
Title string
Description string
Status string
DueAt *time.Time
Tag string
}
type UpdateTaskInput struct {
Title string
Description string
Status string
Completed bool
NextProjectID uint
Tag string
}
type CreateFileSourceInput struct {
Title string
FilePath string
}
type CreateCronPlanInput struct {
Title string
Schedule string
Enabled bool
NextRunAt *time.Time
}
func NewService() *Service {
return &Service{}
}
func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) {
return s.CreateProjectWithInput(ownerID, CreateProjectRequest{Name: name, Description: description})
}
func (s *Service) CreateProjectWithInput(ownerID uint, input CreateProjectRequest) (*models.SenlinAgentProject, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, ErrProjectNameRequired
}
identifier := strings.TrimSpace(input.Identifier)
if identifier == "" {
identifier = projectInitials(name)
}
var count int64
if err := models.DBService.Model(&models.SenlinAgentProject{}).
Where("owner_id = ? AND identifier = ?", ownerID, identifier).
Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, ErrProjectIdentifierConflict
}
project := &models.SenlinAgentProject{
OwnerID: ownerID,
Name: name,
Identifier: identifier,
Icon: strings.TrimSpace(input.Icon),
Background: strings.TrimSpace(input.Background),
Description: strings.TrimSpace(input.Description),
}
return project, models.DBService.Create(project).Error
}
// ListProjects 只返回当前所有者的公开 DTO不让 handler 接触或序列化数据库模型。
func (s *Service) ListProjects(ownerID uint) ([]ProjectDTO, error) {
var projects []models.SenlinAgentProject
if err := models.DBService.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error; err != nil {
return nil, err
}
result := make([]ProjectDTO, 0, len(projects))
for _, project := range projects {
result = append(result, projectDTO(project))
}
return result, nil
}
// GetProject 使用公开 identity 和所有权边界读取单个项目。
func (s *Service) GetProject(ownerID uint, identity string) (ProjectDTO, error) {
project, err := FindOwnedProject(ownerID, identity)
if err != nil {
return ProjectDTO{}, err
}
return projectDTO(*project), nil
}
var (
ErrProjectNameRequired = errors.New("project name is required")
ErrProjectIdentifierRequired = errors.New("project identifier is required")
ErrProjectIdentifierConflict = errors.New("project identifier already exists")
)
// UpdateProject 仅更新项目设置白名单字段,查询和冲突检查均限定在当前所有者内。
func (s *Service) UpdateProject(ownerID uint, identity string, input UpdateProjectRequest) error {
project, err := FindOwnedProject(ownerID, identity)
if err != nil {
return err
}
updates := make(map[string]any, 5)
if input.Name != nil {
name := strings.TrimSpace(*input.Name)
if name == "" {
return ErrProjectNameRequired
}
updates["name"] = name
}
if input.Identifier != nil {
identifier := strings.TrimSpace(*input.Identifier)
if identifier == "" {
return ErrProjectIdentifierRequired
}
var count int64
if err := models.DBService.Model(&models.SenlinAgentProject{}).
Where("owner_id = ? AND identifier = ? AND id <> ?", ownerID, identifier, project.ID).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return ErrProjectIdentifierConflict
}
updates["identifier"] = identifier
}
if input.Icon != nil {
updates["icon"] = strings.TrimSpace(*input.Icon)
}
if input.Background != nil {
updates["background"] = strings.TrimSpace(*input.Background)
}
if input.Description != nil {
updates["description"] = strings.TrimSpace(*input.Description)
}
if len(updates) == 0 {
return nil
}
return models.DBService.Model(project).Updates(updates).Error
}
func (s *Service) CreateTask(ownerID uint, projectID uint, input CreateTaskInput) (*models.SenlinAgentTask, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("task title is required")
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "open"
}
tagID, err := s.findOrCreateTagID(projectID, input.Tag)
if err != nil {
return nil, err
}
task := &models.SenlinAgentTask{
ProjectID: projectID,
CreatedBy: ownerID,
TagID: tagID,
Title: title,
Description: strings.TrimSpace(input.Description),
Status: status,
DueAt: input.DueAt,
}
return task, models.DBService.Create(task).Error
}
func (s *Service) UpdateTask(ownerID uint, projectID uint, taskID uint, input UpdateTaskInput) (*models.SenlinAgentTask, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
var task models.SenlinAgentTask
if err := models.DBService.Where("id = ? AND project_id = ?", taskID, projectID).First(&task).Error; err != nil {
return nil, err
}
nextProjectID := input.NextProjectID
if nextProjectID == 0 {
nextProjectID = task.ProjectID
}
if err := ensureProjectOwner(ownerID, nextProjectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("task title is required")
}
tagID, err := s.findOrCreateTagID(nextProjectID, input.Tag)
if err != nil {
return nil, err
}
projectIdentity, err := projectIdentity(nextProjectID)
if err != nil {
return nil, err
}
tagIdentity, err := optionalTagIdentity(tagID)
if err != nil {
return nil, err
}
status := strings.TrimSpace(input.Status)
if status == "" {
if input.Completed {
status = "done"
} else {
status = "open"
}
}
task.ProjectID = nextProjectID
task.ProjectIdentity = projectIdentity
task.Title = title
task.Description = strings.TrimSpace(input.Description)
task.Status = status
task.TagID = tagID
task.TagIdentity = tagIdentity
if err := models.DBService.Save(&task).Error; err != nil {
return nil, err
}
return &task, nil
}
func (s *Service) CreateFileSource(ownerID uint, projectID uint, input CreateFileSourceInput) (*models.SenlinAgentSource, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("source title is required")
}
filePath := strings.TrimSpace(input.FilePath)
if filePath == "" {
return nil, errors.New("source file path is required")
}
source := &models.SenlinAgentSource{
ProjectID: projectID,
CreatedBy: ownerID,
Kind: "file",
Title: title,
FilePath: filePath,
}
return source, models.DBService.Create(source).Error
}
func (s *Service) CreateCronPlan(ownerID uint, projectID uint, input CreateCronPlanInput) (*models.SenlinAgentCronPlan, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, errors.New("cron plan title is required")
}
schedule := strings.TrimSpace(input.Schedule)
if schedule == "" {
return nil, errors.New("cron schedule is required")
}
plan := &models.SenlinAgentCronPlan{
ProjectID: projectID,
CreatedBy: ownerID,
Title: title,
Schedule: schedule,
Enabled: input.Enabled,
NextRunAt: input.NextRunAt,
LastResult: "Not run yet",
}
return plan, models.DBService.Create(plan).Error
}
func (s *Service) CreateProjectTag(ownerID uint, projectID uint, name string) (*models.SenlinAgentTag, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
return s.CreateTag(projectID, name)
}
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("tag name is required")
}
var existing models.SenlinAgentTag
err := models.DBService.Where("project_id = ? AND name = ?", projectID, name).First(&existing).Error
if err == nil {
return &existing, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name}
return tag, models.DBService.Create(tag).Error
}
func (s *Service) ListProjectTags(ownerID uint, projectID uint) ([]models.SenlinAgentTag, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return nil, err
}
return s.ListTags(projectID)
}
func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
var tags []models.SenlinAgentTag
err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
return tags, err
}
func (s *Service) findOrCreateTagID(projectID uint, name string) (*uint, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, nil
}
tag, err := s.CreateTag(projectID, name)
if err != nil {
return nil, err
}
return &tag.ID, nil
}
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
if err := ensureProjectOwner(ownerID, projectID); err != nil {
return Dashboard{}, err
}
dashboard := Dashboard{ProjectID: projectID}
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil {
return dashboard, err
}
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&dashboard.OpenTaskCount).Error; err != nil {
return dashboard, err
}
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(&dashboard.RecentNoteCount).Error; err != nil {
return dashboard, err
}
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&dashboard.RecentSessionCount).Error; err != nil {
return dashboard, err
}
return dashboard, nil
}
func (s *Service) Workspace(ownerID uint, projectID uint) (ProjectWorkspace, error) {
var project models.SenlinAgentProject
if err := models.DBService.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil {
return ProjectWorkspace{}, err
}
counts, err := s.workspaceCounts(projectID)
if err != nil {
return ProjectWorkspace{}, err
}
tags, err := s.workspaceTags(projectID)
if err != nil {
return ProjectWorkspace{}, err
}
channels, err := s.workspaceChannels(projectID, counts)
if err != nil {
return ProjectWorkspace{}, err
}
inboxItems, err := s.workspaceInbox(projectID)
if err != nil {
return ProjectWorkspace{}, err
}
tasks, err := s.workspaceTasks(projectID)
if err != nil {
return ProjectWorkspace{}, err
}
aiSessions, err := s.workspaceAISessions(projectID)
if err != nil {
return ProjectWorkspace{}, err
}
notesSources, err := s.workspaceNotesSources(projectID)
if err != nil {
return ProjectWorkspace{}, err
}
cronPlans, err := s.workspaceCronPlans(projectID)
if err != nil {
return ProjectWorkspace{}, err
}
return ProjectWorkspace{
Project: WorkbenchProject{
ID: project.ID,
Name: project.Name,
Identifier: project.Identifier,
Icon: project.Icon,
Background: project.Background,
Description: project.Description,
Initials: defaultString(project.Identifier, projectInitials(project.Name)),
UnreadCount: counts.inbox,
},
Channels: channels,
Tags: tags,
RecentSessions: aiSessions,
Inbox: inboxItems,
Tasks: tasks,
AISessions: aiSessions,
NotesSources: notesSources,
CronPlans: cronPlans,
}, nil
}
func ensureProjectOwner(ownerID uint, projectID uint) error {
var count int64
if err := models.DBService.Model(&models.SenlinAgentProject{}).Where("id = ? AND owner_id = ?", projectID, ownerID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("project not found")
}
return nil
}
type workspaceCounts struct {
inbox int64
tasks int64
aiSessions int64
notesSources int64
cronPlans int64
}
func (s *Service) workspaceCounts(projectID uint) (workspaceCounts, error) {
var counts workspaceCounts
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&counts.inbox).Error; err != nil {
return counts, err
}
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&counts.tasks).Error; err != nil {
return counts, err
}
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&counts.aiSessions).Error; err != nil {
return counts, err
}
var noteCount int64
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(&noteCount).Error; err != nil {
return counts, err
}
var sourceCount int64
if err := models.DBService.Model(&models.SenlinAgentSource{}).Where("project_id = ?", projectID).Count(&sourceCount).Error; err != nil {
return counts, err
}
counts.notesSources = noteCount + sourceCount
if err := models.DBService.Model(&models.SenlinAgentCronPlan{}).Where("project_id = ?", projectID).Count(&counts.cronPlans).Error; err != nil {
return counts, err
}
return counts, nil
}
func (s *Service) workspaceTags(projectID uint) ([]string, error) {
var tags []models.SenlinAgentTag
if err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error; err != nil {
return nil, err
}
names := []string{"all"}
for _, tag := range tags {
names = append(names, tag.Name)
}
return names, nil
}
func (s *Service) workspaceChannels(projectID uint, counts workspaceCounts) ([]WorkbenchChannel, error) {
total := counts.inbox + counts.tasks + counts.aiSessions + counts.notesSources + counts.cronPlans
channels := []WorkbenchChannel{
{ID: fmt.Sprintf("%d-overview", projectID), ProjectID: projectID, Type: "overview", Title: "Overview", Icon: "home", Count: &total, SortOrder: 1},
{ID: fmt.Sprintf("%d-inbox", projectID), ProjectID: projectID, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: &counts.inbox, SortOrder: 2},
{ID: fmt.Sprintf("%d-tasks", projectID), ProjectID: projectID, Type: "tasks", Title: "Work Plan", Icon: "list", Count: &counts.tasks, SortOrder: 3},
{ID: fmt.Sprintf("%d-ai", projectID), ProjectID: projectID, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: &counts.aiSessions, SortOrder: 4},
{ID: fmt.Sprintf("%d-notes", projectID), ProjectID: projectID, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: &counts.notesSources, SortOrder: 5},
{ID: fmt.Sprintf("%d-cron", projectID), ProjectID: projectID, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: &counts.cronPlans, SortOrder: 6},
}
var customChannels []models.SenlinAgentProjectChannel
if err := models.DBService.Where("project_id = ?", projectID).Order("sort_order asc, id asc").Find(&customChannels).Error; err != nil {
return nil, err
}
for _, channel := range customChannels {
channels = append(channels, WorkbenchChannel{
ID: fmt.Sprintf("%d-custom-%d", projectID, channel.ID),
ProjectID: projectID,
Type: "custom_link",
Title: channel.Title,
Icon: channel.Icon,
URL: channel.URL,
SortOrder: channel.SortOrder,
})
}
return channels, nil
}
func (s *Service) workspaceInbox(projectID uint) ([]InboxMessage, error) {
var items []models.SenlinAgentInboxItem
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&items).Error; err != nil {
return nil, err
}
messages := make([]InboxMessage, 0, len(items))
for _, item := range items {
messages = append(messages, InboxMessage{
ID: fmt.Sprint(item.ID),
Source: item.SourceType,
Title: item.Title,
Summary: item.Body,
Status: item.Status,
Tag: "",
Time: displayTime(item.UpdatedAt),
})
}
return messages, nil
}
func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
var tasks []models.SenlinAgentTask
if err := models.DBService.Where("project_id = ?", projectID).Order("CASE WHEN status = 'done' THEN 1 ELSE 0 END asc").Order("sort_order asc, updated_at desc, id desc").Limit(50).Find(&tasks).Error; err != nil {
return nil, err
}
tagNames, err := taskTagNames(tasks)
if err != nil {
return nil, err
}
items := make([]WorkTask, 0, len(tasks))
for _, task := range tasks {
owner, err := userDisplayName(task.AssigneeID, task.CreatedBy)
if err != nil {
return nil, err
}
completed := task.Status == "done"
completedAt := ""
duration := ""
if completed {
completedAt = displayTime(task.UpdatedAt)
duration = displayDuration(task.CreatedAt, task.UpdatedAt)
}
items = append(items, WorkTask{
ID: fmt.Sprint(task.ID),
Title: task.Title,
Summary: task.Description,
Completed: completed,
Owner: owner,
Due: displayOptionalTime(task.DueAt),
CreatedAt: displayTime(task.CreatedAt),
CompletedAt: completedAt,
Duration: duration,
Tag: tagNames[task.ID],
})
}
return items, nil
}
func taskTagNames(tasks []models.SenlinAgentTask) (map[uint]string, error) {
names := make(map[uint]string)
tagIDs := make([]uint, 0)
for _, task := range tasks {
if task.TagID != nil {
tagIDs = append(tagIDs, *task.TagID)
}
}
if len(tagIDs) == 0 {
return names, nil
}
var tags []models.SenlinAgentTag
if err := models.DBService.Where("id IN ?", tagIDs).Find(&tags).Error; err != nil {
return nil, err
}
tagByID := make(map[uint]string, len(tags))
for _, tag := range tags {
tagByID[tag.ID] = tag.Name
}
for _, task := range tasks {
if task.TagID != nil {
names[task.ID] = tagByID[*task.TagID]
}
}
return names, nil
}
func projectIdentity(projectID uint) (string, error) {
var project models.SenlinAgentProject
if err := models.DBService.Select("identity").First(&project, projectID).Error; err != nil {
return "", err
}
return project.Identity, nil
}
func optionalTagIdentity(tagID *uint) (*string, error) {
if tagID == nil {
return nil, nil
}
var tag models.SenlinAgentTag
if err := models.DBService.Select("identity").First(&tag, *tagID).Error; err != nil {
return nil, err
}
return &tag.Identity, nil
}
func (s *Service) workspaceAISessions(projectID uint) ([]AISessionItem, error) {
var sessions []models.SenlinAgentAISession
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sessions).Error; err != nil {
return nil, err
}
items := make([]AISessionItem, 0, len(sessions))
for _, session := range sessions {
items = append(items, AISessionItem{
ID: fmt.Sprint(session.ID),
Title: session.Title,
Summary: session.Context,
UpdatedAt: displayTime(session.UpdatedAt),
References: []string{},
})
}
return items, nil
}
func (s *Service) workspaceNotesSources(projectID uint) ([]NoteSourceItem, error) {
var notes []models.SenlinAgentNote
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&notes).Error; err != nil {
return nil, err
}
items := make([]NoteSourceItem, 0, len(notes))
for _, note := range notes {
items = append(items, NoteSourceItem{
ID: fmt.Sprintf("note-%d", note.ID),
Kind: "note",
Title: note.Title,
UpdatedAt: displayTime(note.UpdatedAt),
Tag: "",
Source: "Markdown",
})
}
var sources []models.SenlinAgentSource
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sources).Error; err != nil {
return nil, err
}
for _, source := range sources {
items = append(items, NoteSourceItem{
ID: fmt.Sprintf("%s-%d", source.Kind, source.ID),
Kind: source.Kind,
Title: source.Title,
UpdatedAt: displayTime(source.UpdatedAt),
Tag: "",
Source: sourceDisplayName(source),
})
}
return items, nil
}
func (s *Service) workspaceCronPlans(projectID uint) ([]CronPlan, error) {
var plans []models.SenlinAgentCronPlan
if err := models.DBService.Where("project_id = ?", projectID).Order("enabled desc, updated_at desc, id desc").Limit(50).Find(&plans).Error; err != nil {
return nil, err
}
items := make([]CronPlan, 0, len(plans))
for _, plan := range plans {
owner, err := userDisplayName(nil, plan.CreatedBy)
if err != nil {
return nil, err
}
items = append(items, CronPlan{
ID: fmt.Sprint(plan.ID),
Title: plan.Title,
Schedule: plan.Schedule,
NextRun: displayOptionalTime(plan.NextRunAt),
Enabled: plan.Enabled,
LastResult: defaultString(plan.LastResult, "Not run yet"),
Owner: owner,
})
}
return items, nil
}
func projectInitials(name string) string {
words := strings.Fields(name)
if len(words) == 0 {
return "P"
}
for i := len(words) - 1; i >= 0; i-- {
word := strings.Trim(words[i], "#_-")
if isShortCode(word) {
return strings.ToUpper(word)
}
}
if len(words) == 1 {
runes := []rune(words[0])
if len(runes) == 1 {
return strings.ToUpper(string(runes[0]))
}
return strings.ToUpper(string(runes[:2]))
}
return strings.ToUpper(string([]rune(words[0])[0]) + string([]rune(words[len(words)-1])[0]))
}
func isShortCode(value string) bool {
runes := []rune(value)
if len(runes) == 0 || len(runes) > 3 {
return false
}
hasDigit := false
for _, r := range runes {
if r >= '0' && r <= '9' {
hasDigit = true
continue
}
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
continue
}
return false
}
return hasDigit
}
func displayTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format("2006-01-02 15:04")
}
func displayOptionalTime(value *time.Time) string {
if value == nil {
return ""
}
return displayTime(*value)
}
func displayDuration(start time.Time, end time.Time) string {
if start.IsZero() || end.IsZero() || end.Before(start) {
return ""
}
duration := end.Sub(start)
days := int(duration.Hours()) / 24
if days > 0 {
return fmt.Sprintf("%d 天", days)
}
hours := int(duration.Hours())
if hours > 0 {
return fmt.Sprintf("%d 小时", hours)
}
minutes := int(duration.Minutes())
if minutes > 0 {
return fmt.Sprintf("%d 分钟", minutes)
}
return "1 分钟内"
}
func sourceDisplayName(source models.SenlinAgentSource) string {
switch source.Kind {
case "file":
return "Attachment"
case "link":
return "URL"
default:
return strings.Title(source.Kind)
}
}
func userDisplayName(assigneeID *uint, createdBy uint) (string, error) {
userID := createdBy
if assigneeID != nil && *assigneeID != 0 {
userID = *assigneeID
}
var user models.SenlinAgentUser
if err := models.DBService.Select("display_name").Where("id = ?", userID).First(&user).Error; err != nil {
return fmt.Sprintf("User %d", userID), nil
}
return user.DisplayName, nil
}
func defaultString(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}