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{}