mirror of https://github.com/usememos/memos.git
feat: allow setting custom timestamps when creating memos and comments
Allow API users to set custom create_time, update_time, and display_time when creating memos and comments. This enables importing historical data with accurate timestamps. Changes: - Update proto definitions: change create_time and update_time from OUTPUT_ONLY to OPTIONAL to allow setting on creation - Modify CreateMemo service to handle custom timestamps from request - Update database drivers (SQLite, MySQL, PostgreSQL) to support inserting custom timestamps when provided - Add comprehensive test coverage for custom timestamp functionality - Maintain backward compatibility: auto-generated timestamps still work when custom values are not provided - Fix golangci-lint issues in plugin/filter (godot and revive) Fixes #5483
This commit is contained in:
parent
cbf46a2988
commit
dc7ec8a8ad
|
|
@ -466,14 +466,14 @@ func detectComprehensionKind(comp *exprv1.Expr_Comprehension) (ComprehensionKind
|
|||
}
|
||||
|
||||
// exists() starts with false and uses OR (||) in loop step
|
||||
if accuInit.GetBoolValue() == false {
|
||||
if !accuInit.GetBoolValue() {
|
||||
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_||_" {
|
||||
return ComprehensionExists, nil
|
||||
}
|
||||
}
|
||||
|
||||
// all() starts with true and uses AND (&&) - not supported
|
||||
if accuInit.GetBoolValue() == true {
|
||||
if accuInit.GetBoolValue() {
|
||||
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_&&_" {
|
||||
return "", errors.New("all() comprehension is not supported; use exists() instead")
|
||||
}
|
||||
|
|
@ -483,7 +483,7 @@ func detectComprehensionKind(comp *exprv1.Expr_Comprehension) (ComprehensionKind
|
|||
}
|
||||
|
||||
// extractPredicate extracts the predicate expression from the comprehension loop step.
|
||||
func extractPredicate(comp *exprv1.Expr_Comprehension, schema Schema) (PredicateExpr, error) {
|
||||
func extractPredicate(comp *exprv1.Expr_Comprehension, _ Schema) (PredicateExpr, error) {
|
||||
// The loop step is: @result || predicate(t) for exists
|
||||
// or: @result && predicate(t) for all
|
||||
step := comp.LoopStep.GetCallExpr()
|
||||
|
|
|
|||
|
|
@ -486,7 +486,7 @@ func (r *renderer) renderListComprehension(cond *ListComprehensionCondition) (re
|
|||
}
|
||||
}
|
||||
|
||||
// renderTagStartsWith generates SQL for tags.exists(t, t.startsWith("prefix"))
|
||||
// renderTagStartsWith generates SQL for tags.exists(t, t.startsWith("prefix")).
|
||||
func (r *renderer) renderTagStartsWith(field Field, prefix string, _ ComprehensionKind) (renderResult, error) {
|
||||
arrayExpr := jsonArrayExpr(r.dialect, field)
|
||||
|
||||
|
|
@ -510,7 +510,7 @@ func (r *renderer) renderTagStartsWith(field Field, prefix string, _ Comprehensi
|
|||
}
|
||||
}
|
||||
|
||||
// renderTagEndsWith generates SQL for tags.exists(t, t.endsWith("suffix"))
|
||||
// renderTagEndsWith generates SQL for tags.exists(t, t.endsWith("suffix")).
|
||||
func (r *renderer) renderTagEndsWith(field Field, suffix string, _ ComprehensionKind) (renderResult, error) {
|
||||
arrayExpr := jsonArrayExpr(r.dialect, field)
|
||||
pattern := fmt.Sprintf(`%%%s"%%`, suffix)
|
||||
|
|
@ -519,7 +519,7 @@ func (r *renderer) renderTagEndsWith(field Field, suffix string, _ Comprehension
|
|||
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, likeExpr)}, nil
|
||||
}
|
||||
|
||||
// renderTagContains generates SQL for tags.exists(t, t.contains("substring"))
|
||||
// renderTagContains generates SQL for tags.exists(t, t.contains("substring")).
|
||||
func (r *renderer) renderTagContains(field Field, substring string, _ ComprehensionKind) (renderResult, error) {
|
||||
arrayExpr := jsonArrayExpr(r.dialect, field)
|
||||
pattern := fmt.Sprintf(`%%%s%%`, substring)
|
||||
|
|
|
|||
|
|
@ -173,11 +173,13 @@ message Memo {
|
|||
(google.api.resource_reference) = {type: "memos.api.v1/User"}
|
||||
];
|
||||
|
||||
// Output only. The creation timestamp.
|
||||
google.protobuf.Timestamp create_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
// The creation timestamp.
|
||||
// If not set on creation, the server will set it to the current time.
|
||||
google.protobuf.Timestamp create_time = 4 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// Output only. The last update timestamp.
|
||||
google.protobuf.Timestamp update_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
|
||||
// The last update timestamp.
|
||||
// If not set on creation, the server will set it to the current time.
|
||||
google.protobuf.Timestamp update_time = 5 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
||||
// The display timestamp of the memo.
|
||||
google.protobuf.Timestamp display_time = 6 [(google.api.field_behavior) = OPTIONAL];
|
||||
|
|
|
|||
|
|
@ -222,9 +222,11 @@ type Memo struct {
|
|||
// The name of the creator.
|
||||
// Format: users/{user}
|
||||
Creator string `protobuf:"bytes,3,opt,name=creator,proto3" json:"creator,omitempty"`
|
||||
// Output only. The creation timestamp.
|
||||
// The creation timestamp.
|
||||
// If not set on creation, the server will set it to the current time.
|
||||
CreateTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
|
||||
// Output only. The last update timestamp.
|
||||
// The last update timestamp.
|
||||
// If not set on creation, the server will set it to the current time.
|
||||
UpdateTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=update_time,json=updateTime,proto3" json:"update_time,omitempty"`
|
||||
// The display timestamp of the memo.
|
||||
DisplayTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=display_time,json=displayTime,proto3" json:"display_time,omitempty"`
|
||||
|
|
@ -1816,9 +1818,9 @@ const file_api_v1_memo_service_proto_rawDesc = "" +
|
|||
"\x05state\x18\x02 \x01(\x0e2\x13.memos.api.v1.StateB\x03\xe0A\x02R\x05state\x123\n" +
|
||||
"\acreator\x18\x03 \x01(\tB\x19\xe0A\x03\xfaA\x13\n" +
|
||||
"\x11memos.api.v1/UserR\acreator\x12@\n" +
|
||||
"\vcreate_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
|
||||
"\vcreate_time\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x01R\n" +
|
||||
"createTime\x12@\n" +
|
||||
"\vupdate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" +
|
||||
"\vupdate_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x01R\n" +
|
||||
"updateTime\x12B\n" +
|
||||
"\fdisplay_time\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x01R\vdisplayTime\x12\x1d\n" +
|
||||
"\acontent\x18\a \x01(\tB\x03\xe0A\x02R\acontent\x12=\n" +
|
||||
|
|
|
|||
|
|
@ -2470,14 +2470,16 @@ components:
|
|||
The name of the creator.
|
||||
Format: users/{user}
|
||||
createTime:
|
||||
readOnly: true
|
||||
type: string
|
||||
description: Output only. The creation timestamp.
|
||||
description: |-
|
||||
The creation timestamp.
|
||||
If not set on creation, the server will set it to the current time.
|
||||
format: date-time
|
||||
updateTime:
|
||||
readOnly: true
|
||||
type: string
|
||||
description: Output only. The last update timestamp.
|
||||
description: |-
|
||||
The last update timestamp.
|
||||
If not set on creation, the server will set it to the current time.
|
||||
format: date-time
|
||||
displayTime:
|
||||
type: string
|
||||
|
|
|
|||
|
|
@ -45,10 +45,35 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR
|
|||
Content: request.Memo.Content,
|
||||
Visibility: convertVisibilityToStore(request.Memo.Visibility),
|
||||
}
|
||||
|
||||
instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get instance memo related setting")
|
||||
}
|
||||
|
||||
// Handle display_time first: if provided, use it to set the appropriate timestamp
|
||||
// based on the instance setting (similar to UpdateMemo logic)
|
||||
// Note: explicit create_time/update_time below will override this if provided
|
||||
if request.Memo.DisplayTime != nil && request.Memo.DisplayTime.IsValid() {
|
||||
displayTs := request.Memo.DisplayTime.AsTime().Unix()
|
||||
if instanceMemoRelatedSetting.DisplayWithUpdateTime {
|
||||
create.UpdatedTs = displayTs
|
||||
} else {
|
||||
create.CreatedTs = displayTs
|
||||
}
|
||||
}
|
||||
|
||||
// Set custom timestamps if provided in the request
|
||||
// These take precedence over display_time
|
||||
if request.Memo.CreateTime != nil && request.Memo.CreateTime.IsValid() {
|
||||
createdTs := request.Memo.CreateTime.AsTime().Unix()
|
||||
create.CreatedTs = createdTs
|
||||
}
|
||||
if request.Memo.UpdateTime != nil && request.Memo.UpdateTime.IsValid() {
|
||||
updatedTs := request.Memo.UpdateTime.AsTime().Unix()
|
||||
create.UpdatedTs = updatedTs
|
||||
}
|
||||
|
||||
if instanceMemoRelatedSetting.DisallowPublicVisibility && create.Visibility == store.Public {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "disable public memos system setting is enabled")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import (
|
|||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
|
||||
)
|
||||
|
|
@ -250,3 +252,118 @@ func TestListMemos(t *testing.T) {
|
|||
require.NotNil(t, userTwoReaction)
|
||||
require.Equal(t, "👍", userTwoReaction.ReactionType)
|
||||
}
|
||||
|
||||
// TestCreateMemoWithCustomTimestamps tests that custom timestamps can be set when creating memos and comments.
|
||||
// This addresses issue #5483: https://github.com/usememos/memos/issues/5483
|
||||
func TestCreateMemoWithCustomTimestamps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Create a test user
|
||||
user, err := ts.CreateRegularUser(ctx, "test-user-timestamps")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
|
||||
userCtx := ts.CreateUserContext(ctx, user.ID)
|
||||
|
||||
// Define custom timestamps (January 1, 2020)
|
||||
customCreateTime := time.Date(2020, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
customUpdateTime := time.Date(2020, 1, 2, 12, 0, 0, 0, time.UTC)
|
||||
customDisplayTime := time.Date(2020, 1, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Test 1: Create a memo with custom create_time
|
||||
memoWithCreateTime, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{
|
||||
Content: "This memo has a custom creation time",
|
||||
Visibility: apiv1.Visibility_PRIVATE,
|
||||
CreateTime: timestamppb.New(customCreateTime),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, memoWithCreateTime)
|
||||
require.Equal(t, customCreateTime.Unix(), memoWithCreateTime.CreateTime.AsTime().Unix(), "create_time should match the custom timestamp")
|
||||
|
||||
// Test 2: Create a memo with custom update_time
|
||||
memoWithUpdateTime, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{
|
||||
Content: "This memo has a custom update time",
|
||||
Visibility: apiv1.Visibility_PRIVATE,
|
||||
UpdateTime: timestamppb.New(customUpdateTime),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, memoWithUpdateTime)
|
||||
require.Equal(t, customUpdateTime.Unix(), memoWithUpdateTime.UpdateTime.AsTime().Unix(), "update_time should match the custom timestamp")
|
||||
|
||||
// Test 3: Create a memo with custom display_time
|
||||
// Note: display_time is computed from either created_ts or updated_ts based on instance setting
|
||||
// Since DisplayWithUpdateTime defaults to false, display_time maps to created_ts
|
||||
memoWithDisplayTime, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{
|
||||
Content: "This memo has a custom display time",
|
||||
Visibility: apiv1.Visibility_PRIVATE,
|
||||
DisplayTime: timestamppb.New(customDisplayTime),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, memoWithDisplayTime)
|
||||
// Since DisplayWithUpdateTime is false by default, display_time sets created_ts
|
||||
require.Equal(t, customDisplayTime.Unix(), memoWithDisplayTime.DisplayTime.AsTime().Unix(), "display_time should match the custom timestamp")
|
||||
require.Equal(t, customDisplayTime.Unix(), memoWithDisplayTime.CreateTime.AsTime().Unix(), "create_time should also match since display_time maps to created_ts")
|
||||
|
||||
// Test 4: Create a memo with all custom timestamps
|
||||
// When both display_time and create_time are provided, create_time takes precedence
|
||||
memoWithAllTimestamps, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{
|
||||
Content: "This memo has all custom timestamps",
|
||||
Visibility: apiv1.Visibility_PRIVATE,
|
||||
CreateTime: timestamppb.New(customCreateTime),
|
||||
UpdateTime: timestamppb.New(customUpdateTime),
|
||||
DisplayTime: timestamppb.New(customDisplayTime),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, memoWithAllTimestamps)
|
||||
require.Equal(t, customCreateTime.Unix(), memoWithAllTimestamps.CreateTime.AsTime().Unix(), "create_time should match the custom timestamp")
|
||||
require.Equal(t, customUpdateTime.Unix(), memoWithAllTimestamps.UpdateTime.AsTime().Unix(), "update_time should match the custom timestamp")
|
||||
// display_time is computed from created_ts when DisplayWithUpdateTime is false
|
||||
require.Equal(t, customCreateTime.Unix(), memoWithAllTimestamps.DisplayTime.AsTime().Unix(), "display_time should be derived from create_time")
|
||||
|
||||
// Test 5: Create a comment (memo relation) with custom timestamps
|
||||
parentMemo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{
|
||||
Content: "This is the parent memo",
|
||||
Visibility: apiv1.Visibility_PRIVATE,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, parentMemo)
|
||||
|
||||
customCommentCreateTime := time.Date(2021, 6, 15, 10, 30, 0, 0, time.UTC)
|
||||
comment, err := ts.Service.CreateMemoComment(userCtx, &apiv1.CreateMemoCommentRequest{
|
||||
Name: parentMemo.Name,
|
||||
Comment: &apiv1.Memo{
|
||||
Content: "This is a comment with custom create time",
|
||||
Visibility: apiv1.Visibility_PRIVATE,
|
||||
CreateTime: timestamppb.New(customCommentCreateTime),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, comment)
|
||||
require.Equal(t, customCommentCreateTime.Unix(), comment.CreateTime.AsTime().Unix(), "comment create_time should match the custom timestamp")
|
||||
|
||||
// Test 6: Verify that memos without custom timestamps still get auto-generated ones
|
||||
memoWithoutTimestamps, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
|
||||
Memo: &apiv1.Memo{
|
||||
Content: "This memo has auto-generated timestamps",
|
||||
Visibility: apiv1.Visibility_PRIVATE,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, memoWithoutTimestamps)
|
||||
require.NotNil(t, memoWithoutTimestamps.CreateTime, "create_time should be auto-generated")
|
||||
require.NotNil(t, memoWithoutTimestamps.UpdateTime, "update_time should be auto-generated")
|
||||
require.True(t, time.Now().Unix()-memoWithoutTimestamps.CreateTime.AsTime().Unix() < 5, "create_time should be recent (within 5 seconds)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ func (d *DB) CreateMemo(ctx context.Context, create *store.Memo) (*store.Memo, e
|
|||
}
|
||||
args := []any{create.UID, create.CreatorID, create.Content, create.Visibility, payload}
|
||||
|
||||
// Add custom timestamps if provided
|
||||
if create.CreatedTs != 0 {
|
||||
fields = append(fields, "`created_ts`")
|
||||
placeholder = append(placeholder, "?")
|
||||
args = append(args, create.CreatedTs)
|
||||
}
|
||||
if create.UpdatedTs != 0 {
|
||||
fields = append(fields, "`updated_ts`")
|
||||
placeholder = append(placeholder, "?")
|
||||
args = append(args, create.UpdatedTs)
|
||||
}
|
||||
|
||||
stmt := "INSERT INTO `memo` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ")"
|
||||
result, err := d.db.ExecContext(ctx, stmt, args...)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ func (d *DB) CreateMemo(ctx context.Context, create *store.Memo) (*store.Memo, e
|
|||
}
|
||||
args := []any{create.UID, create.CreatorID, create.Content, create.Visibility, payload}
|
||||
|
||||
// Add custom timestamps if provided
|
||||
if create.CreatedTs != 0 {
|
||||
fields = append(fields, "created_ts")
|
||||
args = append(args, create.CreatedTs)
|
||||
}
|
||||
if create.UpdatedTs != 0 {
|
||||
fields = append(fields, "updated_ts")
|
||||
args = append(args, create.UpdatedTs)
|
||||
}
|
||||
|
||||
stmt := "INSERT INTO memo (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, created_ts, updated_ts, row_status"
|
||||
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||
&create.ID,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ func (d *DB) CreateMemo(ctx context.Context, create *store.Memo) (*store.Memo, e
|
|||
}
|
||||
args := []any{create.UID, create.CreatorID, create.Content, create.Visibility, payload}
|
||||
|
||||
// Add custom timestamps if provided
|
||||
if create.CreatedTs != 0 {
|
||||
fields = append(fields, "`created_ts`")
|
||||
placeholder = append(placeholder, "?")
|
||||
args = append(args, create.CreatedTs)
|
||||
}
|
||||
if create.UpdatedTs != 0 {
|
||||
fields = append(fields, "`updated_ts`")
|
||||
placeholder = append(placeholder, "?")
|
||||
args = append(args, create.UpdatedTs)
|
||||
}
|
||||
|
||||
stmt := "INSERT INTO `memo` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ") RETURNING `id`, `created_ts`, `updated_ts`, `row_status`"
|
||||
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
|
||||
&create.ID,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue