github.com/cozy/cozy-stack@v0.0.0-20240327093429-939e4a21320e/model/note/step.go (about)

     1  package note
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"time"
     7  
     8  	"github.com/cozy/cozy-stack/model/instance"
     9  	"github.com/cozy/cozy-stack/model/vfs"
    10  	"github.com/cozy/cozy-stack/pkg/consts"
    11  	"github.com/cozy/cozy-stack/pkg/couchdb"
    12  	"github.com/cozy/cozy-stack/pkg/jsonapi"
    13  	"github.com/cozy/cozy-stack/pkg/prefixer"
    14  	"github.com/cozy/prosemirror-go/transform"
    15  )
    16  
    17  // Step is a patch to apply on a note.
    18  type Step map[string]interface{}
    19  
    20  // ID returns the step qualified identifier
    21  func (s Step) ID() string {
    22  	id, _ := s["_id"].(string)
    23  	return id
    24  }
    25  
    26  // Rev returns the step revision
    27  func (s Step) Rev() string {
    28  	rev, _ := s["_rev"].(string)
    29  	return rev
    30  }
    31  
    32  // DocType returns the document type
    33  func (s Step) DocType() string { return consts.NotesSteps }
    34  
    35  // Clone implements couchdb.Doc
    36  func (s Step) Clone() couchdb.Doc {
    37  	cloned := make(Step)
    38  	for k, v := range s {
    39  		cloned[k] = v
    40  	}
    41  	return cloned
    42  }
    43  
    44  // SetID changes the step qualified identifier
    45  func (s Step) SetID(id string) {
    46  	if id == "" {
    47  		delete(s, "_id")
    48  	} else {
    49  		s["_id"] = id
    50  	}
    51  }
    52  
    53  // SetRev changes the step revision
    54  func (s Step) SetRev(rev string) {
    55  	if rev == "" {
    56  		delete(s, "_rev")
    57  	} else {
    58  		s["_rev"] = rev
    59  	}
    60  }
    61  
    62  // Included is part of the jsonapi.Object interface
    63  func (s Step) Included() []jsonapi.Object { return nil }
    64  
    65  // Links is part of the jsonapi.Object interface
    66  func (s Step) Links() *jsonapi.LinksList { return nil }
    67  
    68  // Relationships is part of the jsonapi.Object interface
    69  func (s Step) Relationships() jsonapi.RelationshipMap { return nil }
    70  
    71  func (s Step) timestamp() int64 {
    72  	switch t := s["timestamp"].(type) {
    73  	case float64:
    74  		return int64(t)
    75  	case int64:
    76  		return t
    77  	}
    78  	return 0
    79  }
    80  
    81  func (s Step) version() int64 {
    82  	switch v := s["version"].(type) {
    83  	case float64:
    84  		return int64(v)
    85  	case int64:
    86  		return v
    87  	}
    88  	return 0
    89  }
    90  
    91  func stepID(noteID string, version int64) string {
    92  	return fmt.Sprintf("%s/%08d", noteID, version)
    93  }
    94  
    95  func startkey(noteID string) string {
    96  	return fmt.Sprintf("%s/", noteID)
    97  }
    98  
    99  func endkey(noteID string) string {
   100  	return fmt.Sprintf("%s/%s", noteID, couchdb.MaxString)
   101  }
   102  
   103  // GetSteps returns the steps for the given note, starting from the version.
   104  func GetSteps(inst *instance.Instance, fileID string, version int64) ([]Step, error) {
   105  	lock := inst.NotesLock()
   106  	if err := lock.Lock(); err != nil {
   107  		return nil, err
   108  	}
   109  	defer lock.Unlock()
   110  
   111  	return getSteps(inst, fileID, version)
   112  }
   113  
   114  // getSteps is the same as GetSteps, but with the notes lock already acquired
   115  func getSteps(db prefixer.Prefixer, fileID string, version int64) ([]Step, error) {
   116  	var steps []Step
   117  	req := couchdb.AllDocsRequest{
   118  		Limit:    1000,
   119  		StartKey: stepID(fileID, version),
   120  		EndKey:   endkey(fileID),
   121  	}
   122  	if err := couchdb.GetAllDocs(db, consts.NotesSteps, &req, &steps); err != nil {
   123  		return nil, err
   124  	}
   125  
   126  	// The first step plays the role of a sentinel: if it isn't here, the
   127  	// version is too old. Same if we have too many steps.
   128  	if len(steps) == 0 || len(steps) == req.Limit {
   129  		return nil, ErrTooOld
   130  	}
   131  
   132  	if version > 0 && version == steps[0].version() {
   133  		steps = steps[1:] // Discard the sentinel
   134  	}
   135  	return steps, nil
   136  }
   137  
   138  // ApplySteps takes a note and some steps, and tries to apply them. It is an
   139  // all or nothing change: if there is one error, the note won't be changed.
   140  func ApplySteps(inst *instance.Instance, file *vfs.FileDoc, lastVersion string, steps []Step) (*vfs.FileDoc, error) {
   141  	lock := inst.NotesLock()
   142  	if err := lock.Lock(); err != nil {
   143  		return nil, err
   144  	}
   145  	defer lock.Unlock()
   146  
   147  	if len(steps) == 0 {
   148  		return nil, ErrNoSteps
   149  	}
   150  
   151  	doc, err := get(inst, file)
   152  	if err != nil {
   153  		return nil, err
   154  	}
   155  	if lastVersion != fmt.Sprintf("%d", doc.Version) {
   156  		return nil, ErrCannotApply
   157  	}
   158  
   159  	if err := apply(inst, doc, steps); err != nil {
   160  		return nil, err
   161  	}
   162  	if err := saveSteps(inst, steps); err != nil {
   163  		return nil, err
   164  	}
   165  	publishSteps(inst, file.ID(), steps)
   166  
   167  	if err := saveToCache(inst, doc); err != nil {
   168  		return nil, err
   169  	}
   170  	return doc.asFile(inst, file), nil
   171  }
   172  
   173  func apply(inst *instance.Instance, doc *Document, steps []Step) error {
   174  	schema, err := doc.Schema()
   175  	if err != nil {
   176  		inst.Logger().WithNamespace("notes").
   177  			Infof("Cannot instantiate the schema: %s", err)
   178  		return ErrInvalidSchema
   179  	}
   180  
   181  	content, err := doc.Content()
   182  	if err != nil {
   183  		inst.Logger().WithNamespace("notes").
   184  			Infof("Cannot instantiate the document: %s", err)
   185  		return ErrInvalidFile
   186  	}
   187  
   188  	now := time.Now().Unix()
   189  	for i, s := range steps {
   190  		step, err := transform.StepFromJSON(schema, s)
   191  		if err != nil {
   192  			inst.Logger().WithNamespace("notes").
   193  				Infof("Cannot instantiate a step: %s", err)
   194  			return ErrInvalidSteps
   195  		}
   196  		result := step.Apply(content)
   197  		if result.Failed != "" {
   198  			inst.Logger().WithNamespace("notes").
   199  				Infof("Cannot apply a step: %s (version=%d)", result.Failed, doc.Version)
   200  			return ErrCannotApply
   201  		}
   202  		content = result.Doc
   203  		doc.Version++
   204  		steps[i].SetID(stepID(doc.ID(), doc.Version))
   205  		steps[i]["version"] = doc.Version
   206  		steps[i]["timestamp"] = now
   207  	}
   208  	doc.SetContent(content)
   209  	return nil
   210  }
   211  
   212  func saveSteps(inst *instance.Instance, steps []Step) error {
   213  	olds := make([]interface{}, len(steps))
   214  	news := make([]interface{}, len(steps))
   215  	for i, s := range steps {
   216  		news[i] = s
   217  	}
   218  	return couchdb.BulkUpdateDocs(inst, consts.NotesSteps, news, olds)
   219  }
   220  
   221  func purgeOldSteps(inst *instance.Instance, fileID string) {
   222  	var steps []Step
   223  	req := couchdb.AllDocsRequest{
   224  		Limit:    1000,
   225  		StartKey: stepID(fileID, 0),
   226  		EndKey:   endkey(fileID),
   227  	}
   228  	if err := couchdb.GetAllDocs(inst, consts.NotesSteps, &req, &steps); err != nil {
   229  		if !couchdb.IsNoDatabaseError(err) {
   230  			inst.Logger().WithNamespace("notes").
   231  				Warnf("Cannot purge old steps for file %s: %s", fileID, err)
   232  		}
   233  		return
   234  	}
   235  	if len(steps) == 0 {
   236  		return
   237  	}
   238  
   239  	limit := time.Now().Add(-cleanStepsAfter).Unix()
   240  	docs := make([]couchdb.Doc, 0, len(steps))
   241  	for i := range steps {
   242  		if steps[i].timestamp() > limit {
   243  			break
   244  		}
   245  		docs = append(docs, &steps[i])
   246  	}
   247  	if len(docs) == 0 {
   248  		return
   249  	}
   250  	if err := couchdb.BulkDeleteDocs(inst, consts.NotesSteps, docs); err != nil {
   251  		inst.Logger().WithNamespace("notes").
   252  			Warnf("Cannot purge old steps for file %s: %s", fileID, err)
   253  	}
   254  }
   255  
   256  func purgeAllSteps(inst *instance.Instance, fileID string) {
   257  	var docs []couchdb.Doc
   258  	err := couchdb.ForeachDocsWithCustomPagination(inst, consts.NotesSteps, 1000, func(_ string, raw json.RawMessage) error {
   259  		var doc Step
   260  		if err := json.Unmarshal(raw, &doc); err != nil {
   261  			return err
   262  		}
   263  		docs = append(docs, doc)
   264  		return nil
   265  	})
   266  	if err != nil {
   267  		if !couchdb.IsNoDatabaseError(err) {
   268  			inst.Logger().WithNamespace("notes").
   269  				Warnf("Cannot purge all steps for file %s: %s", fileID, err)
   270  		}
   271  		return
   272  	}
   273  	if len(docs) == 0 {
   274  		return
   275  	}
   276  
   277  	if err := couchdb.BulkDeleteDocs(inst, consts.NotesSteps, docs); err != nil {
   278  		inst.Logger().WithNamespace("notes").
   279  			Warnf("Cannot purge all steps for file %s: %s", fileID, err)
   280  	}
   281  }
   282  
   283  var _ jsonapi.Object = &Step{}