package v1 import ( "context" "encoding/json" "fmt" "log/slog" "time" "github.com/pkg/errors" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" v1pb "github.com/usememos/memos/proto/gen/api/v1" storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/server/runner/memopayload" "github.com/usememos/memos/store" ) // ExportFormat represents the format for export/import operations type ExportFormat string const ( FormatJSON ExportFormat = "json" ) // ExportData represents the structure of exported data type ExportData struct { Version string `json:"version"` ExportedAt time.Time `json:"exported_at"` Memos []ExportMemo `json:"memos"` } // ExportMemo represents a memo in the export format type ExportMemo struct { UID string `json:"uid"` Content string `json:"content"` Visibility string `json:"visibility"` Pinned bool `json:"pinned"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DisplayTime *time.Time `json:"display_time,omitempty"` Tags []string `json:"tags,omitempty"` Location *ExportLocation `json:"location,omitempty"` Attachments []ExportAttachment `json:"attachments,omitempty"` Relations []ExportMemoRelation `json:"relations,omitempty"` } // ExportLocation represents location data in export format type ExportLocation struct { Placeholder string `json:"placeholder,omitempty"` Latitude float64 `json:"latitude,omitempty"` Longitude float64 `json:"longitude,omitempty"` } // ExportAttachment represents attachment data in export format type ExportAttachment struct { UID string `json:"uid"` Filename string `json:"filename"` Type string `json:"type"` Size int64 `json:"size"` } // ExportMemoRelation represents memo relations in export format type ExportMemoRelation struct { RelatedMemoUID string `json:"related_memo_uid"` Type string `json:"type"` } // ExportMemos exports memos for the current user in JSON format func (s *APIV1Service) ExportMemos(ctx context.Context, request *v1pb.ExportMemosRequest) (*v1pb.ExportMemosResponse, error) { user, err := s.GetCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get current user") } // Validate format (default to JSON) format := request.Format if format == "" { format = string(FormatJSON) } if format != string(FormatJSON) { return nil, status.Errorf(codes.InvalidArgument, "unsupported export format: %s", format) } // Get all memos for the user memoFind := &store.FindMemo{ CreatorID: &user.ID, ExcludeComments: true, } // Apply filters if specified if request.Filter != "" { // Use existing filter validation from shortcut service memoFind.Filter = &request.Filter } // Include archived memos if requested if request.ExcludeArchived { normalStatus := store.Normal memoFind.RowStatus = &normalStatus } memos, err := s.Store.ListMemos(ctx, memoFind) if err != nil { return nil, status.Errorf(codes.Internal, "failed to list memos: %v", err) } // Convert memos to export format exportMemos := make([]ExportMemo, 0, len(memos)) for _, memo := range memos { exportMemo, err := s.convertMemoToExport(ctx, memo, request.IncludeAttachments, request.IncludeRelations) if err != nil { slog.Warn("Failed to convert memo to export format", slog.Any("memo_id", memo.ID), slog.Any("error", err)) continue } exportMemos = append(exportMemos, *exportMemo) } // Create export data structure exportData := &ExportData{ Version: "1.0", ExportedAt: time.Now(), Memos: exportMemos, } // Serialize to JSON jsonData, err := json.MarshalIndent(exportData, "", " ") if err != nil { return nil, status.Errorf(codes.Internal, "failed to marshal export data: %v", err) } return &v1pb.ExportMemosResponse{ Data: jsonData, Format: format, Filename: fmt.Sprintf("memos_export_%s.json", time.Now().Format("20060102_150405")), MemoCount: int32(len(exportMemos)), SizeBytes: int64(len(jsonData)), }, nil } // ImportMemos imports memos from JSON data func (s *APIV1Service) ImportMemos(ctx context.Context, request *v1pb.ImportMemosRequest) (*v1pb.ImportMemosResponse, error) { startTime := time.Now() user, err := s.GetCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get current user") } // Validate format (default to JSON) format := request.Format if format == "" { format = string(FormatJSON) } if format != string(FormatJSON) { return nil, status.Errorf(codes.InvalidArgument, "unsupported import format: %s", format) } // Parse the JSON data var importData ExportData if err := json.Unmarshal(request.Data, &importData); err != nil { return nil, status.Errorf(codes.InvalidArgument, "failed to parse import data: %v", err) } // Validate import data version if importData.Version != "1.0" { return nil, status.Errorf(codes.InvalidArgument, "unsupported import data version: %s", importData.Version) } var importedCount int32 var skippedCount int32 var createdCount int32 var updatedCount int32 var validationErrors int32 var attachmentsImported int32 var relationsImported int32 var errors []string var warnings []string // Import each memo for _, exportMemo := range importData.Memos { result, err := s.importSingleMemo(ctx, user.ID, &exportMemo, request) if err != nil { errorMsg := fmt.Sprintf("Failed to import memo %s: %v", exportMemo.UID, err) errors = append(errors, errorMsg) skippedCount++ if request.ValidateOnly { validationErrors++ } slog.Warn("Failed to import memo", slog.String("uid", exportMemo.UID), slog.Any("error", err)) continue } importedCount++ if result.Created { createdCount++ } else { updatedCount++ } attachmentsImported += result.AttachmentsImported relationsImported += result.RelationsImported if len(result.Warnings) > 0 { warnings = append(warnings, result.Warnings...) } } duration := time.Since(startTime) summary := &v1pb.ImportSummary{ TotalMemos: int32(len(importData.Memos)), CreatedCount: createdCount, UpdatedCount: updatedCount, AttachmentsImported: attachmentsImported, RelationsImported: relationsImported, DurationMs: duration.Milliseconds(), } return &v1pb.ImportMemosResponse{ ImportedCount: importedCount, SkippedCount: skippedCount, ValidationErrors: validationErrors, Errors: errors, Warnings: warnings, Summary: summary, }, nil } // convertMemoToExport converts a store memo to export format func (s *APIV1Service) convertMemoToExport(ctx context.Context, memo *store.Memo, includeAttachments, includeRelations bool) (*ExportMemo, error) { exportMemo := &ExportMemo{ UID: memo.UID, Content: memo.Content, Visibility: memo.Visibility.String(), Pinned: memo.Pinned, CreatedAt: time.Unix(memo.CreatedTs, 0), UpdatedAt: time.Unix(memo.UpdatedTs, 0), } // Extract tags from payload if memo.Payload != nil && len(memo.Payload.Tags) > 0 { exportMemo.Tags = memo.Payload.Tags } // Add location if present if memo.Payload != nil && memo.Payload.Location != nil { exportMemo.Location = &ExportLocation{ Placeholder: memo.Payload.Location.Placeholder, Latitude: memo.Payload.Location.Latitude, Longitude: memo.Payload.Location.Longitude, } } // Add attachments if requested if includeAttachments { attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoID: &memo.ID}) if err != nil { return nil, errors.Wrap(err, "failed to list attachments") } for _, attachment := range attachments { exportMemo.Attachments = append(exportMemo.Attachments, ExportAttachment{ UID: attachment.UID, Filename: attachment.Filename, Type: attachment.Type, Size: attachment.Size, }) } } // Add relations if requested if includeRelations { relations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{MemoID: &memo.ID}) if err != nil { return nil, errors.Wrap(err, "failed to list memo relations") } for _, relation := range relations { relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &relation.RelatedMemoID}) if err != nil || relatedMemo == nil { continue // Skip if related memo not found } exportMemo.Relations = append(exportMemo.Relations, ExportMemoRelation{ RelatedMemoUID: relatedMemo.UID, Type: string(relation.Type), }) } } return exportMemo, nil } // ImportResult represents the result of importing a single memo type ImportResult struct { Created bool AttachmentsImported int32 RelationsImported int32 Warnings []string } // importSingleMemo imports a single memo func (s *APIV1Service) importSingleMemo(ctx context.Context, userID int32, exportMemo *ExportMemo, request *v1pb.ImportMemosRequest) (*ImportResult, error) { result := &ImportResult{ Warnings: []string{}, } // Check if memo with this UID already exists existingMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &exportMemo.UID}) if err != nil { return nil, errors.Wrap(err, "failed to check for existing memo") } if existingMemo != nil && !request.OverwriteExisting { return nil, fmt.Errorf("memo with UID %s already exists", exportMemo.UID) } // Validate memo content length contentLengthLimit, err := s.getContentLengthLimit(ctx) if err != nil { return nil, errors.Wrap(err, "failed to get content length limit") } if len(exportMemo.Content) > contentLengthLimit { return nil, fmt.Errorf("content too long (max %d characters)", contentLengthLimit) } // Parse visibility visibility := store.Private switch exportMemo.Visibility { case "PUBLIC": visibility = store.Public case "PROTECTED": visibility = store.Protected case "PRIVATE": visibility = store.Private default: result.Warnings = append(result.Warnings, fmt.Sprintf("Unknown visibility %s for memo %s, defaulting to PRIVATE", exportMemo.Visibility, exportMemo.UID)) } // Create memo payload payload := &storepb.MemoPayload{ Tags: exportMemo.Tags, } if exportMemo.Location != nil { payload.Location = &storepb.MemoPayload_Location{ Placeholder: exportMemo.Location.Placeholder, Latitude: exportMemo.Location.Latitude, Longitude: exportMemo.Location.Longitude, } } // Set timestamps createdTs := exportMemo.CreatedAt.Unix() updatedTs := exportMemo.UpdatedAt.Unix() if !request.PreserveTimestamps { now := time.Now().Unix() createdTs = now updatedTs = now } if request.ValidateOnly { // Just validate, don't actually create/update return result, nil } if existingMemo != nil { // Update existing memo update := &store.UpdateMemo{ ID: existingMemo.ID, Content: &exportMemo.Content, Visibility: &visibility, Pinned: &exportMemo.Pinned, Payload: payload, } if request.PreserveTimestamps { update.CreatedTs = &createdTs update.UpdatedTs = &updatedTs } if err := s.Store.UpdateMemo(ctx, update); err != nil { return nil, errors.Wrap(err, "failed to update existing memo") } result.Created = false } else { // Create new memo create := &store.Memo{ UID: exportMemo.UID, CreatorID: userID, CreatedTs: createdTs, UpdatedTs: updatedTs, Content: exportMemo.Content, Visibility: visibility, Pinned: exportMemo.Pinned, Payload: payload, } // Rebuild memo payload to extract tags and other metadata if err := memopayload.RebuildMemoPayload(create); err != nil { return nil, errors.Wrap(err, "failed to rebuild memo payload") } _, err := s.Store.CreateMemo(ctx, create) if err != nil { return nil, errors.Wrap(err, "failed to create memo") } result.Created = true } // Import attachments if not skipped if !request.SkipAttachments && len(exportMemo.Attachments) > 0 { result.Warnings = append(result.Warnings, fmt.Sprintf("Attachments for memo %s were skipped (attachment import not yet implemented)", exportMemo.UID)) // TODO: Implement attachment import // This would require handling file uploads and storage } // Import relations if not skipped if !request.SkipRelations && len(exportMemo.Relations) > 0 { result.Warnings = append(result.Warnings, fmt.Sprintf("Relations for memo %s were skipped (relation import not yet implemented)", exportMemo.UID)) // TODO: Implement relation import // This would require resolving related memo UIDs and creating relations } return result, nil }