github.com/cozy/cozy-stack@v0.0.0-20240327093429-939e4a21320e/model/note/note.go (about) 1 // Package note is the glue between the prosemirror models, the VFS, redis, the 2 // hub for realtime, etc. 3 package note 4 5 import ( 6 "archive/tar" 7 "bytes" 8 "encoding/json" 9 "errors" 10 "fmt" 11 "io" 12 "os" 13 "path" 14 "runtime" 15 "strings" 16 "time" 17 18 "github.com/cozy/cozy-stack/model/instance" 19 "github.com/cozy/cozy-stack/model/job" 20 "github.com/cozy/cozy-stack/model/vfs" 21 "github.com/cozy/cozy-stack/pkg/config/config" 22 "github.com/cozy/cozy-stack/pkg/consts" 23 "github.com/cozy/cozy-stack/pkg/couchdb" 24 "github.com/cozy/cozy-stack/pkg/couchdb/mango" 25 "github.com/cozy/prosemirror-go/model" 26 "github.com/hashicorp/go-multierror" 27 ) 28 29 const ( 30 persistenceDebouce = "3m" 31 cacheDuration = 30 * time.Minute 32 cleanStepsAfter = 24 * time.Hour 33 ) 34 35 // Document is the note document in memory. It is persisted to the VFS as a 36 // file, but with a debounce: the intermediate states are saved in Redis. 37 type Document struct { 38 DocID string `json:"_id"` 39 DocRev string `json:"_rev,omitempty"` 40 CreatedBy string `json:"-"` 41 DirID string `json:"dir_id,omitempty"` // Only used at creation 42 Title string `json:"title"` 43 Version int64 `json:"version"` 44 SchemaSpec map[string]interface{} `json:"schema"` 45 RawContent map[string]interface{} `json:"content"` 46 47 // Use cache for some computed properties 48 schema *model.Schema 49 content *model.Node 50 } 51 52 // ID returns the document qualified identifier 53 func (d *Document) ID() string { return d.DocID } 54 55 // Rev returns the document revision 56 func (d *Document) Rev() string { return d.DocRev } 57 58 // DocType returns the document type 59 func (d *Document) DocType() string { return consts.NotesDocuments } 60 61 // Clone implements couchdb.Doc 62 func (d *Document) Clone() couchdb.Doc { 63 cloned := *d 64 // XXX The schema and the content are supposed to be immutable and, as 65 // such, are not cloned. 66 return &cloned 67 } 68 69 // SetID changes the document qualified identifier 70 func (d *Document) SetID(id string) { d.DocID = id } 71 72 // SetRev changes the document revision 73 func (d *Document) SetRev(rev string) { d.DocRev = rev } 74 75 // Metadata returns the file metadata for this note. 76 func (d *Document) Metadata() map[string]interface{} { 77 return map[string]interface{}{ 78 "title": d.Title, 79 "content": d.RawContent, 80 "version": d.Version, 81 "schema": d.SchemaSpec, 82 } 83 } 84 85 // Schema returns the prosemirror schema for this note 86 func (d *Document) Schema() (*model.Schema, error) { 87 if d.schema == nil { 88 spec := model.SchemaSpecFromJSON(d.SchemaSpec) 89 schema, err := model.NewSchema(&spec) 90 if err != nil { 91 return nil, ErrInvalidSchema 92 } 93 d.schema = schema 94 } 95 return d.schema, nil 96 } 97 98 // SetContent updates the content of this note, and clears the cache. 99 func (d *Document) SetContent(content *model.Node) { 100 d.RawContent = content.ToJSON() 101 d.content = content 102 } 103 104 // Content returns the prosemirror content for this note. 105 func (d *Document) Content() (*model.Node, error) { 106 if d.content == nil { 107 if len(d.RawContent) == 0 { 108 return nil, ErrInvalidFile 109 } 110 schema, err := d.Schema() 111 if err != nil { 112 return nil, err 113 } 114 content, err := model.NodeFromJSON(schema, d.RawContent) 115 if err != nil { 116 return nil, err 117 } 118 d.content = content 119 } 120 return d.content, nil 121 } 122 123 // Markdown returns a markdown serialization of the content. 124 func (d *Document) Markdown(images []*Image) ([]byte, error) { 125 content, err := d.Content() 126 if err != nil { 127 return nil, err 128 } 129 md := markdownSerializer(images).Serialize(content) 130 return []byte(md), nil 131 } 132 133 func (d *Document) Text() (string, error) { 134 content, err := d.Content() 135 if err != nil { 136 return "", err 137 } 138 text := textSerializer().Serialize(content) 139 return text, nil 140 } 141 142 // GetDirID returns the ID of the directory where the note will be created. 143 func (d *Document) GetDirID(inst *instance.Instance) (string, error) { 144 if d.DirID != "" { 145 return d.DirID, nil 146 } 147 parent, err := ensureNotesDir(inst) 148 if err != nil { 149 return "", err 150 } 151 d.DirID = parent.ID() 152 return d.DirID, nil 153 } 154 155 func (d *Document) asFile(inst *instance.Instance, old *vfs.FileDoc) *vfs.FileDoc { 156 now := time.Now() 157 file := old.Clone().(*vfs.FileDoc) 158 vfs.MergeMetadata(file, d.Metadata()) 159 file.Mime = consts.NoteMimeType 160 file.MD5Sum = nil // Let the VFS compute the md5sum 161 162 // If the file was renamed manually before, we will keep its name. Else, we 163 // can rename with the new title. 164 newTitle := titleToFilename(inst, d.Title, old.CreatedAt) 165 oldTitle, _ := old.Metadata["title"].(string) 166 rename := d.Title != "" && titleToFilename(inst, oldTitle, old.CreatedAt) == old.DocName 167 if old.DocName == "" { 168 rename = true 169 } 170 if strings.Contains(old.DocName, " - conflict - ") && oldTitle != newTitle { 171 rename = true 172 } 173 if rename { 174 file.DocName = newTitle 175 file.ResetFullpath() 176 _, _ = file.Path(inst.VFS()) // Prefill the fullpath 177 } 178 179 file.UpdatedAt = now 180 file.CozyMetadata.UpdatedAt = file.UpdatedAt 181 return file 182 } 183 184 // Create the file in the VFS for this note. 185 func Create(inst *instance.Instance, doc *Document) (*vfs.FileDoc, error) { 186 lock := inst.NotesLock() 187 if err := lock.Lock(); err != nil { 188 return nil, err 189 } 190 defer lock.Unlock() 191 192 doc.Version = 0 193 if len(doc.RawContent) > 0 { 194 // Check that the content satisfies the schema 195 _, err := doc.Content() 196 if err != nil { 197 return nil, err 198 } 199 } else { 200 content, err := initialContent(inst, doc) 201 if err != nil { 202 return nil, err 203 } 204 doc.SetContent(content) 205 } 206 207 file, err := writeFile(inst, doc, nil) 208 if err != nil { 209 return nil, err 210 } 211 if err := SetupTrigger(inst, file.ID()); err != nil { 212 return nil, err 213 } 214 return file, nil 215 } 216 217 func initialContent(inst *instance.Instance, doc *Document) (*model.Node, error) { 218 schema, err := doc.Schema() 219 if err != nil { 220 inst.Logger().WithNamespace("notes"). 221 Infof("Cannot instantiate the schema: %s", err) 222 return nil, ErrInvalidSchema 223 } 224 225 // Create an empty document that matches the schema constraints. 226 typ, err := schema.NodeType(schema.Spec.TopNode) 227 if err != nil { 228 inst.Logger().WithNamespace("notes"). 229 Infof("The schema is invalid: %s", err) 230 return nil, ErrInvalidSchema 231 } 232 node, err := typ.CreateAndFill() 233 if err != nil { 234 inst.Logger().WithNamespace("notes"). 235 Infof("The topNode cannot be created: %s", err) 236 return nil, ErrInvalidSchema 237 } 238 return node, nil 239 } 240 241 func newFileDoc(inst *instance.Instance, doc *Document) (*vfs.FileDoc, error) { 242 dirID, err := doc.GetDirID(inst) 243 if err != nil { 244 return nil, err 245 } 246 cm := vfs.NewCozyMetadata(inst.PageURL("/", nil)) 247 248 fileDoc, err := vfs.NewFileDoc( 249 titleToFilename(inst, doc.Title, cm.UpdatedAt), 250 dirID, 251 0, 252 nil, // Let the VFS compute the md5sum 253 consts.NoteMimeType, 254 "text", 255 cm.UpdatedAt, 256 false, // Not executable 257 false, // Not trashed 258 false, // Not encrypted 259 nil, // No tags 260 ) 261 if err != nil { 262 return nil, err 263 } 264 265 fileDoc.Metadata = doc.Metadata() 266 fileDoc.CozyMetadata = cm 267 fileDoc.CozyMetadata.CozyMetadata.CreatedByApp = doc.CreatedBy 268 return fileDoc, nil 269 } 270 271 func titleToFilename(inst *instance.Instance, title string, updatedAt time.Time) string { 272 name := strings.SplitN(title, "\n", 2)[0] 273 if name == "" { 274 name = inst.Translate("Notes New note") 275 name += " " + updatedAt.Format(time.RFC3339) 276 } 277 // Create file with a name compatible with Windows/macOS to avoid 278 // synchronization issues with the desktop client 279 r := strings.NewReplacer("/", "-", ":", "-", "<", "-", ">", "-", 280 `"`, "-", "'", "-", "?", "-", "*", "-", "|", "-", "\\", "-") 281 name = r.Replace(name) 282 // Avoid too long filenames for the same reason 283 if len(name) > 240 { 284 name = name[:240] 285 } 286 return name + ".cozy-note" 287 } 288 289 func ensureNotesDir(inst *instance.Instance) (*vfs.DirDoc, error) { 290 ref := couchdb.DocReference{ 291 Type: consts.Apps, 292 ID: consts.Apps + "/" + consts.NotesSlug, 293 } 294 key := []string{ref.Type, ref.ID} 295 end := []string{ref.Type, ref.ID, couchdb.MaxString} 296 req := &couchdb.ViewRequest{ 297 StartKey: key, 298 EndKey: end, 299 IncludeDocs: true, 300 } 301 var res couchdb.ViewResponse 302 err := couchdb.ExecView(inst, couchdb.FilesReferencedByView, req, &res) 303 if err != nil { 304 return nil, err 305 } 306 307 fs := inst.VFS() 308 if len(res.Rows) > 0 { 309 dir, err := fs.DirByID(res.Rows[0].ID) 310 if err != nil { 311 return nil, err 312 } 313 if !strings.HasPrefix(dir.Fullpath, vfs.TrashDirName) { 314 return dir, nil 315 } 316 return vfs.RestoreDir(fs, dir) 317 } 318 319 dirname := inst.Translate("Tree Notes") 320 dir, err := vfs.NewDirDocWithPath(dirname, consts.RootDirID, "/", nil) 321 if err != nil { 322 return nil, err 323 } 324 dir.AddReferencedBy(ref) 325 dir.CozyMetadata = vfs.NewCozyMetadata(inst.PageURL("/", nil)) 326 if err = fs.CreateDir(dir); err != nil { 327 dir, err = fs.DirByPath(dir.Fullpath) 328 if err != nil { 329 return nil, err 330 } 331 olddoc := dir.Clone().(*vfs.DirDoc) 332 dir.AddReferencedBy(ref) 333 _ = fs.UpdateDirDoc(olddoc, dir) 334 } 335 return dir, nil 336 } 337 338 // DebounceMessage is used by the trigger for saving the note to the VFS with a 339 // debounce. 340 type DebounceMessage struct { 341 NoteID string `json:"note_id"` 342 } 343 344 func SetupTrigger(inst *instance.Instance, fileID string) error { 345 sched := job.System() 346 infos := job.TriggerInfos{ 347 Type: "@event", 348 WorkerType: "notes-save", 349 Arguments: fmt.Sprintf("%s:UPDATED:%s", consts.NotesEvents, fileID), 350 Debounce: persistenceDebouce, 351 } 352 if sched.HasTrigger(inst, infos) { 353 return nil 354 } 355 356 msg := &DebounceMessage{NoteID: fileID} 357 t, err := job.NewTrigger(inst, infos, msg) 358 if err != nil { 359 return err 360 } 361 return sched.AddTrigger(t) 362 } 363 364 func writeFile(inst *instance.Instance, doc *Document, oldDoc *vfs.FileDoc) (fileDoc *vfs.FileDoc, err error) { 365 images, _ := getImages(inst, doc.DocID) 366 md, err := doc.Markdown(images) 367 if err != nil { 368 return nil, err 369 } 370 cleanImages(inst, images) 371 372 if oldDoc == nil { 373 fileDoc, err = newFileDoc(inst, doc) 374 if err != nil { 375 return 376 } 377 } else { 378 fileDoc = doc.asFile(inst, oldDoc) 379 // XXX if the name has changed, we have to rename the doc before 380 // writing the content (2 revisions in CouchDB) to ensure that changes 381 // are correctly propagated in Cozy to Cozy sharings. 382 if fileDoc.DocName != oldDoc.DocName { 383 oldDoc, err = forceRename(inst, oldDoc, fileDoc) 384 if err != nil { 385 return nil, err 386 } 387 fileDoc.DocName = oldDoc.DocName 388 fileDoc.ResetFullpath() 389 } 390 } 391 392 content := md 393 if hasImages(images) { 394 content, _ = buildArchive(inst, md, images) 395 } 396 fileDoc.ByteSize = int64(len(content)) 397 398 fs := inst.VFS() 399 basename := fileDoc.DocName 400 var file vfs.File 401 for i := 2; i < 100; i++ { 402 file, err = fs.CreateFile(fileDoc, oldDoc) 403 if err == nil { 404 break 405 } else if !errors.Is(err, os.ErrExist) { 406 return 407 } 408 filename := strings.TrimSuffix(path.Base(basename), path.Ext(basename)) 409 fileDoc.DocName = fmt.Sprintf("%s (%d).cozy-note", filename, i) 410 fileDoc.ResetFullpath() 411 } 412 _, err = file.Write(content) 413 if cerr := file.Close(); cerr != nil && err == nil { 414 err = cerr 415 } 416 if err == nil { 417 // XXX Write to the cache, not just clean it, to avoid the stack 418 // fetching the rename but not the new content/metadata on next step 419 // apply, as CouchDB is not strongly consistent. 420 if doc, _ := fromMetadata(fileDoc); doc != nil { 421 _ = saveToCache(inst, doc) 422 } 423 } 424 return 425 } 426 427 // forceRename will update the FileDoc in CouchDB with the new name (but the 428 // old content). It will return the updated doc, or an error if it fails. 429 func forceRename(inst *instance.Instance, old *vfs.FileDoc, file *vfs.FileDoc) (*vfs.FileDoc, error) { 430 // We clone file, and not old, to keep the fullpath (1 less CouchDB request) 431 tmp := file.Clone().(*vfs.FileDoc) 432 tmp.ByteSize = old.ByteSize 433 tmp.MD5Sum = make([]byte, len(old.MD5Sum)) 434 copy(tmp.MD5Sum, old.MD5Sum) 435 tmp.Metadata = make(vfs.Metadata, len(old.Metadata)) 436 for k, v := range old.Metadata { 437 tmp.Metadata[k] = v 438 } 439 440 basename := tmp.DocName 441 for i := 2; i < 100; i++ { 442 err := inst.VFS().UpdateFileDoc(old, tmp) 443 if err == nil { 444 break 445 } else if !errors.Is(err, os.ErrExist) { 446 return nil, err 447 } 448 filename := strings.TrimSuffix(path.Base(basename), path.Ext(basename)) 449 tmp.DocName = fmt.Sprintf("%s (%d).cozy-note", filename, i) 450 tmp.ResetFullpath() 451 } 452 return tmp, nil 453 } 454 455 func buildArchive(inst *instance.Instance, md []byte, images []*Image) ([]byte, error) { 456 var buf bytes.Buffer 457 tw := tar.NewWriter(&buf) 458 459 // Add markdown to the archive 460 hdr := &tar.Header{ 461 Name: "index.md", 462 Mode: 0640, 463 Size: int64(len(md)), 464 } 465 if err := tw.WriteHeader(hdr); err != nil { 466 return nil, err 467 } 468 if _, err := tw.Write(md); err != nil { 469 return nil, err 470 } 471 472 // Add images to the archive 473 fs := inst.ThumbsFS() 474 for _, image := range images { 475 if !image.seen { 476 continue 477 } 478 th, err := fs.OpenNoteThumb(image.ID(), consts.NoteImageOriginalFormat) 479 if err != nil { 480 return nil, err 481 } 482 img, err := io.ReadAll(th) 483 if errc := th.Close(); err == nil && errc != nil { 484 err = errc 485 } 486 if err != nil { 487 return nil, err 488 } 489 hdr := &tar.Header{ 490 Name: image.Name, 491 Mode: 0640, 492 Size: int64(len(img)), 493 } 494 if err := tw.WriteHeader(hdr); err != nil { 495 return nil, err 496 } 497 if _, err := tw.Write(img); err != nil { 498 return nil, err 499 } 500 } 501 502 if err := tw.Close(); err != nil { 503 return nil, err 504 } 505 return buf.Bytes(), nil 506 } 507 508 // List returns a list of notes sorted by descending updated_at. It uses 509 // pagination via a mango bookmark. 510 func List(inst *instance.Instance, bookmark string) ([]*vfs.FileDoc, string, error) { 511 lock := inst.NotesLock() 512 if err := lock.Lock(); err != nil { 513 return nil, "", err 514 } 515 defer lock.Unlock() 516 517 var docs []*vfs.FileDoc 518 req := &couchdb.FindRequest{ 519 UseIndex: "by-mime-updated-at", 520 Selector: mango.And( 521 mango.Equal("mime", consts.NoteMimeType), 522 mango.Equal("trashed", false), 523 mango.Exists("updated_at"), 524 ), 525 Sort: mango.SortBy{ 526 {Field: "mime", Direction: mango.Desc}, 527 {Field: "trashed", Direction: mango.Desc}, 528 {Field: "updated_at", Direction: mango.Desc}, 529 }, 530 Limit: 100, 531 Bookmark: bookmark, 532 } 533 res, err := couchdb.FindDocsRaw(inst, consts.Files, req, &docs) 534 if err != nil { 535 return nil, "", err 536 } 537 538 UpdateMetadataFromCache(inst, docs) 539 return docs, res.Bookmark, nil 540 } 541 542 // UpdateMetadataFromCache update the metadata for a file/note with the 543 // information in cache. 544 func UpdateMetadataFromCache(inst *instance.Instance, docs []*vfs.FileDoc) { 545 keys := make([]string, len(docs)) 546 for i, doc := range docs { 547 keys[i] = cacheKey(inst, doc.ID()) 548 } 549 cache := config.GetConfig().CacheStorage 550 bufs := cache.MultiGet(keys) 551 for i, buf := range bufs { 552 if len(buf) == 0 { 553 continue 554 } 555 var note Document 556 if err := json.Unmarshal(buf, ¬e); err == nil { 557 vfs.MergeMetadata(docs[i], note.Metadata()) 558 } 559 } 560 } 561 562 // GetFile takes a file from the VFS as a note and returns its last version. It 563 // is useful when some changes have not yet been persisted to the VFS. 564 func GetFile(inst *instance.Instance, file *vfs.FileDoc) (*vfs.FileDoc, error) { 565 lock := inst.NotesLock() 566 if err := lock.Lock(); err != nil { 567 return nil, err 568 } 569 defer lock.Unlock() 570 doc, err := get(inst, file) 571 if err != nil { 572 return nil, err 573 } 574 return doc.asFile(inst, file), nil 575 } 576 577 // get must be called with the notes lock already acquired. It will try to load 578 // the last version if a note from the cache, and if it fails, it will replay 579 // the new steps on the file from the VFS. 580 func get(inst *instance.Instance, file *vfs.FileDoc) (*Document, error) { 581 if doc := getFromCache(inst, file.ID()); doc != nil { 582 return doc, nil 583 } 584 version, _ := versionFromMetadata(file) 585 steps, err := getSteps(inst, file.ID(), version) 586 if err != nil && !errors.Is(err, ErrTooOld) && !couchdb.IsNoDatabaseError(err) { 587 return nil, err 588 } 589 doc, err := fromMetadata(file) 590 if err != nil { 591 return nil, err 592 } 593 if len(steps) == 0 { 594 return doc, nil 595 } 596 if version, ok := steps[0]["version"].(float64); ok { 597 doc.Version = int64(version) - 1 598 } 599 if err := apply(inst, doc, steps); err != nil { 600 return nil, err 601 } 602 _ = saveToCache(inst, doc) 603 return doc, nil 604 } 605 606 func versionFromMetadata(file *vfs.FileDoc) (int64, error) { 607 switch v := file.Metadata["version"].(type) { 608 case float64: 609 return int64(v), nil 610 case int64: 611 return v, nil 612 } 613 return 0, ErrInvalidFile 614 } 615 616 func fromMetadata(file *vfs.FileDoc) (*Document, error) { 617 version, err := versionFromMetadata(file) 618 if err != nil { 619 return nil, err 620 } 621 title, _ := file.Metadata["title"].(string) 622 schema, ok := file.Metadata["schema"].(map[string]interface{}) 623 if !ok { 624 return nil, ErrInvalidFile 625 } 626 content, ok := file.Metadata["content"].(map[string]interface{}) 627 if !ok { 628 return nil, ErrInvalidFile 629 } 630 return &Document{ 631 DocID: file.ID(), 632 Title: title, 633 Version: version, 634 SchemaSpec: schema, 635 RawContent: content, 636 }, nil 637 } 638 639 func getFromCache(inst *instance.Instance, noteID string) *Document { 640 cache := config.GetConfig().CacheStorage 641 buf, ok := cache.Get(cacheKey(inst, noteID)) 642 if !ok { 643 return nil 644 } 645 var doc Document 646 if err := json.Unmarshal(buf, &doc); err != nil { 647 return nil 648 } 649 return &doc 650 } 651 652 func cacheKey(inst *instance.Instance, noteID string) string { 653 return fmt.Sprintf("note:%s:%s", inst.Domain, noteID) 654 } 655 656 func saveToCache(inst *instance.Instance, doc *Document) error { 657 cache := config.GetConfig().CacheStorage 658 buf, err := json.Marshal(doc) 659 if err != nil { 660 return err 661 } 662 cache.Set(cacheKey(inst, doc.ID()), buf, cacheDuration) 663 return nil 664 } 665 666 func getListFromCache(inst *instance.Instance) []string { 667 cache := config.GetConfig().CacheStorage 668 prefix := fmt.Sprintf("note:%s:", inst.Domain) 669 keys := cache.Keys(prefix) 670 fileIDs := make([]string, len(keys)) 671 for i, key := range keys { 672 fileIDs[i] = strings.TrimPrefix(key, prefix) 673 } 674 return fileIDs 675 } 676 677 func GetText(inst *instance.Instance, file *vfs.FileDoc) (string, error) { 678 lock := inst.NotesLock() 679 if err := lock.Lock(); err != nil { 680 return "", err 681 } 682 defer lock.Unlock() 683 684 doc, err := get(inst, file) 685 if err != nil { 686 return "", err 687 } 688 689 return doc.Text() 690 } 691 692 // UpdateTitle changes the title of a note and renames the associated file. 693 func UpdateTitle(inst *instance.Instance, file *vfs.FileDoc, title, sessionID string) (*vfs.FileDoc, error) { 694 lock := inst.NotesLock() 695 if err := lock.Lock(); err != nil { 696 return nil, err 697 } 698 defer lock.Unlock() 699 700 doc, err := get(inst, file) 701 if err != nil { 702 return nil, err 703 } 704 705 if doc.Title == title { 706 return file, nil 707 } 708 doc.Title = title 709 if err := saveToCache(inst, doc); err != nil { 710 return nil, err 711 } 712 713 publishUpdatedTitle(inst, file.ID(), title, sessionID) 714 return doc.asFile(inst, file), nil 715 } 716 717 // Update is used to persist changes on a note to its file in the VFS. 718 func Update(inst *instance.Instance, fileID string) error { 719 lock := inst.NotesLock() 720 if err := lock.Lock(); err != nil { 721 return err 722 } 723 defer lock.Unlock() 724 725 old, err := inst.VFS().FileByID(fileID) 726 if err != nil { 727 return err 728 } 729 doc, err := get(inst, old) 730 if err != nil { 731 return err 732 } 733 734 oldVersion, _ := old.Metadata["version"].(float64) 735 if doc.Title == old.Metadata["title"] && 736 doc.Version == int64(oldVersion) && 737 consts.NoteMimeType == old.Mime { 738 // Nothing to do 739 return nil 740 } 741 742 _, err = writeFile(inst, doc, old) 743 if err != nil { 744 return err 745 } 746 purgeOldSteps(inst, fileID) 747 return nil 748 } 749 750 // UpdateSchema updates the schema of a note, and invalidates the previous steps. 751 func UpdateSchema(inst *instance.Instance, file *vfs.FileDoc, schema map[string]interface{}) (*vfs.FileDoc, error) { 752 lock := inst.NotesLock() 753 if err := lock.Lock(); err != nil { 754 return nil, err 755 } 756 defer lock.Unlock() 757 758 doc, err := get(inst, file) 759 if err != nil { 760 return nil, err 761 } 762 763 doc.SchemaSpec = schema 764 updated, err := writeFile(inst, doc, file) 765 if err != nil { 766 return nil, err 767 } 768 769 // Purging all steps can take a few seconds, so it is better to do that in 770 // a goroutine to avoid blocking the user that wants to read their note. 771 go func() { 772 defer func() { 773 if r := recover(); r != nil { 774 var err error 775 switch r := r.(type) { 776 case error: 777 err = r 778 default: 779 err = fmt.Errorf("%v", r) 780 } 781 stack := make([]byte, 4<<10) // 4 KB 782 length := runtime.Stack(stack, false) 783 log := inst.Logger().WithNamespace("note").WithField("panic", true) 784 log.Errorf("PANIC RECOVER %s: %s", err.Error(), stack[:length]) 785 } 786 }() 787 purgeAllSteps(inst, doc.ID()) 788 }() 789 790 return updated, nil 791 } 792 793 // FlushPendings is used to persist all the notes before an export. 794 func FlushPendings(inst *instance.Instance) error { 795 var errm error 796 list := getListFromCache(inst) 797 for _, fileID := range list { 798 if err := Update(inst, fileID); err != nil { 799 errm = multierror.Append(errm, err) 800 } 801 } 802 return errm 803 } 804 805 var _ couchdb.Doc = &Document{}