Skip to content

Commit dc7ec8a

Browse files
committed
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
1 parent cbf46a2 commit dc7ec8a

File tree

11 files changed

+205
-21
lines changed

11 files changed

+205
-21
lines changed

plugin/filter/parser.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -466,14 +466,14 @@ func detectComprehensionKind(comp *exprv1.Expr_Comprehension) (ComprehensionKind
466466
}
467467

468468
// exists() starts with false and uses OR (||) in loop step
469-
if accuInit.GetBoolValue() == false {
469+
if !accuInit.GetBoolValue() {
470470
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_||_" {
471471
return ComprehensionExists, nil
472472
}
473473
}
474474

475475
// all() starts with true and uses AND (&&) - not supported
476-
if accuInit.GetBoolValue() == true {
476+
if accuInit.GetBoolValue() {
477477
if step := comp.LoopStep.GetCallExpr(); step != nil && step.Function == "_&&_" {
478478
return "", errors.New("all() comprehension is not supported; use exists() instead")
479479
}
@@ -483,7 +483,7 @@ func detectComprehensionKind(comp *exprv1.Expr_Comprehension) (ComprehensionKind
483483
}
484484

485485
// extractPredicate extracts the predicate expression from the comprehension loop step.
486-
func extractPredicate(comp *exprv1.Expr_Comprehension, schema Schema) (PredicateExpr, error) {
486+
func extractPredicate(comp *exprv1.Expr_Comprehension, _ Schema) (PredicateExpr, error) {
487487
// The loop step is: @result || predicate(t) for exists
488488
// or: @result && predicate(t) for all
489489
step := comp.LoopStep.GetCallExpr()

plugin/filter/render.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ func (r *renderer) renderListComprehension(cond *ListComprehensionCondition) (re
486486
}
487487
}
488488

489-
// renderTagStartsWith generates SQL for tags.exists(t, t.startsWith("prefix"))
489+
// renderTagStartsWith generates SQL for tags.exists(t, t.startsWith("prefix")).
490490
func (r *renderer) renderTagStartsWith(field Field, prefix string, _ ComprehensionKind) (renderResult, error) {
491491
arrayExpr := jsonArrayExpr(r.dialect, field)
492492

@@ -510,7 +510,7 @@ func (r *renderer) renderTagStartsWith(field Field, prefix string, _ Comprehensi
510510
}
511511
}
512512

513-
// renderTagEndsWith generates SQL for tags.exists(t, t.endsWith("suffix"))
513+
// renderTagEndsWith generates SQL for tags.exists(t, t.endsWith("suffix")).
514514
func (r *renderer) renderTagEndsWith(field Field, suffix string, _ ComprehensionKind) (renderResult, error) {
515515
arrayExpr := jsonArrayExpr(r.dialect, field)
516516
pattern := fmt.Sprintf(`%%%s"%%`, suffix)
@@ -519,7 +519,7 @@ func (r *renderer) renderTagEndsWith(field Field, suffix string, _ Comprehension
519519
return renderResult{sql: r.wrapWithNullCheck(arrayExpr, likeExpr)}, nil
520520
}
521521

522-
// renderTagContains generates SQL for tags.exists(t, t.contains("substring"))
522+
// renderTagContains generates SQL for tags.exists(t, t.contains("substring")).
523523
func (r *renderer) renderTagContains(field Field, substring string, _ ComprehensionKind) (renderResult, error) {
524524
arrayExpr := jsonArrayExpr(r.dialect, field)
525525
pattern := fmt.Sprintf(`%%%s%%`, substring)

proto/api/v1/memo_service.proto

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,13 @@ message Memo {
173173
(google.api.resource_reference) = {type: "memos.api.v1/User"}
174174
];
175175

176-
// Output only. The creation timestamp.
177-
google.protobuf.Timestamp create_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
176+
// The creation timestamp.
177+
// If not set on creation, the server will set it to the current time.
178+
google.protobuf.Timestamp create_time = 4 [(google.api.field_behavior) = OPTIONAL];
178179

179-
// Output only. The last update timestamp.
180-
google.protobuf.Timestamp update_time = 5 [(google.api.field_behavior) = OUTPUT_ONLY];
180+
// The last update timestamp.
181+
// If not set on creation, the server will set it to the current time.
182+
google.protobuf.Timestamp update_time = 5 [(google.api.field_behavior) = OPTIONAL];
181183

182184
// The display timestamp of the memo.
183185
google.protobuf.Timestamp display_time = 6 [(google.api.field_behavior) = OPTIONAL];

proto/gen/api/v1/memo_service.pb.go

Lines changed: 6 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

proto/gen/openapi.yaml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2470,14 +2470,16 @@ components:
24702470
The name of the creator.
24712471
Format: users/{user}
24722472
createTime:
2473-
readOnly: true
24742473
type: string
2475-
description: Output only. The creation timestamp.
2474+
description: |-
2475+
The creation timestamp.
2476+
If not set on creation, the server will set it to the current time.
24762477
format: date-time
24772478
updateTime:
2478-
readOnly: true
24792479
type: string
2480-
description: Output only. The last update timestamp.
2480+
description: |-
2481+
The last update timestamp.
2482+
If not set on creation, the server will set it to the current time.
24812483
format: date-time
24822484
displayTime:
24832485
type: string

server/router/api/v1/memo_service.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,35 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR
4545
Content: request.Memo.Content,
4646
Visibility: convertVisibilityToStore(request.Memo.Visibility),
4747
}
48+
4849
instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx)
4950
if err != nil {
5051
return nil, status.Errorf(codes.Internal, "failed to get instance memo related setting")
5152
}
53+
54+
// Handle display_time first: if provided, use it to set the appropriate timestamp
55+
// based on the instance setting (similar to UpdateMemo logic)
56+
// Note: explicit create_time/update_time below will override this if provided
57+
if request.Memo.DisplayTime != nil && request.Memo.DisplayTime.IsValid() {
58+
displayTs := request.Memo.DisplayTime.AsTime().Unix()
59+
if instanceMemoRelatedSetting.DisplayWithUpdateTime {
60+
create.UpdatedTs = displayTs
61+
} else {
62+
create.CreatedTs = displayTs
63+
}
64+
}
65+
66+
// Set custom timestamps if provided in the request
67+
// These take precedence over display_time
68+
if request.Memo.CreateTime != nil && request.Memo.CreateTime.IsValid() {
69+
createdTs := request.Memo.CreateTime.AsTime().Unix()
70+
create.CreatedTs = createdTs
71+
}
72+
if request.Memo.UpdateTime != nil && request.Memo.UpdateTime.IsValid() {
73+
updatedTs := request.Memo.UpdateTime.AsTime().Unix()
74+
create.UpdatedTs = updatedTs
75+
}
76+
5277
if instanceMemoRelatedSetting.DisallowPublicVisibility && create.Visibility == store.Public {
5378
return nil, status.Errorf(codes.PermissionDenied, "disable public memos system setting is enabled")
5479
}

server/router/api/v1/test/memo_service_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"fmt"
66
"slices"
77
"testing"
8+
"time"
89

910
"github.com/stretchr/testify/require"
11+
"google.golang.org/protobuf/types/known/timestamppb"
1012

1113
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
1214
)
@@ -250,3 +252,118 @@ func TestListMemos(t *testing.T) {
250252
require.NotNil(t, userTwoReaction)
251253
require.Equal(t, "👍", userTwoReaction.ReactionType)
252254
}
255+
256+
// TestCreateMemoWithCustomTimestamps tests that custom timestamps can be set when creating memos and comments.
257+
// This addresses issue #5483: https://github.com/usememos/memos/issues/5483
258+
func TestCreateMemoWithCustomTimestamps(t *testing.T) {
259+
ctx := context.Background()
260+
261+
ts := NewTestService(t)
262+
defer ts.Cleanup()
263+
264+
// Create a test user
265+
user, err := ts.CreateRegularUser(ctx, "test-user-timestamps")
266+
require.NoError(t, err)
267+
require.NotNil(t, user)
268+
269+
userCtx := ts.CreateUserContext(ctx, user.ID)
270+
271+
// Define custom timestamps (January 1, 2020)
272+
customCreateTime := time.Date(2020, 1, 1, 12, 0, 0, 0, time.UTC)
273+
customUpdateTime := time.Date(2020, 1, 2, 12, 0, 0, 0, time.UTC)
274+
customDisplayTime := time.Date(2020, 1, 3, 12, 0, 0, 0, time.UTC)
275+
276+
// Test 1: Create a memo with custom create_time
277+
memoWithCreateTime, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
278+
Memo: &apiv1.Memo{
279+
Content: "This memo has a custom creation time",
280+
Visibility: apiv1.Visibility_PRIVATE,
281+
CreateTime: timestamppb.New(customCreateTime),
282+
},
283+
})
284+
require.NoError(t, err)
285+
require.NotNil(t, memoWithCreateTime)
286+
require.Equal(t, customCreateTime.Unix(), memoWithCreateTime.CreateTime.AsTime().Unix(), "create_time should match the custom timestamp")
287+
288+
// Test 2: Create a memo with custom update_time
289+
memoWithUpdateTime, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
290+
Memo: &apiv1.Memo{
291+
Content: "This memo has a custom update time",
292+
Visibility: apiv1.Visibility_PRIVATE,
293+
UpdateTime: timestamppb.New(customUpdateTime),
294+
},
295+
})
296+
require.NoError(t, err)
297+
require.NotNil(t, memoWithUpdateTime)
298+
require.Equal(t, customUpdateTime.Unix(), memoWithUpdateTime.UpdateTime.AsTime().Unix(), "update_time should match the custom timestamp")
299+
300+
// Test 3: Create a memo with custom display_time
301+
// Note: display_time is computed from either created_ts or updated_ts based on instance setting
302+
// Since DisplayWithUpdateTime defaults to false, display_time maps to created_ts
303+
memoWithDisplayTime, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
304+
Memo: &apiv1.Memo{
305+
Content: "This memo has a custom display time",
306+
Visibility: apiv1.Visibility_PRIVATE,
307+
DisplayTime: timestamppb.New(customDisplayTime),
308+
},
309+
})
310+
require.NoError(t, err)
311+
require.NotNil(t, memoWithDisplayTime)
312+
// Since DisplayWithUpdateTime is false by default, display_time sets created_ts
313+
require.Equal(t, customDisplayTime.Unix(), memoWithDisplayTime.DisplayTime.AsTime().Unix(), "display_time should match the custom timestamp")
314+
require.Equal(t, customDisplayTime.Unix(), memoWithDisplayTime.CreateTime.AsTime().Unix(), "create_time should also match since display_time maps to created_ts")
315+
316+
// Test 4: Create a memo with all custom timestamps
317+
// When both display_time and create_time are provided, create_time takes precedence
318+
memoWithAllTimestamps, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
319+
Memo: &apiv1.Memo{
320+
Content: "This memo has all custom timestamps",
321+
Visibility: apiv1.Visibility_PRIVATE,
322+
CreateTime: timestamppb.New(customCreateTime),
323+
UpdateTime: timestamppb.New(customUpdateTime),
324+
DisplayTime: timestamppb.New(customDisplayTime),
325+
},
326+
})
327+
require.NoError(t, err)
328+
require.NotNil(t, memoWithAllTimestamps)
329+
require.Equal(t, customCreateTime.Unix(), memoWithAllTimestamps.CreateTime.AsTime().Unix(), "create_time should match the custom timestamp")
330+
require.Equal(t, customUpdateTime.Unix(), memoWithAllTimestamps.UpdateTime.AsTime().Unix(), "update_time should match the custom timestamp")
331+
// display_time is computed from created_ts when DisplayWithUpdateTime is false
332+
require.Equal(t, customCreateTime.Unix(), memoWithAllTimestamps.DisplayTime.AsTime().Unix(), "display_time should be derived from create_time")
333+
334+
// Test 5: Create a comment (memo relation) with custom timestamps
335+
parentMemo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
336+
Memo: &apiv1.Memo{
337+
Content: "This is the parent memo",
338+
Visibility: apiv1.Visibility_PRIVATE,
339+
},
340+
})
341+
require.NoError(t, err)
342+
require.NotNil(t, parentMemo)
343+
344+
customCommentCreateTime := time.Date(2021, 6, 15, 10, 30, 0, 0, time.UTC)
345+
comment, err := ts.Service.CreateMemoComment(userCtx, &apiv1.CreateMemoCommentRequest{
346+
Name: parentMemo.Name,
347+
Comment: &apiv1.Memo{
348+
Content: "This is a comment with custom create time",
349+
Visibility: apiv1.Visibility_PRIVATE,
350+
CreateTime: timestamppb.New(customCommentCreateTime),
351+
},
352+
})
353+
require.NoError(t, err)
354+
require.NotNil(t, comment)
355+
require.Equal(t, customCommentCreateTime.Unix(), comment.CreateTime.AsTime().Unix(), "comment create_time should match the custom timestamp")
356+
357+
// Test 6: Verify that memos without custom timestamps still get auto-generated ones
358+
memoWithoutTimestamps, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{
359+
Memo: &apiv1.Memo{
360+
Content: "This memo has auto-generated timestamps",
361+
Visibility: apiv1.Visibility_PRIVATE,
362+
},
363+
})
364+
require.NoError(t, err)
365+
require.NotNil(t, memoWithoutTimestamps)
366+
require.NotNil(t, memoWithoutTimestamps.CreateTime, "create_time should be auto-generated")
367+
require.NotNil(t, memoWithoutTimestamps.UpdateTime, "update_time should be auto-generated")
368+
require.True(t, time.Now().Unix()-memoWithoutTimestamps.CreateTime.AsTime().Unix() < 5, "create_time should be recent (within 5 seconds)")
369+
}

store/db/mysql/memo.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ func (d *DB) CreateMemo(ctx context.Context, create *store.Memo) (*store.Memo, e
2626
}
2727
args := []any{create.UID, create.CreatorID, create.Content, create.Visibility, payload}
2828

29+
// Add custom timestamps if provided
30+
if create.CreatedTs != 0 {
31+
fields = append(fields, "`created_ts`")
32+
placeholder = append(placeholder, "?")
33+
args = append(args, create.CreatedTs)
34+
}
35+
if create.UpdatedTs != 0 {
36+
fields = append(fields, "`updated_ts`")
37+
placeholder = append(placeholder, "?")
38+
args = append(args, create.UpdatedTs)
39+
}
40+
2941
stmt := "INSERT INTO `memo` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ")"
3042
result, err := d.db.ExecContext(ctx, stmt, args...)
3143
if err != nil {

store/db/postgres/memo.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ func (d *DB) CreateMemo(ctx context.Context, create *store.Memo) (*store.Memo, e
2525
}
2626
args := []any{create.UID, create.CreatorID, create.Content, create.Visibility, payload}
2727

28+
// Add custom timestamps if provided
29+
if create.CreatedTs != 0 {
30+
fields = append(fields, "created_ts")
31+
args = append(args, create.CreatedTs)
32+
}
33+
if create.UpdatedTs != 0 {
34+
fields = append(fields, "updated_ts")
35+
args = append(args, create.UpdatedTs)
36+
}
37+
2838
stmt := "INSERT INTO memo (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, created_ts, updated_ts, row_status"
2939
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
3040
&create.ID,

store/db/sqlite/memo.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ func (d *DB) CreateMemo(ctx context.Context, create *store.Memo) (*store.Memo, e
2626
}
2727
args := []any{create.UID, create.CreatorID, create.Content, create.Visibility, payload}
2828

29+
// Add custom timestamps if provided
30+
if create.CreatedTs != 0 {
31+
fields = append(fields, "`created_ts`")
32+
placeholder = append(placeholder, "?")
33+
args = append(args, create.CreatedTs)
34+
}
35+
if create.UpdatedTs != 0 {
36+
fields = append(fields, "`updated_ts`")
37+
placeholder = append(placeholder, "?")
38+
args = append(args, create.UpdatedTs)
39+
}
40+
2941
stmt := "INSERT INTO `memo` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ") RETURNING `id`, `created_ts`, `updated_ts`, `row_status`"
3042
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
3143
&create.ID,

0 commit comments

Comments
 (0)