package story import ( "crypto/rand" "encoding/binary" "errors" "fmt" "os" "strconv" "time" "git.aiterp.net/rpdata/api/internal/store" "github.com/globalsign/mgo" "github.com/globalsign/mgo/bson" ) var storyCollection *mgo.Collection // ErrTagAlreadyExists is an error returned by Story.AddTag var ErrTagAlreadyExists = errors.New("Tag already exists") // ErrTagNotExists is an error returned by Story.RemoveTag var ErrTagNotExists = errors.New("Tag does not exist") // A Story is user content that does not have a wiki-suitable format. Documents, new stories, short stories, and so on. // The story model is a container for multiple chapters this time, in contrast to the previous version. type Story struct { ID string `bson:"_id"` Author string `bson:"author"` Name string `bson:"name"` Category string `bson:"category"` Open bool `bson:"open"` Listed bool `bson:"listed"` Tags []Tag `bson:"tags"` CreatedDate time.Time `bson:"createdDate"` FictionalDate time.Time `bson:"fictionalDate,omitempty"` UpdatedDate time.Time `bson:"updatedDate"` } // AddTag adds a tag to the story. It returns ErrTagAlreadyExists if the tag is already there func (story *Story) AddTag(tag Tag) error { for i := range story.Tags { if story.Tags[i].Equal(tag) { return ErrTagAlreadyExists } } err := storyCollection.UpdateId(story.ID, bson.M{"$push": bson.M{"tags": tag}}) if err != nil { return err } story.Tags = append(story.Tags, tag) return nil } // RemoveTag removes a tag to the story. It returns ErrTagNotExists if the tag does not exist. func (story *Story) RemoveTag(tag Tag) error { index := -1 for i := range story.Tags { if story.Tags[i].Equal(tag) { index = i break } } if index == -1 { return ErrTagNotExists } err := storyCollection.UpdateId(story.ID, bson.M{"$pull": bson.M{"tags": tag}}) if err != nil { return err } story.Tags = append(story.Tags[:index], story.Tags[index+1:]...) return nil } // Edit edits the story, reflecting the new values in the story's struct values. If nothing will be // changed, it will silently return without a database roundtrip. func (story *Story) Edit(name, category *string, listed, open *bool, fictionalDate *time.Time) error { changes := bson.M{} changed := *story if name != nil && *name != story.Name { changes["name"] = *name changed.Name = *name } if category != nil && *category != story.Category { changes["category"] = *category changed.Name = *category } if listed != nil && *listed != story.Listed { changes["listed"] = *listed changed.Listed = *listed } if open != nil && *open != story.Open { changes["open"] = *open changed.Open = *open } if fictionalDate != nil && !fictionalDate.Equal(story.FictionalDate) { changes["fictionalDate"] = *fictionalDate changed.FictionalDate = *fictionalDate } if len(changes) == 0 { return nil } err := storyCollection.UpdateId(story.ID, bson.M{"$set": changes}) if err != nil { return err } *story = changed return nil } // Remove the story from the database func (story *Story) Remove() error { return storyCollection.RemoveId(story.ID) } // Chapters calls ListChapterStoryID with the story's ID: func (story *Story) Chapters() ([]Chapter, error) { return ListChapterStoryID(story.ID) } // AddChapter adds a chapter to the story. This does not enforce the `Open` setting, but it will log a warning if it // occurs func (story *Story) AddChapter(title, author, source string, createdDate, finctionalDate time.Time) (Chapter, error) { if !story.Open && author != story.Author { fmt.Fprintf(os.Stderr, "WARNING: AddChapter is breaking Open rules (story.id=%#+v, story.name=%#+v, chapter.author=%#+v, chapter.title=%#+v)", story.ID, story.Name, author, title) } chapter := Chapter{ ID: makeChapterID(), StoryID: story.ID, Title: title, Author: author, Source: source, CreatedDate: createdDate, FictionalDate: finctionalDate, EditedDate: createdDate, } err := chapterCollection.Insert(chapter) if err != nil { return Chapter{}, err } if createdDate.After(story.UpdatedDate) { if err := storyCollection.UpdateId(story.ID, bson.M{"$set": bson.M{"updatedDate": createdDate}}); err == nil { story.UpdatedDate = createdDate } } return chapter, nil } // New creates a new story. func New(name, author, category string, listed, open bool, tags []Tag, createdDate, fictionalDate time.Time) (Story, error) { story := Story{ ID: makeStoryID(), Name: name, Author: author, Category: category, Listed: listed, Open: open, Tags: tags, CreatedDate: createdDate, FictionalDate: fictionalDate, UpdatedDate: createdDate, } err := storyCollection.Insert(story) if err != nil { return Story{}, err } return story, nil } // FindID finds a story by ID func FindID(id string) (Story, error) { story := Story{} err := storyCollection.FindId(id).One(&story) return story, err } // List lists stories by any non-zero criteria passed with it. func List(author string, tags []Tag, earliest, latest time.Time, limit int) ([]Story, error) { query := bson.M{} if author != "" { query["author"] = author } if len(tags) > 0 { query["tags"] = bson.M{"$in": tags} } if !earliest.IsZero() && !latest.IsZero() { query["fictionalDate"] = bson.M{ "$gte": earliest, "$lt": latest, } } else if !latest.IsZero() { query["fictionalDate"] = bson.M{ "$lt": latest, } } else if !earliest.IsZero() { query["fictionalDate"] = bson.M{ "$gte": earliest, } } stories := make([]Story, 0, 128) err := storyCollection.Find(query).Limit(limit).One(&stories) return stories, err } // makeStoryID makes a random story ID that's 16 characters long func makeStoryID() string { result := "S" offset := 0 data := make([]byte, 32) rand.Read(data) for len(result) < 16 { result += strconv.FormatUint(binary.LittleEndian.Uint64(data[offset:]), 36) offset += 8 if offset >= 32 { rand.Read(data) offset = 0 } } return result[:16] } func init() { store.HandleInit(func(db *mgo.Database) { storyCollection = db.C("story.stories") storyCollection.EnsureIndexKey("tags") storyCollection.EnsureIndexKey("author") storyCollection.EnsureIndexKey("updatedDate") storyCollection.EnsureIndexKey("fictionalDate") storyCollection.EnsureIndexKey("listed") }) }