diff --git a/models/issues/issue.go b/models/issues/issue.go index 90aad10bb9001..74debee7031e5 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -7,7 +7,6 @@ package issues import ( "context" "fmt" - "regexp" "slices" "code.gitea.io/gitea/models/db" @@ -16,6 +15,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" @@ -141,11 +141,6 @@ type Issue struct { ShowRole RoleDescriptor `xorm:"-"` } -var ( - issueTasksPat = regexp.MustCompile(`(^\s*[-*]\s\[[\sxX]\]\s.)|(\n\s*[-*]\s\[[\sxX]\]\s.)`) - issueTasksDonePat = regexp.MustCompile(`(^\s*[-*]\s\[[xX]\]\s.)|(\n\s*[-*]\s\[[xX]\]\s.)`) -) - // IssueIndex represents the issue index table type IssueIndex db.ResourceIndex @@ -443,12 +438,12 @@ func (issue *Issue) IsPoster(uid int64) bool { // GetTasks returns the amount of tasks in the issues content func (issue *Issue) GetTasks() int { - return len(issueTasksPat.FindAllStringIndex(issue.Content, -1)) + return len(markdown.MarkdownTasksRegex.FindAllStringIndex(issue.Content, -1)) } // GetTasksDone returns the amount of completed tasks in the issues content func (issue *Issue) GetTasksDone() int { - return len(issueTasksDonePat.FindAllStringIndex(issue.Content, -1)) + return len(markdown.MarkdownTasksDoneRegex.FindAllStringIndex(issue.Content, -1)) } // GetLastEventTimestamp returns the last user visible event timestamp, either the creation of this issue or the close. diff --git a/models/migrations/v1_22/v287.go b/models/migrations/v1_22/v287.go new file mode 100644 index 0000000000000..9fd2e693edca8 --- /dev/null +++ b/models/migrations/v1_22/v287.go @@ -0,0 +1,51 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_22 //nolint + +import ( + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/xorm" +) + +type BoardNote struct { + ID int64 `xorm:"pk autoincr"` + Title string `xorm:"TEXT NOT NULL"` + Content string `xorm:"LONGTEXT"` + Sorting int64 `xorm:"NOT NULL DEFAULT 0"` + PinOrder int64 `xorm:"NOT NULL DEFAULT 0"` + MilestoneID int64 `xorm:"INDEX"` + + ProjectID int64 `xorm:"INDEX NOT NULL"` + BoardID int64 `xorm:"INDEX NOT NULL"` + CreatorID int64 `xorm:"NOT NULL"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +// TableName xorm will read the table name from this method +func (BoardNote) TableName() string { + return "project_board_note" +} + +type BoardNoteLabel struct { + ID int64 `xorm:"pk autoincr"` + BoardNoteID int64 `xorm:"UNIQUE(s) NOT NULL"` + LabelID int64 `xorm:"UNIQUE(s) NOT NULL"` +} + +// TableName xorm will read the table name from this method +func (BoardNoteLabel) TableName() string { + return "project_board_note_label" +} + +func CreateTablesForBoardNotes(x *xorm.Engine) error { + err := x.Sync(new(BoardNote)) + if err != nil { + return err + } + + return x.Sync(new(BoardNoteLabel)) +} diff --git a/models/project/board.go b/models/project/board.go index 3e2d8e0472c51..04265dbb19f93 100644 --- a/models/project/board.go +++ b/models/project/board.go @@ -82,6 +82,27 @@ func (b *Board) NumIssues(ctx context.Context) int { return int(c) } +// NumBoardNotes return counter of all notes assigned to the board +func (b *Board) NumBoardNotes(ctx context.Context) int { + c, err := db.GetEngine(ctx).Table("project_board_note"). + Where("project_id=?", b.ProjectID). + And("board_id=?", b.ID). + GroupBy("id"). + Cols("id"). + Count() + if err != nil { + return 0 + } + return int(c) +} + +// NumIssuesAndNotes return counter of all issues and notes assigned to the board +func (b *Board) NumIssuesAndNotes(ctx context.Context) int { + numIssues := b.NumIssues(ctx) + numBoardNotes := b.NumBoardNotes(ctx) + return numIssues + numBoardNotes +} + func init() { db.RegisterModel(new(Board)) } @@ -180,6 +201,10 @@ func deleteBoardByID(ctx context.Context, boardID int64) error { return err } + if err = board.removeBoardNotes(ctx); err != nil { + return err + } + if _, err := db.GetEngine(ctx).ID(board.ID).NoAutoCondition().Delete(board); err != nil { return err } diff --git a/models/project/note.go b/models/project/note.go new file mode 100644 index 0000000000000..43600b4d6d01a --- /dev/null +++ b/models/project/note.go @@ -0,0 +1,392 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// BoardNote is used to represent a note on a boards +type BoardNote struct { + ID int64 `xorm:"pk autoincr"` + Title string `xorm:"TEXT NOT NULL"` + Content string `xorm:"LONGTEXT"` + Sorting int64 `xorm:"NOT NULL DEFAULT 0"` + PinOrder int64 `xorm:"NOT NULL DEFAULT 0"` + LabelIDs []int64 `xorm:"-"` // can't be []*Label because of 'import cycle not allowed' + MilestoneID int64 `xorm:"INDEX"` + + ProjectID int64 `xorm:"INDEX NOT NULL"` + BoardID int64 `xorm:"INDEX NOT NULL"` + CreatorID int64 `xorm:"NOT NULL"` + Creator *user_model.User `xorm:"-"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +// TableName xorm will read the table name from this method +func (*BoardNote) TableName() string { + return "project_board_note" +} + +type BoardNoteList []*BoardNote + +// BoardNotesOptions represents options of an note. +type BoardNotesOptions struct { + ProjectID int64 + BoardID int64 + IsPinned util.OptionalBool +} + +func init() { + db.RegisterModel(new(BoardNote)) + db.RegisterModel(new(BoardNoteLabel)) +} + +// GetBoardNoteByID load note by ID +func GetBoardNoteByID(ctx context.Context, projectBoardNoteID int64) (*BoardNote, error) { + projectBoardNote := new(BoardNote) + + has, err := db.GetEngine(ctx).ID(projectBoardNoteID).Get(projectBoardNote) + if err != nil { + return nil, err + } else if !has { + return nil, ErrBoardNoteNotExist{BoardNoteID: projectBoardNoteID} + } + + return projectBoardNote, nil +} + +// GetBoardNotesByIds return notes with the given IDs. +func GetBoardNotesByIds(ctx context.Context, projectBoardNoteIDs []int64) (BoardNoteList, error) { + projectBoardNoteList := make(BoardNoteList, 0, len(projectBoardNoteIDs)) + + if err := db.GetEngine(ctx).In("id", projectBoardNoteIDs).Find(&projectBoardNoteList); err != nil { + return nil, err + } + + if err := projectBoardNoteList.LoadAttributes(ctx); err != nil { + return nil, err + } + + return projectBoardNoteList, nil +} + +// GetBoardNotesByProjectID load pinned notes assigned to the project +func GetBoardNotesByProjectID(ctx context.Context, projectID int64, isPinned bool) (BoardNoteList, error) { + projectBoardNoteList, err := BoardNotes(ctx, &BoardNotesOptions{ + ProjectID: projectID, + BoardID: -1, + IsPinned: util.OptionalBoolOf(isPinned), + }) + if err != nil { + return nil, err + } + + return projectBoardNoteList, nil +} + +// LoadBoardNotesFromBoardList load notes assigned to the boards +func (p *Project) LoadBoardNotesFromBoardList(ctx context.Context, boardList BoardList) (map[int64]BoardNoteList, error) { + projectBoardNoteListMap := make(map[int64]BoardNoteList, len(boardList)) + for i := range boardList { + il, err := LoadBoardNotesFromBoard(ctx, boardList[i]) + if err != nil { + return nil, err + } + projectBoardNoteListMap[boardList[i].ID] = il + } + return projectBoardNoteListMap, nil +} + +// LoadBoardNotesFromBoard load notes assigned to this board +func LoadBoardNotesFromBoard(ctx context.Context, board *Board) (BoardNoteList, error) { + projectBoardNoteList, err := BoardNotes(ctx, &BoardNotesOptions{ + ProjectID: board.ProjectID, + BoardID: board.ID, + }) + if err != nil { + return nil, err + } + + return projectBoardNoteList, nil +} + +// BoardNotes returns a list of notes by given conditions. +func BoardNotes(ctx context.Context, opts *BoardNotesOptions) (BoardNoteList, error) { + sess := db.GetEngine(ctx) + + if opts.BoardID >= 0 { + sess.Where(builder.Eq{"board_id": opts.BoardID}) + } + if opts.ProjectID >= 0 { + sess.Where(builder.Eq{"project_id": opts.ProjectID}) + } + if !opts.IsPinned.IsNone() { + if opts.IsPinned.IsTrue() { + sess.Where(builder.NotNull{"pin_order"}).And(builder.Gt{"pin_order": 0}) + } else { + sess.Where(builder.IsNull{"pin_order"}).Or(builder.Eq{"pin_order": 0}) + } + } + + projectBoardNoteList := BoardNoteList{} + if err := sess.Asc("sorting").Desc("updated_unix").Desc("id").Find(&projectBoardNoteList); err != nil { + return nil, fmt.Errorf("unable to query project-board-notes: %w", err) + } + + if err := projectBoardNoteList.LoadAttributes(ctx); err != nil { + return nil, err + } + + return projectBoardNoteList, nil +} + +// NewBoardNote adds a new note to a given board +func NewBoardNote(ctx context.Context, projectBoardNote *BoardNote) error { + _, err := db.GetEngine(ctx).Insert(projectBoardNote) + return err +} + +// LoadAttributes prerenders the markdown content and sets the creator +func (projectBoardNoteList BoardNoteList) LoadAttributes(ctx context.Context) error { + for _, projectBoardNote := range projectBoardNoteList { + creator := new(user_model.User) + has, err := db.GetEngine(ctx).ID(projectBoardNote.CreatorID).Get(creator) + if err != nil { + return err + } + if !has { + return user_model.ErrUserNotExist{UID: projectBoardNote.CreatorID} + } + projectBoardNote.Creator = creator + + if err := projectBoardNote.LoadLabelIDs(ctx); err != nil { + return err + } + } + + return nil +} + +var ErrBoardNoteMaxPinReached = util.NewInvalidArgumentErrorf("the max number of pinned project-board-notes has been readched") + +// IsPinned returns if a BoardNote is pinned +func (projectBoardNote *BoardNote) IsPinned() bool { + return projectBoardNote.PinOrder != 0 +} + +// IsPinned returns if a BoardNote is pinned +func (projectBoardNote *BoardNote) GetMaxPinOrder(ctx context.Context) (int64, error) { + var maxPin int64 + _, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM project_board_note WHERE project_id = ?", projectBoardNote.ProjectID).Get(&maxPin) + if err != nil { + return -1, err + } + return maxPin, nil +} + +// IsPinned returns if a BoardNote is pinned +func (projectBoardNote *BoardNote) IsNewPinAllowed(ctx context.Context) bool { + maxPin, err := projectBoardNote.GetMaxPinOrder(ctx) + if err != nil { + return false + } + + // Check if the maximum allowed Pins reached + return maxPin < setting.Repository.Project.MaxPinned +} + +// Pin pins a BoardNote +func (projectBoardNote *BoardNote) Pin(ctx context.Context) error { + // If the BoardNote is already pinned, we don't need to pin it twice + if projectBoardNote.IsPinned() { + return nil + } + + maxPin, err := projectBoardNote.GetMaxPinOrder(ctx) + if err != nil { + return err + } + + // Check if the maximum allowed Pins reached + if maxPin >= setting.Repository.Project.MaxPinned { + return ErrBoardNoteMaxPinReached + } + + _, err = db.GetEngine(ctx).Table("project_board_note"). + Where("id = ?", projectBoardNote.ID). + Update(map[string]any{ + "pin_order": maxPin + 1, + }) + if err != nil { + return err + } + + return nil +} + +// Unpin unpins a BoardNote +func (projectBoardNote *BoardNote) Unpin(ctx context.Context) error { + // If the BoardNote is not pinned, we don't need to unpin it + if !projectBoardNote.IsPinned() { + return nil + } + + // This sets the Pin for all BoardNotes that come after the unpined BoardNote to the correct value + _, err := db.GetEngine(ctx).Exec("UPDATE project_board_note SET pin_order = pin_order - 1 WHERE project_id = ? AND pin_order > ?", projectBoardNote.ProjectID, projectBoardNote.PinOrder) + if err != nil { + return err + } + + _, err = db.GetEngine(ctx).Table("project_board_note"). + Where("id = ?", projectBoardNote.ID). + Update(map[string]any{ + "pin_order": 0, + }) + if err != nil { + return err + } + + return nil +} + +// MovePin moves a Pinned BoardNote to a new Position +func (projectBoardNote *BoardNote) MovePin(ctx context.Context, newPosition int64) error { + // If the BoardNote is not pinned, we can't move them + if !projectBoardNote.IsPinned() { + return nil + } + + if newPosition < 1 { + return fmt.Errorf("The Position can't be lower than 1") + } + + dbctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + maxPin, err := projectBoardNote.GetMaxPinOrder(ctx) + if err != nil { + return err + } + + // If the new Position bigger than the current Maximum, set it to the Maximum + if newPosition > maxPin+1 { + newPosition = maxPin + 1 + } + + // Lower the Position of all Pinned BoardNotes that came after the current Position + _, err = db.GetEngine(dbctx).Exec("UPDATE project_board_note SET pin_order = pin_order - 1 WHERE project_id = ? AND pin_order > ?", projectBoardNote.ProjectID, projectBoardNote.PinOrder) + if err != nil { + return err + } + + // Higher the Position of all Pinned BoardNotes that comes after the new Position + _, err = db.GetEngine(dbctx).Exec("UPDATE project_board_note SET pin_order = pin_order + 1 WHERE project_id = ? AND pin_order >= ?", projectBoardNote.ProjectID, newPosition) + if err != nil { + return err + } + + _, err = db.GetEngine(dbctx).Table("project_board_note"). + Where("id = ?", projectBoardNote.ID). + Update(map[string]any{ + "pin_order": newPosition, + }) + if err != nil { + return err + } + + return committer.Commit() +} + +// GetPinnedBoardNotes returns the pinned BaordNotes for the given Project +func GetPinnedBoardNotes(ctx context.Context, projectID int64) (BoardNoteList, error) { + projectBoardNoteList := make(BoardNoteList, 0) + + err := db.GetEngine(ctx). + Table("project_board_note"). + Where("project_id = ?", projectID). + And("pin_order > 0"). + OrderBy("pin_order"). + Find(&projectBoardNoteList) + if err != nil { + return nil, err + } + + if err := projectBoardNoteList.LoadAttributes(ctx); err != nil { + return nil, err + } + + return projectBoardNoteList, nil +} + +// GetTasks returns the amount of tasks in the project-board-notes content +func (projectBoardNote *BoardNote) GetTasks() int { + return len(markdown.MarkdownTasksRegex.FindAllStringIndex(projectBoardNote.Content, -1)) +} + +// GetTasksDone returns the amount of completed tasks in the project-board-notes content +func (projectBoardNote *BoardNote) GetTasksDone() int { + return len(markdown.MarkdownTasksDoneRegex.FindAllStringIndex(projectBoardNote.Content, -1)) +} + +// UpdateBoardNote changes a BoardNote +func UpdateBoardNote(ctx context.Context, projectBoardNote *BoardNote) error { + var fieldToUpdate []string + + fieldToUpdate = append(fieldToUpdate, "title") + fieldToUpdate = append(fieldToUpdate, "content") + fieldToUpdate = append(fieldToUpdate, "milestone_id") + + _, err := db.GetEngine(ctx).ID(projectBoardNote.ID).Cols(fieldToUpdate...).Update(projectBoardNote) + return err +} + +// MoveBoardNoteOnProjectBoard moves or keeps notes in a column and sorts them inside that column +func MoveBoardNoteOnProjectBoard(ctx context.Context, board *Board, sortedBoardNoteIDs map[int64]int64) error { + return db.WithTx(ctx, func(ctx context.Context) error { + sess := db.GetEngine(ctx) + + for sorting, issueID := range sortedBoardNoteIDs { + _, err := sess.Exec("UPDATE `project_board_note` SET board_id=?, sorting=? WHERE id=?", board.ID, sorting, issueID) + if err != nil { + return err + } + } + return nil + }) +} + +func deleteBoardNoteByProjectID(ctx context.Context, projectID int64) error { + _, err := db.GetEngine(ctx).Where("project_id=?", projectID).Delete(&BoardNote{}) + return err +} + +// DeleteBoardNote removes the BoardNote from the project board. +func DeleteBoardNote(ctx context.Context, boardNote *BoardNote) error { + if _, err := db.GetEngine(ctx).Delete(boardNote); err != nil { + return err + } + return nil +} + +// removeBoardNotes sets the boardID to 0 for the board +func (b *Board) removeBoardNotes(ctx context.Context) error { + _, err := db.GetEngine(ctx).Exec("UPDATE `project_board_note` SET board_id = 0 WHERE board_id = ?", b.ID) + return err +} diff --git a/models/project/note_label.go b/models/project/note_label.go new file mode 100644 index 0000000000000..48bbb61c7e60d --- /dev/null +++ b/models/project/note_label.go @@ -0,0 +1,70 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package project + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" +) + +// BoardNoteLabel represents an project-baord-note-label relation. +type BoardNoteLabel struct { + ID int64 `xorm:"pk autoincr"` + BoardNoteID int64 `xorm:"UNIQUE(s) NOT NULL"` + LabelID int64 `xorm:"UNIQUE(s) NOT NULL"` +} + +// TableName xorm will read the table name from this method +func (*BoardNoteLabel) TableName() string { + return "project_board_note_label" +} + +// LoadLabels loads labels +func (projectBoardNote *BoardNote) LoadLabelIDs(ctx context.Context) (err error) { + if projectBoardNote.LabelIDs != nil || len(projectBoardNote.LabelIDs) == 0 { + projectBoardNote.LabelIDs, err = GetLabelsByBoardNoteID(ctx, projectBoardNote.ID) + if err != nil { + return fmt.Errorf("GetLabelsByBoardNoteID [%d]: %w", projectBoardNote.ID, err) + } + } + return nil +} + +// LoadLabels removes all labels from project-board-note +func (projectBoardNote *BoardNote) RemoveAllLabels(ctx context.Context) error { + _, err := db.GetEngine(ctx).Where("board_note_id = ?", projectBoardNote.ID).Delete(BoardNoteLabel{}) + return err +} + +// LoadLabels add a label to project-board-note -> requires a valid labelID +func (projectBoardNote *BoardNote) AddLabel(ctx context.Context, labelID int64) error { + _, err := db.GetEngine(ctx).Insert(BoardNoteLabel{ + BoardNoteID: projectBoardNote.ID, + LabelID: labelID, + }) + return err +} + +// LoadLabels removes a label from project-board-note +func (projectBoardNote *BoardNote) RemoveLabelByID(ctx context.Context, labelID int64) error { + _, err := db.GetEngine(ctx).Delete(BoardNoteLabel{ + BoardNoteID: projectBoardNote.ID, + LabelID: labelID, + }) + return err +} + +// GetLabelsByBoardNoteID returns all labelIDs that belong to given projectBoardNote by ID. +func GetLabelsByBoardNoteID(ctx context.Context, projectBoardNoteID int64) ([]int64, error) { + var labelIDs []int64 + return labelIDs, db.GetEngine(ctx). + Table("label"). + Cols("label.id"). + Asc("label.name"). + Where("project_board_note_label.board_note_id = ?", projectBoardNoteID). + Join("INNER", "project_board_note_label", "project_board_note_label.label_id = label.id"). + Find(&labelIDs) +} diff --git a/models/project/project.go b/models/project/project.go index d2fca6cdc8a8a..9ceb6979ea125 100644 --- a/models/project/project.go +++ b/models/project/project.go @@ -85,6 +85,44 @@ func (err ErrProjectBoardNotExist) Unwrap() error { return util.ErrNotExist } +// ErrBoardNoteNotExist represents a "ProjectBoardNotExist" kind of error. +type ErrBoardNoteNotExist struct { + BoardNoteID int64 +} + +// IsErrBoardNoteNotExist checks if an error is a ErrBoardNoteNotExist +func IsErrBoardNoteNotExist(err error) bool { + _, ok := err.(ErrBoardNoteNotExist) + return ok +} + +func (err ErrBoardNoteNotExist) Error() string { + return fmt.Sprintf("project-board-note does not exist [id: %d]", err.BoardNoteID) +} + +func (err ErrBoardNoteNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrBoardNoteLabelNotExist represents a "ProjectBoardNotExist" kind of error. +type ErrBoardNoteLabelNotExist struct { + BoardNoteLabelID int64 +} + +// IsErrBoardNoteLabelNotExist checks if an error is a ErrBoardNoteLabelNotExist +func IsErrBoardNoteLabelNotExist(err error) bool { + _, ok := err.(ErrBoardNoteLabelNotExist) + return ok +} + +func (err ErrBoardNoteLabelNotExist) Error() string { + return fmt.Sprintf("project-board-note-label does not exist [id: %d]", err.BoardNoteLabelID) +} + +func (err ErrBoardNoteLabelNotExist) Unwrap() error { + return util.ErrNotExist +} + // Project represents a project board type Project struct { ID int64 `xorm:"pk autoincr"` @@ -100,7 +138,8 @@ type Project struct { CardType CardType Type Type - RenderedContent string `xorm:"-"` + RenderedContent string `xorm:"-"` + FirstPinnedBoardNote *BoardNote `xorm:"-"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` @@ -411,6 +450,10 @@ func DeleteProjectByID(ctx context.Context, id int64) error { return err } + if err := deleteBoardNoteByProjectID(ctx, id); err != nil { + return err + } + if err := deleteBoardByProjectID(ctx, id); err != nil { return err } diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index 771162b9a3f12..f2732650efc0b 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -7,6 +7,7 @@ package markdown import ( "fmt" "io" + "regexp" "strings" "sync" @@ -38,6 +39,11 @@ var ( renderConfigKey = parser.NewContextKey() ) +var ( + MarkdownTasksRegex = regexp.MustCompile(`(^\s*[-*]\s\[[\sxX]\]\s.)|(\n\s*[-*]\s\[[\sxX]\]\s.)`) + MarkdownTasksDoneRegex = regexp.MustCompile(`(^\s*[-*]\s\[[xX]\]\s.)|(\n\s*[-*]\s\[[xX]\]\s.)`) +) + type limitWriter struct { w io.Writer sum int64 diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 7990021aaa2b5..c39427e7301fc 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -94,6 +94,10 @@ var ( MaxPinned int } `ini:"repository.issue"` + Project struct { + MaxPinned int64 + } `ini:"repository.project"` + Release struct { AllowedTypes string DefaultPagingNum int @@ -237,6 +241,12 @@ var ( MaxPinned: 3, }, + Project: struct { + MaxPinned int64 + }{ + MaxPinned: 3, + }, + Release: struct { AllowedTypes string DefaultPagingNum int diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 0f397675869c4..b7de05bb87c58 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -165,6 +165,9 @@ func NewFuncMap() template.FuncMap { "RenderMarkdownToHtml": RenderMarkdownToHtml, "RenderLabel": RenderLabel, "RenderLabels": RenderLabels, + "RenderLabelsFromIDs": RenderLabelsFromIDs, + + "RenderMilestone": RenderMilestone, // ----------------------------------------------------------------- // misc diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 1d9635410b370..e680fdbdebadf 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -15,11 +15,13 @@ import ( "unicode" issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/emoji" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/util" ) @@ -224,3 +226,43 @@ func RenderLabels(ctx context.Context, labels []*issues_model.Label, repoLink st htmlCode += "" return template.HTML(htmlCode) } + +func RenderLabelsFromIDs(ctx context.Context, labelIDs []int64, repoLink string) template.HTML { + labels, err := issues_model.GetLabelsByIDs(ctx, labelIDs) + if err != nil { + log.Error("GetLabelsByIDs", err) + return "" + } + + htmlCode := `` + for _, label := range labels { + // Protect against nil value in labels - shouldn't happen but would cause a panic if so + if label == nil { + continue + } + htmlCode += fmt.Sprintf("%s ", + repoLink, label.ID, RenderLabel(ctx, label)) + } + htmlCode += "" + return template.HTML(htmlCode) +} + +func RenderMilestone(ctx context.Context, milestoneID, repoID int64, classes ...string) template.HTML { + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + log.Error("GetRepositoryByID", err) + return "" + } + + milestone, err := issues_model.GetMilestoneByRepoID(ctx, repo.ID, milestoneID) + if err != nil { + log.Error("GetMilestoneByRepoID", err) + return "" + } + + htmlCode := fmt.Sprintf("", strings.Join(classes, " "), repo.Link(), milestone.ID) + htmlCode += string(svg.RenderHTML("octicon-milestone", 16, "gt-mr-2 gt-vm")) + htmlCode += fmt.Sprintf("%s", milestone.Name) + htmlCode += "" + return template.HTML(htmlCode) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index a0ad09f776440..1491c0834cce9 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1345,7 +1345,7 @@ projects.column.set_default_desc = "Set this column as default for uncategorized projects.column.unset_default = "Unset Default" projects.column.unset_default_desc = "Unset this column as default" projects.column.delete = "Delete Column" -projects.column.deletion_desc = "Deleting a project column moves all related issues to 'Uncategorized'. Continue?" +projects.column.deletion_desc = "Deleting a project column moves all related issues and notes to 'Uncategorized'. Continue?" projects.column.color = "Color" projects.open = Open projects.close = Close @@ -1354,6 +1354,24 @@ projects.card_type.desc = "Card Previews" projects.card_type.images_and_text = "Images and Text" projects.card_type.text_only = "Text Only" +projects.note.view = View Note +projects.note.new = New Note +projects.note.title = Title +projects.note.labels = Labels +projects.note.no_label = No Label +projects.note.milestone = Milestone +projects.note.no_milestone = No Milestone +projects.note.no_milestones = No Milestones +projects.note.open_milestones = Open Milestones +projects.note.closed_milestones = Closed Milestones +projects.note.description = Description +projects.note.edit = Edit Note +projects.note.delete = Delete Note +projects.note.deletion_description = Deleting removes the note from the project column. Continue? +projects.note.created_by = created %[1]s by %[3]s +projects.note.updated_by = updated %[1]s by %[3]s +projects.note.max_pinned = You can't pin more notes + issues.desc = Organize bug reports, tasks and milestones. issues.filter_assignees = Filter Assignee issues.filter_milestones = Filter Milestone diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index f062127d24fd3..96f74468bee12 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -105,6 +105,15 @@ func Projects(ctx *context.Context) { for _, project := range projects { project.RenderedContent = project.Description + + pinnedBoardNotes, err := project_model.GetBoardNotesByProjectID(ctx, project.ID, true) + if err != nil { + ctx.ServerError("GetBoardNotesByProjectID", err) + return + } + if len(pinnedBoardNotes) > 0 { + project.FirstPinnedBoardNote = pinnedBoardNotes[0] + } } err = shared_user.LoadHeaderCount(ctx) @@ -362,6 +371,12 @@ func ViewProject(ctx *context.Context) { return } + notesMap, err := project.LoadBoardNotesFromBoardList(ctx, boards) + if err != nil { + ctx.ServerError("LoadBoardNotesOfBoards", err) + return + } + if project.CardType != project_model.CardTypeTextOnly { issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) for _, issuesList := range issuesMap { @@ -395,6 +410,12 @@ func ViewProject(ctx *context.Context) { } } + pinnedBoardNotes, err := project_model.GetPinnedBoardNotes(ctx, project.ID) + if err != nil { + ctx.ServerError("GetPinnedBoardNotes", err) + return + } + project.RenderedContent = project.Description ctx.Data["LinkedPRs"] = linkedPrsMap ctx.Data["PageIsViewProjects"] = true @@ -402,6 +423,8 @@ func ViewProject(ctx *context.Context) { ctx.Data["Project"] = project ctx.Data["IssuesMap"] = issuesMap ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend + ctx.Data["PinnedColumnNotes"] = pinnedBoardNotes + ctx.Data["ColumnNotesMap"] = notesMap shared_user.RenderUserHeader(ctx) err = shared_user.LoadHeaderCount(ctx) @@ -749,3 +772,291 @@ func MoveIssues(ctx *context.Context) { ctx.JSONOK() } + +func checkBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.BoardNote) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("ProjectNotFound", err) + } else { + ctx.ServerError("GetProjectByID", err) + } + return nil, nil + } + if project.OwnerID != ctx.ContextUser.ID { + ctx.NotFound("InvalidRepoID", nil) + return nil, nil + } + + projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID")) + if err != nil { + if project_model.IsErrBoardNoteNotExist(err) { + ctx.NotFound("BoardNoteNotFound", err) + } else { + ctx.ServerError("GetBoardNoteById", err) + } + return nil, nil + } + + if !ctx.Doer.IsAdmin && ctx.Doer.ID != projectBoardNote.CreatorID { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only the creator or an admin can perform this action.", + }) + return nil, nil + } + + if projectBoardNote.ProjectID != project.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("BoardNote[%d] is not in Project[%d] as expected", projectBoardNote.ID, project.ID), + }) + return nil, nil + } + + return project, projectBoardNote +} + +func AddBoardNoteToBoard(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + form := web.GetForm(ctx).(*forms.BoardNoteForm) + + // LabelIDs is send without parentheses - maybe because of multipart/form-data + labelIdsString := "[" + ctx.Req.FormValue("labelIds") + "]" + var labelIDs []int64 + if err := json.Unmarshal([]byte(labelIdsString), &labelIDs); err != nil { + ctx.ServerError("Unmarshal", err) + } + + // check that all LabelsIDs are valid + for _, labelID := range labelIDs { + _, err := issues_model.GetLabelByID(ctx, labelID) + if err != nil { + if issues_model.IsErrLabelNotExist(err) { + ctx.Error(http.StatusNotFound, "GetLabelByID") + } else { + ctx.ServerError("GetLabelByID", err) + } + return + } + } + + projectBoardNote := project_model.BoardNote{ + Title: form.Title, + Content: form.Content, + + ProjectID: ctx.ParamsInt64(":id"), + BoardID: ctx.ParamsInt64(":boardID"), + CreatorID: ctx.Doer.ID, + } + err := project_model.NewBoardNote(ctx, &projectBoardNote) + if err != nil { + ctx.ServerError("NewBoardNote", err) + return + } + + if len(labelIDs) > 0 { + for _, labelID := range labelIDs { + err := projectBoardNote.AddLabel(ctx, labelID) + if err != nil { + ctx.ServerError("AddLabel", err) + return + } + } + } + + ctx.JSONOK() +} + +func EditBoardNote(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.BoardNoteForm) + _, projectBoardNote := checkBoardNoteChangePermissions(ctx) + if ctx.Written() { + return + } + + projectBoardNote.Title = form.Title + projectBoardNote.Content = form.Content + + if err := project_model.UpdateBoardNote(ctx, projectBoardNote); err != nil { + ctx.ServerError("UpdateBoardNote", err) + return + } + + ctx.JSONOK() +} + +func DeleteBoardNote(ctx *context.Context) { + _, projectBoardNote := checkBoardNoteChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.DeleteBoardNote(ctx, projectBoardNote); err != nil { + ctx.ServerError("DeleteBoardNote", err) + return + } + + ctx.JSONOK() +} + +func MoveBoardNote(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("ProjectNotFound", err) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + var board *project_model.Board + + if ctx.ParamsInt64(":boardID") == 0 { + board = &project_model.Board{ + ID: 0, + ProjectID: project.ID, + Title: ctx.Tr("repo.projects.type.uncategorized"), + } + } else { + board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + if project_model.IsErrProjectBoardNotExist(err) { + ctx.NotFound("ProjectBoardNotExist", nil) + } else { + ctx.ServerError("GetProjectBoard", err) + } + return + } + if board.ProjectID != project.ID { + ctx.NotFound("BoardNotInProject", nil) + return + } + } + + type MovedBoardNotesForm struct { + BoardNotes []struct { + BoardNoteID int64 `json:"projectBoardNoteID"` + Sorting int64 `json:"sorting"` + } `json:"projectBoardNotes"` + } + + form := &MovedBoardNotesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedBoardNotesForm", err) + return + } + + projectBoardNoteIDs := make([]int64, 0, len(form.BoardNotes)) + sortedBoardNoteIDs := make(map[int64]int64) + for _, boardNote := range form.BoardNotes { + projectBoardNoteIDs = append(projectBoardNoteIDs, boardNote.BoardNoteID) + sortedBoardNoteIDs[boardNote.Sorting] = boardNote.BoardNoteID + } + movedBoardNotes, err := project_model.GetBoardNotesByIds(ctx, projectBoardNoteIDs) + if err != nil { + if project_model.IsErrBoardNoteNotExist(err) { + ctx.NotFound("BoardNoteNotExisting", nil) + } else { + ctx.ServerError("GetBoardNoteByIds", err) + } + return + } + + if len(movedBoardNotes) != len(form.BoardNotes) { + ctx.ServerError("some project-board-notes do not exist", errors.New("some project-board-notes do not exist")) + return + } + + if err = project_model.MoveBoardNoteOnProjectBoard(ctx, board, sortedBoardNoteIDs); err != nil { + ctx.ServerError("MoveBoardNoteOnProjectBoard", err) + return + } + + ctx.JSONOK() +} + +// PinBoardNote pins the BoardNote +func PinBoardNote(ctx *context.Context) { + projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID")) + if err != nil { + ctx.ServerError("GetBoardNoteByID", err) + return + } + + err = projectBoardNote.Pin(ctx) + if err != nil { + ctx.ServerError("PinBoardNote", err) + return + } + + ctx.JSONOK() +} + +// PinBoardNote unpins the BoardNote +func UnPinBoardNote(ctx *context.Context) { + projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID")) + if err != nil { + ctx.ServerError("GetBoardNoteByID", err) + return + } + + err = projectBoardNote.Unpin(ctx) + if err != nil { + ctx.ServerError("UnpinBoardNote", err) + return + } + + ctx.JSONOK() +} + +// PinBoardNote moves a pined the BoardNote +func PinMoveBoardNote(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, "Only signed in users are allowed to perform this action.") + return + } + + type MovePinBoardNoteForm struct { + Position int64 `json:"position"` + } + + form := &MovePinBoardNoteForm{} + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("Decode MovePinBoardNoteForm", err) + return + } + + projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID")) + if err != nil { + ctx.ServerError("GetBoardNoteByID", err) + return + } + + err = projectBoardNote.MovePin(ctx, form.Position) + if err != nil { + ctx.ServerError("MovePinBoardNote", err) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index cc0127e7e173a..fabd115b5d9e4 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -8,6 +8,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "strings" "code.gitea.io/gitea/models/db" @@ -101,6 +102,15 @@ func Projects(ctx *context.Context) { ctx.ServerError("RenderString", err) return } + + pinnedBoardNotes, err := project_model.GetBoardNotesByProjectID(ctx, projects[i].ID, true) + if err != nil { + ctx.ServerError("GetBoardNotesByProjectID", err) + return + } + if len(pinnedBoardNotes) > 0 { + projects[i].FirstPinnedBoardNote = pinnedBoardNotes[0] + } } ctx.Data["Projects"] = projects @@ -324,6 +334,12 @@ func ViewProject(ctx *context.Context) { return } + notesMap, err := project.LoadBoardNotesFromBoardList(ctx, boards) + if err != nil { + ctx.ServerError("LoadBoardNotesOfBoards", err) + return + } + if project.CardType != project_model.CardTypeTextOnly { issuesAttachmentMap := make(map[int64][]*attachment_model.Attachment) for _, issuesList := range issuesMap { @@ -371,11 +387,45 @@ func ViewProject(ctx *context.Context) { return } + pinnedBoardNotes, err := project_model.GetPinnedBoardNotes(ctx, project.ID) + if err != nil { + ctx.ServerError("GetPinnedBoardNotes", err) + return + } + + labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, "", db.ListOptions{}) + if err != nil { + ctx.ServerError("GetLabelsByRepoID", err) + return + } + ctx.Data["Labels"] = labels + + milestones, err := db.Find[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.ServerError("GetAllRepoMilestones", err) + return + } + + openMilestones, closedMilestones := issues_model.MilestoneList{}, issues_model.MilestoneList{} + for _, milestone := range milestones { + if milestone.IsClosed { + closedMilestones = append(closedMilestones, milestone) + } else { + openMilestones = append(openMilestones, milestone) + } + } + ctx.Data["OpenMilestones"] = openMilestones + ctx.Data["ClosedMilestones"] = closedMilestones + ctx.Data["IsProjectsPage"] = true ctx.Data["CanWriteProjects"] = ctx.Repo.Permission.CanWrite(unit.TypeProjects) ctx.Data["Project"] = project ctx.Data["IssuesMap"] = issuesMap ctx.Data["Columns"] = boards // TODO: rename boards to columns in backend + ctx.Data["PinnedColumnNotes"] = pinnedBoardNotes + ctx.Data["ColumnNotesMap"] = notesMap ctx.HTML(http.StatusOK, tplProjectsView) } @@ -661,6 +711,7 @@ func MoveIssues(ctx *context.Context) { form := &movedIssuesForm{} if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { ctx.ServerError("DecodeMovedIssuesForm", err) + return } issueIDs := make([]int64, 0, len(form.Issues)) @@ -698,3 +749,384 @@ func MoveIssues(ctx *context.Context) { ctx.JSONOK() } + +func checkBoardNoteChangePermissions(ctx *context.Context) (*project_model.Project, *project_model.BoardNote) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return nil, nil + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return nil, nil + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("ProjectNotFound", err) + } else { + ctx.ServerError("GetProjectByID", err) + } + return nil, nil + } + + projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID")) + if err != nil { + if project_model.IsErrBoardNoteNotExist(err) { + ctx.NotFound("BoardNoteNotFound", err) + } else { + ctx.ServerError("GetBoardNoteById", err) + } + return nil, nil + } + + if !ctx.Doer.IsAdmin && ctx.Doer.ID != projectBoardNote.CreatorID { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only the creator or an admin can perform this action.", + }) + return nil, nil + } + + if projectBoardNote.ProjectID != project.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("BoardNote[%d] is not in Project[%d] as expected", projectBoardNote.ID, project.ID), + }) + return nil, nil + } + + if project.RepoID != ctx.Repo.Repository.ID { + ctx.JSON(http.StatusUnprocessableEntity, map[string]string{ + "message": fmt.Sprintf("ProjectBoard[%d] is not in Repository[%d] as expected", projectBoardNote.ID, ctx.Repo.Repository.ID), + }) + return nil, nil + } + + return project, projectBoardNote +} + +func AddBoardNoteToBoard(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.BoardNoteForm) + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + // LabelIDs is send without parentheses - maybe because of multipart/form-data + labelIdsString := "[" + ctx.Req.FormValue("labelIds") + "]" + var labelIDs []int64 + if err := json.Unmarshal([]byte(labelIdsString), &labelIDs); err != nil { + ctx.ServerError("Unmarshal", err) + } + + // check that all LabelsIDs are valid + for _, labelID := range labelIDs { + _, err := issues_model.GetLabelByID(ctx, labelID) + if err != nil { + if issues_model.IsErrLabelNotExist(err) { + ctx.Error(http.StatusNotFound, "GetLabelByID") + } else { + ctx.ServerError("GetLabelByID", err) + } + return + } + } + + projectBoardNote := project_model.BoardNote{ + Title: form.Title, + Content: form.Content, + MilestoneID: form.MilestoneID, + + ProjectID: ctx.ParamsInt64(":id"), + BoardID: ctx.ParamsInt64(":boardID"), + CreatorID: ctx.Doer.ID, + } + err := project_model.NewBoardNote(ctx, &projectBoardNote) + if err != nil { + ctx.ServerError("NewBoardNote", err) + return + } + + if len(labelIDs) > 0 { + for _, labelID := range labelIDs { + err := projectBoardNote.AddLabel(ctx, labelID) + if err != nil { + ctx.ServerError("AddLabel", err) + return + } + } + } + + ctx.JSONOK() +} + +func findNewAndRemovedIDs(original, updated []int64) (newValues, removedValues []int64) { + if original == nil { + return updated, nil + } + if updated == nil { + return nil, nil + } + + for _, v := range updated { + if !slices.Contains(original, v) { + // If the value is not in the original map, it's new + newValues = append(newValues, v) + } + } + + for _, v := range original { + if !slices.Contains(updated, v) { + // If the value is not in the updated map, it's removed + removedValues = append(removedValues, v) + } + } + + return newValues, removedValues +} + +func EditBoardNote(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.BoardNoteForm) + _, projectBoardNote := checkBoardNoteChangePermissions(ctx) + if ctx.Written() { + return + } + + projectBoardNote.Title = form.Title + projectBoardNote.Content = form.Content + projectBoardNote.MilestoneID = form.MilestoneID + + err := projectBoardNote.LoadLabelIDs(ctx) + if err != nil { + ctx.ServerError("LoadLabelIDs", err) + } + + if err := project_model.UpdateBoardNote(ctx, projectBoardNote); err != nil { + ctx.ServerError("UpdateBoardNote", err) + return + } + + // LabelIDs is send without parentheses - maybe because of multipart/form-data + labelIdsString := "[" + ctx.Req.FormValue("labelIds") + "]" + var labelIDs []int64 + if err := json.Unmarshal([]byte(labelIdsString), &labelIDs); err != nil { + ctx.ServerError("Unmarshal", err) + } + + newLabelIDs, removedLabelIDs := findNewAndRemovedIDs(projectBoardNote.LabelIDs, labelIDs) + + for _, labelID := range newLabelIDs { + label, err := issues_model.GetLabelByID(ctx, labelID) + if err != nil { + if issues_model.IsErrLabelNotExist(err) { + ctx.Error(http.StatusNotFound, "GetLabelByID") + } else { + ctx.ServerError("GetLabelByID", err) + } + return + } + + err = projectBoardNote.AddLabel(ctx, label.ID) + if err != nil { + ctx.ServerError("AddLabel", err) + return + } + } + + for _, labelID := range removedLabelIDs { + label, err := issues_model.GetLabelByID(ctx, labelID) + if err != nil { + if issues_model.IsErrLabelNotExist(err) { + ctx.Error(http.StatusNotFound, "GetLabelByID") + } else { + ctx.ServerError("GetLabelByID", err) + } + return + } + + err = projectBoardNote.RemoveLabelByID(ctx, label.ID) + if err != nil { + ctx.ServerError("RemoveLabelByID", err) + return + } + } + + ctx.JSONOK() +} + +func DeleteBoardNote(ctx *context.Context) { + _, projectBoardNote := checkBoardNoteChangePermissions(ctx) + if ctx.Written() { + return + } + + if err := project_model.DeleteBoardNote(ctx, projectBoardNote); err != nil { + ctx.ServerError("DeleteBoardNote", err) + return + } + + ctx.JSONOK() +} + +func MoveBoardNote(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only signed in users are allowed to perform this action.", + }) + return + } + + if !ctx.Repo.IsOwner() && !ctx.Repo.IsAdmin() && !ctx.Repo.CanAccess(perm.AccessModeWrite, unit.TypeProjects) { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only authorized users are allowed to perform this action.", + }) + return + } + + project, err := project_model.GetProjectByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if project_model.IsErrProjectNotExist(err) { + ctx.NotFound("ProjectNotFound", err) + } else { + ctx.ServerError("GetProjectByID", err) + } + return + } + + var board *project_model.Board + + if ctx.ParamsInt64(":boardID") == 0 { + board = &project_model.Board{ + ID: 0, + ProjectID: project.ID, + Title: ctx.Tr("repo.projects.type.uncategorized"), + } + } else { + board, err = project_model.GetBoard(ctx, ctx.ParamsInt64(":boardID")) + if err != nil { + if project_model.IsErrProjectBoardNotExist(err) { + ctx.NotFound("ProjectBoardNotExist", nil) + } else { + ctx.ServerError("GetProjectBoard", err) + } + return + } + if board.ProjectID != project.ID { + ctx.NotFound("BoardNotInProject", nil) + return + } + } + + type MovedBoardNotesForm struct { + BoardNotes []struct { + BoardNoteID int64 `json:"projectBoardNoteID"` + Sorting int64 `json:"sorting"` + } `json:"projectBoardNotes"` + } + + form := &MovedBoardNotesForm{} + if err = json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodeMovedBoardNotesForm", err) + return + } + + projectBoardNoteIDs := make([]int64, 0, len(form.BoardNotes)) + sortedBoardNoteIDs := make(map[int64]int64) + for _, boardNote := range form.BoardNotes { + projectBoardNoteIDs = append(projectBoardNoteIDs, boardNote.BoardNoteID) + sortedBoardNoteIDs[boardNote.Sorting] = boardNote.BoardNoteID + } + movedBoardNotes, err := project_model.GetBoardNotesByIds(ctx, projectBoardNoteIDs) + if err != nil { + if project_model.IsErrBoardNoteNotExist(err) { + ctx.NotFound("BoardNoteNotExisting", nil) + } else { + ctx.ServerError("GetBoardNoteByIds", err) + } + return + } + + if len(movedBoardNotes) != len(form.BoardNotes) { + ctx.ServerError("some project-board-notes do not exist", errors.New("some project-board-notes do not exist")) + return + } + + if err = project_model.MoveBoardNoteOnProjectBoard(ctx, board, sortedBoardNoteIDs); err != nil { + ctx.ServerError("MoveBoardNoteOnProjectBoard", err) + return + } + + ctx.JSONOK() +} + +// PinBoardNote pins the BoardNote +func PinBoardNote(ctx *context.Context) { + projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID")) + if err != nil { + ctx.ServerError("GetBoardNoteByID", err) + return + } + + err = projectBoardNote.Pin(ctx) + if err != nil { + ctx.ServerError("PinBoardNote", err) + return + } + + ctx.JSONOK() +} + +// PinBoardNote unpins the BoardNote +func UnPinBoardNote(ctx *context.Context) { + projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID")) + if err != nil { + ctx.ServerError("GetBoardNoteByID", err) + return + } + + err = projectBoardNote.Unpin(ctx) + if err != nil { + ctx.ServerError("UnpinBoardNote", err) + return + } + + ctx.JSONOK() +} + +// PinBoardNote moves a pined the BoardNote +func PinMoveBoardNote(ctx *context.Context) { + if ctx.Doer == nil { + ctx.JSON(http.StatusForbidden, "Only signed in users are allowed to perform this action.") + return + } + + type MovePinBoardNoteForm struct { + Position int64 `json:"position"` + } + + form := &MovePinBoardNoteForm{} + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("Decode MovePinBoardNoteForm", err) + return + } + + projectBoardNote, err := project_model.GetBoardNoteByID(ctx, ctx.ParamsInt64(":noteID")) + if err != nil { + ctx.ServerError("GetBoardNoteByID", err) + return + } + + err = projectBoardNote.MovePin(ctx, form.Position) + if err != nil { + ctx.ServerError("MovePinBoardNote", err) + return + } + + ctx.JSONOK() +} diff --git a/routers/web/web.go b/routers/web/web.go index b1fa5cf3550d3..fc900e732b204 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1006,6 +1006,22 @@ func registerRoutes(m *web.Route) { m.Post("/unsetdefault", org.UnsetDefaultProjectBoard) m.Post("/move", org.MoveIssues) + + m.Group("/note", func() { + m.Post("", web.Bind(forms.BoardNoteForm{}), org.AddBoardNoteToBoard) + m.Post("/move", org.MoveBoardNote) + + m.Group("/{noteID}", func() { + m.Put("", web.Bind(forms.BoardNoteForm{}), org.EditBoardNote) + m.Delete("", org.DeleteBoardNote) + + m.Group("/pin", func() { + m.Post("", web.Bind(forms.BoardNoteForm{}), org.PinBoardNote) + m.Delete("", org.UnPinBoardNote) + m.Post("/move", org.PinMoveBoardNote) + }) + }) + }) }) }) }, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), func(ctx *context.Context) { @@ -1344,6 +1360,22 @@ func registerRoutes(m *web.Route) { m.Post("/unsetdefault", repo.UnSetDefaultProjectBoard) m.Post("/move", repo.MoveIssues) + + m.Group("/note", func() { + m.Post("", web.Bind(forms.BoardNoteForm{}), repo.AddBoardNoteToBoard) + m.Post("/move", repo.MoveBoardNote) + + m.Group("/{noteID}", func() { + m.Put("", web.Bind(forms.BoardNoteForm{}), repo.EditBoardNote) + m.Delete("", repo.DeleteBoardNote) + + m.Group("/pin", func() { + m.Post("", web.Bind(forms.BoardNoteForm{}), repo.PinBoardNote) + m.Delete("", repo.UnPinBoardNote) + m.Post("/move", repo.PinMoveBoardNote) + }) + }) + }) }) }) }, reqRepoProjectsWriter, context.RepoMustNotBeArchived()) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 98b8d610d0abd..1eae5e55b7716 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -534,6 +534,13 @@ type EditProjectBoardForm struct { Color string `binding:"MaxSize(7)"` } +// BoardNoteForm is a form for editing/creating a note to a board +type BoardNoteForm struct { + Title string `binding:"Required;MaxSize(255)"` + Content string + MilestoneID int64 `form:"milestoneId"` +} + // _____ .__.__ __ // / \ |__| | ____ _______/ |_ ____ ____ ____ // / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \ diff --git a/templates/projects/list.tmpl b/templates/projects/list.tmpl index cbff82dd702f3..21c03b58dbea6 100644 --- a/templates/projects/list.tmpl +++ b/templates/projects/list.tmpl @@ -45,39 +45,50 @@