package models import ( "encoding/json" "slices" "strings" "gorm.io/gorm" ) // A material's revision counts changes to its content only (D-03): the // title, type, body, source, url, course, zjazd (meeting) and tag set. // Visibility, shares, publication, the author and attached files never bump // it, so a writer holding an older revision is refused only when the note // itself changed since. // materialFingerprint is the content of a material that its revision // counts, in a fixed key order. type materialFingerprint struct { CourseID *uint `json:"course_id"` MeetingID *uint `json:"meeting_id"` Title string `json:"title"` Type string `json:"type"` Body *string `json:"body"` Source *string `json:"source"` URL *string `json:"url"` Tags []string `json:"tags"` } // MaterialFingerprint is the comparable form of m's content with the given // tag slugs: two materials with the same fingerprint have the same content // under D-03. Blank optional text counts as null, as BeforeSave stores it, // and the tag order does not matter. published_at, is_private, the author // and the shares are not inputs. func MaterialFingerprint(m Material, tagSlugs []string) string { body, source, url := m.Body, m.Source, m.URL blank(&body, &source, &url) tags := slices.Clone(tagSlugs) if tags == nil { tags = []string{} } slices.Sort(tags) raw, _ := json.Marshal(materialFingerprint{ CourseID: m.CourseID, MeetingID: m.MeetingID, Title: m.Title, Type: m.Type, Body: body, Source: source, URL: url, Tags: tags, }) return string(raw) } // materialState reads the content fingerprint and the revision of material // id through tx; a missing row is gorm.ErrRecordNotFound. func materialState(tx *gorm.DB, id uint) (string, uint, error) { db := tx.Session(&gorm.Session{NewDB: true}) var m Material res := db.Select("id", "course_id", "meeting_id", "title", "type", "body", "source", "url", "revision"). Where("id = ?", id).Limit(1).Find(&m) if res.Error != nil { return "", 0, res.Error } if res.RowsAffected == 0 { return "", 0, gorm.ErrRecordNotFound } var slugs []string err := db.Table(Tag{}.TableName()). Joins("JOIN "+MaterialTag{}.TableName()+" ON "+MaterialTag{}.TableName()+".tag_id = "+Tag{}.TableName()+".id"). Where(MaterialTag{}.TableName()+".material_id = ?", id). Pluck(Tag{}.TableName()+".slug", &slugs).Error if err != nil { return "", 0, err } for i := range slugs { slugs[i] = strings.TrimSpace(slugs[i]) } return MaterialFingerprint(m, slugs), m.Revision, nil } // MaterialContent reads the content fingerprint (MaterialFingerprint) of // material id and its tags through tx. A writer takes it before a write and // hands it to ReviseMaterial after; a missing row is gorm.ErrRecordNotFound. func MaterialContent(tx *gorm.DB, id uint) (string, error) { fp, _, err := materialState(tx, id) return fp, err } // ReviseMaterial bumps the revision of material id by one when its content // read through tx differs from before (a MaterialContent taken before the // write), and answers the revision the row has then and whether it went // up. Only content counts (D-03): a write that changed only visibility, // shares, publication or files, or that sent identical content, keeps the // revision. It is the only writer of the revision column, which GORM treats // as read-only. func ReviseMaterial(tx *gorm.DB, id uint, before string) (uint, bool, error) { after, current, err := materialState(tx, id) if err != nil { return 0, false, err } if after == before { return current, false, nil } var revision uint err = tx.Session(&gorm.Session{NewDB: true}). Raw("UPDATE jz_bm_materials SET revision = revision + 1 WHERE id = ? RETURNING revision", id). Scan(&revision).Error if err != nil { return 0, false, err } return revision, true, nil }