github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/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, &note); 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{}