Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions proto/api/v1/user_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ message UserStats {
int32 code_count = 2;
int32 todo_count = 3;
int32 undo_count = 4;
int32 due_date_count = 5;
}
}

Expand Down
15 changes: 12 additions & 3 deletions proto/gen/api/v1/user_service.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions proto/gen/apidocs.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2561,6 +2561,9 @@ definitions:
undoCount:
type: integer
format: int32
dueDateCount:
type: integer
format: int32
description: Memo type statistics.
WorkspaceStorageSettingS3Config:
type: object
Expand Down
16 changes: 13 additions & 3 deletions proto/gen/store/memo.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions proto/store/memo.proto
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ message MemoPayload {
bool has_incomplete_tasks = 4;
// The references of the memo. Should be a list of uuid.
repeated string references = 5;
bool has_due_date = 6;
}

message Location {
Expand Down
8 changes: 8 additions & 0 deletions server/router/api/v1/memo_service_filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ func (s *APIV1Service) buildMemoFindWithFilter(ctx context.Context, find *store.
if filterExpr.HasIncompleteTasks {
find.PayloadFind.HasIncompleteTasks = true
}
if filterExpr.HasDueDate {
find.PayloadFind.HasDueDate = true
}
}
return nil
}
Expand All @@ -83,6 +86,7 @@ var MemoFilterCELAttributes = []cel.EnvOption{
cel.Variable("has_task_list", cel.BoolType),
cel.Variable("has_code", cel.BoolType),
cel.Variable("has_incomplete_tasks", cel.BoolType),
cel.Variable("has_due_date", cel.BoolType),
}

type MemoFilter struct {
Expand All @@ -95,6 +99,7 @@ type MemoFilter struct {
HasTaskList bool
HasCode bool
HasIncompleteTasks bool
HasDueDate bool
}

func parseMemoFilter(expression string) (*MemoFilter, error) {
Expand Down Expand Up @@ -155,6 +160,9 @@ func findMemoField(callExpr *exprv1.Expr_Call, filter *MemoFilter) {
} else if idExpr.Name == "has_incomplete_tasks" {
value := callExpr.Args[1].GetConstExpr().GetBoolValue()
filter.HasIncompleteTasks = value
} else if idExpr.Name == "has_due_date" {
value := callExpr.Args[1].GetConstExpr().GetBoolValue()
filter.HasDueDate = value
}
return
}
Expand Down
13 changes: 9 additions & 4 deletions server/router/api/v1/user_service_stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt
codeCount := int32(0)
todoCount := int32(0)
undoCount := int32(0)
dueDateCount := int32(0)
pinnedMemos := []string{}

for _, memo := range memos {
Expand All @@ -143,6 +144,9 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt
if memo.Payload.Property.HasIncompleteTasks {
undoCount++
}
if memo.Payload.Property.HasDueDate {
dueDateCount++
}
}
}
if memo.Pinned {
Expand All @@ -157,10 +161,11 @@ func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserSt
PinnedMemos: pinnedMemos,
TotalMemoCount: int32(len(memos)),
MemoTypeStats: &v1pb.UserStats_MemoTypeStats{
LinkCount: linkCount,
CodeCount: codeCount,
TodoCount: todoCount,
UndoCount: undoCount,
LinkCount: linkCount,
CodeCount: codeCount,
TodoCount: todoCount,
UndoCount: undoCount,
DueDateCount: dueDateCount,
},
}

Expand Down
50 changes: 50 additions & 0 deletions server/runner/memopayload/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package memopayload
import (
"context"
"log/slog"
"regexp"
"slices"

"github.com/pkg/errors"
Expand Down Expand Up @@ -104,6 +105,10 @@ func RebuildMemoPayload(memo *store.Memo) error {
property.References = append(property.References, n.ResourceName)
}
})

// Check for patterns in raw content
property.HasDueDate = hasDueDate(memo.Content)

memo.Payload.Tags = tags
memo.Payload.Property = property
return nil
Expand Down Expand Up @@ -132,3 +137,48 @@ func TraverseASTNodes(nodes []ast.Node, fn func(ast.Node)) {
}
}
}

// hasDueDate checks if the content contains a valid due date pattern @due(YYYY-MM-DD).
func hasDueDate(content string) bool {
// Regular expression to match @due(YYYY-MM-DD) format
// This pattern ensures:
// - @due( prefix
// - 4 digit year
// - - separator
// - 2 digit month (01-12)
// - - separator
// - 2 digit day (01-31)
// - ) suffix
dueDatePattern := regexp.MustCompile(`@due\((\d{4})-(\d{2})-(\d{2})\)`)
Copy link
Preview

Copilot AI Jul 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compiling the regex on every invocation can be expensive. Move MustCompile to a package-level var so it’s compiled once.

Suggested change
dueDatePattern := regexp.MustCompile(`@due\((\d{4})-(\d{2})-(\d{2})\)`)

Copilot uses AI. Check for mistakes.

matches := dueDatePattern.FindAllStringSubmatch(content, -1)

if len(matches) == 0 {
return false
}

// Validate each match to ensure it's a reasonable date
for _, match := range matches {
if len(match) < 4 {
continue
}
year := match[1]
month := match[2]
day := match[3]

// Basic validation for reasonable date ranges
if year < "1900" || year > "2100" {
continue
}
if month < "01" || month > "12" {
continue
}
if day < "01" || day > "31" {
continue
}

// Found at least one valid due date
return true
}

return false
}
111 changes: 111 additions & 0 deletions server/runner/memopayload/runner_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package memopayload

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/usememos/memos/store"
)

func TestDueDateDetection(t *testing.T) {
tests := []struct {
name string
content string
wantDueDate bool
}{
{
name: "memo with due date",
content: "This is a memo with due date @due(2025-01-15)",
wantDueDate: true,
},
{
name: "memo with due date at beginning",
content: "@due(2025-12-31) This memo has a due date at the beginning",
wantDueDate: true,
},
{
name: "memo with due date in middle",
content: "Meeting prep @due(2025-01-15) for the quarterly review",
wantDueDate: true,
},
{
name: "memo with task and due date",
content: "- [ ] Complete project @due(2025-01-10)",
wantDueDate: true,
},
{
name: "memo with multiple due dates",
content: "Task 1 @due(2025-01-10) and Task 2 @due(2025-01-15)",
wantDueDate: true,
},
{
name: "memo without due date",
content: "This is a regular memo without any due date",
wantDueDate: false,
},
{
name: "memo with malformed due date",
content: "This has a malformed @due(not-a-date) pattern",
wantDueDate: false,
},
{
name: "memo with partial due date pattern",
content: "This mentions @due but not complete pattern",
wantDueDate: false,
},
{
name: "memo with due date in code block",
content: "```\n@due(2025-01-15)\n```",
wantDueDate: true, // Should still detect even in code blocks
},
{
name: "empty memo",
content: "",
wantDueDate: false,
},
{
name: "memo with invalid date format",
content: "Invalid date @due(25-01-15)",
wantDueDate: false,
},
{
name: "memo with valid date formats",
content: "Valid dates @due(2025-01-15) and @due(2025-12-31)",
wantDueDate: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
memo := &store.Memo{
Content: tt.content,
}

err := RebuildMemoPayload(memo)
require.NoError(t, err)
require.NotNil(t, memo.Payload)
require.NotNil(t, memo.Payload.Property)

require.Equal(t, tt.wantDueDate, memo.Payload.Property.HasDueDate,
"Expected HasDueDate to be %v for content: %s", tt.wantDueDate, tt.content)
})
}
}

func TestDueDateDetectionWithOtherProperties(t *testing.T) {
memo := &store.Memo{
Content: "Check out https://example.com and complete task @due(2025-01-15)\n\n- [ ] incomplete task",
}

err := RebuildMemoPayload(memo)
require.NoError(t, err)
require.NotNil(t, memo.Payload)
require.NotNil(t, memo.Payload.Property)

// Should detect due date along with other properties
require.True(t, memo.Payload.Property.HasDueDate)
require.True(t, memo.Payload.Property.HasLink)
require.True(t, memo.Payload.Property.HasTaskList)
require.True(t, memo.Payload.Property.HasIncompleteTasks)
}
3 changes: 3 additions & 0 deletions store/db/mysql/memo.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo
if v.HasIncompleteTasks {
where = append(where, "JSON_EXTRACT(`memo`.`payload`, '$.property.hasIncompleteTasks') IS TRUE")
}
if v.HasDueDate {
where = append(where, "JSON_EXTRACT(`memo`.`payload`, '$.property.hasDueDate') IS TRUE")
Copy link
Preview

Copilot AI Jul 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MySQL JSON path must reference has_due_date instead of hasDueDate to align with the JSON tag in protobuf.

Suggested change
where = append(where, "JSON_EXTRACT(`memo`.`payload`, '$.property.hasDueDate') IS TRUE")
where = append(where, "JSON_EXTRACT(`memo`.`payload`, '$.property.has_due_date') IS TRUE")

Copilot uses AI. Check for mistakes.

}
}
if v := find.Filter; v != nil {
// Parse filter string and return the parsed expression.
Expand Down
3 changes: 3 additions & 0 deletions store/db/postgres/memo.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ func (d *DB) ListMemos(ctx context.Context, find *store.FindMemo) ([]*store.Memo
if v.HasIncompleteTasks {
where = append(where, "(memo.payload->'property'->>'hasIncompleteTasks')::BOOLEAN IS TRUE")
}
if v.HasDueDate {
where = append(where, "(memo.payload->'property'->>'hasDueDate')::BOOLEAN IS TRUE")
Copy link
Preview

Copilot AI Jul 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Postgres JSON operator should use 'has_due_date' to match the protobuf JSON field. Change ->>'hasDueDate' to ->>'has_due_date'.

Suggested change
where = append(where, "(memo.payload->'property'->>'hasDueDate')::BOOLEAN IS TRUE")
where = append(where, "(memo.payload->'property'->>'has_due_date')::BOOLEAN IS TRUE")

Copilot uses AI. Check for mistakes.

}
}
if v := find.Filter; v != nil {
// Parse filter string and return the parsed expression.
Expand Down
Loading