diff --git a/arn/AMV.go b/arn/AMV.go new file mode 100644 index 00000000..7829e46b --- /dev/null +++ b/arn/AMV.go @@ -0,0 +1,267 @@ +package arn + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path" + + "github.com/aerogo/nano" + "github.com/animenotifier/notify.moe/arn/video" +) + +// AMV is an anime music video. +type AMV struct { + File string `json:"file" editable:"true" type:"upload" filetype:"video" endpoint:"/api/upload/amv/:id/file"` + Title AMVTitle `json:"title" editable:"true"` + MainAnimeID string `json:"mainAnimeId" editable:"true"` + ExtraAnimeIDs []string `json:"extraAnimeIds" editable:"true"` + VideoEditorIDs []string `json:"videoEditorIds" editable:"true"` + Links []Link `json:"links" editable:"true"` + Tags []string `json:"tags" editable:"true"` + Info video.Info `json:"info"` + + hasID + hasPosts + hasCreator + hasEditor + hasLikes + hasDraft +} + +// Link returns the permalink for the AMV. +func (amv *AMV) Link() string { + return "/amv/" + amv.ID +} + +// TitleByUser returns the preferred title for the given user. +func (amv *AMV) TitleByUser(user *User) string { + return amv.Title.ByUser(user) +} + +// SetVideoBytes sets the bytes for the video file. +func (amv *AMV) SetVideoBytes(data []byte) error { + fileName := amv.ID + ".webm" + filePath := path.Join(Root, "videos", "amvs", fileName) + err := ioutil.WriteFile(filePath, data, 0644) + + if err != nil { + return err + } + + // Run mkclean + optimizedFile := filePath + ".optimized" + + cmd := exec.Command( + "mkclean", + "--doctype", "4", + "--keep-cues", + "--optimize", + filePath, + optimizedFile, + ) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + err = cmd.Start() + + if err != nil { + return err + } + + err = cmd.Wait() + + if err != nil { + return err + } + + // Now delete the original file and replace it with the optimized file + err = os.Remove(filePath) + + if err != nil { + return err + } + + err = os.Rename(optimizedFile, filePath) + + if err != nil { + return err + } + + // Refresh video file info + amv.File = fileName + return amv.RefreshInfo() +} + +// RefreshInfo refreshes the information about the video file. +func (amv *AMV) RefreshInfo() error { + if amv.File == "" { + return fmt.Errorf("Video file has not been uploaded yet for AMV %s", amv.ID) + } + + info, err := video.GetInfo(path.Join(Root, "videos", "amvs", amv.File)) + + if err != nil { + return err + } + + amv.Info = *info + return nil +} + +// MainAnime returns main anime for the AMV, if available. +func (amv *AMV) MainAnime() *Anime { + mainAnime, _ := GetAnime(amv.MainAnimeID) + return mainAnime +} + +// ExtraAnime returns main anime for the AMV, if available. +func (amv *AMV) ExtraAnime() []*Anime { + objects := DB.GetMany("Anime", amv.ExtraAnimeIDs) + animes := []*Anime{} + + for _, obj := range objects { + if obj == nil { + continue + } + + animes = append(animes, obj.(*Anime)) + } + + return animes +} + +// VideoEditors returns a slice of all the users involved in creating the AMV. +func (amv *AMV) VideoEditors() []*User { + objects := DB.GetMany("User", amv.VideoEditorIDs) + editors := []*User{} + + for _, obj := range objects { + if obj == nil { + continue + } + + editors = append(editors, obj.(*User)) + } + + return editors +} + +// Publish turns the draft into a published object. +func (amv *AMV) Publish() error { + // No title + if amv.Title.String() == "" { + return errors.New("AMV doesn't have a title") + } + + // No anime found + if amv.MainAnimeID == "" && len(amv.ExtraAnimeIDs) == 0 { + return errors.New("Need to specify at least one anime") + } + + // No file uploaded + if amv.File == "" { + return errors.New("You need to upload a WebM file for this AMV") + } + + if _, err := os.Stat(path.Join(Root, "videos", "amvs", amv.File)); os.IsNotExist(err) { + return errors.New("You need to upload a WebM file for this AMV") + } + + return publish(amv) +} + +// Unpublish turns the object back into a draft. +func (amv *AMV) Unpublish() error { + return unpublish(amv) +} + +// OnLike is called when the AMV receives a like. +func (amv *AMV) OnLike(likedBy *User) { + if likedBy.ID == amv.CreatedBy { + return + } + + go func() { + amv.Creator().SendNotification(&PushNotification{ + Title: likedBy.Nick + " liked your AMV " + amv.Title.ByUser(amv.Creator()), + Message: likedBy.Nick + " liked your AMV " + amv.Title.ByUser(amv.Creator()) + ".", + Icon: "https:" + likedBy.AvatarLink("large"), + Link: "https://notify.moe" + likedBy.Link(), + Type: NotificationTypeLike, + }) + }() +} + +// String implements the default string serialization. +func (amv *AMV) String() string { + return amv.Title.ByUser(nil) +} + +// TypeName returns the type name. +func (amv *AMV) TypeName() string { + return "AMV" +} + +// Self returns the object itself. +func (amv *AMV) Self() Loggable { + return amv +} + +// GetAMV returns the AMV with the given ID. +func GetAMV(id string) (*AMV, error) { + obj, err := DB.Get("AMV", id) + + if err != nil { + return nil, err + } + + return obj.(*AMV), nil +} + +// StreamAMVs returns a stream of all AMVs. +func StreamAMVs() <-chan *AMV { + channel := make(chan *AMV, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("AMV") { + channel <- obj.(*AMV) + } + + close(channel) + }() + + return channel +} + +// AllAMVs returns a slice of all AMVs. +func AllAMVs() []*AMV { + all := make([]*AMV, 0, DB.Collection("AMV").Count()) + + stream := StreamAMVs() + + for obj := range stream { + all = append(all, obj) + } + + return all +} + +// FilterAMVs filters all AMVs by a custom function. +func FilterAMVs(filter func(*AMV) bool) []*AMV { + var filtered []*AMV + + for obj := range DB.All("AMV") { + realObject := obj.(*AMV) + + if filter(realObject) { + filtered = append(filtered, realObject) + } + } + + return filtered +} diff --git a/arn/AMVAPI.go b/arn/AMVAPI.go new file mode 100644 index 00000000..63e6f0c2 --- /dev/null +++ b/arn/AMVAPI.go @@ -0,0 +1,139 @@ +package arn + +import ( + "errors" + "fmt" + "os" + "path" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ Publishable = (*AMV)(nil) + _ Likeable = (*AMV)(nil) + _ LikeEventReceiver = (*AMV)(nil) + _ PostParent = (*AMV)(nil) + _ fmt.Stringer = (*AMV)(nil) + _ api.Newable = (*AMV)(nil) + _ api.Editable = (*AMV)(nil) + _ api.Deletable = (*AMV)(nil) + _ api.ArrayEventListener = (*AMV)(nil) +) + +// Actions +func init() { + API.RegisterActions("AMV", []*api.Action{ + // Publish + PublishAction(), + + // Unpublish + UnpublishAction(), + + // Like + LikeAction(), + + // Unlike + UnlikeAction(), + }) +} + +// Create sets the data for a new AMV with data we received from the API request. +func (amv *AMV) Create(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + amv.ID = GenerateID("AMV") + amv.Created = DateTimeUTC() + amv.CreatedBy = user.ID + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "create", "AMV", amv.ID, "", "", "") + logEntry.Save() + + return amv.Unpublish() +} + +// Edit creates an edit log entry. +func (amv *AMV) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + return edit(amv, ctx, key, value, newValue) +} + +// OnAppend saves a log entry. +func (amv *AMV) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(amv, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (amv *AMV) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(amv, ctx, key, index, obj) +} + +// DeleteInContext deletes the amv in the given context. +func (amv *AMV) DeleteInContext(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "delete", "AMV", amv.ID, "", fmt.Sprint(amv), "") + logEntry.Save() + + return amv.Delete() +} + +// Delete deletes the object from the database. +func (amv *AMV) Delete() error { + if amv.IsDraft { + draftIndex := amv.Creator().DraftIndex() + draftIndex.AMVID = "" + draftIndex.Save() + } + + // Remove posts + for _, post := range amv.Posts() { + err := post.Delete() + + if err != nil { + return err + } + } + + // Remove file + if amv.File != "" { + err := os.Remove(path.Join(Root, "videos", "amvs", amv.File)) + + if err != nil { + return err + } + } + + DB.Delete("AMV", amv.ID) + return nil +} + +// Authorize returns an error if the given API POST request is not authorized. +func (amv *AMV) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + if action == "delete" { + if user.Role != "editor" && user.Role != "admin" { + return errors.New("Insufficient permissions") + } + } + + return nil +} + +// Save saves the amv object in the database. +func (amv *AMV) Save() { + DB.Set("AMV", amv.ID, amv) +} diff --git a/arn/AMVTitle.go b/arn/AMVTitle.go new file mode 100644 index 00000000..b25e1a00 --- /dev/null +++ b/arn/AMVTitle.go @@ -0,0 +1,32 @@ +package arn + +// AMVTitle is the same as a soundtrack title. +type AMVTitle SoundTrackTitle + +// String is the default representation of the title. +func (title *AMVTitle) String() string { + return title.ByUser(nil) +} + +// ByUser returns the preferred title for the given user. +func (title *AMVTitle) ByUser(user *User) string { + if user == nil { + if title.Canonical != "" { + return title.Canonical + } + + return title.Native + } + + switch user.Settings().TitleLanguage { + case "japanese": + if title.Native == "" { + return title.Canonical + } + + return title.Native + + default: + return title.ByUser(nil) + } +} diff --git a/arn/APIKeys.go b/arn/APIKeys.go new file mode 100644 index 00000000..cac7a983 --- /dev/null +++ b/arn/APIKeys.go @@ -0,0 +1,121 @@ +package arn + +import ( + "io/ioutil" + "os" + "path" + + "github.com/animenotifier/anilist" + "github.com/animenotifier/osu" + jsoniter "github.com/json-iterator/go" +) + +// Root is the full path to the root directory of notify.moe repository. +var Root = os.Getenv("ARN_ROOT") + +// APIKeys are global API keys for several services +var APIKeys APIKeysData + +// APIKeysData ... +type APIKeysData struct { + Google struct { + ID string `json:"id"` + Secret string `json:"secret"` + } `json:"google"` + + Facebook struct { + ID string `json:"id"` + Secret string `json:"secret"` + } `json:"facebook"` + + Twitter struct { + ID string `json:"id"` + Secret string `json:"secret"` + } `json:"twitter"` + + Discord struct { + ID string `json:"id"` + Secret string `json:"secret"` + Token string `json:"token"` + } `json:"discord"` + + SoundCloud struct { + ID string `json:"id"` + Secret string `json:"secret"` + } `json:"soundcloud"` + + GoogleAPI struct { + Key string `json:"key"` + } `json:"googleAPI"` + + IPInfoDB struct { + ID string `json:"id"` + } `json:"ipInfoDB"` + + AniList struct { + ID string `json:"id"` + Secret string `json:"secret"` + } `json:"anilist"` + + Osu struct { + Secret string `json:"secret"` + } `json:"osu"` + + PayPal struct { + ID string `json:"id"` + Secret string `json:"secret"` + } `json:"paypal"` + + VAPID struct { + Subject string `json:"subject"` + PublicKey string `json:"publicKey"` + PrivateKey string `json:"privateKey"` + } `json:"vapid"` + + SMTP struct { + Server string `json:"server"` + Address string `json:"address"` + Password string `json:"password"` + } `json:"smtp"` + + S3 struct { + ID string `json:"id"` + Secret string `json:"secret"` + } `json:"s3"` +} + +func init() { + // Path for API keys + apiKeysPath := path.Join(Root, "security/api-keys.json") + + // If the API keys file is not available, create a symlink to the default API keys + if _, err := os.Stat(apiKeysPath); os.IsNotExist(err) { + defaultAPIKeysPath := path.Join(Root, "security/default/api-keys.json") + err := os.Link(defaultAPIKeysPath, apiKeysPath) + + if err != nil { + panic(err) + } + } + + // Load API keys + data, err := ioutil.ReadFile(apiKeysPath) + + if err != nil { + panic(err) + } + + // Parse JSON + err = jsoniter.Unmarshal(data, &APIKeys) + + if err != nil { + panic(err) + } + + // Set Osu API key + osu.APIKey = APIKeys.Osu.Secret + + // Set Anilist API keys + anilist.APIKeyID = APIKeys.AniList.ID + anilist.APIKeySecret = APIKeys.AniList.Secret +} diff --git a/arn/Activity.go b/arn/Activity.go new file mode 100644 index 00000000..282e024c --- /dev/null +++ b/arn/Activity.go @@ -0,0 +1,81 @@ +package arn + +import ( + "sort" + "sync" + "time" + + "github.com/aerogo/nano" +) + +// Activity is a user activity that appears in the follower's feeds. +type Activity interface { + Creator() *User + TypeName() string + GetID() string + GetCreated() string + GetCreatedBy() UserID + GetCreatedTime() time.Time +} + +// SortActivitiesLatestFirst puts the latest entries on top. +func SortActivitiesLatestFirst(entries []Activity) { + sort.Slice(entries, func(i, j int) bool { + return entries[i].GetCreated() > entries[j].GetCreated() + }) +} + +// StreamActivities returns a stream of all activities. +func StreamActivities() <-chan Activity { + channel := make(chan Activity, nano.ChannelBufferSize) + wg := sync.WaitGroup{} + wg.Add(2) + + go func() { + for obj := range DB.All("ActivityCreate") { + channel <- obj.(Activity) + } + + wg.Done() + }() + + go func() { + for obj := range DB.All("ActivityConsumeAnime") { + channel <- obj.(Activity) + } + + wg.Done() + }() + + go func() { + wg.Wait() + close(channel) + }() + + return channel +} + +// AllActivities returns a slice of all activities. +func AllActivities() []Activity { + all := make([]Activity, 0, DB.Collection("ActivityCreate").Count()+DB.Collection("ActivityConsumeAnime").Count()) + stream := StreamActivities() + + for obj := range stream { + all = append(all, obj) + } + + return all +} + +// FilterActivities filters all Activities by a custom function. +func FilterActivities(filter func(Activity) bool) []Activity { + var filtered []Activity + + for obj := range StreamActivities() { + if filter(obj) { + filtered = append(filtered, obj) + } + } + + return filtered +} diff --git a/arn/ActivityConsumeAnime.go b/arn/ActivityConsumeAnime.go new file mode 100644 index 00000000..85d842e4 --- /dev/null +++ b/arn/ActivityConsumeAnime.go @@ -0,0 +1,97 @@ +package arn + +import "sort" + +// ActivityConsumeAnime is a user activity that consumes anime. +type ActivityConsumeAnime struct { + AnimeID string `json:"animeId"` + FromEpisode int `json:"fromEpisode"` + ToEpisode int `json:"toEpisode"` + + hasID + hasCreator + hasLikes +} + +// NewActivityConsumeAnime creates a new activity. +func NewActivityConsumeAnime(animeID string, fromEpisode int, toEpisode int, userID UserID) *ActivityConsumeAnime { + return &ActivityConsumeAnime{ + hasID: hasID{ + ID: GenerateID("ActivityConsumeAnime"), + }, + hasCreator: hasCreator{ + Created: DateTimeUTC(), + CreatedBy: userID, + }, + AnimeID: animeID, + FromEpisode: fromEpisode, + ToEpisode: toEpisode, + } +} + +// Anime returns the anime. +func (activity *ActivityConsumeAnime) Anime() *Anime { + anime, _ := GetAnime(activity.AnimeID) + return anime +} + +// TypeName returns the type name. +func (activity *ActivityConsumeAnime) TypeName() string { + return "ActivityConsumeAnime" +} + +// Self returns the object itself. +func (activity *ActivityConsumeAnime) Self() Loggable { + return activity +} + +// LastActivityConsumeAnime returns the last activity for the given anime. +func (user *User) LastActivityConsumeAnime(animeID string) *ActivityConsumeAnime { + activities := FilterActivitiesConsumeAnime(func(activity *ActivityConsumeAnime) bool { + return activity.AnimeID == animeID && activity.CreatedBy == user.ID + }) + + if len(activities) == 0 { + return nil + } + + sort.Slice(activities, func(i, j int) bool { + return activities[i].Created > activities[j].Created + }) + + return activities[0] +} + +// FilterActivitiesConsumeAnime filters all anime consumption activities by a custom function. +func FilterActivitiesConsumeAnime(filter func(*ActivityConsumeAnime) bool) []*ActivityConsumeAnime { + var filtered []*ActivityConsumeAnime + + for obj := range DB.All("ActivityConsumeAnime") { + realObject := obj.(*ActivityConsumeAnime) + + if filter(realObject) { + filtered = append(filtered, realObject) + } + } + + return filtered +} + +// // OnLike is called when the activity receives a like. +// func (activity *Activity) OnLike(likedBy *User) { +// if likedBy.ID == activity.CreatedBy { +// return +// } + +// go func() { +// notifyUser := activity.Creator() + +// notifyUser.SendNotification(&PushNotification{ +// Title: likedBy.Nick + " liked your activity", +// Message: activity.TextByUser(notifyUser), +// Icon: "https:" + likedBy.AvatarLink("large"), +// Link: "https://notify.moe" + activity.Link(), +// Type: NotificationTypeLike, +// }) +// }() +// } diff --git a/arn/ActivityConsumeAnimeAPI.go b/arn/ActivityConsumeAnimeAPI.go new file mode 100644 index 00000000..68738c30 --- /dev/null +++ b/arn/ActivityConsumeAnimeAPI.go @@ -0,0 +1,80 @@ +package arn + +import ( + "errors" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ Activity = (*ActivityConsumeAnime)(nil) + _ api.Deletable = (*ActivityConsumeAnime)(nil) + _ api.Savable = (*ActivityConsumeAnime)(nil) +) + +// Authorize returns an error if the given API POST request is not authorized. +func (activity *ActivityConsumeAnime) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + if user.ID != activity.CreatedBy { + return errors.New("Can't modify activities from other users") + } + + return nil +} + +// Save saves the activity object in the database. +func (activity *ActivityConsumeAnime) Save() { + DB.Set("ActivityConsumeAnime", activity.ID, activity) +} + +// DeleteInContext deletes the activity in the given context. +func (activity *ActivityConsumeAnime) DeleteInContext(ctx aero.Context) error { + return activity.Delete() +} + +// Delete deletes the object from the database. +func (activity *ActivityConsumeAnime) Delete() error { + DB.Delete("ActivityConsumeAnime", activity.ID) + return nil +} + +// // Force interface implementations +// var ( +// _ Likeable = (*Activity)(nil) +// _ LikeEventReceiver = (*Activity)(nil) +// _ api.Deletable = (*Activity)(nil) +// ) + +// // Actions +// func init() { +// API.RegisterActions("Activity", []*api.Action{ +// // Like +// LikeAction(), + +// // Unlike +// UnlikeAction(), +// }) +// } + +// // Authorize returns an error if the given API request is not authorized. +// func (activity *Activity) Authorize(ctx aero.Context, action string) error { +// user := GetUserFromContext(ctx) + +// if user == nil { +// return errors.New("Not logged in") +// } + +// return nil +// } + +// // DeleteInContext deletes the activity in the given context. +// func (activity *Activity) DeleteInContext(ctx aero.Context) error { +// return activity.Delete() +// } diff --git a/arn/ActivityCreate.go b/arn/ActivityCreate.go new file mode 100644 index 00000000..a4694e4c --- /dev/null +++ b/arn/ActivityCreate.go @@ -0,0 +1,63 @@ +package arn + +import "github.com/aerogo/nano" + +// ActivityCreate is a user activity that creates something. +type ActivityCreate struct { + ObjectType string `json:"objectType"` + ObjectID string `json:"objectId"` + + hasID + hasCreator +} + +// NewActivityCreate creates a new activity. +func NewActivityCreate(objectType string, objectID string, userID UserID) *ActivityCreate { + return &ActivityCreate{ + hasID: hasID{ + ID: GenerateID("ActivityCreate"), + }, + hasCreator: hasCreator{ + Created: DateTimeUTC(), + CreatedBy: userID, + }, + ObjectType: objectType, + ObjectID: objectID, + } +} + +// Object returns the object that was created. +func (activity *ActivityCreate) Object() Likeable { + obj, _ := DB.Get(activity.ObjectType, activity.ObjectID) + return obj.(Likeable) +} + +// Postable casts the object to the Postable interface. +func (activity *ActivityCreate) Postable() Postable { + return activity.Object().(Postable) +} + +// TypeName returns the type name. +func (activity *ActivityCreate) TypeName() string { + return "ActivityCreate" +} + +// Self returns the object itself. +func (activity *ActivityCreate) Self() Loggable { + return activity +} + +// StreamActivityCreates returns a stream of all ActivityCreate objects. +func StreamActivityCreates() <-chan *ActivityCreate { + channel := make(chan *ActivityCreate, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("ActivityCreate") { + channel <- obj.(*ActivityCreate) + } + + close(channel) + }() + + return channel +} diff --git a/arn/ActivityCreateAPI.go b/arn/ActivityCreateAPI.go new file mode 100644 index 00000000..0db5837c --- /dev/null +++ b/arn/ActivityCreateAPI.go @@ -0,0 +1,22 @@ +package arn + +import ( + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ Activity = (*ActivityCreate)(nil) + _ api.Savable = (*ActivityCreate)(nil) +) + +// Save saves the activity object in the database. +func (activity *ActivityCreate) Save() { + DB.Set("ActivityCreate", activity.ID, activity) +} + +// Delete deletes the activity object from the database. +func (activity *ActivityCreate) Delete() error { + DB.Delete("ActivityCreate", activity.ID) + return nil +} diff --git a/arn/AiringDate.go b/arn/AiringDate.go new file mode 100644 index 00000000..24fa0c69 --- /dev/null +++ b/arn/AiringDate.go @@ -0,0 +1,43 @@ +package arn + +import ( + "time" +) + +// AiringDate represents the airing date of an anime. +type AiringDate struct { + Start string `json:"start" editable:"true"` + End string `json:"end" editable:"true"` +} + +// StartDateHuman returns the start date of the anime in human readable form. +func (airing *AiringDate) StartDateHuman() string { + t, _ := time.Parse(time.RFC3339, airing.Start) + humanReadable := t.Format(time.RFC1123) + + return humanReadable[:len("Thu, 25 May 2017")] +} + +// EndDateHuman returns the end date of the anime in human readable form. +func (airing *AiringDate) EndDateHuman() string { + t, _ := time.Parse(time.RFC3339, airing.End) + humanReadable := t.Format(time.RFC1123) + + return humanReadable[:len("Thu, 25 May 2017")] +} + +// StartTimeHuman returns the start time of the anime in human readable form. +func (airing *AiringDate) StartTimeHuman() string { + t, _ := time.Parse(time.RFC3339, airing.Start) + humanReadable := t.Format(time.RFC1123) + + return humanReadable[len("Thu, 25 May 2017 "):] +} + +// EndTimeHuman returns the end time of the anime in human readable form. +func (airing *AiringDate) EndTimeHuman() string { + t, _ := time.Parse(time.RFC3339, airing.End) + humanReadable := t.Format(time.RFC1123) + + return humanReadable[len("Thu, 25 May 2017 "):] +} diff --git a/arn/Analytics.go b/arn/Analytics.go new file mode 100644 index 00000000..6dd120cb --- /dev/null +++ b/arn/Analytics.go @@ -0,0 +1,78 @@ +package arn + +import "github.com/aerogo/nano" + +// Analytics stores user-related statistics. +type Analytics struct { + UserID string `json:"userId"` + General GeneralAnalytics `json:"general"` + Screen ScreenAnalytics `json:"screen"` + System SystemAnalytics `json:"system"` + Connection ConnectionAnalytics `json:"connection"` +} + +// GeneralAnalytics stores general information. +type GeneralAnalytics struct { + TimezoneOffset int `json:"timezoneOffset"` +} + +// ScreenAnalytics stores information about the device screen. +type ScreenAnalytics struct { + Width int `json:"width"` + Height int `json:"height"` + AvailableWidth int `json:"availableWidth"` + AvailableHeight int `json:"availableHeight"` + PixelRatio float64 `json:"pixelRatio"` +} + +// SystemAnalytics stores information about the CPU and OS. +type SystemAnalytics struct { + CPUCount int `json:"cpuCount"` + Platform string `json:"platform"` +} + +// ConnectionAnalytics stores information about connection speed and ping. +type ConnectionAnalytics struct { + DownLink float64 `json:"downLink"` + RoundTripTime float64 `json:"roundTripTime"` + EffectiveType string `json:"effectiveType"` +} + +// GetAnalytics returns the analytics for the given user ID. +func GetAnalytics(userID UserID) (*Analytics, error) { + obj, err := DB.Get("Analytics", userID) + + if err != nil { + return nil, err + } + + return obj.(*Analytics), nil +} + +// StreamAnalytics returns a stream of all analytics. +func StreamAnalytics() <-chan *Analytics { + channel := make(chan *Analytics, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Analytics") { + channel <- obj.(*Analytics) + } + + close(channel) + }() + + return channel +} + +// AllAnalytics returns a slice of all analytics. +func AllAnalytics() []*Analytics { + all := make([]*Analytics, 0, DB.Collection("Analytics").Count()) + + stream := StreamAnalytics() + + for obj := range stream { + all = append(all, obj) + } + + return all +} diff --git a/arn/AnalyticsAPI.go b/arn/AnalyticsAPI.go new file mode 100644 index 00000000..d42d9935 --- /dev/null +++ b/arn/AnalyticsAPI.go @@ -0,0 +1,41 @@ +package arn + +import ( + "github.com/aerogo/aero" + "github.com/aerogo/api" + jsoniter "github.com/json-iterator/go" +) + +// Force interface implementations +var ( + _ api.Newable = (*Analytics)(nil) +) + +// Authorize returns an error if the given API POST request is not authorized. +func (analytics *Analytics) Authorize(ctx aero.Context, action string) error { + return AuthorizeIfLoggedIn(ctx) +} + +// Create creates a new analytics object. +func (analytics *Analytics) Create(ctx aero.Context) error { + body, err := ctx.Request().Body().Bytes() + + if err != nil { + return err + } + + err = jsoniter.Unmarshal(body, analytics) + + if err != nil { + return err + } + + analytics.UserID = GetUserFromContext(ctx).ID + + return nil +} + +// Save saves the analytics in the database. +func (analytics *Analytics) Save() { + DB.Set("Analytics", analytics.UserID, analytics) +} diff --git a/arn/AniList.go b/arn/AniList.go new file mode 100644 index 00000000..d764cf19 --- /dev/null +++ b/arn/AniList.go @@ -0,0 +1,95 @@ +package arn + +import ( + "fmt" + + "github.com/animenotifier/anilist" +) + +// AniListAnimeFinder holds an internal map of ID to anime mappings +// and is therefore very efficient to use when trying to find +// anime by a given service and ID. +type AniListAnimeFinder struct { + idToAnime map[string]*Anime + malIDToAnime map[string]*Anime +} + +// NewAniListAnimeFinder creates a new finder for Anilist anime. +func NewAniListAnimeFinder() *AniListAnimeFinder { + finder := &AniListAnimeFinder{ + idToAnime: map[string]*Anime{}, + malIDToAnime: map[string]*Anime{}, + } + + for anime := range StreamAnime() { + id := anime.GetMapping("anilist/anime") + + if id != "" { + finder.idToAnime[id] = anime + } + + malID := anime.GetMapping("myanimelist/anime") + + if malID != "" { + finder.malIDToAnime[malID] = anime + } + } + + return finder +} + +// GetAnime tries to find an AniList anime in our anime database. +func (finder *AniListAnimeFinder) GetAnime(id string, malID string) *Anime { + animeByID, existsByID := finder.idToAnime[id] + animeByMALID, existsByMALID := finder.malIDToAnime[malID] + + // Add anilist mapping to the MAL mapped anime if it's missing + if existsByMALID && animeByMALID.GetMapping("anilist/anime") != id { + animeByMALID.SetMapping("anilist/anime", id) + animeByMALID.Save() + + finder.idToAnime[id] = animeByMALID + } + + // If both MAL ID and AniList ID are matched, but the matched anime are different, + // while the MAL IDs are different as well, + // then we're trusting the MAL ID matching more and deleting the incorrect mapping. + if existsByID && existsByMALID && animeByID.ID != animeByMALID.ID && animeByID.GetMapping("myanimelist/anime") != animeByMALID.GetMapping("myanimelist/anime") { + animeByID.RemoveMapping("anilist/anime") + animeByID.Save() + + delete(finder.idToAnime, id) + + fmt.Println("MAL / Anilist mismatch:") + fmt.Println(animeByID.ID, animeByID) + fmt.Println(animeByMALID.ID, animeByMALID) + } + + if existsByID { + return animeByID + } + + if existsByMALID { + return animeByMALID + } + + return nil +} + +// AniListAnimeListStatus returns the ARN version of the anime status. +func AniListAnimeListStatus(item *anilist.AnimeListItem) string { + switch item.Status { + case "CURRENT", "REPEATING": + return AnimeListStatusWatching + case "COMPLETED": + return AnimeListStatusCompleted + case "PLANNING": + return AnimeListStatusPlanned + case "PAUSED": + return AnimeListStatusHold + case "DROPPED": + return AnimeListStatusDropped + default: + return AnimeListStatusPlanned + } +} diff --git a/arn/AniListMatch.go b/arn/AniListMatch.go new file mode 100644 index 00000000..27f96744 --- /dev/null +++ b/arn/AniListMatch.go @@ -0,0 +1,19 @@ +package arn + +import ( + "github.com/animenotifier/anilist" + jsoniter "github.com/json-iterator/go" +) + +// AniListMatch ... +type AniListMatch struct { + AniListItem *anilist.AnimeListItem `json:"anilistItem"` + ARNAnime *Anime `json:"arnAnime"` +} + +// JSON ... +func (match *AniListMatch) JSON() string { + b, err := jsoniter.Marshal(match) + PanicOnError(err) + return string(b) +} diff --git a/arn/Anime.go b/arn/Anime.go new file mode 100644 index 00000000..bbfd6b7c --- /dev/null +++ b/arn/Anime.go @@ -0,0 +1,1015 @@ +package arn + +import ( + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/aerogo/nano" + "github.com/animenotifier/notify.moe/arn/validate" + "github.com/animenotifier/twist" + + "github.com/akyoto/color" + "github.com/animenotifier/kitsu" + "github.com/animenotifier/shoboi" +) + +// AnimeDateFormat describes the anime date format for the date conversion. +const AnimeDateFormat = validate.DateFormat + +// AnimeSourceHumanReadable maps the anime source to a human readable version. +var AnimeSourceHumanReadable = map[string]string{} + +// Register a list of supported anime status and source types. +func init() { + DataLists["anime-types"] = []*Option{ + {"tv", "TV"}, + {"movie", "Movie"}, + {"ova", "OVA"}, + {"ona", "ONA"}, + {"special", "Special"}, + {"music", "Music"}, + } + + DataLists["anime-status"] = []*Option{ + {"current", "Current"}, + {"finished", "Finished"}, + {"upcoming", "Upcoming"}, + {"tba", "To be announced"}, + } + + DataLists["anime-sources"] = []*Option{ + {"", "Unknown"}, + {"original", "Original"}, + {"manga", "Manga"}, + {"novel", "Novel"}, + {"light novel", "Light novel"}, + {"visual novel", "Visual novel"}, + {"game", "Game"}, + {"book", "Book"}, + {"4-koma manga", "4-koma Manga"}, + {"music", "Music"}, + {"picture book", "Picture book"}, + {"web manga", "Web manga"}, + {"other", "Other"}, + } + + for _, option := range DataLists["anime-sources"] { + AnimeSourceHumanReadable[option.Value] = option.Label + } +} + +// Anime represents an anime. +type Anime struct { + Type string `json:"type" editable:"true" datalist:"anime-types"` + Title *AnimeTitle `json:"title" editable:"true"` + Summary string `json:"summary" editable:"true" type:"textarea"` + Status string `json:"status" editable:"true" datalist:"anime-status"` + Genres []string `json:"genres" editable:"true"` + StartDate string `json:"startDate" editable:"true"` + EndDate string `json:"endDate" editable:"true"` + EpisodeCount int `json:"episodeCount" editable:"true"` + EpisodeLength int `json:"episodeLength" editable:"true"` + Source string `json:"source" editable:"true" datalist:"anime-sources"` + Image AnimeImage `json:"image"` + FirstChannel string `json:"firstChannel"` + Rating *AnimeRating `json:"rating"` + Popularity *AnimePopularity `json:"popularity"` + Trailers []*ExternalMedia `json:"trailers" editable:"true"` + + // Mixins + hasID + hasMappings + hasPosts + hasLikes + hasCreator + hasEditor + hasDraft + + // Company IDs + StudioIDs []string `json:"studios" editable:"true"` + ProducerIDs []string `json:"producers" editable:"true"` + LicensorIDs []string `json:"licensors" editable:"true"` + + // Links to external websites + Links []*Link `json:"links" editable:"true"` + + // SynopsisSource string `json:"synopsisSource" editable:"true"` + // Hashtag string `json:"hashtag"` +} + +// NewAnime creates a new anime. +func NewAnime() *Anime { + return &Anime{ + hasID: hasID{ + ID: GenerateID("Anime"), + }, + Title: &AnimeTitle{}, + Rating: &AnimeRating{}, + Popularity: &AnimePopularity{}, + Trailers: []*ExternalMedia{}, + hasCreator: hasCreator{ + Created: DateTimeUTC(), + }, + hasMappings: hasMappings{ + Mappings: []*Mapping{}, + }, + } +} + +// GetAnime gets the anime with the given ID. +func GetAnime(id string) (*Anime, error) { + obj, err := DB.Get("Anime", id) + + if err != nil { + return nil, err + } + + return obj.(*Anime), nil +} + +// TitleByUser returns the preferred title for the given user. +func (anime *Anime) TitleByUser(user *User) string { + return anime.Title.ByUser(user) +} + +// AddStudio adds the company ID to the studio ID list if it doesn't exist already. +func (anime *Anime) AddStudio(companyID string) { + // Is the ID valid? + if companyID == "" { + return + } + + // If it already exists we don't need to add it + for _, id := range anime.StudioIDs { + if id == companyID { + return + } + } + + anime.StudioIDs = append(anime.StudioIDs, companyID) +} + +// AddProducer adds the company ID to the producer ID list if it doesn't exist already. +func (anime *Anime) AddProducer(companyID string) { + // Is the ID valid? + if companyID == "" { + return + } + + // If it already exists we don't need to add it + for _, id := range anime.ProducerIDs { + if id == companyID { + return + } + } + + anime.ProducerIDs = append(anime.ProducerIDs, companyID) +} + +// AddLicensor adds the company ID to the licensor ID list if it doesn't exist already. +func (anime *Anime) AddLicensor(companyID string) { + // Is the ID valid? + if companyID == "" { + return + } + + // If it already exists we don't need to add it + for _, id := range anime.LicensorIDs { + if id == companyID { + return + } + } + + anime.LicensorIDs = append(anime.LicensorIDs, companyID) +} + +// Studios returns the list of studios for this anime. +func (anime *Anime) Studios() []*Company { + companies := []*Company{} + + for _, obj := range DB.GetMany("Company", anime.StudioIDs) { + if obj == nil { + continue + } + + companies = append(companies, obj.(*Company)) + } + + return companies +} + +// Producers returns the list of producers for this anime. +func (anime *Anime) Producers() []*Company { + companies := []*Company{} + + for _, obj := range DB.GetMany("Company", anime.ProducerIDs) { + if obj == nil { + continue + } + + companies = append(companies, obj.(*Company)) + } + + return companies +} + +// Licensors returns the list of licensors for this anime. +func (anime *Anime) Licensors() []*Company { + companies := []*Company{} + + for _, obj := range DB.GetMany("Company", anime.LicensorIDs) { + if obj == nil { + continue + } + + companies = append(companies, obj.(*Company)) + } + + return companies +} + +// Prequels returns the list of prequels for that anime. +func (anime *Anime) Prequels() []*Anime { + prequels := []*Anime{} + relations := anime.Relations() + + relations.Lock() + defer relations.Unlock() + + for _, relation := range relations.Items { + if relation.Type != "prequel" { + continue + } + + prequel := relation.Anime() + + if prequel == nil { + color.Red("Anime %s has invalid anime relation ID %s", anime.ID, relation.AnimeID) + continue + } + + prequels = append(prequels, prequel) + } + + return prequels +} + +// ImageLink ... +func (anime *Anime) ImageLink(size string) string { + extension := ".jpg" + + if size == "original" { + extension = anime.Image.Extension + } + + return fmt.Sprintf("//%s/images/anime/%s/%s%s?%v", MediaHost, size, anime.ID, extension, anime.Image.LastModified) +} + +// HasImage returns whether the anime has an image or not. +func (anime *Anime) HasImage() bool { + return anime.Image.Extension != "" && anime.Image.Width > 0 +} + +// AverageColor returns the average color of the image. +func (anime *Anime) AverageColor() string { + color := anime.Image.AverageColor + + if color.Hue == 0 && color.Saturation == 0 && color.Lightness == 0 { + return "" + } + + return color.String() +} + +// Season returns the season the anime started airing in. +func (anime *Anime) Season() string { + if !validate.Date(anime.StartDate) { + return "" + } + + return DateToSeason(anime.StartDateTime()) +} + +// Characters ... +func (anime *Anime) Characters() *AnimeCharacters { + characters, _ := GetAnimeCharacters(anime.ID) + + if characters != nil { + // TODO: Sort by role in sync-characters job + // Sort by role + sort.Slice(characters.Items, func(i, j int) bool { + // A little trick because "main" < "supporting" + return characters.Items[i].Role < characters.Items[j].Role + }) + } + + return characters +} + +// Relations ... +func (anime *Anime) Relations() *AnimeRelations { + relations, _ := GetAnimeRelations(anime.ID) + return relations +} + +// Link returns the URI to the anime page. +func (anime *Anime) Link() string { + return "/anime/" + anime.ID +} + +// StartDateTime returns the start date as a time object. +func (anime *Anime) StartDateTime() time.Time { + format := AnimeDateFormat + + switch { + case len(anime.StartDate) >= len(AnimeDateFormat): + // ... + case len(anime.StartDate) >= len("2006-01"): + format = "2006-01" + case len(anime.StartDate) >= len("2006"): + format = "2006" + } + + t, _ := time.Parse(format, anime.StartDate) + return t +} + +// EndDateTime returns the end date as a time object. +func (anime *Anime) EndDateTime() time.Time { + format := AnimeDateFormat + + switch { + case len(anime.EndDate) >= len(AnimeDateFormat): + // ... + case len(anime.EndDate) >= len("2006-01"): + format = "2006-01" + case len(anime.EndDate) >= len("2006"): + format = "2006" + } + + t, _ := time.Parse(format, anime.EndDate) + return t +} + +// Episodes returns the anime episodes wrapper. +func (anime *Anime) Episodes() *AnimeEpisodes { + record, err := DB.Get("AnimeEpisodes", anime.ID) + + if err != nil { + return nil + } + + return record.(*AnimeEpisodes) +} + +// UsersWatchingOrPlanned returns a list of users who are watching the anime right now. +func (anime *Anime) UsersWatchingOrPlanned() []*User { + users := FilterUsers(func(user *User) bool { + item := user.AnimeList().Find(anime.ID) + + if item == nil { + return false + } + + return item.Status == AnimeListStatusWatching || item.Status == AnimeListStatusPlanned + }) + + return users +} + +// RefreshEpisodes will refresh the episode data. +func (anime *Anime) RefreshEpisodes() error { + // Fetch episodes + episodes := anime.Episodes() + + if episodes == nil { + episodes = &AnimeEpisodes{ + AnimeID: anime.ID, + Items: []*AnimeEpisode{}, + } + } + + // Save number of available episodes for comparison later + oldAvailableCount := episodes.AvailableCount() + + // Shoboi + shoboiEpisodes, err := anime.ShoboiEpisodes() + + if err != nil { + color.Red(err.Error()) + } + + episodes.Merge(shoboiEpisodes) + + // AnimeTwist + twistEpisodes, err := anime.TwistEpisodes() + + if err != nil { + color.Red(err.Error()) + } + + episodes.Merge(twistEpisodes) + + // Count number of available episodes + newAvailableCount := episodes.AvailableCount() + + if anime.Status != "finished" && newAvailableCount > oldAvailableCount { + // New episodes have been released. + // Notify all users who are watching the anime. + go func() { + for _, user := range anime.UsersWatchingOrPlanned() { + if !user.Settings().Notification.AnimeEpisodeReleases { + continue + } + + user.SendNotification(&PushNotification{ + Title: anime.Title.ByUser(user), + Message: "Episode " + strconv.Itoa(newAvailableCount) + " has been released!", + Icon: anime.ImageLink("medium"), + Link: "https://notify.moe" + anime.Link(), + Type: NotificationTypeAnimeEpisode, + }) + } + }() + } + + // Number remaining episodes + startNumber := 0 + + for _, episode := range episodes.Items { + if episode.Number != -1 { + startNumber = episode.Number + continue + } + + startNumber++ + episode.Number = startNumber + } + + // Guess airing dates + oneWeek := 7 * 24 * time.Hour + lastAiringDate := "" + timeDifference := oneWeek + + for _, episode := range episodes.Items { + if validate.DateTime(episode.AiringDate.Start) { + if lastAiringDate != "" { + a, _ := time.Parse(time.RFC3339, lastAiringDate) + b, _ := time.Parse(time.RFC3339, episode.AiringDate.Start) + timeDifference = b.Sub(a) + + // Cap time difference at one week + if timeDifference > oneWeek { + timeDifference = oneWeek + } + } + + lastAiringDate = episode.AiringDate.Start + continue + } + + // Add 1 week to the last known airing date + nextAiringDate, _ := time.Parse(time.RFC3339, lastAiringDate) + nextAiringDate = nextAiringDate.Add(timeDifference) + + // Guess start and end time + episode.AiringDate.Start = nextAiringDate.Format(time.RFC3339) + episode.AiringDate.End = nextAiringDate.Add(30 * time.Minute).Format(time.RFC3339) + + // Set this date as the new last known airing date + lastAiringDate = episode.AiringDate.Start + } + + episodes.Save() + + return nil +} + +// ShoboiEpisodes returns a slice of episode info from cal.syoboi.jp. +func (anime *Anime) ShoboiEpisodes() ([]*AnimeEpisode, error) { + shoboiID := anime.GetMapping("shoboi/anime") + + if shoboiID == "" { + return nil, errors.New("Missing shoboi/anime mapping") + } + + shoboiAnime, err := shoboi.GetAnime(shoboiID) + + if err != nil { + return nil, err + } + + arnEpisodes := []*AnimeEpisode{} + shoboiEpisodes := shoboiAnime.Episodes() + + for _, shoboiEpisode := range shoboiEpisodes { + episode := NewAnimeEpisode() + episode.Number = shoboiEpisode.Number + episode.Title.Japanese = shoboiEpisode.TitleJapanese + + // Try to get airing date + airingDate := shoboiEpisode.AiringDate + + if airingDate != nil { + episode.AiringDate.Start = airingDate.Start + episode.AiringDate.End = airingDate.End + } else { + episode.AiringDate.Start = "" + episode.AiringDate.End = "" + } + + arnEpisodes = append(arnEpisodes, episode) + } + + return arnEpisodes, nil +} + +// TwistEpisodes returns a slice of episode info from twist.moe. +func (anime *Anime) TwistEpisodes() ([]*AnimeEpisode, error) { + idList, err := GetIDList("animetwist index") + + if err != nil { + return nil, err + } + + // Does the index contain the ID? + kitsuID := anime.GetMapping("kitsu/anime") + found := false + + for _, id := range idList { + if id == kitsuID { + found = true + break + } + } + + // If the ID is not the index we don't need to query the feed + if !found { + return nil, errors.New("Not available in twist.moe anime index") + } + + // Get twist.moe feed + feed, err := twist.GetFeedByKitsuID(kitsuID) + + if err != nil { + return nil, err + } + + episodes := feed.Episodes + + // Sort by episode number + sort.Slice(episodes, func(a, b int) bool { + return episodes[a].Number < episodes[b].Number + }) + + arnEpisodes := []*AnimeEpisode{} + + for _, episode := range episodes { + arnEpisode := NewAnimeEpisode() + arnEpisode.Number = episode.Number + arnEpisode.Links = map[string]string{ + "twist.moe": strings.Replace(episode.Link, "https://test.twist.moe/", "https://twist.moe/", 1), + } + + arnEpisodes = append(arnEpisodes, arnEpisode) + } + + return arnEpisodes, nil +} + +// UpcomingEpisodes ... +func (anime *Anime) UpcomingEpisodes() []*UpcomingEpisode { + var upcomingEpisodes []*UpcomingEpisode + + now := time.Now().UTC().Format(time.RFC3339) + + for _, episode := range anime.Episodes().Items { + if episode.AiringDate.Start > now && validate.DateTime(episode.AiringDate.Start) { + upcomingEpisodes = append(upcomingEpisodes, &UpcomingEpisode{ + Anime: anime, + Episode: episode, + }) + } + } + + return upcomingEpisodes +} + +// UpcomingEpisode ... +func (anime *Anime) UpcomingEpisode() *UpcomingEpisode { + now := time.Now().UTC().Format(time.RFC3339) + + for _, episode := range anime.Episodes().Items { + if episode.AiringDate.Start > now && validate.DateTime(episode.AiringDate.Start) { + return &UpcomingEpisode{ + Anime: anime, + Episode: episode, + } + } + } + + return nil +} + +// EpisodeCountString ... +func (anime *Anime) EpisodeCountString() string { + if anime.EpisodeCount == 0 { + return "?" + } + + return strconv.Itoa(anime.EpisodeCount) +} + +// ImportKitsuMapping imports the given Kitsu mapping. +func (anime *Anime) ImportKitsuMapping(mapping *kitsu.Mapping) { + switch mapping.Attributes.ExternalSite { + case "myanimelist/anime": + anime.SetMapping("myanimelist/anime", mapping.Attributes.ExternalID) + case "anidb": + anime.SetMapping("anidb/anime", mapping.Attributes.ExternalID) + case "trakt": + anime.SetMapping("trakt/anime", mapping.Attributes.ExternalID) + // case "hulu": + // anime.SetMapping("hulu/anime", mapping.Attributes.ExternalID) + case "anilist": + externalID := mapping.Attributes.ExternalID + externalID = strings.TrimPrefix(externalID, "anime/") + + anime.SetMapping("anilist/anime", externalID) + case "thetvdb", "thetvdb/series": + externalID := mapping.Attributes.ExternalID + slashPos := strings.Index(externalID, "/") + + if slashPos != -1 { + externalID = externalID[:slashPos] + } + + anime.SetMapping("thetvdb/anime", externalID) + case "thetvdb/season": + // Ignore + default: + color.Yellow("Unknown mapping: %s %s", mapping.Attributes.ExternalSite, mapping.Attributes.ExternalID) + } +} + +// TypeHumanReadable ... +func (anime *Anime) TypeHumanReadable() string { + switch anime.Type { + case "tv": + return "TV" + case "movie": + return "Movie" + case "ova": + return "OVA" + case "ona": + return "ONA" + case "special": + return "Special" + case "music": + return "Music" + default: + return anime.Type + } +} + +// StatusHumanReadable ... +func (anime *Anime) StatusHumanReadable() string { + switch anime.Status { + case "finished": + return "Finished" + case "current": + return "Airing" + case "upcoming": + return "Upcoming" + case "tba": + return "To be announced" + default: + return anime.Status + } +} + +// CalculatedStatus returns the status of the anime inferred by the start and end date. +func (anime *Anime) CalculatedStatus() string { + // If we are past the end date, the anime is finished. + if validate.Date(anime.EndDate) { + end := anime.EndDateTime() + + if time.Since(end) > 0 { + return "finished" + } + } + + // If we have a start date and we didn't reach the end date, it's either current or upcoming. + if validate.Date(anime.StartDate) { + start := anime.StartDateTime() + + if time.Since(start) > 0 { + return "current" + } + + return "upcoming" + } + + // If we have no date information it's to be announced. + return "tba" +} + +// EpisodeByNumber returns the episode with the given number. +func (anime *Anime) EpisodeByNumber(number int) *AnimeEpisode { + for _, episode := range anime.Episodes().Items { + if number == episode.Number { + return episode + } + } + + return nil +} + +// RefreshAnimeCharacters ... +func (anime *Anime) RefreshAnimeCharacters() (*AnimeCharacters, error) { + resp, err := kitsu.GetAnimeCharactersForAnime(anime.GetMapping("kitsu/anime")) + + if err != nil { + return nil, err + } + + animeCharacters := &AnimeCharacters{ + AnimeID: anime.ID, + Items: []*AnimeCharacter{}, + } + + for _, incl := range resp.Included { + if incl.Type != "animeCharacters" { + continue + } + + role := incl.Attributes["role"].(string) + characterID := incl.Relationships.Character.Data.ID + + animeCharacter := &AnimeCharacter{ + CharacterID: characterID, + Role: role, + } + + animeCharacters.Items = append(animeCharacters.Items, animeCharacter) + } + + animeCharacters.Save() + + return animeCharacters, nil +} + +// String implements the default string serialization. +func (anime *Anime) String() string { + return anime.Title.Canonical +} + +// TypeName returns the type name. +func (anime *Anime) TypeName() string { + return "Anime" +} + +// Self returns the object itself. +func (anime *Anime) Self() Loggable { + return anime +} + +// StreamAnime returns a stream of all anime. +func StreamAnime() <-chan *Anime { + channel := make(chan *Anime, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Anime") { + channel <- obj.(*Anime) + } + + close(channel) + }() + + return channel +} + +// AllAnime returns a slice of all anime. +func AllAnime() []*Anime { + all := make([]*Anime, 0, DB.Collection("Anime").Count()) + + stream := StreamAnime() + + for obj := range stream { + all = append(all, obj) + } + + return all +} + +// FilterAnime filters all anime by a custom function. +func FilterAnime(filter func(*Anime) bool) []*Anime { + var filtered []*Anime + + channel := DB.All("Anime") + + for obj := range channel { + realObject := obj.(*Anime) + + if filter(realObject) { + filtered = append(filtered, realObject) + } + } + + return filtered +} + +// // SetID performs a database-wide ID change. +// // Calling this will automatically save the anime. +// func (anime *Anime) SetID(newID string) { +// oldID := anime.ID + +// // Update anime ID in character list +// characters, _ := GetAnimeCharacters(oldID) +// characters.Delete() +// characters.AnimeID = newID +// characters.Save() + +// // Update anime ID in relation list +// relations, _ := GetAnimeRelations(oldID) +// relations.Delete() +// relations.AnimeID = newID +// relations.Save() + +// // Update anime ID in episode list +// episodes, _ := GetAnimeEpisodes(oldID) +// episodes.Delete() +// episodes.AnimeID = newID +// episodes.Save() + +// // Update anime list items +// for animeList := range StreamAnimeLists() { +// item := animeList.Find(oldID) + +// if item != nil { +// item.AnimeID = newID +// animeList.Save() +// } +// } + +// // Update relations pointing to this anime +// for relations := range StreamAnimeRelations() { +// relation := relations.Find(oldID) + +// if relation != nil { +// relation.AnimeID = newID +// relations.Save() +// } +// } + +// // Update quotes +// for quote := range StreamQuotes() { +// if quote.AnimeID == oldID { +// quote.AnimeID = newID +// quote.Save() +// } +// } + +// // Update log entries +// for entry := range StreamEditLogEntries() { +// switch entry.ObjectType { +// case "Anime", "AnimeRelations", "AnimeCharacters", "AnimeEpisodes": +// if entry.ObjectID == oldID { +// entry.ObjectID = newID +// entry.Save() +// } +// } +// } + +// // Update ignored anime differences +// for ignore := range StreamIgnoreAnimeDifferences() { +// // ID example: arn:10052|mal:28701|RomajiTitle +// arnPart := strings.Split(ignore.ID, "|")[0] +// actualID := strings.Split(arnPart, ":")[1] + +// if actualID == oldID { +// DB.Delete("IgnoreAnimeDifference", ignore.ID) +// ignore.ID = strings.Replace(ignore.ID, arnPart, "arn:"+newID, 1) +// ignore.Save() +// } +// } + +// // Update soundtrack tags +// for track := range StreamSoundTracks() { +// newTags := []string{} +// modified := false + +// for _, tag := range track.Tags { +// if strings.HasPrefix(tag, "anime:") { +// parts := strings.Split(tag, ":") +// id := parts[1] + +// if id == oldID { +// newTags = append(newTags, "anime:"+newID) +// modified = true +// continue +// } +// } + +// newTags = append(newTags, tag) +// } + +// if modified { +// track.Tags = newTags +// track.Save() +// } +// } + +// // Update images on file system +// anime.MoveImageFiles(oldID, newID) + +// // Delete old anime ID +// DB.Delete("Anime", oldID) + +// // Change anime ID and save it +// anime.ID = newID +// anime.Save() +// } + +// // MoveImageFiles ... +// func (anime *Anime) MoveImageFiles(oldID string, newID string) { +// if anime.Image.Extension == "" { +// return +// } + +// err := os.Rename( +// path.Join(Root, "images/anime/original/", oldID+anime.Image.Extension), +// path.Join(Root, "images/anime/original/", newID+anime.Image.Extension), +// ) + +// if err != nil { +// // Don't return the error. +// // It's too late to stop the process at this point. +// // Instead, log the error. +// color.Red(err.Error()) +// } + +// os.Rename( +// path.Join(Root, "images/anime/large/", oldID+".jpg"), +// path.Join(Root, "images/anime/large/", newID+".jpg"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/large/", oldID+"@2.jpg"), +// path.Join(Root, "images/anime/large/", newID+"@2.jpg"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/large/", oldID+".webp"), +// path.Join(Root, "images/anime/large/", newID+".webp"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/large/", oldID+"@2.webp"), +// path.Join(Root, "images/anime/large/", newID+"@2.webp"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/medium/", oldID+".jpg"), +// path.Join(Root, "images/anime/medium/", newID+".jpg"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/medium/", oldID+"@2.jpg"), +// path.Join(Root, "images/anime/medium/", newID+"@2.jpg"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/medium/", oldID+".webp"), +// path.Join(Root, "images/anime/medium/", newID+".webp"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/medium/", oldID+"@2.webp"), +// path.Join(Root, "images/anime/medium/", newID+"@2.webp"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/small/", oldID+".jpg"), +// path.Join(Root, "images/anime/small/", newID+".jpg"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/small/", oldID+"@2.jpg"), +// path.Join(Root, "images/anime/small/", newID+"@2.jpg"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/small/", oldID+".webp"), +// path.Join(Root, "images/anime/small/", newID+".webp"), +// ) + +// os.Rename( +// path.Join(Root, "images/anime/small/", oldID+"@2.webp"), +// path.Join(Root, "images/anime/small/", newID+"@2.webp"), +// ) +// } diff --git a/arn/AnimeAPI.go b/arn/AnimeAPI.go new file mode 100644 index 00000000..16bbf59a --- /dev/null +++ b/arn/AnimeAPI.go @@ -0,0 +1,210 @@ +package arn + +import ( + "errors" + "fmt" + "os" + "path" + "reflect" + "strings" + + "github.com/aerogo/aero" + "github.com/aerogo/api" + "github.com/akyoto/color" +) + +// Force interface implementations +var ( + _ fmt.Stringer = (*Anime)(nil) + _ Likeable = (*Anime)(nil) + _ PostParent = (*Anime)(nil) + _ api.Deletable = (*Anime)(nil) + _ api.Editable = (*Anime)(nil) + _ api.CustomEditable = (*Anime)(nil) + _ api.ArrayEventListener = (*Anime)(nil) +) + +// Actions +func init() { + API.RegisterActions("Anime", []*api.Action{ + // Like anime + LikeAction(), + + // Unlike anime + UnlikeAction(), + }) +} + +// Edit creates an edit log entry. +func (anime *Anime) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + user := GetUserFromContext(ctx) + + if key == "Status" { + oldStatus := value.String() + newStatus := newValue.String() + + // Notify people who want to know about finished series + if oldStatus == "current" && newStatus == "finished" { + go func() { + for _, user := range anime.UsersWatchingOrPlanned() { + if !user.Settings().Notification.AnimeFinished { + continue + } + + user.SendNotification(&PushNotification{ + Title: anime.Title.ByUser(user), + Message: anime.Title.ByUser(user) + " has finished airing!", + Icon: anime.ImageLink("medium"), + Link: "https://notify.moe" + anime.Link(), + Type: NotificationTypeAnimeFinished, + }) + } + }() + } + } + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "edit", "Anime", anime.ID, key, fmt.Sprint(value.Interface()), fmt.Sprint(newValue.Interface())) + logEntry.Save() + + return false, nil +} + +// OnAppend saves a log entry. +func (anime *Anime) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(anime, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (anime *Anime) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(anime, ctx, key, index, obj) +} + +// Authorize returns an error if the given API POST request is not authorized. +func (anime *Anime) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil || (user.Role != "editor" && user.Role != "admin") { + return errors.New("Not logged in or not authorized to edit this anime") + } + + return nil +} + +// DeleteInContext deletes the anime in the given context. +func (anime *Anime) DeleteInContext(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "delete", "Anime", anime.ID, "", fmt.Sprint(anime), "") + logEntry.Save() + + return anime.Delete() +} + +// Delete deletes the anime from the database. +func (anime *Anime) Delete() error { + // Delete anime characters + DB.Delete("AnimeCharacters", anime.ID) + + // Delete anime relations + DB.Delete("AnimeRelations", anime.ID) + + // Delete anime episodes + DB.Delete("AnimeEpisodes", anime.ID) + + // Delete anime list items + for animeList := range StreamAnimeLists() { + removed := animeList.Remove(anime.ID) + + if removed { + animeList.Save() + } + } + + // Delete anime ID from existing relations + for relations := range StreamAnimeRelations() { + removed := relations.Remove(anime.ID) + + if removed { + relations.Save() + } + } + + // Delete anime ID from quotes + for quote := range StreamQuotes() { + if quote.AnimeID == anime.ID { + quote.AnimeID = "" + quote.Save() + } + } + + // Remove posts + for _, post := range anime.Posts() { + err := post.Delete() + + if err != nil { + return err + } + } + + // Delete soundtrack tags + for track := range StreamSoundTracks() { + newTags := []string{} + + for _, tag := range track.Tags { + if strings.HasPrefix(tag, "anime:") { + parts := strings.Split(tag, ":") + id := parts[1] + + if id == anime.ID { + continue + } + } + + newTags = append(newTags, tag) + } + + if len(track.Tags) != len(newTags) { + track.Tags = newTags + track.Save() + } + } + + // Delete images on file system + if anime.HasImage() { + err := os.Remove(path.Join(Root, "images/anime/original/", anime.ID+anime.Image.Extension)) + + if err != nil { + // Don't return the error. + // It's too late to stop the process at this point. + // Instead, log the error. + color.Red(err.Error()) + } + + os.Remove(path.Join(Root, "images/anime/large/", anime.ID+".jpg")) + os.Remove(path.Join(Root, "images/anime/large/", anime.ID+"@2.jpg")) + os.Remove(path.Join(Root, "images/anime/large/", anime.ID+".webp")) + os.Remove(path.Join(Root, "images/anime/large/", anime.ID+"@2.webp")) + + os.Remove(path.Join(Root, "images/anime/medium/", anime.ID+".jpg")) + os.Remove(path.Join(Root, "images/anime/medium/", anime.ID+"@2.jpg")) + os.Remove(path.Join(Root, "images/anime/medium/", anime.ID+".webp")) + os.Remove(path.Join(Root, "images/anime/medium/", anime.ID+"@2.webp")) + + os.Remove(path.Join(Root, "images/anime/small/", anime.ID+".jpg")) + os.Remove(path.Join(Root, "images/anime/small/", anime.ID+"@2.jpg")) + os.Remove(path.Join(Root, "images/anime/small/", anime.ID+".webp")) + os.Remove(path.Join(Root, "images/anime/small/", anime.ID+"@2.webp")) + } + + // Delete the actual anime + DB.Delete("Anime", anime.ID) + + return nil +} + +// Save saves the anime in the database. +func (anime *Anime) Save() { + DB.Set("Anime", anime.ID, anime) +} diff --git a/arn/AnimeCharacter.go b/arn/AnimeCharacter.go new file mode 100644 index 00000000..157313fe --- /dev/null +++ b/arn/AnimeCharacter.go @@ -0,0 +1,21 @@ +package arn + +// Register a list of supported character roles. +func init() { + DataLists["anime-character-roles"] = []*Option{ + {"main", "Main character"}, + {"supporting", "Supporting character"}, + } +} + +// AnimeCharacter ... +type AnimeCharacter struct { + CharacterID string `json:"characterId" editable:"true"` + Role string `json:"role" editable:"true" datalist:"anime-character-roles"` +} + +// Character ... +func (char *AnimeCharacter) Character() *Character { + character, _ := GetCharacter(char.CharacterID) + return character +} diff --git a/arn/AnimeCharacterAPI.go b/arn/AnimeCharacterAPI.go new file mode 100644 index 00000000..4274e4ce --- /dev/null +++ b/arn/AnimeCharacterAPI.go @@ -0,0 +1,17 @@ +package arn + +import ( + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ api.Creatable = (*AnimeCharacter)(nil) +) + +// Create sets the data for new anime characters. +func (character *AnimeCharacter) Create(ctx aero.Context) error { + character.Role = "supporting" + return nil +} diff --git a/arn/AnimeCharacters.go b/arn/AnimeCharacters.go new file mode 100644 index 00000000..76b69733 --- /dev/null +++ b/arn/AnimeCharacters.go @@ -0,0 +1,134 @@ +package arn + +import ( + "errors" + "sync" + + "github.com/aerogo/nano" + "github.com/akyoto/color" +) + +// AnimeCharacters is a list of characters for an anime. +type AnimeCharacters struct { + AnimeID string `json:"animeId" mainID:"true"` + Items []*AnimeCharacter `json:"items" editable:"true"` + + sync.Mutex +} + +// Anime returns the anime the characters refer to. +func (characters *AnimeCharacters) Anime() *Anime { + anime, _ := GetAnime(characters.AnimeID) + return anime +} + +// Add adds an anime character to the list. +func (characters *AnimeCharacters) Add(animeCharacter *AnimeCharacter) error { + if animeCharacter.CharacterID == "" || animeCharacter.Role == "" { + return errors.New("Empty ID or role") + } + + characters.Lock() + characters.Items = append(characters.Items, animeCharacter) + characters.Unlock() + + return nil +} + +// FindByMapping finds an anime character by the given mapping. +func (characters *AnimeCharacters) FindByMapping(service string, serviceID string) *AnimeCharacter { + characters.Lock() + defer characters.Unlock() + + for _, animeCharacter := range characters.Items { + character := animeCharacter.Character() + + if character == nil { + color.Red("Anime %s has an incorrect Character ID inside AnimeCharacter: %s", characters.AnimeID, animeCharacter.CharacterID) + continue + } + + if character.GetMapping(service) == serviceID { + return animeCharacter + } + } + + return nil +} + +// Link returns the link for that object. +func (characters *AnimeCharacters) Link() string { + return "/anime/" + characters.AnimeID + "/characters" +} + +// String implements the default string serialization. +func (characters *AnimeCharacters) String() string { + return characters.Anime().String() +} + +// GetID returns the anime ID. +func (characters *AnimeCharacters) GetID() string { + return characters.AnimeID +} + +// TypeName returns the type name. +func (characters *AnimeCharacters) TypeName() string { + return "AnimeCharacters" +} + +// Self returns the object itself. +func (characters *AnimeCharacters) Self() Loggable { + return characters +} + +// Contains tells you whether the given character ID exists. +func (characters *AnimeCharacters) Contains(characterID string) bool { + characters.Lock() + defer characters.Unlock() + + for _, item := range characters.Items { + if item.CharacterID == characterID { + return true + } + } + + return false +} + +// First gives you the first "count" anime characters. +func (characters *AnimeCharacters) First(count int) []*AnimeCharacter { + characters.Lock() + defer characters.Unlock() + + if count > len(characters.Items) { + count = len(characters.Items) + } + + return characters.Items[:count] +} + +// GetAnimeCharacters ... +func GetAnimeCharacters(animeID string) (*AnimeCharacters, error) { + obj, err := DB.Get("AnimeCharacters", animeID) + + if err != nil { + return nil, err + } + + return obj.(*AnimeCharacters), nil +} + +// StreamAnimeCharacters returns a stream of all anime characters. +func StreamAnimeCharacters() <-chan *AnimeCharacters { + channel := make(chan *AnimeCharacters, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("AnimeCharacters") { + channel <- obj.(*AnimeCharacters) + } + + close(channel) + }() + + return channel +} diff --git a/arn/AnimeCharactersAPI.go b/arn/AnimeCharactersAPI.go new file mode 100644 index 00000000..c475f7c0 --- /dev/null +++ b/arn/AnimeCharactersAPI.go @@ -0,0 +1,54 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ fmt.Stringer = (*AnimeCharacters)(nil) + _ api.Editable = (*AnimeCharacters)(nil) + _ api.ArrayEventListener = (*AnimeCharacters)(nil) +) + +// Authorize returns an error if the given API POST request is not authorized. +func (characters *AnimeCharacters) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil || (user.Role != "editor" && user.Role != "admin") { + return errors.New("Not logged in or not authorized to edit") + } + + return nil +} + +// Edit creates an edit log entry. +func (characters *AnimeCharacters) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + return edit(characters, ctx, key, value, newValue) +} + +// OnAppend saves a log entry. +func (characters *AnimeCharacters) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(characters, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (characters *AnimeCharacters) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(characters, ctx, key, index, obj) +} + +// Save saves the character in the database. +func (characters *AnimeCharacters) Save() { + DB.Set("AnimeCharacters", characters.AnimeID, characters) +} + +// Delete deletes the character list from the database. +func (characters *AnimeCharacters) Delete() error { + DB.Delete("AnimeCharacters", characters.AnimeID) + return nil +} diff --git a/arn/AnimeEpisode.go b/arn/AnimeEpisode.go new file mode 100644 index 00000000..b7627f7c --- /dev/null +++ b/arn/AnimeEpisode.go @@ -0,0 +1,78 @@ +package arn + +import "github.com/animenotifier/notify.moe/arn/validate" + +// AnimeEpisode ... +type AnimeEpisode struct { + Number int `json:"number" editable:"true"` + Title EpisodeTitle `json:"title" editable:"true"` + AiringDate AiringDate `json:"airingDate" editable:"true"` + Links map[string]string `json:"links"` +} + +// EpisodeTitle ... +type EpisodeTitle struct { + Romaji string `json:"romaji" editable:"true"` + English string `json:"english" editable:"true"` + Japanese string `json:"japanese" editable:"true"` +} + +// Available tells you whether the episode is available (triggered when it has a link). +func (a *AnimeEpisode) Available() bool { + return len(a.Links) > 0 +} + +// AvailableOn tells you whether the episode is available on a given service. +func (a *AnimeEpisode) AvailableOn(serviceName string) bool { + return a.Links[serviceName] != "" +} + +// Merge combines the data of both episodes to one. +func (a *AnimeEpisode) Merge(b *AnimeEpisode) { + if b == nil { + return + } + + a.Number = b.Number + + // Titles + if b.Title.Romaji != "" { + a.Title.Romaji = b.Title.Romaji + } + + if b.Title.English != "" { + a.Title.English = b.Title.English + } + + if b.Title.Japanese != "" { + a.Title.Japanese = b.Title.Japanese + } + + // Airing date + if validate.DateTime(b.AiringDate.Start) { + a.AiringDate.Start = b.AiringDate.Start + } + + if validate.DateTime(b.AiringDate.End) { + a.AiringDate.End = b.AiringDate.End + } + + // Links + if a.Links == nil { + a.Links = map[string]string{} + } + + for name, link := range b.Links { + a.Links[name] = link + } +} + +// NewAnimeEpisode creates an empty anime episode. +func NewAnimeEpisode() *AnimeEpisode { + return &AnimeEpisode{ + Number: -1, + Title: EpisodeTitle{}, + AiringDate: AiringDate{}, + Links: map[string]string{}, + } +} diff --git a/arn/AnimeEpisodes.go b/arn/AnimeEpisodes.go new file mode 100644 index 00000000..1f70d723 --- /dev/null +++ b/arn/AnimeEpisodes.go @@ -0,0 +1,157 @@ +package arn + +import ( + "sort" + "strconv" + "strings" + "sync" + + "github.com/aerogo/nano" +) + +// AnimeEpisodes is a list of episodes for an anime. +type AnimeEpisodes struct { + AnimeID string `json:"animeId" mainID:"true"` + Items []*AnimeEpisode `json:"items" editable:"true"` + + sync.Mutex +} + +// Link returns the link for that object. +func (episodes *AnimeEpisodes) Link() string { + return "/anime/" + episodes.AnimeID + "/episodes" +} + +// Sort sorts the episodes by episode number. +func (episodes *AnimeEpisodes) Sort() { + episodes.Lock() + defer episodes.Unlock() + + sort.Slice(episodes.Items, func(i, j int) bool { + return episodes.Items[i].Number < episodes.Items[j].Number + }) +} + +// Find finds the given episode number. +func (episodes *AnimeEpisodes) Find(episodeNumber int) (*AnimeEpisode, int) { + episodes.Lock() + defer episodes.Unlock() + + for index, episode := range episodes.Items { + if episode.Number == episodeNumber { + return episode, index + } + } + + return nil, -1 +} + +// Merge combines the data of both episode slices to one. +func (episodes *AnimeEpisodes) Merge(b []*AnimeEpisode) { + if b == nil { + return + } + + episodes.Lock() + defer episodes.Unlock() + + for index, episode := range b { + if index >= len(episodes.Items) { + episodes.Items = append(episodes.Items, episode) + } else { + episodes.Items[index].Merge(episode) + } + } +} + +// Last returns the last n items. +func (episodes *AnimeEpisodes) Last(count int) []*AnimeEpisode { + return episodes.Items[len(episodes.Items)-count:] +} + +// AvailableCount counts the number of available episodes. +func (episodes *AnimeEpisodes) AvailableCount() int { + episodes.Lock() + defer episodes.Unlock() + + available := 0 + + for _, episode := range episodes.Items { + if len(episode.Links) > 0 { + available++ + } + } + + return available +} + +// Anime returns the anime the episodes refer to. +func (episodes *AnimeEpisodes) Anime() *Anime { + anime, _ := GetAnime(episodes.AnimeID) + return anime +} + +// String implements the default string serialization. +func (episodes *AnimeEpisodes) String() string { + return episodes.Anime().String() +} + +// GetID returns the anime ID. +func (episodes *AnimeEpisodes) GetID() string { + return episodes.AnimeID +} + +// TypeName returns the type name. +func (episodes *AnimeEpisodes) TypeName() string { + return "AnimeEpisodes" +} + +// Self returns the object itself. +func (episodes *AnimeEpisodes) Self() Loggable { + return episodes +} + +// ListString returns a text representation of the anime episodes. +func (episodes *AnimeEpisodes) ListString() string { + episodes.Lock() + defer episodes.Unlock() + + b := strings.Builder{} + + for _, episode := range episodes.Items { + b.WriteString(strconv.Itoa(episode.Number)) + b.WriteString(" | ") + b.WriteString(episode.Title.Japanese) + b.WriteString(" | ") + b.WriteString(episode.AiringDate.StartDateHuman()) + b.WriteByte('\n') + } + + return strings.TrimRight(b.String(), "\n") +} + +// StreamAnimeEpisodes returns a stream of all anime episodes. +func StreamAnimeEpisodes() <-chan *AnimeEpisodes { + channel := make(chan *AnimeEpisodes, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("AnimeEpisodes") { + channel <- obj.(*AnimeEpisodes) + } + + close(channel) + }() + + return channel +} + +// GetAnimeEpisodes ... +func GetAnimeEpisodes(id string) (*AnimeEpisodes, error) { + obj, err := DB.Get("AnimeEpisodes", id) + + if err != nil { + return nil, err + } + + return obj.(*AnimeEpisodes), nil +} diff --git a/arn/AnimeEpisodesAPI.go b/arn/AnimeEpisodesAPI.go new file mode 100644 index 00000000..642f0eab --- /dev/null +++ b/arn/AnimeEpisodesAPI.go @@ -0,0 +1,54 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ fmt.Stringer = (*AnimeEpisodes)(nil) + _ api.Editable = (*AnimeEpisodes)(nil) + _ api.ArrayEventListener = (*AnimeEpisodes)(nil) +) + +// Authorize returns an error if the given API POST request is not authorized. +func (episodes *AnimeEpisodes) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil || (user.Role != "editor" && user.Role != "admin") { + return errors.New("Not logged in or not authorized to edit") + } + + return nil +} + +// Edit creates an edit log entry. +func (episodes *AnimeEpisodes) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + return edit(episodes, ctx, key, value, newValue) +} + +// OnAppend saves a log entry. +func (episodes *AnimeEpisodes) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(episodes, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (episodes *AnimeEpisodes) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(episodes, ctx, key, index, obj) +} + +// Save saves the episodes in the database. +func (episodes *AnimeEpisodes) Save() { + DB.Set("AnimeEpisodes", episodes.AnimeID, episodes) +} + +// Delete deletes the episode list from the database. +func (episodes *AnimeEpisodes) Delete() error { + DB.Delete("AnimeEpisodes", episodes.AnimeID) + return nil +} diff --git a/arn/AnimeFinder.go b/arn/AnimeFinder.go new file mode 100644 index 00000000..bc0f0d5b --- /dev/null +++ b/arn/AnimeFinder.go @@ -0,0 +1,30 @@ +package arn + +// AnimeFinder holds an internal map of ID to anime mappings +// and is therefore very efficient to use when trying to find +// anime by a given service and ID. +type AnimeFinder struct { + idToAnime map[string]*Anime +} + +// NewAnimeFinder creates a new finder for external anime. +func NewAnimeFinder(mappingName string) *AnimeFinder { + finder := &AnimeFinder{ + idToAnime: map[string]*Anime{}, + } + + for anime := range StreamAnime() { + id := anime.GetMapping(mappingName) + + if id != "" { + finder.idToAnime[id] = anime + } + } + + return finder +} + +// GetAnime tries to find an external anime in our anime database. +func (finder *AnimeFinder) GetAnime(id string) *Anime { + return finder.idToAnime[id] +} diff --git a/arn/AnimeImage.go b/arn/AnimeImage.go new file mode 100644 index 00000000..ae4378b7 --- /dev/null +++ b/arn/AnimeImage.go @@ -0,0 +1,213 @@ +package arn + +import ( + "bytes" + "image" + "path" + "time" + + "github.com/akyoto/imageserver" +) + +const ( + // AnimeImageLargeWidth is the minimum width in pixels of a large anime image. + AnimeImageLargeWidth = 250 + + // AnimeImageLargeHeight is the minimum height in pixels of a large anime image. + AnimeImageLargeHeight = 350 + + // AnimeImageMediumWidth is the minimum width in pixels of a medium anime image. + AnimeImageMediumWidth = 142 + + // AnimeImageMediumHeight is the minimum height in pixels of a medium anime image. + AnimeImageMediumHeight = 200 + + // AnimeImageSmallWidth is the minimum width in pixels of a small anime image. + AnimeImageSmallWidth = 55 + + // AnimeImageSmallHeight is the minimum height in pixels of a small anime image. + AnimeImageSmallHeight = 78 + + // AnimeImageWebPQuality is the WebP quality of anime images. + AnimeImageWebPQuality = 70 + + // AnimeImageJPEGQuality is the JPEG quality of anime images. + AnimeImageJPEGQuality = 70 + + // AnimeImageQualityBonusLowDPI ... + AnimeImageQualityBonusLowDPI = 10 + + // AnimeImageQualityBonusLarge ... + AnimeImageQualityBonusLarge = 5 + + // AnimeImageQualityBonusMedium ... + AnimeImageQualityBonusMedium = 10 + + // AnimeImageQualityBonusSmall ... + AnimeImageQualityBonusSmall = 10 +) + +// Define the anime image outputs +var animeImageOutputs = []imageserver.Output{ + // Original at full size + &imageserver.OriginalFile{ + Directory: path.Join(Root, "images/anime/original/"), + Width: 0, + Height: 0, + Quality: 0, + }, + + // JPEG - Large + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/anime/large/"), + Width: AnimeImageLargeWidth, + Height: AnimeImageLargeHeight, + Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusLarge, + }, + + // JPEG - Medium + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/anime/medium/"), + Width: AnimeImageMediumWidth, + Height: AnimeImageMediumHeight, + Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusMedium, + }, + + // JPEG - Small + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/anime/small/"), + Width: AnimeImageSmallWidth, + Height: AnimeImageSmallHeight, + Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusSmall, + }, + + // WebP - Large + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/anime/large/"), + Width: AnimeImageLargeWidth, + Height: AnimeImageLargeHeight, + Quality: AnimeImageWebPQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusLarge, + }, + + // WebP - Medium + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/anime/medium/"), + Width: AnimeImageMediumWidth, + Height: AnimeImageMediumHeight, + Quality: AnimeImageWebPQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusMedium, + }, + + // WebP - Small + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/anime/small/"), + Width: AnimeImageSmallWidth, + Height: AnimeImageSmallHeight, + Quality: AnimeImageWebPQuality + AnimeImageQualityBonusLowDPI + AnimeImageQualityBonusSmall, + }, +} + +// Define the high DPI anime image outputs +var animeImageOutputsHighDPI = []imageserver.Output{ + // JPEG - Large + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/anime/large/"), + Width: AnimeImageLargeWidth * 2, + Height: AnimeImageLargeHeight * 2, + Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusLarge, + }, + + // JPEG - Medium + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/anime/medium/"), + Width: AnimeImageMediumWidth * 2, + Height: AnimeImageMediumHeight * 2, + Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusMedium, + }, + + // JPEG - Small + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/anime/small/"), + Width: AnimeImageSmallWidth * 2, + Height: AnimeImageSmallHeight * 2, + Quality: AnimeImageJPEGQuality + AnimeImageQualityBonusSmall, + }, + + // WebP - Large + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/anime/large/"), + Width: AnimeImageLargeWidth * 2, + Height: AnimeImageLargeHeight * 2, + Quality: AnimeImageWebPQuality + AnimeImageQualityBonusLarge, + }, + + // WebP - Medium + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/anime/medium/"), + Width: AnimeImageMediumWidth * 2, + Height: AnimeImageMediumHeight * 2, + Quality: AnimeImageWebPQuality + AnimeImageQualityBonusMedium, + }, + + // WebP - Small + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/anime/small/"), + Width: AnimeImageSmallWidth * 2, + Height: AnimeImageSmallHeight * 2, + Quality: AnimeImageWebPQuality + AnimeImageQualityBonusSmall, + }, +} + +// AnimeImage ... +type AnimeImage struct { + Extension string `json:"extension"` + Width int `json:"width"` + Height int `json:"height"` + AverageColor HSLColor `json:"averageColor"` + LastModified int64 `json:"lastModified"` +} + +// SetImageBytes accepts a byte buffer that represents an image file and updates the anime image. +func (anime *Anime) SetImageBytes(data []byte) error { + // Decode + img, format, err := image.Decode(bytes.NewReader(data)) + + if err != nil { + return err + } + + return anime.SetImage(&imageserver.MetaImage{ + Image: img, + Format: format, + Data: data, + }) +} + +// SetImage sets the anime image to the given MetaImage. +func (anime *Anime) SetImage(metaImage *imageserver.MetaImage) error { + var lastError error + + // Save the different image formats and sizes in low DPI + for _, output := range animeImageOutputs { + err := output.Save(metaImage, anime.ID) + + if err != nil { + lastError = err + } + } + + // Save the different image formats and sizes in high DPI + for _, output := range animeImageOutputsHighDPI { + err := output.Save(metaImage, anime.ID+"@2") + + if err != nil { + lastError = err + } + } + + anime.Image.Extension = metaImage.Extension() + anime.Image.Width = metaImage.Image.Bounds().Dx() + anime.Image.Height = metaImage.Image.Bounds().Dy() + anime.Image.AverageColor = GetAverageColor(metaImage.Image) + anime.Image.LastModified = time.Now().Unix() + return lastError +} diff --git a/arn/AnimeList.go b/arn/AnimeList.go new file mode 100644 index 00000000..f399b033 --- /dev/null +++ b/arn/AnimeList.go @@ -0,0 +1,507 @@ +package arn + +import ( + "errors" + "fmt" + "sort" + "sync" + + "github.com/aerogo/nano" +) + +// AnimeList is a list of anime list items. +type AnimeList struct { + UserID UserID `json:"userId"` + Items []*AnimeListItem `json:"items"` + + sync.Mutex +} + +// Add adds an anime to the list if it hasn't been added yet. +func (list *AnimeList) Add(animeID string) error { + if list.Contains(animeID) { + return errors.New("Anime " + animeID + " has already been added") + } + + creationDate := DateTimeUTC() + + item := &AnimeListItem{ + AnimeID: animeID, + Status: AnimeListStatusPlanned, + Rating: AnimeListItemRating{}, + Created: creationDate, + Edited: creationDate, + } + + if item.Anime() == nil { + return errors.New("Invalid anime ID") + } + + list.Lock() + list.Items = append(list.Items, item) + list.Unlock() + + return nil +} + +// Remove removes the anime ID from the list. +func (list *AnimeList) Remove(animeID string) bool { + list.Lock() + defer list.Unlock() + + for index, item := range list.Items { + if item.AnimeID == animeID { + list.Items = append(list.Items[:index], list.Items[index+1:]...) + return true + } + } + + return false +} + +// Contains checks if the list contains the anime ID already. +func (list *AnimeList) Contains(animeID string) bool { + list.Lock() + defer list.Unlock() + + for _, item := range list.Items { + if item.AnimeID == animeID { + return true + } + } + + return false +} + +// HasItemsWithStatus checks if the list contains an anime with the given status. +func (list *AnimeList) HasItemsWithStatus(status string) bool { + list.Lock() + defer list.Unlock() + + for _, item := range list.Items { + if item.Status == status { + return true + } + } + + return false +} + +// Find returns the list item with the specified anime ID, if available. +func (list *AnimeList) Find(animeID string) *AnimeListItem { + list.Lock() + defer list.Unlock() + + for _, item := range list.Items { + if item.AnimeID == animeID { + return item + } + } + + return nil +} + +// Import adds an anime to the list if it hasn't been added yet +// and if it did exist it will update episode, rating and notes. +func (list *AnimeList) Import(item *AnimeListItem) { + existing := list.Find(item.AnimeID) + + // If it doesn't exist yet: Simply add it. + if existing == nil { + list.Lock() + list.Items = append(list.Items, item) + list.Unlock() + return + } + + // Temporary save it before changing the status + // because status changes can modify the episode count. + // This will prevent loss of "episodes watched" data. + existingEpisodes := existing.Episodes + + // Status + existing.Status = item.Status + existing.OnStatusChange() + + // Episodes + if item.Episodes > existingEpisodes { + existing.Episodes = item.Episodes + } else { + existing.Episodes = existingEpisodes + } + + existing.OnEpisodesChange() + + // Rating + if existing.Rating.Overall == 0 { + existing.Rating.Overall = item.Rating.Overall + existing.Rating.Clamp() + } + + if existing.Notes == "" { + existing.Notes = item.Notes + } + + if item.RewatchCount > existing.RewatchCount { + existing.RewatchCount = item.RewatchCount + } + + // Edited + existing.Edited = DateTimeUTC() +} + +// User returns the user this anime list belongs to. +func (list *AnimeList) User() *User { + user, _ := GetUser(list.UserID) + return user +} + +// Sort ... +func (list *AnimeList) Sort() { + list.Lock() + defer list.Unlock() + + sort.Slice(list.Items, func(i, j int) bool { + a := list.Items[i] + b := list.Items[j] + + if (a.Status != AnimeListStatusWatching && a.Status != AnimeListStatusPlanned) && (b.Status != AnimeListStatusWatching && b.Status != AnimeListStatusPlanned) { + if a.Rating.Overall == b.Rating.Overall { + return a.Anime().Title.Canonical < b.Anime().Title.Canonical + } + + return a.Rating.Overall > b.Rating.Overall + } + + epsA := a.Anime().UpcomingEpisode() + epsB := b.Anime().UpcomingEpisode() + + if epsA == nil && epsB == nil { + if a.Rating.Overall == b.Rating.Overall { + return a.Anime().Title.Canonical < b.Anime().Title.Canonical + } + + return a.Rating.Overall > b.Rating.Overall + } + + if epsA == nil { + return false + } + + if epsB == nil { + return true + } + + return epsA.Episode.AiringDate.Start < epsB.Episode.AiringDate.Start + }) +} + +// SortByRating sorts the anime list by overall rating. +func (list *AnimeList) SortByRating() { + list.Lock() + defer list.Unlock() + + sort.Slice(list.Items, func(i, j int) bool { + a := list.Items[i] + b := list.Items[j] + + if a.Rating.Overall == b.Rating.Overall { + return a.Anime().Title.Canonical < b.Anime().Title.Canonical + } + + return a.Rating.Overall > b.Rating.Overall + }) +} + +// Top returns the top entries. +func (list *AnimeList) Top(count int) []*AnimeListItem { + list.Lock() + defer list.Unlock() + + sort.Slice(list.Items, func(i, j int) bool { + a := list.Items[i] + b := list.Items[j] + + if a.Rating.Overall == b.Rating.Overall { + return a.Anime().Title.Canonical < b.Anime().Title.Canonical + } + + return a.Rating.Overall > b.Rating.Overall + }) + + if count > len(list.Items) { + count = len(list.Items) + } + + tmp := make([]*AnimeListItem, count) + copy(tmp, list.Items[:count]) + return tmp +} + +// Watching ... +func (list *AnimeList) Watching() *AnimeList { + return list.FilterStatus(AnimeListStatusWatching) +} + +// FilterStatus ... +func (list *AnimeList) FilterStatus(status string) *AnimeList { + newList := &AnimeList{ + UserID: list.UserID, + Items: []*AnimeListItem{}, + } + + list.Lock() + defer list.Unlock() + + for _, item := range list.Items { + if item.Status == status { + newList.Items = append(newList.Items, item) + } + } + + return newList +} + +// WithoutPrivateItems returns a new anime list with the private items removed. +func (list *AnimeList) WithoutPrivateItems() *AnimeList { + list.Lock() + defer list.Unlock() + + newList := &AnimeList{ + UserID: list.UserID, + Items: make([]*AnimeListItem, 0, len(list.Items)), + } + + for _, item := range list.Items { + if !item.Private { + newList.Items = append(newList.Items, item) + } + } + + return newList +} + +// SplitByStatus splits the anime list into multiple ones by status. +func (list *AnimeList) SplitByStatus() map[string]*AnimeList { + statusToList := map[string]*AnimeList{} + + statusToList[AnimeListStatusWatching] = &AnimeList{ + UserID: list.UserID, + Items: []*AnimeListItem{}, + } + + statusToList[AnimeListStatusCompleted] = &AnimeList{ + UserID: list.UserID, + Items: []*AnimeListItem{}, + } + + statusToList[AnimeListStatusPlanned] = &AnimeList{ + UserID: list.UserID, + Items: []*AnimeListItem{}, + } + + statusToList[AnimeListStatusHold] = &AnimeList{ + UserID: list.UserID, + Items: []*AnimeListItem{}, + } + + statusToList[AnimeListStatusDropped] = &AnimeList{ + UserID: list.UserID, + Items: []*AnimeListItem{}, + } + + list.Lock() + defer list.Unlock() + + for _, item := range list.Items { + statusList := statusToList[item.Status] + statusList.Items = append(statusList.Items, item) + } + + return statusToList +} + +// NormalizeRatings normalizes all ratings so that they are perfectly stretched among the full scale. +func (list *AnimeList) NormalizeRatings() { + list.Lock() + defer list.Unlock() + + mapped := map[float64]float64{} + all := make([]float64, 0, len(list.Items)) + + for _, item := range list.Items { + // Zero rating counts as not rated + if item.Rating.Overall == 0 { + continue + } + + _, found := mapped[item.Rating.Overall] + + if !found { + mapped[item.Rating.Overall] = item.Rating.Overall + all = append(all, item.Rating.Overall) + } + } + + sort.Slice(all, func(i, j int) bool { + return all[i] < all[j] + }) + + count := len(all) + + // Prevent division by zero + if count <= 1 { + return + } + + step := 9.9 / float64(count-1) + currentRating := 0.1 + + for _, rating := range all { + mapped[rating] = currentRating + currentRating += step + } + + for _, item := range list.Items { + item.Rating.Overall = mapped[item.Rating.Overall] + item.Rating.Clamp() + } +} + +// GetID returns the anime ID. +func (list *AnimeList) GetID() string { + return list.UserID +} + +// TypeName returns the type name. +func (list *AnimeList) TypeName() string { + return "AnimeList" +} + +// Self returns the object itself. +func (list *AnimeList) Self() Loggable { + return list +} + +// Genres returns a map of genre names mapped to the list items that belong to that genre. +func (list *AnimeList) Genres() map[string][]*AnimeListItem { + genreToListItems := map[string][]*AnimeListItem{} + + for _, item := range list.Items { + for _, genre := range item.Anime().Genres { + genreToListItems[genre] = append(genreToListItems[genre], item) + } + } + + return genreToListItems +} + +// TopGenres returns the most liked genres for the user's anime list. +func (list *AnimeList) TopGenres(count int) []string { + genreItems := list.Genres() + genreAffinity := map[string]float64{} + bestGenres := make([]string, 0, len(genreItems)) + + for genre, animeListItems := range genreItems { + if genre == "Action" || genre == "Comedy" { + continue + } + + affinity := 0.0 + + for _, item := range animeListItems { + if item.Status != AnimeListStatusCompleted { + continue + } + + if item.Rating.Overall != 0 { + affinity += item.Rating.Overall - AverageRating + } else { + // Add 0.1 to avoid all affinities being 0 when a user doesn't have any rated anime. + affinity += 0.1 + } + } + + genreAffinity[genre] = affinity + bestGenres = append(bestGenres, genre) + } + + sort.Slice(bestGenres, func(i, j int) bool { + aAffinity := genreAffinity[bestGenres[i]] + bAffinity := genreAffinity[bestGenres[j]] + + if aAffinity == bAffinity { + return bestGenres[i] < bestGenres[j] + } + + return aAffinity > bAffinity + }) + + if len(bestGenres) > count { + bestGenres = bestGenres[:count] + } + + return bestGenres +} + +// RemoveDuplicates removes duplicate entries. +func (list *AnimeList) RemoveDuplicates() { + list.Lock() + defer list.Unlock() + + existed := map[string]bool{} + newItems := make([]*AnimeListItem, 0, len(list.Items)) + + for _, item := range list.Items { + _, exists := existed[item.AnimeID] + + if exists { + fmt.Println(list.User().Nick, "removed anime list item duplicate", item.AnimeID) + continue + } + + newItems = append(newItems, item) + existed[item.AnimeID] = true + } + + list.Items = newItems +} + +// StreamAnimeLists returns a stream of all anime. +func StreamAnimeLists() <-chan *AnimeList { + channel := make(chan *AnimeList, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("AnimeList") { + channel <- obj.(*AnimeList) + } + + close(channel) + }() + + return channel +} + +// AllAnimeLists returns a slice of all anime. +func AllAnimeLists() ([]*AnimeList, error) { + all := make([]*AnimeList, 0, DB.Collection("AnimeList").Count()) + + stream := StreamAnimeLists() + + for obj := range stream { + all = append(all, obj) + } + + return all, nil +} + +// GetAnimeList ... +func GetAnimeList(userID UserID) (*AnimeList, error) { + animeList, err := DB.Get("AnimeList", userID) + + if err != nil { + return nil, err + } + + return animeList.(*AnimeList), nil +} diff --git a/arn/AnimeListAPI.go b/arn/AnimeListAPI.go new file mode 100644 index 00000000..ca7169a2 --- /dev/null +++ b/arn/AnimeListAPI.go @@ -0,0 +1,33 @@ +package arn + +import ( + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ api.Editable = (*AnimeList)(nil) + _ IDCollection = (*AnimeList)(nil) +) + +// Actions +func init() { + API.RegisterActions("AnimeList", []*api.Action{ + // Add follow + AddAction(), + + // Remove follow + RemoveAction(), + }) +} + +// Authorize returns an error if the given API request is not authorized. +func (list *AnimeList) Authorize(ctx aero.Context, action string) error { + return AuthorizeIfLoggedInAndOwnData(ctx, "id") +} + +// Save saves the anime list in the database. +func (list *AnimeList) Save() { + DB.Set("AnimeList", list.UserID, list) +} diff --git a/arn/AnimeListItem.go b/arn/AnimeListItem.go new file mode 100644 index 00000000..06e317ff --- /dev/null +++ b/arn/AnimeListItem.go @@ -0,0 +1,104 @@ +package arn + +// AnimeListStatus values for anime list items +const ( + AnimeListStatusWatching = "watching" + AnimeListStatusCompleted = "completed" + AnimeListStatusPlanned = "planned" + AnimeListStatusHold = "hold" + AnimeListStatusDropped = "dropped" +) + +// AnimeListItem ... +type AnimeListItem struct { + AnimeID string `json:"animeId"` + Status string `json:"status" editable:"true"` + Episodes int `json:"episodes" editable:"true"` + Rating AnimeListItemRating `json:"rating"` + Notes string `json:"notes" editable:"true"` + RewatchCount int `json:"rewatchCount" editable:"true"` + Private bool `json:"private" editable:"true"` + Created string `json:"created"` + Edited string `json:"edited"` +} + +// Anime fetches the associated anime data. +func (item *AnimeListItem) Anime() *Anime { + anime, _ := GetAnime(item.AnimeID) + return anime +} + +// Link returns the URI for the given item. +func (item *AnimeListItem) Link(userNick string) string { + return "/+" + userNick + "/animelist/anime/" + item.AnimeID +} + +// StatusHumanReadable returns the human readable representation of the status. +func (item *AnimeListItem) StatusHumanReadable() string { + switch item.Status { + case AnimeListStatusWatching: + return "Watching" + case AnimeListStatusCompleted: + return "Completed" + case AnimeListStatusPlanned: + return "Planned" + case AnimeListStatusHold: + return "On Hold" + case AnimeListStatusDropped: + return "Dropped" + default: + return "Unknown" + } +} + +// OnEpisodesChange is called when the watched episode count changes. +func (item *AnimeListItem) OnEpisodesChange() { + maxEpisodesKnown := item.Anime().EpisodeCount != 0 + + // If we update episodes to the max, set status to completed automatically. + if item.Anime().Status == "finished" && maxEpisodesKnown && item.Episodes == item.Anime().EpisodeCount { + // Complete automatically. + item.Status = AnimeListStatusCompleted + } + + // We set episodes lower than the max but the status is set as completed. + if item.Status == AnimeListStatusCompleted && maxEpisodesKnown && item.Episodes < item.Anime().EpisodeCount { + // Set status back to watching. + item.Status = AnimeListStatusWatching + } + + // If we increase the episodes and status is planned, set it to watching. + if item.Status == AnimeListStatusPlanned && item.Episodes > 0 { + // Set status to watching. + item.Status = AnimeListStatusWatching + } + + // If we set the episodes to 0 and status is not planned or dropped, set it to planned. + if item.Episodes == 0 && (item.Status != AnimeListStatusPlanned && item.Status != AnimeListStatusDropped) { + // Set status to planned. + item.Status = AnimeListStatusPlanned + } +} + +// OnStatusChange is called when the status changes. +func (item *AnimeListItem) OnStatusChange() { + maxEpisodesKnown := item.Anime().EpisodeCount != 0 + + // We just switched to completed status but the episodes aren't max yet. + if item.Status == AnimeListStatusCompleted && maxEpisodesKnown && item.Episodes < item.Anime().EpisodeCount { + // Set episodes to max. + item.Episodes = item.Anime().EpisodeCount + } + + // We just switched to plan to watch status but the episodes are greater than zero. + if item.Status == AnimeListStatusPlanned && item.Episodes > 0 { + // Set episodes back to zero. + item.Episodes = 0 + } + + // If we have an anime with max episodes watched and we change status to not completed, lower episode count by 1. + if maxEpisodesKnown && item.Status != AnimeListStatusCompleted && item.Episodes == item.Anime().EpisodeCount { + // Lower episodes by 1. + item.Episodes-- + } +} diff --git a/arn/AnimeListItemAPI.go b/arn/AnimeListItemAPI.go new file mode 100644 index 00000000..3a9b0776 --- /dev/null +++ b/arn/AnimeListItemAPI.go @@ -0,0 +1,95 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + "time" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ api.CustomEditable = (*AnimeListItem)(nil) + _ api.AfterEditable = (*AnimeListItem)(nil) +) + +// Edit ... +func (item *AnimeListItem) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (bool, error) { + user := GetUserFromContext(ctx) + + if user == nil { + return true, errors.New("Not logged in") + } + + switch key { + case "Episodes": + if !newValue.IsValid() { + return true, errors.New("Invalid episode number") + } + + oldEpisodes := item.Episodes + newEpisodes := int(newValue.Float()) + + // Fetch last activity + lastActivity := user.LastActivityConsumeAnime(item.AnimeID) + + if lastActivity == nil || time.Since(lastActivity.GetCreatedTime()) > 1*time.Hour { + // If there is no last activity for the given anime, + // or if the last activity happened more than an hour ago, + // create a new activity. + if newEpisodes > oldEpisodes { + activity := NewActivityConsumeAnime(item.AnimeID, newEpisodes, newEpisodes, user.ID) + activity.Save() + + // Broadcast event to all users so they can reload the activity page if needed. + for receiver := range StreamUsers() { + receiverIsFollowing := Contains(receiver.Follows().Items, user.ID) + + receiver.BroadcastEvent(&aero.Event{ + Name: "activity", + Data: receiverIsFollowing, + }) + } + } + } else if newEpisodes >= lastActivity.FromEpisode { + // Otherwise, update the last activity. + lastActivity.ToEpisode = newEpisodes + lastActivity.Created = DateTimeUTC() + lastActivity.Save() + } + + item.Episodes = newEpisodes + + if item.Episodes < 0 { + item.Episodes = 0 + } + + item.OnEpisodesChange() + return true, nil + + case "Status": + newStatus := newValue.String() + + switch newStatus { + case AnimeListStatusWatching, AnimeListStatusCompleted, AnimeListStatusPlanned, AnimeListStatusHold, AnimeListStatusDropped: + item.Status = newStatus + item.OnStatusChange() + return true, nil + + default: + return true, fmt.Errorf("Invalid anime list item status: %s", newStatus) + } + } + + return false, nil +} + +// AfterEdit is called after the item is edited. +func (item *AnimeListItem) AfterEdit(ctx aero.Context) error { + item.Rating.Clamp() + item.Edited = DateTimeUTC() + return nil +} diff --git a/arn/AnimeListItemRating.go b/arn/AnimeListItemRating.go new file mode 100644 index 00000000..e0daf36d --- /dev/null +++ b/arn/AnimeListItemRating.go @@ -0,0 +1,57 @@ +package arn + +// AnimeListItemRating ... +type AnimeListItemRating struct { + Overall float64 `json:"overall" editable:"true"` + Story float64 `json:"story" editable:"true"` + Visuals float64 `json:"visuals" editable:"true"` + Soundtrack float64 `json:"soundtrack" editable:"true"` +} + +// IsNotRated tells you whether all ratings are zero. +func (rating *AnimeListItemRating) IsNotRated() bool { + return rating.Overall == 0 && rating.Story == 0 && rating.Visuals == 0 && rating.Soundtrack == 0 +} + +// Reset sets all values to the default anime average rating. +func (rating *AnimeListItemRating) Reset() { + rating.Overall = DefaultRating + rating.Story = DefaultRating + rating.Visuals = DefaultRating + rating.Soundtrack = DefaultRating +} + +// Clamp ... +func (rating *AnimeListItemRating) Clamp() { + if rating.Overall < 0 { + rating.Overall = 0 + } + + if rating.Story < 0 { + rating.Story = 0 + } + + if rating.Visuals < 0 { + rating.Visuals = 0 + } + + if rating.Soundtrack < 0 { + rating.Soundtrack = 0 + } + + if rating.Overall > MaxRating { + rating.Overall = MaxRating + } + + if rating.Story > MaxRating { + rating.Story = MaxRating + } + + if rating.Visuals > MaxRating { + rating.Visuals = MaxRating + } + + if rating.Soundtrack > MaxRating { + rating.Soundtrack = MaxRating + } +} diff --git a/arn/AnimeList_test.go b/arn/AnimeList_test.go new file mode 100644 index 00000000..ba99bc52 --- /dev/null +++ b/arn/AnimeList_test.go @@ -0,0 +1,13 @@ +package arn_test + +import ( + "testing" + + "github.com/animenotifier/notify.moe/arn" +) + +func TestNormalizeRatings(t *testing.T) { + user, _ := arn.GetUser("4J6qpK1ve") + animeList := user.AnimeList() + animeList.NormalizeRatings() +} diff --git a/arn/AnimePopularity.go b/arn/AnimePopularity.go new file mode 100644 index 00000000..378e9875 --- /dev/null +++ b/arn/AnimePopularity.go @@ -0,0 +1,15 @@ +package arn + +// AnimePopularity shows how many users have that anime in a certain list. +type AnimePopularity struct { + Watching int `json:"watching"` + Completed int `json:"completed"` + Planned int `json:"planned"` + Hold int `json:"hold"` + Dropped int `json:"dropped"` +} + +// Total returns the total number of users that added this anime to their collection. +func (p *AnimePopularity) Total() int { + return p.Watching + p.Completed + p.Planned + p.Hold + p.Dropped +} diff --git a/arn/AnimeRating.go b/arn/AnimeRating.go new file mode 100644 index 00000000..5e2c53d6 --- /dev/null +++ b/arn/AnimeRating.go @@ -0,0 +1,31 @@ +package arn + +// DefaultRating is the default rating value. +const DefaultRating = 0.0 + +// AverageRating is the center rating in the system. +// Note that the mathematically correct center would be a little higher, +// but we don't care about these slight offsets. +const AverageRating = 5.0 + +// MaxRating is the maximum rating users can give. +const MaxRating = 10.0 + +// RatingCountThreshold is the number of users threshold that, when passed, doesn't dampen the result. +const RatingCountThreshold = 4 + +// AnimeRating ... +type AnimeRating struct { + AnimeListItemRating + + // The amount of people who rated + Count AnimeRatingCount `json:"count"` +} + +// AnimeRatingCount ... +type AnimeRatingCount struct { + Overall int `json:"overall"` + Story int `json:"story"` + Visuals int `json:"visuals"` + Soundtrack int `json:"soundtrack"` +} diff --git a/arn/AnimeRelation.go b/arn/AnimeRelation.go new file mode 100644 index 00000000..1e9ac1fc --- /dev/null +++ b/arn/AnimeRelation.go @@ -0,0 +1,62 @@ +package arn + +// Register a list of supported anime relation types. +func init() { + DataLists["anime-relation-types"] = []*Option{ + {"prequel", HumanReadableAnimeRelation("prequel")}, + {"sequel", HumanReadableAnimeRelation("sequel")}, + {"alternative version", "Alternative version"}, + {"alternative setting", "Alternative setting"}, + {"side story", HumanReadableAnimeRelation("side story")}, + {"parent story", HumanReadableAnimeRelation("parent story")}, + {"full story", HumanReadableAnimeRelation("full story")}, + {"spinoff", HumanReadableAnimeRelation("spinoff")}, + {"summary", HumanReadableAnimeRelation("summary")}, + {"other", HumanReadableAnimeRelation("other")}, + } +} + +// AnimeRelation ... +type AnimeRelation struct { + AnimeID string `json:"animeId" editable:"true"` + Type string `json:"type" editable:"true" datalist:"anime-relation-types"` +} + +// Anime ... +func (relation *AnimeRelation) Anime() *Anime { + anime, _ := GetAnime(relation.AnimeID) + return anime +} + +// HumanReadableType ... +func (relation *AnimeRelation) HumanReadableType() string { + return HumanReadableAnimeRelation(relation.Type) +} + +// HumanReadableAnimeRelation ... +func HumanReadableAnimeRelation(relationName string) string { + switch relationName { + case "prequel": + return "Prequel" + case "sequel": + return "Sequel" + case "alternative version": + return "Alternative" + case "alternative setting": + return "Alternative" + case "side story": + return "Side story" + case "parent story": + return "Parent story" + case "full story": + return "Full story" + case "spinoff": + return "Spin-off" + case "summary": + return "Summary" + case "other": + return "Other" + } + + return relationName +} diff --git a/arn/AnimeRelations.go b/arn/AnimeRelations.go new file mode 100644 index 00000000..4bb373c4 --- /dev/null +++ b/arn/AnimeRelations.go @@ -0,0 +1,127 @@ +package arn + +import ( + "sort" + "sync" + + "github.com/aerogo/nano" +) + +// AnimeRelations is a list of relations for an anime. +type AnimeRelations struct { + AnimeID string `json:"animeId" mainID:"true"` + Items []*AnimeRelation `json:"items" editable:"true"` + + sync.Mutex +} + +// Link returns the link for that object. +func (relations *AnimeRelations) Link() string { + return "/anime/" + relations.AnimeID + "/relations" +} + +// SortByStartDate ... +func (relations *AnimeRelations) SortByStartDate() { + relations.Lock() + defer relations.Unlock() + + sort.Slice(relations.Items, func(i, j int) bool { + a := relations.Items[i].Anime() + b := relations.Items[j].Anime() + + if a == nil { + return false + } + + if b == nil { + return true + } + + if a.StartDate == b.StartDate { + return a.Title.Canonical < b.Title.Canonical + } + + return a.StartDate < b.StartDate + }) +} + +// Anime returns the anime the relations list refers to. +func (relations *AnimeRelations) Anime() *Anime { + anime, _ := GetAnime(relations.AnimeID) + return anime +} + +// String implements the default string serialization. +func (relations *AnimeRelations) String() string { + return relations.Anime().String() +} + +// GetID returns the anime ID. +func (relations *AnimeRelations) GetID() string { + return relations.AnimeID +} + +// TypeName returns the type name. +func (relations *AnimeRelations) TypeName() string { + return "AnimeRelations" +} + +// Self returns the object itself. +func (relations *AnimeRelations) Self() Loggable { + return relations +} + +// Find returns the relation with the specified anime ID, if available. +func (relations *AnimeRelations) Find(animeID string) *AnimeRelation { + relations.Lock() + defer relations.Unlock() + + for _, item := range relations.Items { + if item.AnimeID == animeID { + return item + } + } + + return nil +} + +// Remove removes the anime ID from the relations. +func (relations *AnimeRelations) Remove(animeID string) bool { + relations.Lock() + defer relations.Unlock() + + for index, item := range relations.Items { + if item.AnimeID == animeID { + relations.Items = append(relations.Items[:index], relations.Items[index+1:]...) + return true + } + } + + return false +} + +// GetAnimeRelations ... +func GetAnimeRelations(animeID string) (*AnimeRelations, error) { + obj, err := DB.Get("AnimeRelations", animeID) + + if err != nil { + return nil, err + } + + return obj.(*AnimeRelations), nil +} + +// StreamAnimeRelations returns a stream of all anime relations. +func StreamAnimeRelations() <-chan *AnimeRelations { + channel := make(chan *AnimeRelations, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("AnimeRelations") { + channel <- obj.(*AnimeRelations) + } + + close(channel) + }() + + return channel +} diff --git a/arn/AnimeRelationsAPI.go b/arn/AnimeRelationsAPI.go new file mode 100644 index 00000000..32f66ed1 --- /dev/null +++ b/arn/AnimeRelationsAPI.go @@ -0,0 +1,54 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ fmt.Stringer = (*AnimeRelations)(nil) + _ api.Editable = (*AnimeRelations)(nil) + _ api.ArrayEventListener = (*AnimeRelations)(nil) +) + +// Authorize returns an error if the given API POST request is not authorized. +func (relations *AnimeRelations) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil || (user.Role != "editor" && user.Role != "admin") { + return errors.New("Not logged in or not authorized to edit") + } + + return nil +} + +// Edit creates an edit log entry. +func (relations *AnimeRelations) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + return edit(relations, ctx, key, value, newValue) +} + +// OnAppend saves a log entry. +func (relations *AnimeRelations) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(relations, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (relations *AnimeRelations) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(relations, ctx, key, index, obj) +} + +// Save saves the anime relations object in the database. +func (relations *AnimeRelations) Save() { + DB.Set("AnimeRelations", relations.AnimeID, relations) +} + +// Delete deletes the relation list from the database. +func (relations *AnimeRelations) Delete() error { + DB.Delete("AnimeRelations", relations.AnimeID) + return nil +} diff --git a/arn/AnimeSort.go b/arn/AnimeSort.go new file mode 100644 index 00000000..52d9e46b --- /dev/null +++ b/arn/AnimeSort.go @@ -0,0 +1,107 @@ +package arn + +import ( + "fmt" + "sort" + "time" +) + +const ( + currentlyAiringBonus = 5.0 + longSummaryBonus = 0.1 + popularityThreshold = 5 + popularityPenalty = 8.0 + watchingPopularityWeight = 0.07 + completedPopularityWeight = watchingPopularityWeight + plannedPopularityWeight = watchingPopularityWeight * (2.0 / 3.0) + droppedPopularityWeight = -plannedPopularityWeight + visualsWeight = 0.0075 + storyWeight = 0.0075 + soundtrackWeight = 0.0075 + movieBonus = 0.28 + agePenalty = 11.0 + ageThreshold = 6 * 30 * 24 * time.Hour +) + +// SortAnimeByPopularity sorts the given slice of anime by popularity. +func SortAnimeByPopularity(animes []*Anime) { + sort.Slice(animes, func(i, j int) bool { + aPopularity := animes[i].Popularity.Total() + bPopularity := animes[j].Popularity.Total() + + if aPopularity == bPopularity { + return animes[i].Title.Canonical < animes[j].Title.Canonical + } + + return aPopularity > bPopularity + }) +} + +// SortAnimeByQuality sorts the given slice of anime by quality. +func SortAnimeByQuality(animes []*Anime) { + SortAnimeByQualityDetailed(animes, "") +} + +// SortAnimeByQualityDetailed sorts the given slice of anime by quality. +func SortAnimeByQualityDetailed(animes []*Anime, filterStatus string) { + sort.Slice(animes, func(i, j int) bool { + a := animes[i] + b := animes[j] + + scoreA := a.Score() + scoreB := b.Score() + + // If we show currently running shows, rank shows that started a long time ago a bit lower + if filterStatus == "current" { + if a.StartDate != "" && time.Since(a.StartDateTime()) > ageThreshold { + scoreA -= agePenalty + } + + if b.StartDate != "" && time.Since(b.StartDateTime()) > ageThreshold { + scoreB -= agePenalty + } + } + + if scoreA == scoreB { + return a.Title.Canonical < b.Title.Canonical + } + + return scoreA > scoreB + }) +} + +// Score returns the score used for the anime ranking. +func (anime *Anime) Score() float64 { + score := anime.Rating.Overall + score += anime.Rating.Story * storyWeight + score += anime.Rating.Visuals * visualsWeight + score += anime.Rating.Soundtrack * soundtrackWeight + + score += float64(anime.Popularity.Watching) * watchingPopularityWeight + score += float64(anime.Popularity.Planned) * plannedPopularityWeight + score += float64(anime.Popularity.Completed) * completedPopularityWeight + score += float64(anime.Popularity.Dropped) * droppedPopularityWeight + + if anime.Status == "current" { + score += currentlyAiringBonus + } + + if anime.Type == "movie" { + score += movieBonus + } + + if anime.Popularity.Total() < popularityThreshold { + score -= popularityPenalty + } + + if len(anime.Summary) >= 140 { + score += longSummaryBonus + } + + return score +} + +// ScoreHumanReadable returns the score used for the anime ranking in human readable format. +func (anime *Anime) ScoreHumanReadable() string { + return fmt.Sprintf("%.1f", anime.Score()) +} diff --git a/arn/AnimeSort_test.go b/arn/AnimeSort_test.go new file mode 100644 index 00000000..bb24e1e6 --- /dev/null +++ b/arn/AnimeSort_test.go @@ -0,0 +1,19 @@ +package arn_test + +import ( + "testing" + + "github.com/animenotifier/notify.moe/arn" + "github.com/stretchr/testify/assert" +) + +func TestAnimeSort(t *testing.T) { + anime2011 := arn.FilterAnime(func(anime *arn.Anime) bool { + return anime.StartDateTime().Year() == 2011 + }) + + arn.SortAnimeByQuality(anime2011) + + // Best anime of 2011 needs to be Steins;Gate + assert.Equal(t, "0KUWpFmig", anime2011[0].ID) +} diff --git a/arn/AnimeTitle.go b/arn/AnimeTitle.go new file mode 100644 index 00000000..c9839080 --- /dev/null +++ b/arn/AnimeTitle.go @@ -0,0 +1,43 @@ +package arn + +// AnimeTitle ... +type AnimeTitle struct { + Canonical string `json:"canonical" editable:"true"` + Romaji string `json:"romaji" editable:"true"` + English string `json:"english" editable:"true"` + Japanese string `json:"japanese" editable:"true"` + Hiragana string `json:"hiragana" editable:"true"` + Synonyms []string `json:"synonyms" editable:"true"` +} + +// ByUser returns the preferred title for the given user. +func (title *AnimeTitle) ByUser(user *User) string { + if user == nil { + return title.Canonical + } + + switch user.Settings().TitleLanguage { + case "canonical": + return title.Canonical + case "romaji": + if title.Romaji == "" { + return title.Canonical + } + + return title.Romaji + case "english": + if title.English == "" { + return title.Canonical + } + + return title.English + case "japanese": + if title.Japanese == "" { + return title.Canonical + } + + return title.Japanese + default: + panic("Invalid title language") + } +} diff --git a/arn/Anime_test.go b/arn/Anime_test.go new file mode 100644 index 00000000..dc3f7d15 --- /dev/null +++ b/arn/Anime_test.go @@ -0,0 +1,72 @@ +package arn_test + +import ( + "testing" + + "github.com/animenotifier/notify.moe/arn" + "github.com/stretchr/testify/assert" +) + +func TestNewAnime(t *testing.T) { + anime := arn.NewAnime() + assert.NotNil(t, anime) + assert.NotEmpty(t, anime.ID) + assert.NotEmpty(t, anime.Created) +} + +func TestGetAnime(t *testing.T) { + // Existing anime + anime, err := arn.GetAnime("74y2cFiiR") + assert.NoError(t, err) + assert.NotNil(t, anime) + assert.NotEmpty(t, anime.ID) + assert.NotEmpty(t, anime.Title.Canonical) + + // Not existing anime + anime, err = arn.GetAnime("does not exist") + assert.Error(t, err) + assert.Nil(t, anime) +} + +func TestAllAnime(t *testing.T) { + validAnimeStatus := []string{ + "finished", + "current", + "upcoming", + "tba", + } + + validAnimeType := []string{ + "tv", + "movie", + "ova", + "ona", + "special", + "music", + } + + allAnime := arn.AllAnime() + + for _, anime := range allAnime { + + assert.NotEmpty(t, anime.ID) + assert.Contains(t, validAnimeStatus, anime.Status, "[%s] %s", anime.ID, anime.String()) + assert.Contains(t, validAnimeType, anime.Type, "[%s] %s", anime.ID, anime.String()) + assert.Contains(t, validAnimeStatus, anime.CalculatedStatus(), "[%s] %s", anime.ID, anime.String()) + assert.NotEmpty(t, anime.StatusHumanReadable(), "[%s] %s", anime.ID, anime.String()) + assert.NotEmpty(t, anime.TypeHumanReadable(), "[%s] %s", anime.ID, anime.String()) + assert.NotEmpty(t, anime.Link(), "[%s] %s", anime.ID, anime.String()) + assert.NotEmpty(t, anime.EpisodeCountString(), "[%s] %s", anime.ID, anime.String()) + + anime.Episodes() + anime.Characters() + anime.StartDateTime() + anime.EndDateTime() + anime.HasImage() + anime.GetMapping("shoboi/anime") + anime.Studios() + anime.Producers() + anime.Licensors() + anime.Prequels() + } +} diff --git a/arn/AuthorizeHelper.go b/arn/AuthorizeHelper.go new file mode 100644 index 00000000..c911cc71 --- /dev/null +++ b/arn/AuthorizeHelper.go @@ -0,0 +1,40 @@ +package arn + +import ( + "errors" + + "github.com/aerogo/aero" +) + +// AuthorizeIfLoggedInAndOwnData authorizes the given request if a user is logged in +// and the user ID matches the ID in the request. +func AuthorizeIfLoggedInAndOwnData(ctx aero.Context, userIDParameterName string) error { + err := AuthorizeIfLoggedIn(ctx) + + if err != nil { + return err + } + + userID := ctx.Session().Get("userId").(string) + + if userID != ctx.Get(userIDParameterName) { + return errors.New("Can not modify data from other users") + } + + return nil +} + +// AuthorizeIfLoggedIn authorizes the given request if a user is logged in. +func AuthorizeIfLoggedIn(ctx aero.Context) error { + if !ctx.HasSession() { + return errors.New("Neither logged in nor in session") + } + + userID, ok := ctx.Session().Get("userId").(string) + + if !ok || userID == "" { + return errors.New("Not logged in") + } + + return nil +} diff --git a/arn/Avatar.go b/arn/Avatar.go new file mode 100644 index 00000000..81e8fa0a --- /dev/null +++ b/arn/Avatar.go @@ -0,0 +1,42 @@ +package arn + +import ( + "image" + "os" + "path" +) + +// OriginalImageExtensions includes all the formats that an avatar source could have sent to us. +var OriginalImageExtensions = []string{ + ".jpg", + ".png", + ".gif", +} + +// LoadImage loads an image from the given path. +func LoadImage(path string) (img image.Image, format string, err error) { + f, openErr := os.Open(path) + + if openErr != nil { + return nil, "", openErr + } + + img, format, decodeErr := image.Decode(f) + + if decodeErr != nil { + return nil, "", decodeErr + } + + return img, format, nil +} + +// FindFileWithExtension tries to test different file extensions. +func FindFileWithExtension(baseName string, dir string, extensions []string) string { + for _, ext := range extensions { + if _, err := os.Stat(path.Join(dir, baseName+ext)); !os.IsNotExist(err) { + return dir + baseName + ext + } + } + + return "" +} diff --git a/arn/Bot.go b/arn/Bot.go new file mode 100644 index 00000000..8242ae92 --- /dev/null +++ b/arn/Bot.go @@ -0,0 +1,4 @@ +package arn + +// BotUserID is the user ID of the anime notifier bot. +const BotUserID = "3wUBnfUkR" diff --git a/arn/Character.go b/arn/Character.go new file mode 100644 index 00000000..497046fb --- /dev/null +++ b/arn/Character.go @@ -0,0 +1,262 @@ +package arn + +import ( + "errors" + "fmt" + "sort" + + "github.com/aerogo/nano" +) + +// Character represents an anime or manga character. +type Character struct { + Name CharacterName `json:"name" editable:"true"` + Image CharacterImage `json:"image"` + MainQuoteID string `json:"mainQuoteId" editable:"true"` + Description string `json:"description" editable:"true" type:"textarea"` + Spoilers []Spoiler `json:"spoilers" editable:"true"` + Attributes []*CharacterAttribute `json:"attributes" editable:"true"` + + hasID + hasPosts + hasMappings + hasCreator + hasEditor + hasLikes + hasDraft +} + +// NewCharacter creates a new character. +func NewCharacter() *Character { + return &Character{ + hasID: hasID{ + ID: GenerateID("Character"), + }, + hasCreator: hasCreator{ + Created: DateTimeUTC(), + }, + } +} + +// Link ... +func (character *Character) Link() string { + return "/character/" + character.ID +} + +// TitleByUser returns the preferred title for the given user. +func (character *Character) TitleByUser(user *User) string { + return character.Name.ByUser(user) +} + +// String returns the canonical name of the character. +func (character *Character) String() string { + return character.Name.Canonical +} + +// TypeName returns the type name. +func (character *Character) TypeName() string { + return "Character" +} + +// Self returns the object itself. +func (character *Character) Self() Loggable { + return character +} + +// MainQuote ... +func (character *Character) MainQuote() *Quote { + quote, _ := GetQuote(character.MainQuoteID) + return quote +} + +// AverageColor returns the average color of the image. +func (character *Character) AverageColor() string { + color := character.Image.AverageColor + + if color.Hue == 0 && color.Saturation == 0 && color.Lightness == 0 { + return "" + } + + return color.String() +} + +// ImageLink ... +func (character *Character) ImageLink(size string) string { + extension := ".jpg" + + if size == "original" { + extension = character.Image.Extension + } + + return fmt.Sprintf("//%s/images/characters/%s/%s%s?%v", MediaHost, size, character.ID, extension, character.Image.LastModified) +} + +// Publish publishes the character draft. +func (character *Character) Publish() error { + // No name + if character.Name.Canonical == "" { + return errors.New("No canonical character name") + } + + // No image + if !character.HasImage() { + return errors.New("No character image") + } + + return publish(character) +} + +// Unpublish turns the character into a draft. +func (character *Character) Unpublish() error { + return unpublish(character) +} + +// Anime returns a list of all anime the character appears in. +func (character *Character) Anime() []*Anime { + var results []*Anime + + for animeCharacters := range StreamAnimeCharacters() { + if animeCharacters.Contains(character.ID) { + anime, err := GetAnime(animeCharacters.AnimeID) + + if err != nil { + continue + } + + results = append(results, anime) + } + } + + return results +} + +// GetCharacter ... +func GetCharacter(id string) (*Character, error) { + obj, err := DB.Get("Character", id) + + if err != nil { + return nil, err + } + + return obj.(*Character), nil +} + +// Merge deletes the character and moves all existing references to the new character. +func (character *Character) Merge(target *Character) { + // Check anime characters + for list := range StreamAnimeCharacters() { + for _, animeCharacter := range list.Items { + if animeCharacter.CharacterID == character.ID { + animeCharacter.CharacterID = target.ID + list.Save() + break + } + } + } + + // Check quotes + for quote := range StreamQuotes() { + if quote.CharacterID == character.ID { + quote.CharacterID = target.ID + quote.Save() + } + } + + // Check log + for entry := range StreamEditLogEntries() { + if entry.ObjectType != "Character" { + continue + } + + if entry.ObjectID == character.ID { + // Delete log entries for the old character + DB.Delete("EditLogEntry", entry.ID) + } + } + + // Merge likes + for _, userID := range character.Likes { + if !Contains(target.Likes, userID) { + target.Likes = append(target.Likes, userID) + } + } + + target.Save() + + // Delete image files + character.DeleteImages() + + // Delete character + DB.Delete("Character", character.ID) +} + +// DeleteImages deletes all images for the character. +func (character *Character) DeleteImages() { + deleteImages("characters", character.ID, character.Image.Extension) +} + +// Quotes returns the list of quotes for this character. +func (character *Character) Quotes() []*Quote { + return FilterQuotes(func(quote *Quote) bool { + return !quote.IsDraft && quote.CharacterID == character.ID + }) +} + +// SortCharactersByLikes sorts the given slice of characters by the amount of likes. +func SortCharactersByLikes(characters []*Character) { + sort.Slice(characters, func(i, j int) bool { + aLikes := len(characters[i].Likes) + bLikes := len(characters[j].Likes) + + if aLikes == bLikes { + return characters[i].Name.Canonical < characters[j].Name.Canonical + } + + return aLikes > bLikes + }) +} + +// StreamCharacters returns a stream of all characters. +func StreamCharacters() <-chan *Character { + channel := make(chan *Character, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Character") { + channel <- obj.(*Character) + } + + close(channel) + }() + + return channel +} + +// FilterCharacters filters all characters by a custom function. +func FilterCharacters(filter func(*Character) bool) []*Character { + var filtered []*Character + + channel := DB.All("Character") + + for obj := range channel { + realObject := obj.(*Character) + + if filter(realObject) { + filtered = append(filtered, realObject) + } + } + + return filtered +} + +// AllCharacters returns a slice of all characters. +func AllCharacters() []*Character { + all := make([]*Character, 0, DB.Collection("Character").Count()) + + stream := StreamCharacters() + + for obj := range stream { + all = append(all, obj) + } + + return all +} diff --git a/arn/CharacterAPI.go b/arn/CharacterAPI.go new file mode 100644 index 00000000..4eae258e --- /dev/null +++ b/arn/CharacterAPI.go @@ -0,0 +1,150 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ Likeable = (*Character)(nil) + _ Publishable = (*Character)(nil) + _ PostParent = (*Character)(nil) + _ fmt.Stringer = (*Character)(nil) + _ api.Newable = (*Character)(nil) + _ api.Editable = (*Character)(nil) + _ api.Deletable = (*Character)(nil) +) + +// Actions +func init() { + API.RegisterActions("Character", []*api.Action{ + // Publish + PublishAction(), + + // Unpublish + UnpublishAction(), + + // Like character + LikeAction(), + + // Unlike character + UnlikeAction(), + }) +} + +// Create sets the data for a new character with data we received from the API request. +func (character *Character) Create(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + character.ID = GenerateID("Character") + character.Created = DateTimeUTC() + character.CreatedBy = user.ID + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "create", "Character", character.ID, "", "", "") + logEntry.Save() + + return character.Unpublish() +} + +// Authorize returns an error if the given API request is not authorized. +func (character *Character) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + // Allow custom actions (like, unlike) for normal users + if action == "like" || action == "unlike" { + return nil + } + + if user.Role != "editor" && user.Role != "admin" { + return errors.New("Insufficient permissions") + } + + return nil +} + +// Edit creates an edit log entry. +func (character *Character) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + return edit(character, ctx, key, value, newValue) +} + +// OnAppend saves a log entry. +func (character *Character) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(character, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (character *Character) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(character, ctx, key, index, obj) +} + +// DeleteInContext deletes the character in the given context. +func (character *Character) DeleteInContext(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "delete", "Character", character.ID, "", fmt.Sprint(character), "") + logEntry.Save() + + return character.Delete() +} + +// Delete deletes the object from the database. +func (character *Character) Delete() error { + if character.IsDraft { + draftIndex := character.Creator().DraftIndex() + draftIndex.CharacterID = "" + draftIndex.Save() + } + + // Delete from anime characters + for list := range StreamAnimeCharacters() { + list.Lock() + + for index, animeCharacter := range list.Items { + if animeCharacter.CharacterID == character.ID { + list.Items = append(list.Items[:index], list.Items[index+1:]...) + list.Save() + break + } + } + + list.Unlock() + } + + // Delete from quotes + for quote := range StreamQuotes() { + if quote.CharacterID == character.ID { + err := quote.Delete() + + if err != nil { + return err + } + } + } + + // Delete image files + character.DeleteImages() + + // Delete character + DB.Delete("Character", character.ID) + return nil +} + +// Save saves the character in the database. +func (character *Character) Save() { + DB.Set("Character", character.ID, character) +} diff --git a/arn/CharacterAttribute.go b/arn/CharacterAttribute.go new file mode 100644 index 00000000..ac14de7f --- /dev/null +++ b/arn/CharacterAttribute.go @@ -0,0 +1,7 @@ +package arn + +// CharacterAttribute describes one attribute of a character, e.g. height or age. +type CharacterAttribute struct { + Name string `json:"name" editable:"true"` + Value string `json:"value" editable:"true"` +} diff --git a/arn/CharacterFinder.go b/arn/CharacterFinder.go new file mode 100644 index 00000000..cda053e5 --- /dev/null +++ b/arn/CharacterFinder.go @@ -0,0 +1,37 @@ +package arn + +// CharacterFinder holds an internal map of ID to anime mappings +// and is therefore very efficient to use when trying to find +// anime by a given service and ID. +type CharacterFinder struct { + idToCharacter map[string]*Character + mappingName string +} + +// NewCharacterFinder creates a new finder for external characters. +func NewCharacterFinder(mappingName string) *CharacterFinder { + finder := &CharacterFinder{ + idToCharacter: map[string]*Character{}, + mappingName: mappingName, + } + + for character := range StreamCharacters() { + finder.Add(character) + } + + return finder +} + +// Add adds a character to the search pool. +func (finder *CharacterFinder) Add(character *Character) { + id := character.GetMapping(finder.mappingName) + + if id != "" { + finder.idToCharacter[id] = character + } +} + +// GetCharacter tries to find an external anime in our anime database. +func (finder *CharacterFinder) GetCharacter(id string) *Character { + return finder.idToCharacter[id] +} diff --git a/arn/CharacterImage.go b/arn/CharacterImage.go new file mode 100644 index 00000000..942dd093 --- /dev/null +++ b/arn/CharacterImage.go @@ -0,0 +1,219 @@ +package arn + +import ( + "bytes" + "fmt" + "image" + "net/http" + "path" + "time" + + "github.com/aerogo/http/client" + "github.com/akyoto/imageserver" +) + +const ( + // CharacterImageLargeWidth is the minimum width in pixels of a large character image. + // We subtract 6 pixels due to border removal which can remove up to 6 pixels. + CharacterImageLargeWidth = 225 - 6 + + // CharacterImageLargeHeight is the minimum height in pixels of a large character image. + // We subtract 6 pixels due to border removal which can remove up to 6 pixels. + CharacterImageLargeHeight = 350 - 6 + + // CharacterImageMediumWidth is the minimum width in pixels of a medium character image. + CharacterImageMediumWidth = 112 + + // CharacterImageMediumHeight is the minimum height in pixels of a medium character image. + CharacterImageMediumHeight = 112 + + // CharacterImageSmallWidth is the minimum width in pixels of a small character image. + CharacterImageSmallWidth = 56 + + // CharacterImageSmallHeight is the minimum height in pixels of a small character image. + CharacterImageSmallHeight = 56 + + // CharacterImageWebPQuality is the WebP quality of character images. + CharacterImageWebPQuality = 70 + + // CharacterImageJPEGQuality is the JPEG quality of character images. + CharacterImageJPEGQuality = 70 + + // CharacterImageQualityBonusLowDPI ... + CharacterImageQualityBonusLowDPI = 12 + + // CharacterImageQualityBonusLarge ... + CharacterImageQualityBonusLarge = 10 + + // CharacterImageQualityBonusMedium ... + CharacterImageQualityBonusMedium = 15 + + // CharacterImageQualityBonusSmall ... + CharacterImageQualityBonusSmall = 15 +) + +// Define the character image outputs +var characterImageOutputs = []imageserver.Output{ + // Original at full size + &imageserver.OriginalFile{ + Directory: path.Join(Root, "images/characters/original/"), + Width: 0, + Height: 0, + Quality: 0, + }, + + // JPEG - Large + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/characters/large/"), + Width: CharacterImageLargeWidth, + Height: CharacterImageLargeHeight, + Quality: CharacterImageJPEGQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusLarge, + }, + + // JPEG - Medium + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/characters/medium/"), + Width: CharacterImageMediumWidth, + Height: CharacterImageMediumHeight, + Quality: CharacterImageJPEGQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusMedium, + }, + + // JPEG - Small + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/characters/small/"), + Width: CharacterImageSmallWidth, + Height: CharacterImageSmallHeight, + Quality: CharacterImageJPEGQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusSmall, + }, + + // WebP - Large + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/characters/large/"), + Width: CharacterImageLargeWidth, + Height: CharacterImageLargeHeight, + Quality: CharacterImageWebPQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusLarge, + }, + + // WebP - Medium + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/characters/medium/"), + Width: CharacterImageMediumWidth, + Height: CharacterImageMediumHeight, + Quality: CharacterImageWebPQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusMedium, + }, + + // WebP - Small + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/characters/small/"), + Width: CharacterImageSmallWidth, + Height: CharacterImageSmallHeight, + Quality: CharacterImageWebPQuality + CharacterImageQualityBonusLowDPI + CharacterImageQualityBonusSmall, + }, +} + +// Define the high DPI character image outputs +var characterImageOutputsHighDPI = []imageserver.Output{ + // NOTE: We don't save "large" images in double size because that's usually the maximum size anyway. + + // JPEG - Medium + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/characters/medium/"), + Width: CharacterImageMediumWidth * 2, + Height: CharacterImageMediumHeight * 2, + Quality: CharacterImageJPEGQuality + CharacterImageQualityBonusMedium, + }, + + // JPEG - Small + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/characters/small/"), + Width: CharacterImageSmallWidth * 2, + Height: CharacterImageSmallHeight * 2, + Quality: CharacterImageJPEGQuality + CharacterImageQualityBonusSmall, + }, + + // WebP - Medium + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/characters/medium/"), + Width: CharacterImageMediumWidth * 2, + Height: CharacterImageMediumHeight * 2, + Quality: CharacterImageWebPQuality + CharacterImageQualityBonusMedium, + }, + + // WebP - Small + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/characters/small/"), + Width: CharacterImageSmallWidth * 2, + Height: CharacterImageSmallHeight * 2, + Quality: CharacterImageWebPQuality + CharacterImageQualityBonusSmall, + }, +} + +// CharacterImage ... +type CharacterImage AnimeImage + +// SetImageBytes accepts a byte buffer that represents an image file and updates the character image. +func (character *Character) SetImageBytes(data []byte) error { + // Decode + img, format, err := image.Decode(bytes.NewReader(data)) + + if err != nil { + return err + } + + return character.SetImage(&imageserver.MetaImage{ + Image: img, + Format: format, + Data: data, + }) +} + +// SetImage sets the character image to the given MetaImage. +func (character *Character) SetImage(metaImage *imageserver.MetaImage) error { + var lastError error + + // Save the different image formats and sizes in low DPI + for _, output := range characterImageOutputs { + err := output.Save(metaImage, character.ID) + + if err != nil { + lastError = err + } + } + + // Save the different image formats and sizes in high DPI + for _, output := range characterImageOutputsHighDPI { + err := output.Save(metaImage, character.ID+"@2") + + if err != nil { + lastError = err + } + } + + character.Image.Extension = metaImage.Extension() + character.Image.Width = metaImage.Image.Bounds().Dx() + character.Image.Height = metaImage.Image.Bounds().Dy() + character.Image.AverageColor = GetAverageColor(metaImage.Image) + character.Image.LastModified = time.Now().Unix() + return lastError +} + +// DownloadImage ... +func (character *Character) DownloadImage(url string) error { + response, err := client.Get(url).End() + + // Cancel the import if image could not be fetched + if err != nil { + return err + } + + if response.StatusCode() != http.StatusOK { + return fmt.Errorf("Image response status code: %d", response.StatusCode()) + } + + return character.SetImageBytes(response.Bytes()) +} + +// HasImage returns true if the character has an image. +func (character *Character) HasImage() bool { + return character.Image.Extension != "" && character.Image.Width > 0 +} diff --git a/arn/CharacterName.go b/arn/CharacterName.go new file mode 100644 index 00000000..3090c2a8 --- /dev/null +++ b/arn/CharacterName.go @@ -0,0 +1,35 @@ +package arn + +// CharacterName ... +type CharacterName struct { + Canonical string `json:"canonical" editable:"true"` + English string `json:"english" editable:"true"` + Japanese string `json:"japanese" editable:"true"` + Synonyms []string `json:"synonyms" editable:"true"` +} + +// ByUser returns the preferred name for the given user. +func (name *CharacterName) ByUser(user *User) string { + if user == nil { + return name.Canonical + } + + switch user.Settings().TitleLanguage { + case "canonical", "romaji": + return name.Canonical + case "english": + if name.English == "" { + return name.Canonical + } + + return name.English + case "japanese": + if name.Japanese == "" { + return name.Canonical + } + + return name.Japanese + default: + panic("Invalid name language") + } +} diff --git a/arn/ClientErrorReport.go b/arn/ClientErrorReport.go new file mode 100644 index 00000000..df3dd017 --- /dev/null +++ b/arn/ClientErrorReport.go @@ -0,0 +1,43 @@ +package arn + +import "github.com/aerogo/nano" + +// ClientErrorReport saves JavaScript errors that happen in web clients like browsers. +type ClientErrorReport struct { + ID string `json:"id"` + Message string `json:"message"` + Stack string `json:"stack"` + FileName string `json:"fileName"` + LineNumber int `json:"lineNumber"` + ColumnNumber int `json:"columnNumber"` + + hasCreator +} + +// StreamClientErrorReports returns a stream of all characters. +func StreamClientErrorReports() <-chan *ClientErrorReport { + channel := make(chan *ClientErrorReport, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("ClientErrorReport") { + channel <- obj.(*ClientErrorReport) + } + + close(channel) + }() + + return channel +} + +// AllClientErrorReports returns a slice of all characters. +func AllClientErrorReports() []*ClientErrorReport { + all := make([]*ClientErrorReport, 0, DB.Collection("ClientErrorReport").Count()) + + stream := StreamClientErrorReports() + + for obj := range stream { + all = append(all, obj) + } + + return all +} diff --git a/arn/ClientErrorReportAPI.go b/arn/ClientErrorReportAPI.go new file mode 100644 index 00000000..146b93c3 --- /dev/null +++ b/arn/ClientErrorReportAPI.go @@ -0,0 +1,54 @@ +package arn + +import ( + "errors" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +var ( + _ api.Newable = (*ClientErrorReport)(nil) +) + +// Create sets the data for a new report with data we received from the API request. +func (report *ClientErrorReport) Create(ctx aero.Context) error { + data, err := ctx.Request().Body().JSONObject() + + if err != nil { + return err + } + + // Get user + user := GetUserFromContext(ctx) + + // Create report + report.ID = GenerateID("ClientErrorReport") + report.Message = data["message"].(string) + report.Stack = data["stack"].(string) + report.FileName = data["fileName"].(string) + report.LineNumber = int(data["lineNumber"].(float64)) + report.ColumnNumber = int(data["columnNumber"].(float64)) + report.Created = DateTimeUTC() + + if user != nil { + report.CreatedBy = user.ID + } + + report.Save() + return nil +} + +// Save saves the client error report in the database. +func (report *ClientErrorReport) Save() { + DB.Set("ClientErrorReport", report.ID, report) +} + +// Authorize returns an error if the given API request is not authorized. +func (report *ClientErrorReport) Authorize(ctx aero.Context, action string) error { + if action == "create" { + return nil + } + + return errors.New("Action " + action + " not allowed") +} diff --git a/arn/CollectionUtils.go b/arn/CollectionUtils.go new file mode 100644 index 00000000..91376f66 --- /dev/null +++ b/arn/CollectionUtils.go @@ -0,0 +1,52 @@ +package arn + +// IndexOf ... +func IndexOf(collection []string, t string) int { + for i, v := range collection { + if v == t { + return i + } + } + return -1 +} + +// Contains ... +func Contains(collection []string, t string) bool { + return IndexOf(collection, t) >= 0 +} + +// func Any(collection []string, f func(string) bool) bool { +// for _, v := range collection { +// if f(v) { +// return true +// } +// } +// return false +// } + +// func All(collection []string, f func(string) bool) bool { +// for _, v := range collection { +// if !f(v) { +// return false +// } +// } +// return true +// } + +// func Filter(collection []string, f func(string) bool) []string { +// vsf := make([]string, 0) +// for _, v := range collection { +// if f(v) { +// vsf = append(vsf, v) +// } +// } +// return vsf +// } + +// func Map(collection []string, f func(string) string) []string { +// vsm := make([]string, len(collection)) +// for i, v := range collection { +// vsm[i] = f(v) +// } +// return vsm +// } diff --git a/arn/Company.go b/arn/Company.go new file mode 100644 index 00000000..98d4966f --- /dev/null +++ b/arn/Company.go @@ -0,0 +1,161 @@ +package arn + +import ( + "errors" + + "github.com/aerogo/nano" +) + +// Company represents an anime studio, producer or licensor. +type Company struct { + Name CompanyName `json:"name" editable:"true"` + Description string `json:"description" editable:"true" type:"textarea"` + Email string `json:"email" editable:"true"` + Links []*Link `json:"links" editable:"true"` + + // Mixins + hasID + hasMappings + hasLikes + hasDraft + + // Other editable fields + Location *Location `json:"location" editable:"true"` + Tags []string `json:"tags" editable:"true"` + + // Editing dates + hasCreator + hasEditor +} + +// NewCompany creates a new company. +func NewCompany() *Company { + return &Company{ + hasID: hasID{ + ID: GenerateID("Company"), + }, + Name: CompanyName{}, + Links: []*Link{}, + Tags: []string{}, + hasCreator: hasCreator{ + Created: DateTimeUTC(), + }, + hasMappings: hasMappings{ + Mappings: []*Mapping{}, + }, + } +} + +// Link returns a single company. +func (company *Company) Link() string { + return "/company/" + company.ID +} + +// Anime returns the anime connected with this company. +func (company *Company) Anime() (studioAnime []*Anime, producedAnime []*Anime, licensedAnime []*Anime) { + for anime := range StreamAnime() { + if Contains(anime.StudioIDs, company.ID) { + studioAnime = append(studioAnime, anime) + } + + if Contains(anime.ProducerIDs, company.ID) { + producedAnime = append(producedAnime, anime) + } + + if Contains(anime.LicensorIDs, company.ID) { + licensedAnime = append(licensedAnime, anime) + } + } + + SortAnimeByQuality(studioAnime) + SortAnimeByQuality(producedAnime) + SortAnimeByQuality(licensedAnime) + + return studioAnime, producedAnime, licensedAnime +} + +// Publish publishes the company draft. +func (company *Company) Publish() error { + // No title + if company.Name.English == "" { + return errors.New("No English company name") + } + + return publish(company) +} + +// Unpublish turns the company into a draft. +func (company *Company) Unpublish() error { + return unpublish(company) +} + +// String implements the default string serialization. +func (company *Company) String() string { + return company.Name.English +} + +// TypeName returns the type name. +func (company *Company) TypeName() string { + return "Company" +} + +// Self returns the object itself. +func (company *Company) Self() Loggable { + return company +} + +// GetCompany returns a single company. +func GetCompany(id string) (*Company, error) { + obj, err := DB.Get("Company", id) + + if err != nil { + return nil, err + } + + return obj.(*Company), nil +} + +// StreamCompanies returns a stream of all companies. +func StreamCompanies() <-chan *Company { + channel := make(chan *Company, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Company") { + channel <- obj.(*Company) + } + + close(channel) + }() + + return channel +} + +// FilterCompanies filters all companies by a custom function. +func FilterCompanies(filter func(*Company) bool) []*Company { + var filtered []*Company + + channel := DB.All("Company") + + for obj := range channel { + realObject := obj.(*Company) + + if filter(realObject) { + filtered = append(filtered, realObject) + } + } + + return filtered +} + +// AllCompanies returns a slice of all companies. +func AllCompanies() []*Company { + all := make([]*Company, 0, DB.Collection("Company").Count()) + + stream := StreamCompanies() + + for obj := range stream { + all = append(all, obj) + } + + return all +} diff --git a/arn/CompanyAPI.go b/arn/CompanyAPI.go new file mode 100644 index 00000000..9973d02e --- /dev/null +++ b/arn/CompanyAPI.go @@ -0,0 +1,139 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ Publishable = (*Company)(nil) + _ fmt.Stringer = (*Company)(nil) + _ api.Newable = (*Company)(nil) + _ api.Editable = (*Company)(nil) + _ api.Deletable = (*Company)(nil) + _ api.ArrayEventListener = (*Company)(nil) +) + +// Actions +func init() { + API.RegisterActions("Company", []*api.Action{ + // Publish + PublishAction(), + + // Unpublish + UnpublishAction(), + + // Like + LikeAction(), + + // Unlike + UnlikeAction(), + }) +} + +// Create sets the data for a new company with data we received from the API request. +func (company *Company) Create(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + company.ID = GenerateID("Company") + company.Created = DateTimeUTC() + company.CreatedBy = user.ID + company.Location = &Location{} + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "create", "Company", company.ID, "", "", "") + logEntry.Save() + + return company.Unpublish() +} + +// Edit creates an edit log entry. +func (company *Company) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + return edit(company, ctx, key, value, newValue) +} + +// OnAppend saves a log entry. +func (company *Company) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(company, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (company *Company) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(company, ctx, key, index, obj) +} + +// Save saves the company in the database. +func (company *Company) Save() { + DB.Set("Company", company.ID, company) +} + +// DeleteInContext deletes the company in the given context. +func (company *Company) DeleteInContext(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "delete", "Company", company.ID, "", fmt.Sprint(company), "") + logEntry.Save() + + return company.Delete() +} + +// Delete deletes the object from the database. +func (company *Company) Delete() error { + if company.IsDraft { + draftIndex := company.Creator().DraftIndex() + draftIndex.CompanyID = "" + draftIndex.Save() + } + + // Remove company ID from all anime + for anime := range StreamAnime() { + for index, id := range anime.StudioIDs { + if id == company.ID { + anime.StudioIDs = append(anime.StudioIDs[:index], anime.StudioIDs[index+1:]...) + break + } + } + + for index, id := range anime.ProducerIDs { + if id == company.ID { + anime.ProducerIDs = append(anime.ProducerIDs[:index], anime.ProducerIDs[index+1:]...) + break + } + } + + for index, id := range anime.LicensorIDs { + if id == company.ID { + anime.LicensorIDs = append(anime.LicensorIDs[:index], anime.LicensorIDs[index+1:]...) + break + } + } + } + + DB.Delete("Company", company.ID) + return nil +} + +// Authorize returns an error if the given API request is not authorized. +func (company *Company) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + if user.Role != "editor" && user.Role != "admin" { + return errors.New("Insufficient permissions") + } + + return nil +} diff --git a/arn/CompanyName.go b/arn/CompanyName.go new file mode 100644 index 00000000..3e8307e9 --- /dev/null +++ b/arn/CompanyName.go @@ -0,0 +1,8 @@ +package arn + +// CompanyName ... +type CompanyName struct { + English string `json:"english" editable:"true"` + Japanese string `json:"japanese" editable:"true"` + Synonyms []string `json:"synonyms" editable:"true"` +} diff --git a/arn/CompanySort.go b/arn/CompanySort.go new file mode 100644 index 00000000..25bc088b --- /dev/null +++ b/arn/CompanySort.go @@ -0,0 +1,48 @@ +package arn + +import ( + "sort" +) + +// GetCompanyToAnimeMap returns a map that contains company IDs as keys and their anime as values. +func GetCompanyToAnimeMap() map[string][]*Anime { + companyToAnimes := map[string][]*Anime{} + + allAnime := AllAnime() + SortAnimeByQuality(allAnime) + + for _, anime := range allAnime { + for _, studioID := range anime.StudioIDs { + companyToAnimes[studioID] = append(companyToAnimes[studioID], anime) + } + } + + return companyToAnimes +} + +// SortCompaniesPopularFirst ... +func SortCompaniesPopularFirst(companies []*Company) { + // Generate company ID to popularity map + popularity := map[string]int{} + + for anime := range StreamAnime() { + for _, studio := range anime.Studios() { + popularity[studio.ID] += anime.Popularity.Watching + anime.Popularity.Completed + } + } + + // Sort by using the popularity map + sort.Slice(companies, func(i, j int) bool { + a := companies[i] + b := companies[j] + + aPopularity := popularity[a.ID] + bPopularity := popularity[b.ID] + + if aPopularity == bPopularity { + return a.Name.English < b.Name.English + } + + return aPopularity > bPopularity + }) +} diff --git a/arn/DataLists.go b/arn/DataLists.go new file mode 100644 index 00000000..1673b651 --- /dev/null +++ b/arn/DataLists.go @@ -0,0 +1,11 @@ +package arn + +// Option is a selection list item. +type Option struct { + Value string + Label string +} + +// DataLists maps an ID to a list of keys and values. +// Used for selection lists in UIs. +var DataLists = map[string][]*Option{} diff --git a/arn/Database.go b/arn/Database.go new file mode 100644 index 00000000..003d03a6 --- /dev/null +++ b/arn/Database.go @@ -0,0 +1,71 @@ +package arn + +import ( + "github.com/aerogo/api" + "github.com/aerogo/nano" + "github.com/animenotifier/kitsu" + "github.com/animenotifier/mal" +) + +// Node represents the database node. +var Node = nano.New(nano.Configuration{ + Port: 5000, +}) + +// DB is the main database client. +var DB = Node.Namespace("arn").RegisterTypes( + (*ActivityCreate)(nil), + (*ActivityConsumeAnime)(nil), + (*AMV)(nil), + (*Analytics)(nil), + (*Anime)(nil), + (*AnimeCharacters)(nil), + (*AnimeEpisodes)(nil), + (*AnimeRelations)(nil), + (*AnimeList)(nil), + (*Character)(nil), + (*ClientErrorReport)(nil), + (*Company)(nil), + (*DraftIndex)(nil), + (*EditLogEntry)(nil), + (*EmailToUser)(nil), + (*FacebookToUser)(nil), + (*GoogleToUser)(nil), + (*Group)(nil), + (*IDList)(nil), + (*IgnoreAnimeDifference)(nil), + (*Inventory)(nil), + (*NickToUser)(nil), + (*Notification)(nil), + (*PayPalPayment)(nil), + (*Person)(nil), + (*Post)(nil), + (*Purchase)(nil), + (*PushSubscriptions)(nil), + (*Quote)(nil), + (*Session)(nil), + (*Settings)(nil), + (*ShopItem)(nil), + (*SoundTrack)(nil), + (*Thread)(nil), + (*TwitterToUser)(nil), + (*User)(nil), + (*UserFollows)(nil), + (*UserNotifications)(nil), +) + +// MAL is the client for the MyAnimeList database. +var MAL = Node.Namespace("mal").RegisterTypes( + (*mal.Anime)(nil), + (*mal.Character)(nil), +) + +// Kitsu is the client for the Kitsu database. +var Kitsu = Node.Namespace("kitsu").RegisterTypes( + (*kitsu.Anime)(nil), + (*kitsu.Mapping)(nil), + (*kitsu.Character)(nil), +) + +// API ... +var API = api.New("/api/", DB) diff --git a/arn/Database_test.go b/arn/Database_test.go new file mode 100644 index 00000000..a01d95ac --- /dev/null +++ b/arn/Database_test.go @@ -0,0 +1,12 @@ +package arn_test + +import ( + "testing" + + "github.com/animenotifier/notify.moe/arn" + "github.com/stretchr/testify/assert" +) + +func TestConnect(t *testing.T) { + assert.NotEmpty(t, arn.DB.Node().Address().String()) +} diff --git a/arn/DraftIndex.go b/arn/DraftIndex.go new file mode 100644 index 00000000..f2cf33b7 --- /dev/null +++ b/arn/DraftIndex.go @@ -0,0 +1,61 @@ +package arn + +import ( + "errors" + "reflect" +) + +// DraftIndex has references to unpublished drafts a user created. +type DraftIndex struct { + UserID string `json:"userId"` + GroupID string `json:"groupId"` + SoundTrackID string `json:"soundTrackId"` + CompanyID string `json:"companyId"` + QuoteID string `json:"quoteId"` + CharacterID string `json:"characterId"` + AnimeID string `json:"animeId"` + AMVID string `json:"amvId"` +} + +// NewDraftIndex ... +func NewDraftIndex(userID UserID) *DraftIndex { + return &DraftIndex{ + UserID: userID, + } +} + +// GetID gets the ID for the given type name. +func (index *DraftIndex) GetID(typeName string) (string, error) { + v := reflect.ValueOf(index).Elem() + fieldValue := v.FieldByName(typeName + "ID") + + if !fieldValue.IsValid() { + return "", errors.New("Invalid draft index ID type: " + typeName) + } + + return fieldValue.String(), nil +} + +// SetID sets the ID for the given type name. +func (index *DraftIndex) SetID(typeName string, id string) error { + v := reflect.ValueOf(index).Elem() + fieldValue := v.FieldByName(typeName + "ID") + + if !fieldValue.IsValid() { + return errors.New("Invalid draft index ID type: " + typeName) + } + + fieldValue.SetString(id) + return nil +} + +// GetDraftIndex ... +func GetDraftIndex(id string) (*DraftIndex, error) { + obj, err := DB.Get("DraftIndex", id) + + if err != nil { + return nil, err + } + + return obj.(*DraftIndex), nil +} diff --git a/arn/DraftIndexAPI.go b/arn/DraftIndexAPI.go new file mode 100644 index 00000000..46485565 --- /dev/null +++ b/arn/DraftIndexAPI.go @@ -0,0 +1,13 @@ +package arn + +import "github.com/aerogo/api" + +// Force interface implementations +var ( + _ api.Savable = (*DraftIndex)(nil) +) + +// Save saves the index in the database. +func (index *DraftIndex) Save() { + DB.Set("DraftIndex", index.UserID, index) +} diff --git a/arn/Draftable.go b/arn/Draftable.go new file mode 100644 index 00000000..0e327f39 --- /dev/null +++ b/arn/Draftable.go @@ -0,0 +1,7 @@ +package arn + +// Draftable describes a type where drafts can be created. +type Draftable interface { + GetIsDraft() bool + SetIsDraft(bool) +} diff --git a/arn/EditLogEntry.go b/arn/EditLogEntry.go new file mode 100644 index 00000000..787da48d --- /dev/null +++ b/arn/EditLogEntry.go @@ -0,0 +1,168 @@ +package arn + +import ( + "reflect" + "sort" + + "github.com/aerogo/nano" +) + +// EditLogEntry is an entry in the editor log. +type EditLogEntry struct { + ID string `json:"id"` + UserID string `json:"userId"` + Action string `json:"action"` + ObjectType string `json:"objectType"` // The typename of what was edited + ObjectID string `json:"objectId"` // The ID of what was edited + Key string `json:"key"` + OldValue string `json:"oldValue"` + NewValue string `json:"newValue"` + Created string `json:"created"` +} + +// NewEditLogEntry ... +func NewEditLogEntry(userID, action, objectType, objectID, key, oldValue, newValue string) *EditLogEntry { + return &EditLogEntry{ + ID: GenerateID("EditLogEntry"), + UserID: userID, + Action: action, + ObjectType: objectType, + ObjectID: objectID, + Key: key, + OldValue: oldValue, + NewValue: newValue, + Created: DateTimeUTC(), + } +} + +// User returns the user the log entry belongs to. +func (entry *EditLogEntry) User() *User { + user, _ := GetUser(entry.UserID) + return user +} + +// Object returns the object the log entry refers to. +func (entry *EditLogEntry) Object() interface{} { + obj, _ := DB.Get(entry.ObjectType, entry.ObjectID) + return obj +} + +// EditorScore returns the editing score for this log entry. +func (entry *EditLogEntry) EditorScore() int { + switch entry.Action { + case "create": + obj, err := DB.Get(entry.ObjectType, entry.ObjectID) + + if err != nil { + return 0 + } + + v := reflect.Indirect(reflect.ValueOf(obj)) + isDraft := v.FieldByName("IsDraft") + + if isDraft.Kind() == reflect.Bool && isDraft.Bool() { + // No score for drafts + return 0 + } + + return 4 + + case "edit": + score := 4 + + // Bonus score for editing anime + if entry.ObjectType == "Anime" { + score++ + + // Bonus score for editing anime synopsis + if entry.Key == "Summary" || entry.Key == "Synopsis" { + score++ + } + } + + return score + + case "delete", "arrayRemove": + return 3 + + case "arrayAppend": + return 0 + } + + return 0 +} + +// ActionHumanReadable returns the human readable version of the action. +func (entry *EditLogEntry) ActionHumanReadable() string { + switch entry.Action { + case "create": + return "Created" + + case "edit": + return "Edited" + + case "delete": + return "Deleted" + + case "arrayAppend": + return "Added an element" + + case "arrayRemove": + return "Removed an element" + + default: + return entry.Action + } +} + +// StreamEditLogEntries returns a stream of all log entries. +func StreamEditLogEntries() <-chan *EditLogEntry { + channel := make(chan *EditLogEntry, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("EditLogEntry") { + channel <- obj.(*EditLogEntry) + } + + close(channel) + }() + + return channel +} + +// AllEditLogEntries returns a slice of all log entries. +func AllEditLogEntries() []*EditLogEntry { + all := make([]*EditLogEntry, 0, DB.Collection("EditLogEntry").Count()) + + stream := StreamEditLogEntries() + + for obj := range stream { + all = append(all, obj) + } + + return all +} + +// FilterEditLogEntries filters all log entries by a custom function. +func FilterEditLogEntries(filter func(*EditLogEntry) bool) []*EditLogEntry { + var filtered []*EditLogEntry + + channel := DB.All("EditLogEntry") + + for obj := range channel { + realObject := obj.(*EditLogEntry) + + if filter(realObject) { + filtered = append(filtered, realObject) + } + } + + return filtered +} + +// SortEditLogEntriesLatestFirst puts the latest entries on top. +func SortEditLogEntriesLatestFirst(entries []*EditLogEntry) { + sort.Slice(entries, func(i, j int) bool { + return entries[i].Created > entries[j].Created + }) +} diff --git a/arn/EditLogEntryAPI.go b/arn/EditLogEntryAPI.go new file mode 100644 index 00000000..27625829 --- /dev/null +++ b/arn/EditLogEntryAPI.go @@ -0,0 +1,6 @@ +package arn + +// Save saves the log entry in the database. +func (entry *EditLogEntry) Save() { + DB.Set("EditLogEntry", entry.ID, entry) +} diff --git a/arn/EmailToUser.go b/arn/EmailToUser.go new file mode 100644 index 00000000..15a32494 --- /dev/null +++ b/arn/EmailToUser.go @@ -0,0 +1,7 @@ +package arn + +// EmailToUser stores the user ID for an email address. +type EmailToUser struct { + Email string `json:"email"` + UserID UserID `json:"userId"` +} diff --git a/arn/ExternalMedia.go b/arn/ExternalMedia.go new file mode 100644 index 00000000..3a522da5 --- /dev/null +++ b/arn/ExternalMedia.go @@ -0,0 +1,32 @@ +package arn + +// Register a list of supported media services. +func init() { + DataLists["media-services"] = []*Option{ + {"Youtube", "Youtube"}, + {"SoundCloud", "SoundCloud"}, + {"DailyMotion", "DailyMotion"}, + } +} + +// ExternalMedia ... +type ExternalMedia struct { + Service string `json:"service" editable:"true" datalist:"media-services"` + ServiceID string `json:"serviceId" editable:"true"` +} + +// EmbedLink returns the embed link used in iframes for the given media. +func (media *ExternalMedia) EmbedLink() string { + switch media.Service { + case "SoundCloud": + return "//w.soundcloud.com/player/?url=https://api.soundcloud.com/tracks/" + media.ServiceID + "?auto_play=false&hide_related=true&show_comments=false&show_user=false&show_reposts=false&visual=true" + case "Youtube": + return "//youtube.com/embed/" + media.ServiceID + "?showinfo=0" + case "DailyMotion": + return "//www.dailymotion.com/embed/video/" + media.ServiceID + case "NicoVideo": + return "//ext.nicovideo.jp/thumb/" + media.ServiceID + default: + return "" + } +} diff --git a/arn/ExternalMediaAPI.go b/arn/ExternalMediaAPI.go new file mode 100644 index 00000000..42260234 --- /dev/null +++ b/arn/ExternalMediaAPI.go @@ -0,0 +1,17 @@ +package arn + +import ( + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ api.Creatable = (*ExternalMedia)(nil) +) + +// Create sets the data for new external media. +func (media *ExternalMedia) Create(ctx aero.Context) error { + media.Service = "Youtube" + return nil +} diff --git a/arn/FacebookToUser.go b/arn/FacebookToUser.go new file mode 100644 index 00000000..77ff0b33 --- /dev/null +++ b/arn/FacebookToUser.go @@ -0,0 +1,4 @@ +package arn + +// FacebookToUser stores the user ID by Facebook user ID. +type FacebookToUser GoogleToUser diff --git a/arn/ForumIcons.go b/arn/ForumIcons.go new file mode 100644 index 00000000..4ac88df2 --- /dev/null +++ b/arn/ForumIcons.go @@ -0,0 +1,22 @@ +package arn + +// Icons +var forumIcons = map[string]string{ + "general": "paperclip", + "news": "newspaper-o", + "anime": "television", + "update": "cubes", + "suggestion": "lightbulb-o", + "bug": "bug", +} + +// GetForumIcon returns the unprefixed icon class name for the forum. +func GetForumIcon(category string) string { + icon, exists := forumIcons[category] + + if exists { + return icon + } + + return "comments" +} diff --git a/arn/Genres.go b/arn/Genres.go new file mode 100644 index 00000000..9cacc383 --- /dev/null +++ b/arn/Genres.go @@ -0,0 +1,61 @@ +package arn + +import "sort" + +// Genres ... +var Genres []string + +// Icons +var genreIcons = map[string]string{ + "Action": "bomb", + "Adventure": "diamond", + "Cars": "car", + "Comedy": "smile-o", + "Drama": "heartbeat", + "Ecchi": "heart-o", + "Fantasy": "tree", + "Game": "gamepad", + "Harem": "group", + "Hentai": "venus-mars", + "Historical": "history", + "Horror": "frown-o", + "Kids": "child", + "Martial Arts": "hand-rock-o", + "Magic": "magic", + "Mecha": "mecha", + "Military": "fighter-jet", + "Music": "music", + "Mystery": "question", + "Psychological": "lightbulb-o", + "Romance": "heart", + "Sci-Fi": "rocket", + "School": "graduation-cap", + "Seinen": "male", + "Shounen": "child", + "Shoujo": "female", + "Slice of Life": "hand-peace-o", + "Space": "space-shuttle", + "Sports": "soccer-ball-o", + "Supernatural": "magic", + "Super Power": "flash", + "Thriller": "hourglass-end", + "Vampire": "eye", +} + +// GetGenreIcon returns the unprefixed icon class name for the genre. +func GetGenreIcon(genre string) string { + icon, exists := genreIcons[genre] + + if exists { + return icon + } + + return "circle" +} + +func init() { + for k := range genreIcons { + Genres = append(Genres, k) + } + sort.Strings(Genres) +} diff --git a/arn/GoogleToUser.go b/arn/GoogleToUser.go new file mode 100644 index 00000000..2008f8a0 --- /dev/null +++ b/arn/GoogleToUser.go @@ -0,0 +1,7 @@ +package arn + +// GoogleToUser stores the user ID by Google user ID. +type GoogleToUser struct { + ID string `json:"id"` + UserID UserID `json:"userId"` +} diff --git a/arn/Group.go b/arn/Group.go new file mode 100644 index 00000000..45472370 --- /dev/null +++ b/arn/Group.go @@ -0,0 +1,310 @@ +package arn + +import ( + "errors" + "fmt" + "os" + "path" + "sync" + + "github.com/aerogo/nano" + "github.com/akyoto/color" +) + +// Group represents a group of users. +type Group struct { + Name string `json:"name" editable:"true"` + Tagline string `json:"tagline" editable:"true"` + Image GroupImage `json:"image"` + Description string `json:"description" editable:"true" type:"textarea"` + Rules string `json:"rules" editable:"true" type:"textarea"` + Restricted bool `json:"restricted" editable:"true" tooltip:"Restricted groups can only be joined with the founder's permission."` + Tags []string `json:"tags" editable:"true"` + Members []*GroupMember `json:"members"` + Neighbors []string `json:"neighbors"` + // Applications []UserApplication `json:"applications"` + + // Mixins + hasID + hasPosts + hasCreator + hasEditor + hasDraft + + // Mutex + membersMutex sync.Mutex +} + +// Link returns the URI to the group page. +func (group *Group) Link() string { + return "/group/" + group.ID +} + +// TitleByUser returns the preferred title for the given user. +func (group *Group) TitleByUser(user *User) string { + if group.Name == "" { + return "untitled" + } + + return group.Name +} + +// String is the default text representation of the group. +func (group *Group) String() string { + return group.TitleByUser(nil) +} + +// FindMember returns the group member by user ID, if available. +func (group *Group) FindMember(userID UserID) *GroupMember { + group.membersMutex.Lock() + defer group.membersMutex.Unlock() + + for _, member := range group.Members { + if member.UserID == userID { + return member + } + } + + return nil +} + +// HasMember returns true if the user is a member of the group. +func (group *Group) HasMember(userID UserID) bool { + return group.FindMember(userID) != nil +} + +// Users returns a slice of all users in the group. +func (group *Group) Users() []*User { + group.membersMutex.Lock() + defer group.membersMutex.Unlock() + users := make([]*User, len(group.Members)) + + for index, member := range group.Members { + users[index] = member.User() + } + + return users +} + +// TypeName returns the type name. +func (group *Group) TypeName() string { + return "Group" +} + +// Self returns the object itself. +func (group *Group) Self() Loggable { + return group +} + +// Publish ... +func (group *Group) Publish() error { + if len(group.Name) < 2 { + return errors.New("Name too short: Should be at least 2 characters") + } + + if len(group.Name) > 35 { + return errors.New("Name too long: Should not be more than 35 characters") + } + + if len(group.Tagline) < 4 { + return errors.New("Tagline too short: Should be at least 4 characters") + } + + if len(group.Tagline) > 60 { + return errors.New("Tagline too long: Should not be more than 60 characters") + } + + if len(group.Description) < 10 { + return errors.New("Your group needs a description (at least 10 characters)") + } + + if len(group.Tags) < 1 { + return errors.New("At least one tag is required") + } + + if !group.HasImage() { + return errors.New("Group image required") + } + + return publish(group) +} + +// Unpublish ... +func (group *Group) Unpublish() error { + return unpublish(group) +} + +// Join makes the given user join the group. +func (group *Group) Join(user *User) error { + // Check if the user is already a member + member := group.FindMember(user.ID) + + if member != nil { + return errors.New("Already a member of this group") + } + + // Add user to the members list + group.membersMutex.Lock() + + group.Members = append(group.Members, &GroupMember{ + UserID: user.ID, + Joined: DateTimeUTC(), + }) + + group.membersMutex.Unlock() + + // Trigger notifications + group.OnJoin(user) + return nil +} + +// Leave makes the given user leave the group. +func (group *Group) Leave(user *User) error { + group.membersMutex.Lock() + defer group.membersMutex.Unlock() + + for index, member := range group.Members { + if member.UserID == user.ID { + if member.UserID == group.CreatedBy { + return errors.New("The founder can not leave the group, please contact a staff member") + } + + group.Members = append(group.Members[:index], group.Members[index+1:]...) + return nil + } + } + + return nil +} + +// OnJoin sends notifications to the creator. +func (group *Group) OnJoin(user *User) { + go func() { + group.Creator().SendNotification(&PushNotification{ + Title: fmt.Sprintf(`%s joined your group!`, user.Nick), + Message: fmt.Sprintf(`%s has joined your group "%s"`, user.Nick, group.Name), + Icon: "https:" + user.AvatarLink("large"), + Link: "https://notify.moe" + group.Link() + "/members", + Type: NotificationTypeGroupJoin, + }) + }() +} + +// SendNotification sends a notification to all group members except for the excluded user ID. +func (group *Group) SendNotification(notification *PushNotification, excludeUserID UserID) { + for _, user := range group.Users() { + if user.ID == excludeUserID { + continue + } + + user.SendNotification(notification) + } +} + +// AverageColor returns the average color of the image. +func (group *Group) AverageColor() string { + color := group.Image.AverageColor + + if color.Hue == 0 && color.Saturation == 0 && color.Lightness == 0 { + return "" + } + + return color.String() +} + +// ImageLink returns a link to the group image. +func (group *Group) ImageLink(size string) string { + if !group.HasImage() { + return fmt.Sprintf("//%s/images/elements/no-group-image.svg", MediaHost) + } + + extension := ".jpg" + + if size == "original" { + extension = group.Image.Extension + } + + return fmt.Sprintf("//%s/images/groups/%s/%s%s?%v", MediaHost, size, group.ID, extension, group.Image.LastModified) +} + +// DeleteImages deletes all images for the group. +func (group *Group) DeleteImages() { + if group.Image.Extension == "" { + return + } + + // Original + err := os.Remove(path.Join(Root, "images/groups/original/", group.ID+group.Image.Extension)) + + if err != nil { + // Don't return the error. + // It's too late to stop the process at this point. + // Instead, log the error. + color.Red(err.Error()) + } + + // Small + os.Remove(path.Join(Root, "images/groups/small/", group.ID+".jpg")) + os.Remove(path.Join(Root, "images/groups/small/", group.ID+"@2.jpg")) + os.Remove(path.Join(Root, "images/groups/small/", group.ID+".webp")) + os.Remove(path.Join(Root, "images/groups/small/", group.ID+"@2.webp")) + + // Large + os.Remove(path.Join(Root, "images/groups/large/", group.ID+".jpg")) + os.Remove(path.Join(Root, "images/groups/large/", group.ID+"@2.jpg")) + os.Remove(path.Join(Root, "images/groups/large/", group.ID+".webp")) + os.Remove(path.Join(Root, "images/groups/large/", group.ID+"@2.webp")) +} + +// GetGroup ... +func GetGroup(id string) (*Group, error) { + obj, err := DB.Get("Group", id) + + if err != nil { + return nil, err + } + + return obj.(*Group), nil +} + +// StreamGroups returns a stream of all groups. +func StreamGroups() <-chan *Group { + channel := make(chan *Group, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Group") { + channel <- obj.(*Group) + } + + close(channel) + }() + + return channel +} + +// AllGroups returns a slice of all groups. +func AllGroups() []*Group { + all := make([]*Group, 0, DB.Collection("Group").Count()) + stream := StreamGroups() + + for obj := range stream { + all = append(all, obj) + } + + return all +} + +// FilterGroups filters all groups by a custom function. +func FilterGroups(filter func(*Group) bool) []*Group { + var filtered []*Group + + for obj := range DB.All("Group") { + realObject := obj.(*Group) + + if filter(realObject) { + filtered = append(filtered, realObject) + } + } + + return filtered +} diff --git a/arn/GroupAPI.go b/arn/GroupAPI.go new file mode 100644 index 00000000..f33e123f --- /dev/null +++ b/arn/GroupAPI.go @@ -0,0 +1,137 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ Joinable = (*Group)(nil) + _ Publishable = (*Group)(nil) + _ PostParent = (*Group)(nil) + _ fmt.Stringer = (*Group)(nil) + _ api.Newable = (*Group)(nil) + _ api.Editable = (*Group)(nil) + _ api.Deletable = (*Group)(nil) +) + +// Actions +func init() { + API.RegisterActions("Group", []*api.Action{ + // Publish + PublishAction(), + + // Unpublish + UnpublishAction(), + + // Join + JoinAction(), + + // Leave + LeaveAction(), + }) +} + +// Create ... +func (group *Group) Create(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + if !user.IsPro() { + return errors.New("Not available for normal users during the BETA phase") + } + + group.ID = GenerateID("Group") + group.Created = DateTimeUTC() + group.CreatedBy = user.ID + group.Edited = group.Created + group.EditedBy = group.CreatedBy + + group.Members = []*GroupMember{ + { + UserID: user.ID, + Role: "founder", + Joined: group.Created, + }, + } + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "create", "Group", group.ID, "", "", "") + logEntry.Save() + + return group.Unpublish() +} + +// Edit creates an edit log entry. +func (group *Group) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + return edit(group, ctx, key, value, newValue) +} + +// OnAppend saves a log entry. +func (group *Group) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(group, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (group *Group) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(group, ctx, key, index, obj) +} + +// Delete deletes the object from the database. +func (group *Group) Delete() error { + if group.IsDraft { + draftIndex := group.Creator().DraftIndex() + draftIndex.GroupID = "" + draftIndex.Save() + } + + // Delete image files + group.DeleteImages() + + // Delete group + DB.Delete("Group", group.ID) + return nil +} + +// DeleteInContext deletes the amv in the given context. +func (group *Group) DeleteInContext(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "delete", "Group", group.ID, "", fmt.Sprint(group), "") + logEntry.Save() + + return group.Delete() +} + +// Authorize returns an error if the given API POST request is not authorized. +func (group *Group) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + if action == "edit" && group.CreatedBy != user.ID { + return errors.New("Can't edit groups from other people") + } + + if action == "join" && group.Restricted { + return errors.New("Can't join restricted groups") + } + + return nil +} + +// Save saves the group in the database. +func (group *Group) Save() { + DB.Set("Group", group.ID, group) +} diff --git a/arn/GroupImage.go b/arn/GroupImage.go new file mode 100644 index 00000000..c3e16f95 --- /dev/null +++ b/arn/GroupImage.go @@ -0,0 +1,171 @@ +package arn + +import ( + "bytes" + "image" + "path" + "time" + + "github.com/akyoto/imageserver" +) + +const ( + // GroupImageSmallWidth is the minimum width in pixels of a small group image. + GroupImageSmallWidth = 70 + + // GroupImageSmallHeight is the minimum height in pixels of a small group image. + GroupImageSmallHeight = 70 + + // GroupImageLargeWidth is the minimum width in pixels of a large group image. + GroupImageLargeWidth = 280 + + // GroupImageLargeHeight is the minimum height in pixels of a large group image. + GroupImageLargeHeight = 280 + + // GroupImageWebPQuality is the WebP quality of group images. + GroupImageWebPQuality = 70 + + // GroupImageJPEGQuality is the JPEG quality of group images. + GroupImageJPEGQuality = 70 + + // GroupImageQualityBonusLowDPI ... + GroupImageQualityBonusLowDPI = 12 + + // GroupImageQualityBonusLarge ... + GroupImageQualityBonusLarge = 10 + + // GroupImageQualityBonusSmall ... + GroupImageQualityBonusSmall = 15 +) + +// Define the group image outputs +var groupImageOutputs = []imageserver.Output{ + // Original at full size + &imageserver.OriginalFile{ + Directory: path.Join(Root, "images/groups/original/"), + Width: 0, + Height: 0, + Quality: 0, + }, + + // JPEG - Small + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/groups/small/"), + Width: GroupImageSmallWidth, + Height: GroupImageSmallHeight, + Quality: GroupImageJPEGQuality + GroupImageQualityBonusLowDPI + GroupImageQualityBonusSmall, + }, + + // JPEG - Large + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/groups/large/"), + Width: GroupImageLargeWidth, + Height: GroupImageLargeHeight, + Quality: GroupImageJPEGQuality + GroupImageQualityBonusLowDPI + GroupImageQualityBonusLarge, + }, + + // WebP - Small + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/groups/small/"), + Width: GroupImageSmallWidth, + Height: GroupImageSmallHeight, + Quality: GroupImageWebPQuality + GroupImageQualityBonusLowDPI + GroupImageQualityBonusSmall, + }, + + // WebP - Large + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/groups/large/"), + Width: GroupImageLargeWidth, + Height: GroupImageLargeHeight, + Quality: GroupImageWebPQuality + GroupImageQualityBonusLowDPI + GroupImageQualityBonusLarge, + }, +} + +// Define the high DPI group image outputs +var groupImageOutputsHighDPI = []imageserver.Output{ + // JPEG - Small + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/groups/small/"), + Width: GroupImageSmallWidth * 2, + Height: GroupImageSmallHeight * 2, + Quality: GroupImageJPEGQuality + GroupImageQualityBonusSmall, + }, + + // JPEG - Large + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/groups/large/"), + Width: GroupImageLargeWidth * 2, + Height: GroupImageLargeHeight * 2, + Quality: GroupImageJPEGQuality + GroupImageQualityBonusLarge, + }, + + // WebP - Small + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/groups/small/"), + Width: GroupImageSmallWidth * 2, + Height: GroupImageSmallHeight * 2, + Quality: GroupImageWebPQuality + GroupImageQualityBonusSmall, + }, + + // WebP - Large + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/groups/large/"), + Width: GroupImageLargeWidth * 2, + Height: GroupImageLargeHeight * 2, + Quality: GroupImageWebPQuality + GroupImageQualityBonusLarge, + }, +} + +// GroupImage ... +type GroupImage AnimeImage + +// SetImageBytes accepts a byte buffer that represents an image file and updates the group image. +func (group *Group) SetImageBytes(data []byte) error { + // Decode + img, format, err := image.Decode(bytes.NewReader(data)) + + if err != nil { + return err + } + + return group.SetImage(&imageserver.MetaImage{ + Image: img, + Format: format, + Data: data, + }) +} + +// SetImage sets the group image to the given MetaImage. +func (group *Group) SetImage(metaImage *imageserver.MetaImage) error { + var lastError error + + // Save the different image formats and sizes in low DPI + for _, output := range groupImageOutputs { + err := output.Save(metaImage, group.ID) + + if err != nil { + lastError = err + } + } + + // Save the different image formats and sizes in high DPI + for _, output := range groupImageOutputsHighDPI { + err := output.Save(metaImage, group.ID+"@2") + + if err != nil { + lastError = err + } + } + + group.Image.Extension = metaImage.Extension() + group.Image.Width = metaImage.Image.Bounds().Dx() + group.Image.Height = metaImage.Image.Bounds().Dy() + group.Image.AverageColor = GetAverageColor(metaImage.Image) + group.Image.LastModified = time.Now().Unix() + return lastError +} + +// HasImage returns true if the group has an image. +func (group *Group) HasImage() bool { + return group.Image.Extension != "" && group.Image.Width > 0 +} diff --git a/arn/GroupMember.go b/arn/GroupMember.go new file mode 100644 index 00000000..398bd1e3 --- /dev/null +++ b/arn/GroupMember.go @@ -0,0 +1,20 @@ +package arn + +// GroupMember ... +type GroupMember struct { + UserID UserID `json:"userId"` + Role string `json:"role"` + Joined string `json:"joined"` + + user *User +} + +// User returns the user. +func (member *GroupMember) User() *User { + if member.user != nil { + return member.user + } + + member.user, _ = GetUser(member.UserID) + return member.user +} diff --git a/arn/HSLColor.go b/arn/HSLColor.go new file mode 100644 index 00000000..a0bb4bae --- /dev/null +++ b/arn/HSLColor.go @@ -0,0 +1,102 @@ +package arn + +import ( + "fmt" + "image" + "math" +) + +// HSLColor ... +type HSLColor struct { + Hue float64 `json:"hue"` + Saturation float64 `json:"saturation"` + Lightness float64 `json:"lightness"` +} + +// String returns a representation like hsl(0, 0%, 0%). +func (color HSLColor) String() string { + return fmt.Sprintf("hsl(%.1f, %.1f%%, %.1f%%)", color.Hue*360, color.Saturation*100, color.Lightness*100) +} + +// StringWithAlpha returns a representation like hsla(0, 0%, 0%, 0.5). +func (color HSLColor) StringWithAlpha(alpha float64) string { + return fmt.Sprintf("hsla(%.1f, %.1f%%, %.1f%%, %.2f)", color.Hue*360, color.Saturation*100, color.Lightness*100, alpha) +} + +// GetAverageColor returns the average color of an image in HSL format. +func GetAverageColor(img image.Image) HSLColor { + width := img.Bounds().Dx() + height := img.Bounds().Dy() + + totalR := uint64(0) + totalG := uint64(0) + totalB := uint64(0) + + for x := 0; x < width; x++ { + for y := 0; y < height; y++ { + r, g, b, _ := img.At(x, y).RGBA() + totalR += uint64(r) + totalG += uint64(g) + totalB += uint64(b) + } + } + + pixels := uint64(width * height) + + const max = float64(65535) + averageR := float64(totalR/pixels) / max + averageG := float64(totalG/pixels) / max + averageB := float64(totalB/pixels) / max + + h, s, l := RGBToHSL(averageR, averageG, averageB) + return HSLColor{h, s, l} +} + +// RGBToHSL converts RGB to HSL (RGB input and HSL output are floats in the 0..1 range). +// Original source: https://github.com/gerow/go-color +func RGBToHSL(r, g, b float64) (h, s, l float64) { + max := math.Max(math.Max(r, g), b) + min := math.Min(math.Min(r, g), b) + + // Luminosity is the average of the max and min rgb color intensities. + l = (max + min) / 2 + + // Saturation + delta := max - min + + if delta == 0 { + // It's gray + return 0, 0, l + } + + // It's not gray + if l < 0.5 { + s = delta / (max + min) + } else { + s = delta / (2 - max - min) + } + + // Hue + r2 := (((max - r) / 6) + (delta / 2)) / delta + g2 := (((max - g) / 6) + (delta / 2)) / delta + b2 := (((max - b) / 6) + (delta / 2)) / delta + + switch { + case r == max: + h = b2 - g2 + case g == max: + h = (1.0 / 3.0) + r2 - b2 + case b == max: + h = (2.0 / 3.0) + g2 - r2 + } + + // fix wraparounds + switch { + case h < 0: + h++ + case h > 1: + h-- + } + + return h, s, l +} diff --git a/arn/HasCreator.go b/arn/HasCreator.go new file mode 100644 index 00000000..3b3dcf2b --- /dev/null +++ b/arn/HasCreator.go @@ -0,0 +1,38 @@ +package arn + +import ( + "time" +) + +// HasCreator includes user ID and date for the creation of this object. +type hasCreator struct { + Created string `json:"created"` + CreatedBy UserID `json:"createdBy"` +} + +// Creator returns the user who created this object. +func (obj *hasCreator) Creator() *User { + user, _ := GetUser(obj.CreatedBy) + return user +} + +// CreatorID returns the ID of the user who created this object. +func (obj *hasCreator) CreatorID() UserID { + return obj.CreatedBy +} + +// GetCreated returns the creation time of the object. +func (obj *hasCreator) GetCreated() string { + return obj.Created +} + +// GetCreatedBy returns the ID of the user who created this object. +func (obj *hasCreator) GetCreatedBy() UserID { + return obj.CreatedBy +} + +// GetCreatedTime returns the creation time of the object as a time struct. +func (obj *hasCreator) GetCreatedTime() time.Time { + t, _ := time.Parse(time.RFC3339, obj.Created) + return t +} diff --git a/arn/HasDraft.go b/arn/HasDraft.go new file mode 100644 index 00000000..7a658ab4 --- /dev/null +++ b/arn/HasDraft.go @@ -0,0 +1,16 @@ +package arn + +// HasDraft includes a boolean indicating whether the object is a draft. +type hasDraft struct { + IsDraft bool `json:"isDraft" editable:"true"` +} + +// GetIsDraft tells you whether the object is a draft or not. +func (obj *hasDraft) GetIsDraft() bool { + return obj.IsDraft +} + +// SetIsDraft sets the draft state for this object. +func (obj *hasDraft) SetIsDraft(isDraft bool) { + obj.IsDraft = isDraft +} diff --git a/arn/HasEditing.go b/arn/HasEditing.go new file mode 100644 index 00000000..d5b04434 --- /dev/null +++ b/arn/HasEditing.go @@ -0,0 +1,57 @@ +package arn + +// import ( +// "errors" +// "reflect" + +// "github.com/aerogo/aero" +// "github.com/aerogo/api" +// ) + +// // HasEditing implements basic API functionality for editing the fields in the struct. +// type hasEditing struct { +// Loggable +// } + +// // Force interface implementations +// var ( +// _ api.Editable = (*HasEditing)(nil) +// _ api.ArrayEventListener = (*HasEditing)(nil) +// ) + +// // Authorize returns an error if the given API POST request is not authorized. +// func (editable *hasEditing) Authorize(ctx aero.Context, action string) error { +// user := GetUserFromContext(ctx) + +// if user == nil || (user.Role != "editor" && user.Role != "admin") { +// return errors.New("Not logged in or not authorized to edit") +// } + +// return nil +// } + +// // Edit creates an edit log entry. +// func (editable *hasEditing) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { +// return edit(editable.Self(), ctx, key, value, newValue) +// } + +// // OnAppend saves a log entry. +// func (editable *hasEditing) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { +// onAppend(editable.Self(), ctx, key, index, obj) +// } + +// // OnRemove saves a log entry. +// func (editable *hasEditing) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { +// onRemove(editable.Self(), ctx, key, index, obj) +// } + +// // Save saves the character in the database. +// func (editable *hasEditing) Save() { +// DB.Set(editable.TypeName(), editable.GetID(), editable.Self()) +// } + +// // Delete deletes the character list from the database. +// func (editable *hasEditing) Delete() error { +// DB.Delete(editable.TypeName(), editable.GetID()) +// return nil +// } diff --git a/arn/HasEditor.go b/arn/HasEditor.go new file mode 100644 index 00000000..c4b26108 --- /dev/null +++ b/arn/HasEditor.go @@ -0,0 +1,13 @@ +package arn + +// HasEditor includes user ID and date for the last edit of this object. +type hasEditor struct { + Edited string `json:"edited"` + EditedBy string `json:"editedBy"` +} + +// Editor returns the user who last edited this object. +func (obj *hasEditor) Editor() *User { + user, _ := GetUser(obj.EditedBy) + return user +} diff --git a/arn/HasID.go b/arn/HasID.go new file mode 100644 index 00000000..73a05d8a --- /dev/null +++ b/arn/HasID.go @@ -0,0 +1,11 @@ +package arn + +// hasID includes an object ID. +type hasID struct { + ID string `json:"id"` +} + +// GetID returns the ID. +func (obj *hasID) GetID() string { + return obj.ID +} diff --git a/arn/HasLikes.go b/arn/HasLikes.go new file mode 100644 index 00000000..f2e426bf --- /dev/null +++ b/arn/HasLikes.go @@ -0,0 +1,43 @@ +package arn + +// HasLikes implements common like and unlike methods. +type hasLikes struct { + Likes []string `json:"likes"` +} + +// Like makes the given user ID like the object. +func (obj *hasLikes) Like(userID UserID) { + for _, id := range obj.Likes { + if id == userID { + return + } + } + + obj.Likes = append(obj.Likes, userID) +} + +// Unlike makes the given user ID unlike the object. +func (obj *hasLikes) Unlike(userID UserID) { + for index, id := range obj.Likes { + if id == userID { + obj.Likes = append(obj.Likes[:index], obj.Likes[index+1:]...) + return + } + } +} + +// LikedBy checks to see if the user has liked the object. +func (obj *hasLikes) LikedBy(userID UserID) bool { + for _, id := range obj.Likes { + if id == userID { + return true + } + } + + return false +} + +// CountLikes returns the number of likes the object has received. +func (obj *hasLikes) CountLikes() int { + return len(obj.Likes) +} diff --git a/arn/HasLocked.go b/arn/HasLocked.go new file mode 100644 index 00000000..9494960c --- /dev/null +++ b/arn/HasLocked.go @@ -0,0 +1,21 @@ +package arn + +// HasLocked implements common like and unlike methods. +type hasLocked struct { + Locked bool `json:"locked"` +} + +// Lock locks the object. +func (obj *hasLocked) Lock(userID UserID) { + obj.Locked = true +} + +// Unlock unlocks the object. +func (obj *hasLocked) Unlock(userID UserID) { + obj.Locked = false +} + +// IsLocked implements the Lockable interface. +func (obj *hasLocked) IsLocked() bool { + return obj.Locked +} diff --git a/arn/HasMappings.go b/arn/HasMappings.go new file mode 100644 index 00000000..acdf88f0 --- /dev/null +++ b/arn/HasMappings.go @@ -0,0 +1,51 @@ +package arn + +// HasMappings implements common mapping methods. +type hasMappings struct { + Mappings []*Mapping `json:"mappings" editable:"true"` +} + +// SetMapping sets the ID of an external site to the obj. +func (obj *hasMappings) SetMapping(serviceName string, serviceID string) { + // Is the ID valid? + if serviceID == "" { + return + } + + // If it already exists we don't need to add it + for _, external := range obj.Mappings { + if external.Service == serviceName { + external.ServiceID = serviceID + return + } + } + + // Add the mapping + obj.Mappings = append(obj.Mappings, &Mapping{ + Service: serviceName, + ServiceID: serviceID, + }) +} + +// GetMapping returns the external ID for the given service. +func (obj *hasMappings) GetMapping(name string) string { + for _, external := range obj.Mappings { + if external.Service == name { + return external.ServiceID + } + } + + return "" +} + +// RemoveMapping removes all mappings with the given service name and ID. +func (obj *hasMappings) RemoveMapping(name string) bool { + for index, external := range obj.Mappings { + if external.Service == name { + obj.Mappings = append(obj.Mappings[:index], obj.Mappings[index+1:]...) + return true + } + } + + return false +} diff --git a/arn/HasPosts.go b/arn/HasPosts.go new file mode 100644 index 00000000..ca731500 --- /dev/null +++ b/arn/HasPosts.go @@ -0,0 +1,65 @@ +package arn + +import ( + "sort" +) + +// HasPosts includes a list of Post IDs. +type hasPosts struct { + PostIDs []string `json:"posts"` +} + +// AddPost adds a post to the object. +func (obj *hasPosts) AddPost(postID string) { + obj.PostIDs = append(obj.PostIDs, postID) +} + +// RemovePost removes a post from the object. +func (obj *hasPosts) RemovePost(postID string) bool { + for index, item := range obj.PostIDs { + if item == postID { + obj.PostIDs = append(obj.PostIDs[:index], obj.PostIDs[index+1:]...) + return true + } + } + + return false +} + +// Posts returns a slice of all posts. +func (obj *hasPosts) Posts() []*Post { + objects := DB.GetMany("Post", obj.PostIDs) + posts := make([]*Post, 0, len(objects)) + + for _, post := range objects { + if post == nil { + continue + } + + posts = append(posts, post.(*Post)) + } + + return posts +} + +// PostsRelevantFirst returns a slice of all posts sorted by relevance. +func (obj *hasPosts) PostsRelevantFirst(count int) []*Post { + original := obj.Posts() + newPosts := make([]*Post, len(original)) + copy(newPosts, original) + + sort.Slice(newPosts, func(i, j int) bool { + return newPosts[i].Created > newPosts[j].Created + }) + + if count >= 0 && len(newPosts) > count { + newPosts = newPosts[:count] + } + + return newPosts +} + +// CountPosts returns the number of posts written for this object. +func (obj *hasPosts) CountPosts() int { + return len(obj.PostIDs) +} diff --git a/arn/HasText.go b/arn/HasText.go new file mode 100644 index 00000000..7198a9f0 --- /dev/null +++ b/arn/HasText.go @@ -0,0 +1,11 @@ +package arn + +// HasText includes a text field. +type hasText struct { + Text string `json:"text" editable:"true" type:"textarea"` +} + +// GetText returns the text of the object. +func (obj *hasText) GetText() string { + return obj.Text +} diff --git a/arn/IDCollection.go b/arn/IDCollection.go new file mode 100644 index 00000000..d15f6301 --- /dev/null +++ b/arn/IDCollection.go @@ -0,0 +1,54 @@ +package arn + +import ( + "errors" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// IDCollection ... +type IDCollection interface { + Add(id string) error + Remove(id string) bool + Save() +} + +// AddAction returns an API action that adds a new item to the IDCollection. +func AddAction() *api.Action { + return &api.Action{ + Name: "add", + Route: "/add/:item-id", + Run: func(obj interface{}, ctx aero.Context) error { + list := obj.(IDCollection) + itemID := ctx.Get("item-id") + err := list.Add(itemID) + + if err != nil { + return err + } + + list.Save() + return nil + }, + } +} + +// RemoveAction returns an API action that removes an item from the IDCollection. +func RemoveAction() *api.Action { + return &api.Action{ + Name: "remove", + Route: "/remove/:item-id", + Run: func(obj interface{}, ctx aero.Context) error { + list := obj.(IDCollection) + itemID := ctx.Get("item-id") + + if !list.Remove(itemID) { + return errors.New("This item does not exist in the list") + } + + list.Save() + return nil + }, + } +} diff --git a/arn/IDList.go b/arn/IDList.go new file mode 100644 index 00000000..18c594f2 --- /dev/null +++ b/arn/IDList.go @@ -0,0 +1,20 @@ +package arn + +// IDList stores lists of IDs that are retrievable by name. +type IDList []string + +// GetIDList ... +func GetIDList(id string) (IDList, error) { + obj, err := DB.Get("IDList", id) + + if err != nil { + return nil, err + } + + return *obj.(*IDList), nil +} + +// Append appends the given ID to the end of the list and returns the new IDList. +func (idList IDList) Append(id string) IDList { + return append(idList, id) +} diff --git a/arn/IgnoreAnimeDifference.go b/arn/IgnoreAnimeDifference.go new file mode 100644 index 00000000..4d7dc5bd --- /dev/null +++ b/arn/IgnoreAnimeDifference.go @@ -0,0 +1,88 @@ +package arn + +import ( + "fmt" + + "github.com/aerogo/nano" +) + +// IgnoreAnimeDifferenceEditorScore represents how many points you get for a diff ignore. +const IgnoreAnimeDifferenceEditorScore = 2 + +// IgnoreAnimeDifference saves which differences between anime databases can be ignored. +type IgnoreAnimeDifference struct { + // The ID is built like this: arn:323|mal:356|JapaneseTitle + ID string `json:"id"` + ValueHash uint64 `json:"valueHash"` + + hasCreator +} + +// GetIgnoreAnimeDifference ... +func GetIgnoreAnimeDifference(id string) (*IgnoreAnimeDifference, error) { + obj, err := DB.Get("IgnoreAnimeDifference", id) + + if err != nil { + return nil, err + } + + return obj.(*IgnoreAnimeDifference), nil +} + +// CreateDifferenceID ... +func CreateDifferenceID(animeID string, dataProvider string, malAnimeID string, typeName string) string { + return fmt.Sprintf("arn:%s|%s:%s|%s", animeID, dataProvider, malAnimeID, typeName) +} + +// IsAnimeDifferenceIgnored tells you whether the given difference is being ignored. +func IsAnimeDifferenceIgnored(animeID string, dataProvider string, malAnimeID string, typeName string, hash uint64) bool { + key := CreateDifferenceID(animeID, dataProvider, malAnimeID, typeName) + ignore, err := GetIgnoreAnimeDifference(key) + + if err != nil { + return false + } + + return ignore.ValueHash == hash +} + +// StreamIgnoreAnimeDifferences returns a stream of all ignored differences. +func StreamIgnoreAnimeDifferences() <-chan *IgnoreAnimeDifference { + channel := make(chan *IgnoreAnimeDifference, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("IgnoreAnimeDifference") { + channel <- obj.(*IgnoreAnimeDifference) + } + + close(channel) + }() + + return channel +} + +// AllIgnoreAnimeDifferences returns a slice of all ignored differences. +func AllIgnoreAnimeDifferences() []*IgnoreAnimeDifference { + all := make([]*IgnoreAnimeDifference, 0, DB.Collection("IgnoreAnimeDifference").Count()) + + stream := StreamIgnoreAnimeDifferences() + + for obj := range stream { + all = append(all, obj) + } + + return all +} + +// FilterIgnoreAnimeDifferences filters all ignored differences by a custom function. +func FilterIgnoreAnimeDifferences(filter func(*IgnoreAnimeDifference) bool) []*IgnoreAnimeDifference { + var filtered []*IgnoreAnimeDifference + + for obj := range StreamIgnoreAnimeDifferences() { + if filter(obj) { + filtered = append(filtered, obj) + } + } + + return filtered +} diff --git a/arn/IgnoreAnimeDifferenceAPI.go b/arn/IgnoreAnimeDifferenceAPI.go new file mode 100644 index 00000000..81141131 --- /dev/null +++ b/arn/IgnoreAnimeDifferenceAPI.go @@ -0,0 +1,66 @@ +package arn + +import ( + "errors" + "strconv" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ api.Newable = (*IgnoreAnimeDifference)(nil) +) + +// Authorize returns an error if the given API POST request is not authorized. +func (ignore *IgnoreAnimeDifference) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + if user.Role != "editor" && user.Role != "admin" { + return errors.New("Not authorized") + } + + return nil +} + +// Create constructs the values for this new object with the data we received from the API request. +func (ignore *IgnoreAnimeDifference) Create(ctx aero.Context) error { + data, err := ctx.Request().Body().JSONObject() + + if err != nil { + return err + } + + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + hash, err := strconv.ParseUint(data["hash"].(string), 10, 64) + + if err != nil { + return errors.New("Invalid hash: Not a number") + } + + ignore.ID = data["id"].(string) + ignore.ValueHash = hash + ignore.Created = DateTimeUTC() + ignore.CreatedBy = user.ID + + if ignore.ID == "" { + return errors.New("Invalid ID") + } + + return nil +} + +// Save saves the object in the database. +func (ignore *IgnoreAnimeDifference) Save() { + DB.Set("IgnoreAnimeDifference", ignore.ID, ignore) +} diff --git a/arn/Inventory.go b/arn/Inventory.go new file mode 100644 index 00000000..c29cc080 --- /dev/null +++ b/arn/Inventory.go @@ -0,0 +1,88 @@ +package arn + +import ( + "errors" +) + +// DefaultInventorySlotCount tells you how many slots are available by default in an inventory. +const DefaultInventorySlotCount = 24 + +// Inventory has inventory slots that store shop item IDs and their quantity. +type Inventory struct { + UserID UserID `json:"userId"` + Slots []*InventorySlot `json:"slots"` +} + +// AddItem adds a given item to the inventory. +func (inventory *Inventory) AddItem(itemID string, quantity uint) error { + if itemID == "" { + return nil + } + + // Find the slot with the item + for _, slot := range inventory.Slots { + if slot.ItemID == itemID { + slot.Quantity += quantity + return nil + } + } + + // If the item doesn't exist in the inventory yet, add it to the first free slot + for _, slot := range inventory.Slots { + if slot.ItemID == "" { + slot.ItemID = itemID + slot.Quantity = quantity + return nil + } + } + + // If there is no free slot, return an error + return errors.New("Inventory is full") +} + +// ContainsItem checks if the inventory contains the item ID already. +func (inventory *Inventory) ContainsItem(itemID string) bool { + for _, slot := range inventory.Slots { + if slot.ItemID == itemID { + return true + } + } + + return false +} + +// SwapSlots swaps the slots with the given indices. +func (inventory *Inventory) SwapSlots(a, b int) error { + if a < 0 || b < 0 || a >= len(inventory.Slots) || b >= len(inventory.Slots) { + return errors.New("Inventory slot index out of bounds") + } + + // Swap + inventory.Slots[a], inventory.Slots[b] = inventory.Slots[b], inventory.Slots[a] + return nil +} + +// NewInventory creates a new inventory with the default number of slots. +func NewInventory(userID UserID) *Inventory { + inventory := &Inventory{ + UserID: userID, + Slots: make([]*InventorySlot, DefaultInventorySlotCount), + } + + for i := 0; i < len(inventory.Slots); i++ { + inventory.Slots[i] = &InventorySlot{} + } + + return inventory +} + +// GetInventory ... +func GetInventory(userID UserID) (*Inventory, error) { + obj, err := DB.Get("Inventory", userID) + + if err != nil { + return nil, err + } + + return obj.(*Inventory), nil +} diff --git a/arn/InventoryAPI.go b/arn/InventoryAPI.go new file mode 100644 index 00000000..a8c455b5 --- /dev/null +++ b/arn/InventoryAPI.go @@ -0,0 +1,103 @@ +package arn + +import ( + "errors" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Actions +func init() { + API.RegisterActions("Inventory", []*api.Action{ + // Use slot + { + Name: "use", + Route: "/use/:slot", + Run: func(obj interface{}, ctx aero.Context) error { + inventory := obj.(*Inventory) + slotIndex, err := ctx.GetInt("slot") + + if err != nil { + return err + } + + slot := inventory.Slots[slotIndex] + + if slot.IsEmpty() { + return errors.New("No item in this slot") + } + + if !slot.Item().Consumable { + return errors.New("This item is not consumable") + } + + // Save item ID in case it gets deleted by slot.Decrease() + itemID := slot.ItemID + + // Decrease quantity + err = slot.Decrease(1) + + if err != nil { + return err + } + + // Save inventory + inventory.Save() + + user := GetUserFromContext(ctx) + err = user.ActivateItemEffect(itemID) + + if err != nil { + // Refund item + slot.ItemID = itemID + slot.Increase(1) + inventory.Save() + return nil + } + + return err + }, + }, + + // Swap slots + { + Name: "swap", + Route: "/swap/:slot1/:slot2", + Run: func(obj interface{}, ctx aero.Context) error { + inventory := obj.(*Inventory) + a, err := ctx.GetInt("slot1") + + if err != nil { + return err + } + + b, err := ctx.GetInt("slot2") + + if err != nil { + return err + } + + err = inventory.SwapSlots(a, b) + + if err != nil { + return err + } + + inventory.Save() + + return nil + }, + }, + }) +} + +// Authorize returns an error if the given API request is not authorized. +func (inventory *Inventory) Authorize(ctx aero.Context, action string) error { + return AuthorizeIfLoggedInAndOwnData(ctx, "id") +} + +// Save saves the push items in the database. +func (inventory *Inventory) Save() { + DB.Set("Inventory", inventory.UserID, inventory) +} diff --git a/arn/InventorySlot.go b/arn/InventorySlot.go new file mode 100644 index 00000000..80102480 --- /dev/null +++ b/arn/InventorySlot.go @@ -0,0 +1,44 @@ +package arn + +import "errors" + +// InventorySlot ... +type InventorySlot struct { + ItemID string `json:"itemId"` + Quantity uint `json:"quantity"` +} + +// IsEmpty ... +func (slot *InventorySlot) IsEmpty() bool { + return slot.ItemID == "" +} + +// Item ... +func (slot *InventorySlot) Item() *ShopItem { + if slot.ItemID == "" { + return nil + } + + item, _ := GetShopItem(slot.ItemID) + return item +} + +// Decrease reduces the quantity by the given number. +func (slot *InventorySlot) Decrease(count uint) error { + if slot.Quantity < count { + return errors.New("Not enough items") + } + + slot.Quantity -= count + + if slot.Quantity == 0 { + slot.ItemID = "" + } + + return nil +} + +// Increase increases the quantity by the given number. +func (slot *InventorySlot) Increase(count uint) { + slot.Quantity += count +} diff --git a/arn/Inventory_test.go b/arn/Inventory_test.go new file mode 100644 index 00000000..d95f88f5 --- /dev/null +++ b/arn/Inventory_test.go @@ -0,0 +1,20 @@ +package arn_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/animenotifier/notify.moe/arn" +) + +func TestInventory(t *testing.T) { + inventory := arn.NewInventory("4J6qpK1ve") + + assert.Len(t, inventory.Slots, arn.DefaultInventorySlotCount) + assert.False(t, inventory.ContainsItem("pro-account-3")) + + err := inventory.AddItem("pro-account-3", 1) + assert.NoError(t, err) + assert.True(t, inventory.ContainsItem("pro-account-3")) +} diff --git a/arn/JapaneseTokenizer.go b/arn/JapaneseTokenizer.go new file mode 100644 index 00000000..381d9e9e --- /dev/null +++ b/arn/JapaneseTokenizer.go @@ -0,0 +1,8 @@ +package arn + +import "github.com/animenotifier/japanese/client" + +// JapaneseTokenizer tokenizes a sentence via the HTTP API. +var JapaneseTokenizer = &client.Tokenizer{ + Endpoint: "http://127.0.0.1:6000/", +} diff --git a/arn/Joinable.go b/arn/Joinable.go new file mode 100644 index 00000000..4e966c01 --- /dev/null +++ b/arn/Joinable.go @@ -0,0 +1,53 @@ +package arn + +import ( + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Joinable is an object where users can join and leave. +type Joinable interface { + Join(*User) error + Leave(*User) error + Save() +} + +// JoinAction returns an API action that lets the user join the object. +func JoinAction() *api.Action { + return &api.Action{ + Name: "join", + Route: "/join", + Run: func(obj interface{}, ctx aero.Context) error { + user := GetUserFromContext(ctx) + joinable := obj.(Joinable) + err := joinable.Join(user) + + if err != nil { + return err + } + + joinable.Save() + return nil + }, + } +} + +// LeaveAction returns an API action that unpublishes the object. +func LeaveAction() *api.Action { + return &api.Action{ + Name: "leave", + Route: "/leave", + Run: func(obj interface{}, ctx aero.Context) error { + user := GetUserFromContext(ctx) + joinable := obj.(Joinable) + err := joinable.Leave(user) + + if err != nil { + return err + } + + joinable.Save() + return nil + }, + } +} diff --git a/arn/KitsuAnime.go b/arn/KitsuAnime.go new file mode 100644 index 00000000..86b7a108 --- /dev/null +++ b/arn/KitsuAnime.go @@ -0,0 +1,151 @@ +package arn + +import ( + "net/http" + "strings" + + "github.com/aerogo/http/client" + "github.com/aerogo/nano" + "github.com/akyoto/color" + "github.com/animenotifier/kitsu" +) + +// NewAnimeFromKitsuAnime ... +func NewAnimeFromKitsuAnime(kitsuAnime *kitsu.Anime) (*Anime, *AnimeCharacters, *AnimeRelations, *AnimeEpisodes) { + anime := NewAnime() + attr := kitsuAnime.Attributes + + // General data + anime.Type = strings.ToLower(attr.ShowType) + anime.Title.Canonical = attr.CanonicalTitle + anime.Title.English = attr.Titles.En + anime.Title.Romaji = attr.Titles.EnJp + anime.Title.Japanese = attr.Titles.JaJp + anime.Title.Synonyms = attr.AbbreviatedTitles + anime.StartDate = attr.StartDate + anime.EndDate = attr.EndDate + anime.EpisodeCount = attr.EpisodeCount + anime.EpisodeLength = attr.EpisodeLength + anime.Status = attr.Status + anime.Summary = FixAnimeDescription(attr.Synopsis) + + // Status "unreleased" means the same as "upcoming" so we should normalize it + if anime.Status == "unreleased" { + anime.Status = "upcoming" + } + + // Kitsu mapping + anime.SetMapping("kitsu/anime", kitsuAnime.ID) + + // Import mappings + mappings := FilterKitsuMappings(func(mapping *kitsu.Mapping) bool { + return mapping.Relationships.Item.Data.Type == "anime" && mapping.Relationships.Item.Data.ID == anime.ID + }) + + for _, mapping := range mappings { + anime.ImportKitsuMapping(mapping) + } + + // Download image + response, err := client.Get(attr.PosterImage.Original).End() + + if err == nil && response.StatusCode() == http.StatusOK { + err := anime.SetImageBytes(response.Bytes()) + + if err != nil { + color.Red("Couldn't set image for [%s] %s (%s)", anime.ID, anime, err.Error()) + } + } else { + color.Red("No image for [%s] %s (%d)", anime.ID, anime, response.StatusCode()) + } + + // Rating + if anime.Rating.IsNotRated() { + anime.Rating.Reset() + } + + // Trailers + if attr.YoutubeVideoID != "" { + anime.Trailers = append(anime.Trailers, &ExternalMedia{ + Service: "Youtube", + ServiceID: attr.YoutubeVideoID, + }) + } + + // Characters + characters, _ := GetAnimeCharacters(anime.ID) + + if characters == nil { + characters = &AnimeCharacters{ + AnimeID: anime.ID, + Items: []*AnimeCharacter{}, + } + } + + // Episodes + episodes, _ := GetAnimeEpisodes(anime.ID) + + if episodes == nil { + episodes = &AnimeEpisodes{ + AnimeID: anime.ID, + Items: []*AnimeEpisode{}, + } + } + + // Relations + relations, _ := GetAnimeRelations(anime.ID) + + if relations == nil { + relations = &AnimeRelations{ + AnimeID: anime.ID, + Items: []*AnimeRelation{}, + } + } + + return anime, characters, relations, episodes +} + +// StreamKitsuAnime returns a stream of all Kitsu anime. +func StreamKitsuAnime() <-chan *kitsu.Anime { + channel := make(chan *kitsu.Anime, nano.ChannelBufferSize) + + go func() { + for obj := range Kitsu.All("Anime") { + channel <- obj.(*kitsu.Anime) + } + + close(channel) + }() + + return channel +} + +// FilterKitsuAnime filters all Kitsu anime by a custom function. +func FilterKitsuAnime(filter func(*kitsu.Anime) bool) []*kitsu.Anime { + var filtered []*kitsu.Anime + + channel := Kitsu.All("Anime") + + for obj := range channel { + realObject := obj.(*kitsu.Anime) + + if filter(realObject) { + filtered = append(filtered, realObject) + } + } + + return filtered +} + +// AllKitsuAnime returns a slice of all Kitsu anime. +func AllKitsuAnime() []*kitsu.Anime { + all := make([]*kitsu.Anime, 0, DB.Collection("kitsu.Anime").Count()) + + stream := StreamKitsuAnime() + + for obj := range stream { + all = append(all, obj) + } + + return all +} diff --git a/arn/KitsuMappings.go b/arn/KitsuMappings.go new file mode 100644 index 00000000..9127c148 --- /dev/null +++ b/arn/KitsuMappings.go @@ -0,0 +1,38 @@ +package arn + +import ( + "github.com/aerogo/nano" + "github.com/animenotifier/kitsu" +) + +// StreamKitsuMappings returns a stream of all Kitsu mappings. +func StreamKitsuMappings() <-chan *kitsu.Mapping { + channel := make(chan *kitsu.Mapping, nano.ChannelBufferSize) + + go func() { + for obj := range Kitsu.All("Mapping") { + channel <- obj.(*kitsu.Mapping) + } + + close(channel) + }() + + return channel +} + +// FilterKitsuMappings filters all Kitsu mappings by a custom function. +func FilterKitsuMappings(filter func(*kitsu.Mapping) bool) []*kitsu.Mapping { + var filtered []*kitsu.Mapping + + channel := Kitsu.All("Mapping") + + for obj := range channel { + realObject := obj.(*kitsu.Mapping) + + if filter(realObject) { + filtered = append(filtered, realObject) + } + } + + return filtered +} diff --git a/arn/KitsuMatch.go b/arn/KitsuMatch.go new file mode 100644 index 00000000..38f2fb02 --- /dev/null +++ b/arn/KitsuMatch.go @@ -0,0 +1,19 @@ +package arn + +import ( + "github.com/animenotifier/kitsu" + jsoniter "github.com/json-iterator/go" +) + +// KitsuMatch ... +type KitsuMatch struct { + KitsuItem *kitsu.LibraryEntry `json:"kitsuItem"` + ARNAnime *Anime `json:"arnAnime"` +} + +// JSON ... +func (match *KitsuMatch) JSON() string { + b, err := jsoniter.Marshal(match) + PanicOnError(err) + return string(b) +} diff --git a/arn/LICENSE b/arn/LICENSE new file mode 100644 index 00000000..0a9295b9 --- /dev/null +++ b/arn/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Eduard Urbach + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/arn/Likeable.go b/arn/Likeable.go new file mode 100644 index 00000000..c362b6cd --- /dev/null +++ b/arn/Likeable.go @@ -0,0 +1,78 @@ +package arn + +import ( + "errors" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Likeable ... +type Likeable interface { + Like(userID UserID) + Unlike(userID UserID) + LikedBy(userID UserID) bool + CountLikes() int + Link() string + Save() +} + +// LikeEventReceiver ... +type LikeEventReceiver interface { + OnLike(user *User) +} + +// LikeAction ... +func LikeAction() *api.Action { + return &api.Action{ + Name: "like", + Route: "/like", + Run: func(obj interface{}, ctx aero.Context) error { + field := reflect.ValueOf(obj).Elem().FieldByName("IsDraft") + + if field.IsValid() && field.Bool() { + return errors.New("Drafts need to be published before they can be liked") + } + + likeable := obj.(Likeable) + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + likeable.Like(user.ID) + + // Call OnLike if the object implements it + receiver, ok := likeable.(LikeEventReceiver) + + if ok { + receiver.OnLike(user) + } + + likeable.Save() + return nil + }, + } +} + +// UnlikeAction ... +func UnlikeAction() *api.Action { + return &api.Action{ + Name: "unlike", + Route: "/unlike", + Run: func(obj interface{}, ctx aero.Context) error { + likeable := obj.(Likeable) + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + likeable.Unlike(user.ID) + likeable.Save() + return nil + }, + } +} diff --git a/arn/Link.go b/arn/Link.go new file mode 100644 index 00000000..cd921503 --- /dev/null +++ b/arn/Link.go @@ -0,0 +1,7 @@ +package arn + +// Link describes a single link to an external website. +type Link struct { + Title string `json:"title" editable:"true"` + URL string `json:"url" editable:"true"` +} diff --git a/arn/Linkable.go b/arn/Linkable.go new file mode 100644 index 00000000..b5b0dfd7 --- /dev/null +++ b/arn/Linkable.go @@ -0,0 +1,6 @@ +package arn + +// Linkable defines an object that can be linked. +type Linkable interface { + Link() string +} diff --git a/arn/ListOfMappedIDs.go b/arn/ListOfMappedIDs.go new file mode 100644 index 00000000..b9b827e6 --- /dev/null +++ b/arn/ListOfMappedIDs.go @@ -0,0 +1,42 @@ +package arn + +// import ( +// "github.com/akyoto/color" +// ) + +// // ListOfMappedIDs ... +// type ListOfMappedIDs struct { +// IDList []*MappedID `json:"idList"` +// } + +// // MappedID ... +// type MappedID struct { +// Type string `json:"type"` +// ID string `json:"id"` +// } + +// // Append appends the given mapped ID to the end of the list. +// func (idList *ListOfMappedIDs) Append(typeName string, id string) { +// idList.IDList = append(idList.IDList, &MappedID{ +// Type: typeName, +// ID: id, +// }) +// } + +// // Resolve ... +// func (idList *ListOfMappedIDs) Resolve() []interface{} { +// var data []interface{} + +// for _, mapped := range idList.IDList { +// obj, err := DB.Get(mapped.Type, mapped.ID) + +// if err != nil { +// color.Red(err.Error()) +// continue +// } + +// data = append(data, obj) +// } + +// return data +// } diff --git a/arn/Location.go b/arn/Location.go new file mode 100644 index 00000000..84db041a --- /dev/null +++ b/arn/Location.go @@ -0,0 +1,53 @@ +package arn + +import "math" + +// EarthRadius is the radius of the earth in kilometers. +const EarthRadius = 6371 + +// Location ... +type Location struct { + CountryName string `json:"countryName"` + CountryCode string `json:"countryCode"` + Latitude float64 `json:"latitude" editable:"true"` + Longitude float64 `json:"longitude" editable:"true"` + CityName string `json:"cityName"` + RegionName string `json:"regionName"` + TimeZone string `json:"timeZone"` + ZipCode string `json:"zipCode"` +} + +// IPInfoDBLocation ... +type IPInfoDBLocation struct { + CountryName string `json:"countryName"` + CountryCode string `json:"countryCode"` + Latitude string `json:"latitude"` + Longitude string `json:"longitude"` + CityName string `json:"cityName"` + RegionName string `json:"regionName"` + TimeZone string `json:"timeZone"` + ZipCode string `json:"zipCode"` +} + +// IsValid returns true if latitude and longitude are available. +func (p *Location) IsValid() bool { + return p.Latitude != 0 && p.Longitude != 0 +} + +// Distance calculates the distance in kilometers to the second location. +// Original implementation: https://www.movable-type.co.uk/scripts/latlong.html +func (p *Location) Distance(p2 *Location) float64 { + dLat := (p2.Latitude - p.Latitude) * (math.Pi / 180.0) + dLon := (p2.Longitude - p.Longitude) * (math.Pi / 180.0) + + lat1 := p.Latitude * (math.Pi / 180.0) + lat2 := p2.Latitude * (math.Pi / 180.0) + + a1 := math.Sin(dLat/2) * math.Sin(dLat/2) + a2 := math.Sin(dLon/2) * math.Sin(dLon/2) * math.Cos(lat1) * math.Cos(lat2) + + a := a1 + a2 + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + return EarthRadius * c +} diff --git a/arn/Lockable.go b/arn/Lockable.go new file mode 100644 index 00000000..5de4895f --- /dev/null +++ b/arn/Lockable.go @@ -0,0 +1,84 @@ +package arn + +import ( + "errors" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Lockable ... +type Lockable interface { + Lock(userID UserID) + Unlock(userID UserID) + IsLocked() bool + Save() +} + +// LockEventReceiver ... +type LockEventReceiver interface { + OnLock(user *User) + OnUnlock(user *User) +} + +// LockAction ... +func LockAction() *api.Action { + return &api.Action{ + Name: "lock", + Route: "/lock", + Run: func(obj interface{}, ctx aero.Context) error { + lockable := obj.(Lockable) + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + lockable.Lock(user.ID) + + // Call OnLock if the object implements it + receiver, ok := lockable.(LockEventReceiver) + + if ok { + receiver.OnLock(user) + } + + lockable.Save() + return nil + }, + } +} + +// UnlockAction ... +func UnlockAction() *api.Action { + return &api.Action{ + Name: "unlock", + Route: "/unlock", + Run: func(obj interface{}, ctx aero.Context) error { + lockable := obj.(Lockable) + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + lockable.Unlock(user.ID) + + // Call OnUnlock if the object implements it + receiver, ok := lockable.(LockEventReceiver) + + if ok { + receiver.OnUnlock(user) + } + + lockable.Save() + return nil + }, + } +} + +// IsLocked returns true if the given object is locked. +func IsLocked(obj interface{}) bool { + lockable, isLockable := obj.(Lockable) + return isLockable && lockable.IsLocked() +} diff --git a/arn/Loggable.go b/arn/Loggable.go new file mode 100644 index 00000000..2f2b1a80 --- /dev/null +++ b/arn/Loggable.go @@ -0,0 +1,40 @@ +package arn + +import ( + "fmt" + "reflect" + + "github.com/aerogo/aero" +) + +// Loggable applies to any type that has a TypeName function. +type Loggable interface { + GetID() string + TypeName() string + Self() Loggable +} + +// edit creates an edit log entry. +func edit(loggable Loggable, ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "edit", loggable.TypeName(), loggable.GetID(), key, fmt.Sprint(value.Interface()), fmt.Sprint(newValue.Interface())) + logEntry.Save() + + return false, nil +} + +// onAppend saves a log entry. +func onAppend(loggable Loggable, ctx aero.Context, key string, index int, obj interface{}) { + user := GetUserFromContext(ctx) + logEntry := NewEditLogEntry(user.ID, "arrayAppend", loggable.TypeName(), loggable.GetID(), fmt.Sprintf("%s[%d]", key, index), "", fmt.Sprint(obj)) + logEntry.Save() +} + +// onRemove saves a log entry. +func onRemove(loggable Loggable, ctx aero.Context, key string, index int, obj interface{}) { + user := GetUserFromContext(ctx) + logEntry := NewEditLogEntry(user.ID, "arrayRemove", loggable.TypeName(), loggable.GetID(), fmt.Sprintf("%s[%d]", key, index), fmt.Sprint(obj), "") + logEntry.Save() +} diff --git a/arn/Mapping.go b/arn/Mapping.go new file mode 100644 index 00000000..77a32e78 --- /dev/null +++ b/arn/Mapping.go @@ -0,0 +1,84 @@ +package arn + +// Register a list of supported services. +func init() { + DataLists["mapping-services"] = []*Option{ + {"anidb/anime", "anidb/anime"}, + {"anilist/anime", "anilist/anime"}, + {"anilist/character", "anilist/character"}, + {"anilist/studio", "anilist/studio"}, + {"ann/company", "ann/company"}, + {"imdb/anime", "imdb/anime"}, + {"kitsu/anime", "kitsu/anime"}, + {"kitsu/character", "kitsu/character"}, + {"myanimelist/anime", "myanimelist/anime"}, + {"myanimelist/character", "myanimelist/character"}, + {"myanimelist/producer", "myanimelist/producer"}, + {"shoboi/anime", "shoboi/anime"}, + {"thetvdb/anime", "thetvdb/anime"}, + {"trakt/anime", "trakt/anime"}, + {"trakt/season", "trakt/season"}, + } +} + +// Mapping ... +type Mapping struct { + Service string `json:"service" editable:"true" datalist:"mapping-services"` + ServiceID string `json:"serviceId" editable:"true"` +} + +// Name ... +func (mapping *Mapping) Name() string { + switch mapping.Service { + case "anidb/anime": + return "AniDB" + case "anilist/anime": + return "AniList" + case "imdb/anime": + return "IMDb" + case "kitsu/anime": + return "Kitsu" + case "myanimelist/anime": + return "MAL" + case "shoboi/anime": + return "Shoboi" + case "thetvdb/anime": + return "TVDB" + case "trakt/anime": + return "Trakt" + case "trakt/season": + return "Trakt" + default: + return mapping.Service + } +} + +// Link ... +func (mapping *Mapping) Link() string { + switch mapping.Service { + case "kitsu/anime": + return "https://kitsu.io/anime/" + mapping.ServiceID + case "shoboi/anime": + return "http://cal.syoboi.jp/tid/" + mapping.ServiceID + case "anilist/anime": + return "https://anilist.co/anime/" + mapping.ServiceID + case "anilist/character": + return "https://anilist.co/character/" + mapping.ServiceID + case "anilist/studio": + return "https://anilist.co/studio/" + mapping.ServiceID + case "imdb/anime": + return "https://www.imdb.com/title/" + mapping.ServiceID + case "myanimelist/anime": + return "https://myanimelist.net/anime/" + mapping.ServiceID + case "thetvdb/anime": + return "https://thetvdb.com/?tab=series&id=" + mapping.ServiceID + case "anidb/anime": + return "https://anidb.net/perl-bin/animedb.pl?show=anime&aid=" + mapping.ServiceID + case "trakt/anime": + return "https://trakt.tv/shows/" + mapping.ServiceID + case "trakt/season": + return "https://trakt.tv/seasons/" + mapping.ServiceID + default: + return "" + } +} diff --git a/arn/MyAnimeListMatch.go b/arn/MyAnimeListMatch.go new file mode 100644 index 00000000..f9bcc14e --- /dev/null +++ b/arn/MyAnimeListMatch.go @@ -0,0 +1,19 @@ +package arn + +import ( + "github.com/animenotifier/mal" + jsoniter "github.com/json-iterator/go" +) + +// MyAnimeListMatch ... +type MyAnimeListMatch struct { + MyAnimeListItem *mal.AnimeListItem `json:"malItem"` + ARNAnime *Anime `json:"arnAnime"` +} + +// JSON ... +func (match *MyAnimeListMatch) JSON() string { + b, err := jsoniter.Marshal(match) + PanicOnError(err) + return string(b) +} diff --git a/arn/Name.go b/arn/Name.go new file mode 100644 index 00000000..44a359c9 --- /dev/null +++ b/arn/Name.go @@ -0,0 +1,14 @@ +package arn + +import "fmt" + +// Name is the combination of a first and last name. +type Name struct { + First string `json:"first" editable:"true"` + Last string `json:"last" editable:"true"` +} + +// String returns the default visualization of the name. +func (name Name) String() string { + return fmt.Sprintf("%s %s", name.First, name.Last) +} diff --git a/arn/NickToUser.go b/arn/NickToUser.go new file mode 100644 index 00000000..ac201638 --- /dev/null +++ b/arn/NickToUser.go @@ -0,0 +1,7 @@ +package arn + +// NickToUser stores the user ID by nickname. +type NickToUser struct { + Nick string `json:"nick"` + UserID UserID `json:"userId"` +} diff --git a/arn/Notification.go b/arn/Notification.go new file mode 100644 index 00000000..d7e288f2 --- /dev/null +++ b/arn/Notification.go @@ -0,0 +1,82 @@ +package arn + +import ( + "fmt" + "time" + + "github.com/aerogo/nano" +) + +// Notification represents a user-associated notification. +type Notification struct { + ID string `json:"id"` + UserID string `json:"userId"` + Created string `json:"created"` + Seen string `json:"seen"` + PushNotification +} + +// User retrieves the user the notification was sent to. +func (notification *Notification) User() *User { + user, _ := GetUser(notification.UserID) + return user +} + +// CreatedTime returns the created date as a time object. +func (notification *Notification) CreatedTime() time.Time { + t, _ := time.Parse(time.RFC3339, notification.Created) + return t +} + +// String returns a string representation of the notification. +func (notification *Notification) String() string { + return fmt.Sprintf("[%s] %s", notification.Type, notification.Title) +} + +// NewNotification creates a new notification. +func NewNotification(userID UserID, pushNotification *PushNotification) *Notification { + return &Notification{ + ID: GenerateID("Notification"), + UserID: userID, + Created: DateTimeUTC(), + Seen: "", + PushNotification: *pushNotification, + } +} + +// GetNotification ... +func GetNotification(id string) (*Notification, error) { + obj, err := DB.Get("Notification", id) + + if err != nil { + return nil, err + } + + return obj.(*Notification), nil +} + +// StreamNotifications returns a stream of all notifications. +func StreamNotifications() <-chan *Notification { + channel := make(chan *Notification, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Notification") { + channel <- obj.(*Notification) + } + + close(channel) + }() + + return channel +} + +// AllNotifications returns a slice of all notifications. +func AllNotifications() ([]*Notification, error) { + all := make([]*Notification, 0, DB.Collection("Notification").Count()) + + for obj := range StreamNotifications() { + all = append(all, obj) + } + + return all, nil +} diff --git a/arn/NotificationAPI.go b/arn/NotificationAPI.go new file mode 100644 index 00000000..3e303d70 --- /dev/null +++ b/arn/NotificationAPI.go @@ -0,0 +1,6 @@ +package arn + +// Save saves the notification in the database. +func (notification *Notification) Save() { + DB.Set("Notification", notification.ID, notification) +} diff --git a/arn/NotificationType.go b/arn/NotificationType.go new file mode 100644 index 00000000..45c267e6 --- /dev/null +++ b/arn/NotificationType.go @@ -0,0 +1,14 @@ +package arn + +// NotificationType values +const ( + NotificationTypeTest = "test" + NotificationTypeAnimeEpisode = "anime-episode" + NotificationTypeAnimeFinished = "anime-finished" + NotificationTypeForumReply = "forum-reply" + NotificationTypeFollow = "follow" + NotificationTypeLike = "like" + NotificationTypePurchase = "purchase" + NotificationTypePackageTest = "package-test" + NotificationTypeGroupJoin = "group-join" +) diff --git a/arn/Nyaa.go b/arn/Nyaa.go new file mode 100644 index 00000000..531ce917 --- /dev/null +++ b/arn/Nyaa.go @@ -0,0 +1,48 @@ +package arn + +import ( + "fmt" + "regexp" + "strings" +) + +type nyaaAnimeProvider struct{} + +// Nyaa anime provider (singleton) +var Nyaa = new(nyaaAnimeProvider) + +var nyaaInvalidCharsRegex = regexp.MustCompile(`[^[:alnum:]!']`) +var nyaaTVRegex = regexp.MustCompile(` \(?TV\)?`) + +// GetLink retrieves the Nyaa title for the given anime +func (nyaa *nyaaAnimeProvider) GetLink(anime *Anime, additionalSearchTerm string) string { + searchTitle := nyaa.GetTitle(anime) + "+" + additionalSearchTerm + searchTitle = strings.Replace(searchTitle, " ", "+", -1) + + quality := "" + subs := "" + + nyaaSuffix := fmt.Sprintf("?f=0&c=1_2&q=%s+%s+%s&s=seeders&o=desc", searchTitle, quality, subs) + nyaaSuffix = strings.Replace(nyaaSuffix, "++", "+", -1) + + return "https://nyaa.si/" + nyaaSuffix +} + +// GetTitle retrieves the Nyaa title for the given anime +func (nyaa *nyaaAnimeProvider) GetTitle(anime *Anime) string { + return nyaa.BuildTitle(anime.Title.Canonical) +} + +// BuildTitle tries to create a title for use on Nyaa +func (nyaa *nyaaAnimeProvider) BuildTitle(title string) string { + if title == "" { + return "" + } + + title = nyaaInvalidCharsRegex.ReplaceAllString(title, " ") + title = nyaaTVRegex.ReplaceAllString(title, "") + title = strings.Replace(title, " ", " ", -1) + title = strings.TrimSpace(title) + + return title +} diff --git a/arn/OpenGraph.go b/arn/OpenGraph.go new file mode 100644 index 00000000..5fa09535 --- /dev/null +++ b/arn/OpenGraph.go @@ -0,0 +1,7 @@ +package arn + +// OpenGraph data +type OpenGraph struct { + Tags map[string]string + Meta map[string]string +} diff --git a/arn/PayPal.go b/arn/PayPal.go new file mode 100644 index 00000000..c2e2175e --- /dev/null +++ b/arn/PayPal.go @@ -0,0 +1,32 @@ +package arn + +import ( + "os" + + paypalsdk "github.com/logpacker/PayPal-Go-SDK" +) + +var payPal *paypalsdk.Client + +// PayPal returns the new PayPal SDK client. +func PayPal() (*paypalsdk.Client, error) { + if payPal != nil { + return payPal, nil + } + + apiBase := paypalsdk.APIBaseSandBox + + if IsProduction() { + apiBase = paypalsdk.APIBaseLive + } + + // Create a client instance + c, err := paypalsdk.NewClient(APIKeys.PayPal.ID, APIKeys.PayPal.Secret, apiBase) + c.SetLog(os.Stdout) + + if err != nil { + return nil, err + } + + return c, nil +} diff --git a/arn/PayPalPayment.go b/arn/PayPalPayment.go new file mode 100644 index 00000000..ae5bd444 --- /dev/null +++ b/arn/PayPalPayment.go @@ -0,0 +1,79 @@ +package arn + +import ( + "strconv" + + "github.com/aerogo/nano" +) + +// PayPalPayment is an approved and exeucted PayPal payment. +type PayPalPayment struct { + ID string `json:"id"` + UserID string `json:"userId"` + PayerID string `json:"payerId"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Method string `json:"method"` + Created string `json:"created"` +} + +// Gems returns the total amount of gems. +func (payment *PayPalPayment) Gems() int { + amount, err := strconv.ParseFloat(payment.Amount, 64) + + if err != nil { + return 0 + } + + return int(amount) +} + +// User returns the user who made the payment. +func (payment *PayPalPayment) User() *User { + user, _ := GetUser(payment.UserID) + return user +} + +// Save saves the paypal payment in the database. +func (payment *PayPalPayment) Save() { + DB.Set("PayPalPayment", payment.ID, payment) +} + +// StreamPayPalPayments returns a stream of all paypal payments. +func StreamPayPalPayments() <-chan *PayPalPayment { + channel := make(chan *PayPalPayment, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("PayPalPayment") { + channel <- obj.(*PayPalPayment) + } + + close(channel) + }() + + return channel +} + +// AllPayPalPayments returns a slice of all paypal payments. +func AllPayPalPayments() ([]*PayPalPayment, error) { + all := make([]*PayPalPayment, 0, DB.Collection("PayPalPayment").Count()) + + for obj := range StreamPayPalPayments() { + all = append(all, obj) + } + + return all, nil +} + +// FilterPayPalPayments filters all paypal payments by a custom function. +func FilterPayPalPayments(filter func(*PayPalPayment) bool) ([]*PayPalPayment, error) { + var filtered []*PayPalPayment + + for obj := range StreamPayPalPayments() { + if filter(obj) { + filtered = append(filtered, obj) + } + } + + return filtered, nil +} diff --git a/arn/Person.go b/arn/Person.go new file mode 100644 index 00000000..b59eb8c3 --- /dev/null +++ b/arn/Person.go @@ -0,0 +1,170 @@ +package arn + +import ( + "errors" + "fmt" + "sort" + + "github.com/aerogo/nano" +) + +// Person represents a person in real life. +type Person struct { + Name PersonName `json:"name" editable:"true"` + Image PersonImage `json:"image"` + + hasID + hasPosts + hasCreator + hasEditor + hasLikes + hasDraft +} + +// NewPerson creates a new person. +func NewPerson() *Person { + return &Person{ + hasID: hasID{ + ID: GenerateID("Person"), + }, + hasCreator: hasCreator{ + Created: DateTimeUTC(), + }, + } +} + +// Link ... +func (person *Person) Link() string { + return "/person/" + person.ID +} + +// TitleByUser returns the preferred title for the given user. +func (person *Person) TitleByUser(user *User) string { + return person.Name.ByUser(user) +} + +// String returns the default display name for the person. +func (person *Person) String() string { + return person.Name.ByUser(nil) +} + +// TypeName returns the type name. +func (person *Person) TypeName() string { + return "Person" +} + +// Self returns the object itself. +func (person *Person) Self() Loggable { + return person +} + +// ImageLink ... +func (person *Person) ImageLink(size string) string { + extension := ".jpg" + + if size == "original" { + extension = person.Image.Extension + } + + return fmt.Sprintf("//%s/images/persons/%s/%s%s?%v", MediaHost, size, person.ID, extension, person.Image.LastModified) +} + +// Publish publishes the person draft. +func (person *Person) Publish() error { + // No name + if person.Name.ByUser(nil) == "" { + return errors.New("No person name") + } + + // No image + if !person.HasImage() { + return errors.New("No person image") + } + + return publish(person) +} + +// Unpublish turns the person into a draft. +func (person *Person) Unpublish() error { + return unpublish(person) +} + +// HasImage returns true if the person has an image. +func (person *Person) HasImage() bool { + return person.Image.Extension != "" && person.Image.Width > 0 +} + +// GetPerson ... +func GetPerson(id string) (*Person, error) { + obj, err := DB.Get("Person", id) + + if err != nil { + return nil, err + } + + return obj.(*Person), nil +} + +// DeleteImages deletes all images for the person. +func (person *Person) DeleteImages() { + deleteImages("persons", person.ID, person.Image.Extension) +} + +// SortPersonsByLikes sorts the given slice of persons by the amount of likes. +func SortPersonsByLikes(persons []*Person) { + sort.Slice(persons, func(i, j int) bool { + aLikes := len(persons[i].Likes) + bLikes := len(persons[j].Likes) + + if aLikes == bLikes { + return persons[i].Name.English.First < persons[j].Name.English.First + } + + return aLikes > bLikes + }) +} + +// StreamPersons returns a stream of all persons. +func StreamPersons() <-chan *Person { + channel := make(chan *Person, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Person") { + channel <- obj.(*Person) + } + + close(channel) + }() + + return channel +} + +// FilterPersons filters all persons by a custom function. +func FilterPersons(filter func(*Person) bool) []*Person { + var filtered []*Person + + channel := DB.All("Person") + + for obj := range channel { + realObject := obj.(*Person) + + if filter(realObject) { + filtered = append(filtered, realObject) + } + } + + return filtered +} + +// AllPersons returns a slice of all persons. +func AllPersons() []*Person { + all := make([]*Person, 0, DB.Collection("Person").Count()) + + stream := StreamPersons() + + for obj := range stream { + all = append(all, obj) + } + + return all +} diff --git a/arn/PersonAPI.go b/arn/PersonAPI.go new file mode 100644 index 00000000..5d165676 --- /dev/null +++ b/arn/PersonAPI.go @@ -0,0 +1,115 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ Likeable = (*Person)(nil) + _ Publishable = (*Person)(nil) + _ PostParent = (*Person)(nil) + _ fmt.Stringer = (*Person)(nil) + _ api.Newable = (*Person)(nil) + _ api.Editable = (*Person)(nil) + _ api.Deletable = (*Person)(nil) +) + +// Actions +func init() { + API.RegisterActions("Person", []*api.Action{ + // Publish + PublishAction(), + + // Unpublish + UnpublishAction(), + + // Like + LikeAction(), + + // Unlike + UnlikeAction(), + }) +} + +// Authorize returns an error if the given API request is not authorized. +func (person *Person) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + return nil +} + +// Create sets the data for a new person with data we received from the API request. +func (person *Person) Create(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + person.ID = GenerateID("Person") + person.Created = DateTimeUTC() + person.CreatedBy = user.ID + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "create", "Person", person.ID, "", "", "") + logEntry.Save() + + return person.Unpublish() +} + +// Edit creates an edit log entry. +func (person *Person) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + return edit(person, ctx, key, value, newValue) +} + +// OnAppend saves a log entry. +func (person *Person) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(person, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (person *Person) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(person, ctx, key, index, obj) +} + +// DeleteInContext deletes the person in the given context. +func (person *Person) DeleteInContext(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "delete", "Person", person.ID, "", fmt.Sprint(person), "") + logEntry.Save() + + return person.Delete() +} + +// Delete deletes the object from the database. +func (person *Person) Delete() error { + if person.IsDraft { + draftIndex := person.Creator().DraftIndex() + draftIndex.CharacterID = "" + draftIndex.Save() + } + + // Delete image files + person.DeleteImages() + + // Delete person + DB.Delete("Person", person.ID) + return nil +} + +// Save saves the person in the database. +func (person *Person) Save() { + DB.Set("Person", person.ID, person) +} diff --git a/arn/PersonImage.go b/arn/PersonImage.go new file mode 100644 index 00000000..8fa516da --- /dev/null +++ b/arn/PersonImage.go @@ -0,0 +1,4 @@ +package arn + +// PersonImage ... +type PersonImage CharacterImage diff --git a/arn/PersonName.go b/arn/PersonName.go new file mode 100644 index 00000000..cf292092 --- /dev/null +++ b/arn/PersonName.go @@ -0,0 +1,31 @@ +package arn + +// PersonName represents the name of a person. +type PersonName struct { + English Name `json:"english" editable:"true"` + Japanese Name `json:"japanese" editable:"true"` +} + +// String returns the default visualization of the name. +func (name *PersonName) String() string { + return name.ByUser(nil) +} + +// ByUser returns the preferred name for the given user. +func (name *PersonName) ByUser(user *User) string { + if user == nil { + return name.English.String() + } + + switch user.Settings().TitleLanguage { + case "japanese": + if name.Japanese.String() == "" { + return name.English.String() + } + + return name.Japanese.String() + + default: + return name.English.String() + } +} diff --git a/arn/Post.go b/arn/Post.go new file mode 100644 index 00000000..e8da6e93 --- /dev/null +++ b/arn/Post.go @@ -0,0 +1,246 @@ +package arn + +import ( + "fmt" + "reflect" + "sort" + "strings" + + "github.com/aerogo/markdown" + "github.com/aerogo/nano" +) + +// Post is a comment related to any parent type in the database. +type Post struct { + Tags []string `json:"tags" editable:"true"` + ParentID string `json:"parentId" editable:"true"` + ParentType string `json:"parentType"` + Edited string `json:"edited"` + + hasID + hasText + hasPosts + hasCreator + hasLikes + + html string +} + +// Parent returns the object this post was posted in. +func (post *Post) Parent() PostParent { + obj, _ := DB.Get(post.ParentType, post.ParentID) + return obj.(PostParent) +} + +// TopMostParent returns the first non-post object this post was posted in. +func (post *Post) TopMostParent() PostParent { + topMostParent := post.Parent() + + for { + if topMostParent.TypeName() != "Post" { + return topMostParent + } + + newParent := topMostParent.(*Post).Parent() + + if newParent == nil { + return topMostParent + } + + topMostParent = newParent + } +} + +// GetParentID returns the object ID of the parent. +func (post *Post) GetParentID() string { + return post.ParentID +} + +// SetParent sets a new parent. +func (post *Post) SetParent(newParent PostParent) { + // Remove from old parent + oldParent := post.Parent() + oldParent.RemovePost(post.ID) + oldParent.Save() + + // Update own fields + post.ParentID = newParent.GetID() + post.ParentType = reflect.TypeOf(newParent).Elem().Name() + + // Add to new parent + newParent.AddPost(post.ID) + newParent.Save() +} + +// Link returns the relative URL of the post. +func (post *Post) Link() string { + return "/post/" + post.ID +} + +// TypeName returns the type name. +func (post *Post) TypeName() string { + return "Post" +} + +// Self returns the object itself. +func (post *Post) Self() Loggable { + return post +} + +// TitleByUser returns the preferred title for the given user. +func (post *Post) TitleByUser(user *User) string { + return post.Creator().Nick + "'s comment" +} + +// HTML returns the HTML representation of the post. +func (post *Post) HTML() string { + if post.html != "" { + return post.html + } + + post.html = markdown.Render(post.Text) + return post.html +} + +// String implements the default string serialization. +func (post *Post) String() string { + const maxLen = 170 + + if len(post.Text) > maxLen { + return post.Text[:maxLen-3] + "..." + } + + return post.Text +} + +// OnLike is called when the post receives a like. +func (post *Post) OnLike(likedBy *User) { + if !post.Creator().Settings().Notification.ForumLikes { + return + } + + go func() { + message := "" + notifyUser := post.Creator() + + if post.ParentType == "User" { + if post.ParentID == notifyUser.ID { + // Somebody liked your post on your own profile + message = fmt.Sprintf(`%s liked your profile post.`, likedBy.Nick) + } else { + // Somebody liked your post on someone else's profile + message = fmt.Sprintf(`%s liked your post on %s's profile.`, likedBy.Nick, post.Parent().TitleByUser(notifyUser)) + } + } else { + message = fmt.Sprintf(`%s liked your post in the %s "%s".`, likedBy.Nick, strings.ToLower(post.ParentType), post.Parent().TitleByUser(notifyUser)) + } + + notifyUser.SendNotification(&PushNotification{ + Title: likedBy.Nick + " liked your post", + Message: message, + Icon: "https:" + likedBy.AvatarLink("large"), + Link: "https://notify.moe" + likedBy.Link(), + Type: NotificationTypeLike, + }) + }() +} + +// GetPost ... +func GetPost(id string) (*Post, error) { + obj, err := DB.Get("Post", id) + + if err != nil { + return nil, err + } + + return obj.(*Post), nil +} + +// StreamPosts returns a stream of all posts. +func StreamPosts() <-chan *Post { + channel := make(chan *Post, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Post") { + channel <- obj.(*Post) + } + + close(channel) + }() + + return channel +} + +// AllPosts returns a slice of all posts. +func AllPosts() []*Post { + all := make([]*Post, 0, DB.Collection("Post").Count()) + + for obj := range StreamPosts() { + all = append(all, obj) + } + + return all +} + +// SortPostsLatestFirst sorts the slice of posts. +func SortPostsLatestFirst(posts []*Post) { + sort.Slice(posts, func(i, j int) bool { + return posts[i].Created > posts[j].Created + }) +} + +// SortPostsLatestLast sorts the slice of posts. +func SortPostsLatestLast(posts []*Post) { + sort.Slice(posts, func(i, j int) bool { + return posts[i].Created < posts[j].Created + }) +} + +// FilterPostsWithUniqueThreads removes posts with the same thread until we have enough posts. +func FilterPostsWithUniqueThreads(posts []*Post, limit int) []*Post { + filtered := []*Post{} + threadsProcessed := map[string]bool{} + + for _, post := range posts { + if len(filtered) >= limit { + return filtered + } + + _, found := threadsProcessed[post.ParentID] + + if found { + continue + } + + threadsProcessed[post.ParentID] = true + filtered = append(filtered, post) + } + + return filtered +} + +// GetPostsByUser ... +func GetPostsByUser(user *User) ([]*Post, error) { + var posts []*Post + + for post := range StreamPosts() { + if post.CreatedBy == user.ID { + posts = append(posts, post) + } + } + + return posts, nil +} + +// FilterPosts filters all forum posts by a custom function. +func FilterPosts(filter func(*Post) bool) ([]*Post, error) { + var filtered []*Post + + for post := range StreamPosts() { + if filter(post) { + filtered = append(filtered, post) + } + } + + return filtered, nil +} diff --git a/arn/PostAPI.go b/arn/PostAPI.go new file mode 100644 index 00000000..8e394d52 --- /dev/null +++ b/arn/PostAPI.go @@ -0,0 +1,288 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + "strings" + + "github.com/aerogo/aero" + "github.com/aerogo/api" + "github.com/aerogo/markdown" + "github.com/animenotifier/notify.moe/arn/autocorrect" +) + +// Force interface implementations +var ( + _ Postable = (*Post)(nil) + _ Likeable = (*Post)(nil) + _ LikeEventReceiver = (*Post)(nil) + _ PostParent = (*Post)(nil) + _ fmt.Stringer = (*Post)(nil) + _ api.Newable = (*Post)(nil) + _ api.Editable = (*Post)(nil) + _ api.Actionable = (*Post)(nil) + _ api.Deletable = (*Post)(nil) +) + +// Actions +func init() { + API.RegisterActions("Post", []*api.Action{ + // Like post + LikeAction(), + + // Unlike post + UnlikeAction(), + }) +} + +// Authorize returns an error if the given API POST request is not authorized. +func (post *Post) Authorize(ctx aero.Context, action string) error { + if !ctx.HasSession() { + return errors.New("Neither logged in nor in session") + } + + if action == "edit" { + user := GetUserFromContext(ctx) + + if post.CreatedBy != user.ID && user.Role != "admin" { + return errors.New("Can't edit the posts of other users") + } + } + + return nil +} + +// Create sets the data for a new post with data we received from the API request. +func (post *Post) Create(ctx aero.Context) error { + data, err := ctx.Request().Body().JSONObject() + + if err != nil { + return err + } + + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + post.ID = GenerateID("Post") + post.Text, _ = data["text"].(string) + post.CreatedBy = user.ID + post.ParentID, _ = data["parentId"].(string) + post.ParentType, _ = data["parentType"].(string) + post.Created = DateTimeUTC() + post.Edited = "" + + // Check parent type + if !DB.HasType(post.ParentType) { + return errors.New("Invalid parent type: " + post.ParentType) + } + + // Post-process text + post.Text = autocorrect.PostText(post.Text) + + if len(post.Text) < 5 { + return errors.New("Text too short: Should be at least 5 characters") + } + + // Tags + tags, _ := data["tags"].([]interface{}) + post.Tags = make([]string, len(tags)) + + for i := range post.Tags { + post.Tags[i] = tags[i].(string) + } + + // Parent + parent := post.Parent() + + if parent == nil { + return errors.New(post.ParentType + " does not exist") + } + + // Is the parent locked? + if IsLocked(parent) { + return errors.New(post.ParentType + " is locked") + } + + // Don't allow posting when you're not a group member + topMostParent := post.TopMostParent() + + if topMostParent.TypeName() == "Group" { + group := topMostParent.(*Group) + + if !group.HasMember(user.ID) { + return errors.New("Only group members can post in groups") + } + } + + // Append to posts + parent.AddPost(post.ID) + + // Save the parent thread + parent.Save() + + // Send notification to the author of the parent post + go func() { + notifyUser := parent.Creator() + + // Does the parent have a creator? + if notifyUser == nil { + return + } + + // Don't notify the author himself + if notifyUser.ID == post.CreatedBy { + return + } + + title := user.Nick + " replied" + message := "" + + switch post.ParentType { + case "Post": + message = fmt.Sprintf("%s replied to your comment in \"%s\".", user.Nick, parent.(*Post).Parent().TitleByUser(notifyUser)) + case "User": + title = fmt.Sprintf("%s wrote a comment on your profile.", user.Nick) + message = post.Text + case "Group": + title = fmt.Sprintf(`%s wrote a new post in the group "%s".`, user.Nick, parent.TitleByUser(nil)) + message = post.Text + default: + message = fmt.Sprintf(`%s replied in the %s "%s".`, user.Nick, strings.ToLower(post.ParentType), parent.TitleByUser(notifyUser)) + } + + notification := &PushNotification{ + Title: title, + Message: message, + Icon: "https:" + user.AvatarLink("large"), + Link: post.Link(), + Type: NotificationTypeForumReply, + } + + // If you're posting to a group, + // all members except the author will receive a notification. + if post.ParentType == "Group" { + group := parent.(*Group) + group.SendNotification(notification, user.ID) + return + } + + notifyUser.SendNotification(notification) + }() + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "create", "Post", post.ID, "", "", "") + logEntry.Save() + + // Create activity + activity := NewActivityCreate("Post", post.ID, user.ID) + activity.Save() + + return nil +} + +// Edit saves a log entry for the edit. +func (post *Post) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (bool, error) { + consumed := false + user := GetUserFromContext(ctx) + + // nolint:gocritic (because this should stay a switch statement) + switch key { + case "ParentID": + var newParent PostParent + newParentID := newValue.String() + newParent, err := GetPost(newParentID) + + if err != nil { + newParent, err = GetThread(newParentID) + + if err != nil { + return false, err + } + } + + post.SetParent(newParent) + consumed = true + } + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "edit", "Post", post.ID, key, fmt.Sprint(value.Interface()), fmt.Sprint(newValue.Interface())) + logEntry.Save() + + return consumed, nil +} + +// OnAppend saves a log entry. +func (post *Post) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(post, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (post *Post) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(post, ctx, key, index, obj) +} + +// AfterEdit sets the edited date on the post object. +func (post *Post) AfterEdit(ctx aero.Context) error { + post.Edited = DateTimeUTC() + post.html = markdown.Render(post.Text) + return nil +} + +// DeleteInContext deletes the post in the given context. +func (post *Post) DeleteInContext(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "delete", "Post", post.ID, "", fmt.Sprint(post), "") + logEntry.Save() + + return post.Delete() +} + +// Delete deletes the post from the database. +func (post *Post) Delete() error { + // Remove child posts first + for _, post := range post.Posts() { + err := post.Delete() + + if err != nil { + return err + } + } + + parent := post.Parent() + + if parent == nil { + return fmt.Errorf("Invalid %s parent ID: %s", post.ParentType, post.ParentID) + } + + // Remove the reference of the post in the thread that contains it + if !parent.RemovePost(post.ID) { + return fmt.Errorf("This post does not exist in the %s", strings.ToLower(post.ParentType)) + } + + parent.Save() + + // Remove activities + for activity := range StreamActivityCreates() { + if activity.ObjectID == post.ID && activity.ObjectType == "Post" { + err := activity.Delete() + + if err != nil { + return err + } + } + } + + DB.Delete("Post", post.ID) + return nil +} + +// Save saves the post object in the database. +func (post *Post) Save() { + DB.Set("Post", post.ID, post) +} diff --git a/arn/PostParent.go b/arn/PostParent.go new file mode 100644 index 00000000..fcca6314 --- /dev/null +++ b/arn/PostParent.go @@ -0,0 +1,21 @@ +package arn + +import ( + "github.com/aerogo/api" +) + +// PostParent is an interface that defines common functions for parent objects of posts. +type PostParent interface { + Linkable + api.Savable + GetID() string + TypeName() string + TitleByUser(*User) string + Posts() []*Post + PostsRelevantFirst(count int) []*Post + CountPosts() int + Creator() *User + CreatorID() UserID + AddPost(string) + RemovePost(string) bool +} diff --git a/arn/Postable.go b/arn/Postable.go new file mode 100644 index 00000000..2225f875 --- /dev/null +++ b/arn/Postable.go @@ -0,0 +1,47 @@ +package arn + +import ( + "reflect" + "sort" +) + +// Postable is a generic interface for Threads, Posts and Messages. +type Postable interface { + Likeable + + TitleByUser(*User) string + HTML() string + Parent() PostParent + Posts() []*Post + CountPosts() int + TypeName() string + Creator() *User + + // Use Get prefix for these to avoid a + // name clash with the internal fields. + GetID() string + GetText() string + GetCreated() string + GetParentID() string +} + +// ToPostables converts a slice of specific types to a slice of generic postables. +func ToPostables(sliceOfPosts interface{}) []Postable { + var postables []Postable + + v := reflect.ValueOf(sliceOfPosts) + + for i := 0; i < v.Len(); i++ { + postable := v.Index(i).Interface().(Postable) + postables = append(postables, postable) + } + + return postables +} + +// SortPostablesLatestFirst ... +func SortPostablesLatestFirst(posts []Postable) { + sort.Slice(posts, func(i, j int) bool { + return posts[i].GetCreated() > posts[j].GetCreated() + }) +} diff --git a/arn/Production.go b/arn/Production.go new file mode 100644 index 00000000..cd7311bc --- /dev/null +++ b/arn/Production.go @@ -0,0 +1,22 @@ +package arn + +import ( + "os" + "strings" +) + +// IsProduction returns true if the hostname contains "arn". +func IsProduction() bool { + return strings.Contains(HostName(), "arn") +} + +// IsDevelopment returns true if the hostname does not contain "arn". +func IsDevelopment() bool { + return !IsProduction() +} + +// HostName returns the hostname. +func HostName() string { + host, _ := os.Hostname() + return host +} diff --git a/arn/Publishable.go b/arn/Publishable.go new file mode 100644 index 00000000..f84436d4 --- /dev/null +++ b/arn/Publishable.go @@ -0,0 +1,131 @@ +package arn + +import ( + "errors" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Publishable ... +type Publishable interface { + Publish() error + Unpublish() error + Save() + GetID() string + GetCreatedBy() string + GetIsDraft() bool + SetIsDraft(bool) +} + +// PublishAction returns an API action that publishes the object. +func PublishAction() *api.Action { + return &api.Action{ + Name: "publish", + Route: "/publish", + Run: func(obj interface{}, ctx aero.Context) error { + draft := obj.(Publishable) + err := draft.Publish() + + if err != nil { + return err + } + + draft.Save() + return nil + }, + } +} + +// UnpublishAction returns an API action that unpublishes the object. +func UnpublishAction() *api.Action { + return &api.Action{ + Name: "unpublish", + Route: "/unpublish", + Run: func(obj interface{}, ctx aero.Context) error { + draft := obj.(Publishable) + err := draft.Unpublish() + + if err != nil { + return err + } + + draft.Save() + return nil + }, + } +} + +// publish is the generic publish function. +func publish(draft Publishable) error { + // No draft + if !draft.GetIsDraft() { + return errors.New("Not a draft") + } + + // Get object type + typ := reflect.TypeOf(draft) + + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + + // Get draft index + draftIndex, err := GetDraftIndex(draft.GetCreatedBy()) + + if err != nil { + return err + } + + currentDraftID, _ := draftIndex.GetID(typ.Name()) + + if currentDraftID != draft.GetID() { + return errors.New(typ.Name() + " draft doesn't exist in the user draft index") + } + + // Publish the object + draft.SetIsDraft(false) + err = draftIndex.SetID(typ.Name(), "") + + if err != nil { + return err + } + + draftIndex.Save() + + return nil +} + +// unpublish turns the object back into a draft. +func unpublish(draft Publishable) error { + // Get object type + typ := reflect.TypeOf(draft) + + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + + // Get draft index + draftIndex, err := GetDraftIndex(draft.GetCreatedBy()) + + if err != nil { + return err + } + + draftIndexID, _ := draftIndex.GetID(typ.Name()) + + if draftIndexID != "" { + return errors.New("You still have an unfinished draft") + } + + draft.SetIsDraft(true) + err = draftIndex.SetID(typ.Name(), draft.GetID()) + + if err != nil { + return err + } + + draftIndex.Save() + return nil +} diff --git a/arn/Purchase.go b/arn/Purchase.go new file mode 100644 index 00000000..bfb17ac9 --- /dev/null +++ b/arn/Purchase.go @@ -0,0 +1,78 @@ +package arn + +import "github.com/aerogo/nano" + +// Purchase represents an item purchase by a user. +type Purchase struct { + ID string `json:"id"` + UserID string `json:"userId"` + ItemID string `json:"itemId"` + Quantity int `json:"quantity"` + Price int `json:"price"` + Currency string `json:"currency"` + Date string `json:"date"` +} + +// Item returns the item the user bought. +func (purchase *Purchase) Item() *ShopItem { + item, _ := GetShopItem(purchase.ItemID) + return item +} + +// User returns the user who made the purchase. +func (purchase *Purchase) User() *User { + user, _ := GetUser(purchase.UserID) + return user +} + +// NewPurchase creates a new Purchase object with a generated ID. +func NewPurchase(userID UserID, itemID string, quantity int, price int, currency string) *Purchase { + return &Purchase{ + ID: GenerateID("Purchase"), + UserID: userID, + ItemID: itemID, + Quantity: quantity, + Price: price, + Currency: currency, + Date: DateTimeUTC(), + } +} + +// StreamPurchases returns a stream of all purchases. +func StreamPurchases() <-chan *Purchase { + channel := make(chan *Purchase, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Purchase") { + channel <- obj.(*Purchase) + } + + close(channel) + }() + + return channel +} + +// AllPurchases returns a slice of all anime. +func AllPurchases() ([]*Purchase, error) { + all := make([]*Purchase, 0, DB.Collection("Purchase").Count()) + + for obj := range StreamPurchases() { + all = append(all, obj) + } + + return all, nil +} + +// FilterPurchases filters all purchases by a custom function. +func FilterPurchases(filter func(*Purchase) bool) ([]*Purchase, error) { + var filtered []*Purchase + + for obj := range StreamPurchases() { + if filter(obj) { + filtered = append(filtered, obj) + } + } + + return filtered, nil +} diff --git a/arn/PurchaseAPI.go b/arn/PurchaseAPI.go new file mode 100644 index 00000000..c729d9cf --- /dev/null +++ b/arn/PurchaseAPI.go @@ -0,0 +1,6 @@ +package arn + +// Save saves the purchase in the database. +func (purchase *Purchase) Save() { + DB.Set("Purchase", purchase.ID, purchase) +} diff --git a/arn/PushNotification.go b/arn/PushNotification.go new file mode 100644 index 00000000..e697a582 --- /dev/null +++ b/arn/PushNotification.go @@ -0,0 +1,10 @@ +package arn + +// PushNotification represents a push notification. +type PushNotification struct { + Title string `json:"title"` + Message string `json:"message"` + Icon string `json:"icon"` + Link string `json:"link"` + Type string `json:"type"` +} diff --git a/arn/PushSubscription.go b/arn/PushSubscription.go new file mode 100644 index 00000000..640b0c9c --- /dev/null +++ b/arn/PushSubscription.go @@ -0,0 +1,54 @@ +package arn + +import ( + "net/http" + + webpush "github.com/akyoto/webpush-go" + jsoniter "github.com/json-iterator/go" +) + +// PushSubscription ... +type PushSubscription struct { + Platform string `json:"platform"` + UserAgent string `json:"userAgent"` + Screen struct { + Width int `json:"width"` + Height int `json:"height"` + } `json:"screen"` + Endpoint string `json:"endpoint" private:"true"` + P256DH string `json:"p256dh" private:"true"` + Auth string `json:"auth" private:"true"` + Created string `json:"created"` + LastSuccess string `json:"lastSuccess"` +} + +// ID ... +func (sub *PushSubscription) ID() string { + return sub.Endpoint +} + +// SendNotification ... +func (sub *PushSubscription) SendNotification(notification *PushNotification) (*http.Response, error) { + // Define endpoint and security tokens + s := webpush.Subscription{ + Endpoint: sub.Endpoint, + Keys: webpush.Keys{ + P256dh: sub.P256DH, + Auth: sub.Auth, + }, + } + + // Create notification + data, err := jsoniter.Marshal(notification) + + if err != nil { + return nil, err + } + + // Send Notification + return webpush.SendNotification(data, &s, &webpush.Options{ + Subscriber: APIKeys.VAPID.Subject, + TTL: 60, + VAPIDPrivateKey: APIKeys.VAPID.PrivateKey, + }) +} diff --git a/arn/PushSubscriptions.go b/arn/PushSubscriptions.go new file mode 100644 index 00000000..3dbc9ac6 --- /dev/null +++ b/arn/PushSubscriptions.go @@ -0,0 +1,67 @@ +package arn + +import "errors" + +// PushSubscriptions is a list of push subscriptions made by a user. +type PushSubscriptions struct { + UserID UserID `json:"userId"` + Items []*PushSubscription `json:"items"` +} + +// Add adds a subscription to the list if it hasn't been added yet. +func (list *PushSubscriptions) Add(subscription *PushSubscription) error { + if list.Contains(subscription.ID()) { + return errors.New("PushSubscription " + subscription.ID() + " has already been added") + } + + subscription.Created = DateTimeUTC() + + list.Items = append(list.Items, subscription) + + return nil +} + +// Remove removes the subscription ID from the list. +func (list *PushSubscriptions) Remove(subscriptionID string) bool { + for index, item := range list.Items { + if item.ID() == subscriptionID { + list.Items = append(list.Items[:index], list.Items[index+1:]...) + return true + } + } + + return false +} + +// Contains checks if the list contains the subscription ID already. +func (list *PushSubscriptions) Contains(subscriptionID string) bool { + for _, item := range list.Items { + if item.ID() == subscriptionID { + return true + } + } + + return false +} + +// Find returns the subscription with the specified ID, if available. +func (list *PushSubscriptions) Find(id string) *PushSubscription { + for _, item := range list.Items { + if item.ID() == id { + return item + } + } + + return nil +} + +// GetPushSubscriptions ... +func GetPushSubscriptions(id string) (*PushSubscriptions, error) { + obj, err := DB.Get("PushSubscriptions", id) + + if err != nil { + return nil, err + } + + return obj.(*PushSubscriptions), nil +} diff --git a/arn/PushSubscriptionsAPI.go b/arn/PushSubscriptionsAPI.go new file mode 100644 index 00000000..19e1d751 --- /dev/null +++ b/arn/PushSubscriptionsAPI.go @@ -0,0 +1,116 @@ +package arn + +import ( + "errors" + + "github.com/aerogo/aero" + "github.com/aerogo/api" + jsoniter "github.com/json-iterator/go" +) + +// Force interface implementations +var ( + _ api.Editable = (*PushSubscriptions)(nil) + _ api.Filter = (*PushSubscriptions)(nil) +) + +// Actions +func init() { + API.RegisterActions("PushSubscriptions", []*api.Action{ + // Add subscription + { + Name: "add", + Route: "/add", + Run: func(obj interface{}, ctx aero.Context) error { + subscriptions := obj.(*PushSubscriptions) + + // Parse body + body, err := ctx.Request().Body().Bytes() + + if err != nil { + return err + } + + var subscription *PushSubscription + err = jsoniter.Unmarshal(body, &subscription) + + if err != nil { + return err + } + + // Add subscription + err = subscriptions.Add(subscription) + + if err != nil { + return err + } + + subscriptions.Save() + + return nil + }, + }, + + // Remove subscription + { + Name: "remove", + Route: "/remove", + Run: func(obj interface{}, ctx aero.Context) error { + subscriptions := obj.(*PushSubscriptions) + + // Parse body + body, err := ctx.Request().Body().Bytes() + + if err != nil { + return err + } + + var subscription *PushSubscription + err = jsoniter.Unmarshal(body, &subscription) + + if err != nil { + return err + } + + // Remove subscription + if !subscriptions.Remove(subscription.ID()) { + return errors.New("PushSubscription does not exist") + } + + subscriptions.Save() + + return nil + }, + }, + }) +} + +// Filter removes privacy critical fields from the settings object. +func (list *PushSubscriptions) Filter() { + for _, item := range list.Items { + item.P256DH = "" + item.Auth = "" + item.Endpoint = "" + } +} + +// ShouldFilter tells whether data needs to be filtered in the given context. +func (list *PushSubscriptions) ShouldFilter(ctx aero.Context) bool { + ctxUser := GetUserFromContext(ctx) + + if ctxUser != nil && ctxUser.Role == "admin" { + return false + } + + return true +} + +// Authorize returns an error if the given API request is not authorized. +func (list *PushSubscriptions) Authorize(ctx aero.Context, action string) error { + return AuthorizeIfLoggedInAndOwnData(ctx, "id") +} + +// Save saves the push subscriptions in the database. +func (list *PushSubscriptions) Save() { + DB.Set("PushSubscriptions", list.UserID, list) +} diff --git a/arn/Quote.go b/arn/Quote.go new file mode 100644 index 00000000..404c4633 --- /dev/null +++ b/arn/Quote.go @@ -0,0 +1,240 @@ +package arn + +import ( + "errors" + "fmt" + + "sort" + + "github.com/aerogo/nano" + "github.com/akyoto/color" +) + +// Quote is a quote made by a character in an anime. +type Quote struct { + Text QuoteText `json:"text" editable:"true"` + CharacterID string `json:"characterId" editable:"true"` + AnimeID string `json:"animeId" editable:"true"` + EpisodeNumber int `json:"episode" editable:"true"` + Time int `json:"time" editable:"true"` + + hasID + hasPosts + hasCreator + hasEditor + hasLikes + hasDraft +} + +// IsMainQuote returns true if the quote is the main quote of the character. +func (quote *Quote) IsMainQuote() bool { + return quote.CharacterID != "" && quote.Character().MainQuoteID == quote.ID +} + +// TitleByUser returns the preferred title for the given user. +func (quote *Quote) TitleByUser(user *User) string { + character := quote.Character() + + if character == nil { + return "Quote" + } + + return fmt.Sprintf("%s's quote", character.Name.ByUser(user)) +} + +// Link returns a single quote. +func (quote *Quote) Link() string { + return "/quote/" + quote.ID +} + +// Publish checks the quote and publishes it when no errors were found. +func (quote *Quote) Publish() error { + // No quote text + if quote.Text.English == "" { + return errors.New("English quote text is required") + } + + // No character + if quote.CharacterID == "" { + return errors.New("A character is required") + } + + // No anime + if quote.AnimeID == "" { + return errors.New("An anime is required") + } + + // // No episode number + // if quote.EpisodeNumber == -1 { + // return errors.New("An episode number is required") + // } + + // // No time + // if quote.Time == -1 { + // return errors.New("Time in minutes is required") + // } + + // Invalid anime ID + anime := quote.Anime() + + if anime == nil { + return errors.New("Invalid anime ID") + } + + // Invalid character ID + character := quote.Character() + + if character == nil { + return errors.New("Invalid character ID") + } + + // Invalid episode number + maxEpisodes := anime.EpisodeCount + + if maxEpisodes != 0 && quote.EpisodeNumber > maxEpisodes { + return errors.New("Invalid episode number") + } + + return publish(quote) +} + +// Unpublish ... +func (quote *Quote) Unpublish() error { + return unpublish(quote) +} + +// TypeName returns the type name. +func (quote *Quote) TypeName() string { + return "Quote" +} + +// Self returns the object itself. +func (quote *Quote) Self() Loggable { + return quote +} + +// OnLike is called when the quote receives a like. +func (quote *Quote) OnLike(likedBy *User) { + if !quote.IsValid() { + color.Red("Invalid quote: %s", quote.ID) + return + } + + if likedBy.ID == quote.CreatedBy { + return + } + + if !quote.Creator().Settings().Notification.QuoteLikes { + return + } + + go func() { + quote.Creator().SendNotification(&PushNotification{ + Title: likedBy.Nick + " liked your " + quote.Character().Name.Canonical + " quote", + Message: quote.Text.English, + Icon: "https:" + likedBy.AvatarLink("large"), + Link: "https://notify.moe" + likedBy.Link(), + Type: NotificationTypeLike, + }) + }() +} + +// IsValid tests the field values and returns true if everything is okay. +func (quote *Quote) IsValid() bool { + return quote.Character() != nil && quote.Anime() != nil && quote.Creator() != nil +} + +// String implements the default string serialization. +func (quote *Quote) String() string { + return quote.Text.English +} + +// GetQuote returns a single quote. +func GetQuote(id string) (*Quote, error) { + obj, err := DB.Get("Quote", id) + + if err != nil { + return nil, err + } + + return obj.(*Quote), nil +} + +// StreamQuotes returns a stream of all quotes. +func StreamQuotes() <-chan *Quote { + channel := make(chan *Quote, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Quote") { + channel <- obj.(*Quote) + } + + close(channel) + }() + + return channel +} + +// AllQuotes returns a slice of all quotes. +func AllQuotes() []*Quote { + all := make([]*Quote, 0, DB.Collection("Quote").Count()) + + stream := StreamQuotes() + + for obj := range stream { + all = append(all, obj) + } + + return all +} + +// Character returns the character cited in the quote +func (quote *Quote) Character() *Character { + character, _ := GetCharacter(quote.CharacterID) + return character +} + +// Anime fetches the anime where the quote is said. +func (quote *Quote) Anime() *Anime { + anime, err := GetAnime(quote.AnimeID) + + if err != nil && !quote.IsDraft { + color.Red("Error fetching anime: %v", err) + } + + return anime +} + +// SortQuotesLatestFirst ... +func SortQuotesLatestFirst(quotes []*Quote) { + sort.Slice(quotes, func(i, j int) bool { + return quotes[i].Created > quotes[j].Created + }) +} + +// SortQuotesPopularFirst ... +func SortQuotesPopularFirst(quotes []*Quote) { + sort.Slice(quotes, func(i, j int) bool { + aLikes := len(quotes[i].Likes) + bLikes := len(quotes[j].Likes) + + if aLikes == bLikes { + return quotes[i].Created > quotes[j].Created + } + + return aLikes > bLikes + }) +} + +// FilterQuotes filters all quotes by a custom function. +func FilterQuotes(filter func(*Quote) bool) []*Quote { + var filtered []*Quote + + for obj := range StreamQuotes() { + if filter(obj) { + filtered = append(filtered, obj) + } + } + + return filtered +} diff --git a/arn/QuoteAPI.go b/arn/QuoteAPI.go new file mode 100644 index 00000000..480d5b8e --- /dev/null +++ b/arn/QuoteAPI.go @@ -0,0 +1,133 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ Likeable = (*Quote)(nil) + _ LikeEventReceiver = (*Quote)(nil) + _ Publishable = (*Quote)(nil) + _ PostParent = (*Quote)(nil) + _ fmt.Stringer = (*Quote)(nil) + _ api.Newable = (*Quote)(nil) + _ api.Editable = (*Quote)(nil) + _ api.Deletable = (*Quote)(nil) +) + +// Actions +func init() { + API.RegisterActions("Quote", []*api.Action{ + // Publish + PublishAction(), + + // Unpublish + UnpublishAction(), + + // Like + LikeAction(), + + // Unlike + UnlikeAction(), + }) +} + +// Create sets the data for a new quote with data we received from the API request. +func (quote *Quote) Create(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + quote.ID = GenerateID("Quote") + quote.Created = DateTimeUTC() + quote.CreatedBy = user.ID + quote.EpisodeNumber = -1 + quote.Time = -1 + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "create", "Quote", quote.ID, "", "", "") + logEntry.Save() + + return quote.Unpublish() +} + +// Edit saves a log entry for the edit. +func (quote *Quote) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (bool, error) { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "edit", "Quote", quote.ID, key, fmt.Sprint(value.Interface()), fmt.Sprint(newValue.Interface())) + logEntry.Save() + + return false, nil +} + +// Save saves the quote in the database. +func (quote *Quote) Save() { + DB.Set("Quote", quote.ID, quote) +} + +// DeleteInContext deletes the quote in the given context. +func (quote *Quote) DeleteInContext(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "delete", "Quote", quote.ID, "", fmt.Sprint(quote), "") + logEntry.Save() + + return quote.Delete() +} + +// Delete deletes the object from the database. +func (quote *Quote) Delete() error { + if quote.IsDraft { + draftIndex := quote.Creator().DraftIndex() + draftIndex.QuoteID = "" + draftIndex.Save() + } + + // Remove posts + for _, post := range quote.Posts() { + err := post.Delete() + + if err != nil { + return err + } + } + + // Remove main quote reference + character := quote.Character() + + if character.MainQuoteID == quote.ID { + character.MainQuoteID = "" + character.Save() + } + + DB.Delete("Quote", quote.ID) + return nil +} + +// Authorize returns an error if the given API request is not authorized. +func (quote *Quote) Authorize(ctx aero.Context, action string) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + if action == "delete" { + if user.Role != "editor" && user.Role != "admin" { + return errors.New("Insufficient permissions") + } + } + + return nil +} diff --git a/arn/QuoteText.go b/arn/QuoteText.go new file mode 100644 index 00000000..c75a9e4c --- /dev/null +++ b/arn/QuoteText.go @@ -0,0 +1,7 @@ +package arn + +// QuoteText ... +type QuoteText struct { + English string `json:"english" editable:"true" type:"textarea"` + Japanese string `json:"japanese" editable:"true" type:"textarea"` +} diff --git a/arn/README.md b/arn/README.md new file mode 100644 index 00000000..879c83b5 --- /dev/null +++ b/arn/README.md @@ -0,0 +1,32 @@ +# arn + +[![Godoc][godoc-image]][godoc-url] +[![Report][report-image]][report-url] +[![Tests][tests-image]][tests-url] +[![Coverage][coverage-image]][coverage-url] +[![Sponsor][sponsor-image]][sponsor-url] + +This library provides direct access to the Anime Notifier database. It is not an API client. + +## Style + +Please take a look at the [style guidelines](https://github.com/akyoto/quality/blob/master/STYLE.md) if you'd like to make a pull request. + +## Sponsors + +| [](https://github.com/cedricfung) | [](https://github.com/soulcramer) | [](https://twitter.com/eduardurbach) | +| --- | --- | --- | +| [Cedric Fung](https://github.com/cedricfung) | [Scott Rayapoullé](https://github.com/soulcramer) | [Eduard Urbach](https://eduardurbach.com) | + +Want to see [your own name here?](https://github.com/users/akyoto/sponsorship) + +[godoc-image]: https://godoc.org/github.com/animenotifier/notify.moe/arn?status.svg +[godoc-url]: https://godoc.org/github.com/animenotifier/notify.moe/arn +[report-image]: https://goreportcard.com/badge/github.com/animenotifier/notify.moe/arn +[report-url]: https://goreportcard.com/report/github.com/animenotifier/notify.moe/arn +[tests-image]: https://cloud.drone.io/api/badges/animenotifier/arn/status.svg +[tests-url]: https://cloud.drone.io/animenotifier/arn +[coverage-image]: https://codecov.io/gh/animenotifier/arn/graph/badge.svg +[coverage-url]: https://codecov.io/gh/animenotifier/arn +[sponsor-image]: https://img.shields.io/badge/github-donate-green.svg +[sponsor-url]: https://github.com/users/akyoto/sponsorship diff --git a/arn/README.src.md b/arn/README.src.md new file mode 100644 index 00000000..e9e73d87 --- /dev/null +++ b/arn/README.src.md @@ -0,0 +1,7 @@ +# {name} + +{go:header} + +This library provides direct access to the Anime Notifier database. It is not an API client. + +{go:footer} diff --git a/arn/Session.go b/arn/Session.go new file mode 100644 index 00000000..0ec670f6 --- /dev/null +++ b/arn/Session.go @@ -0,0 +1,4 @@ +package arn + +// Session stores session-related data. +type Session map[string]interface{} diff --git a/arn/Settings.go b/arn/Settings.go new file mode 100644 index 00000000..62a1e6f7 --- /dev/null +++ b/arn/Settings.go @@ -0,0 +1,180 @@ +package arn + +import "fmt" + +const ( + // SortByAiringDate sorts your watching list by airing date. + SortByAiringDate = "airing date" + + // SortByTitle sorts your watching list alphabetically. + SortByTitle = "title" + + // SortByRating sorts your watching list by rating. + SortByRating = "rating" +) + +const ( + // TitleLanguageCanonical ... + TitleLanguageCanonical = "canonical" + + // TitleLanguageRomaji ... + TitleLanguageRomaji = "romaji" + + // TitleLanguageEnglish ... + TitleLanguageEnglish = "english" + + // TitleLanguageJapanese ... + TitleLanguageJapanese = "japanese" +) + +// Settings represents user settings. +type Settings struct { + UserID string `json:"userId"` + SortBy string `json:"sortBy"` + TitleLanguage string `json:"titleLanguage" editable:"true"` + Providers ServiceProviders `json:"providers"` + Avatar AvatarSettings `json:"avatar"` + Format FormatSettings `json:"format"` + Notification NotificationSettings `json:"notification"` + Editor EditorSettings `json:"editor"` + Privacy PrivacySettings `json:"privacy"` + Calendar CalendarSettings `json:"calendar" editable:"true"` + Theme string `json:"theme" editable:"true"` +} + +// PrivacySettings ... +type PrivacySettings struct { + ShowAge bool `json:"showAge" editable:"true"` + ShowGender bool `json:"showGender" editable:"true"` + ShowLocation bool `json:"showLocation" editable:"true"` +} + +// NotificationSettings ... +type NotificationSettings struct { + Email string `json:"email" private:"true"` + NewFollowers bool `json:"newFollowers" editable:"true"` + AnimeEpisodeReleases bool `json:"animeEpisodeReleases" editable:"true"` + AnimeFinished bool `json:"animeFinished" editable:"true"` + ForumLikes bool `json:"forumLikes" editable:"true"` + GroupPostLikes bool `json:"groupPostLikes" editable:"true"` + QuoteLikes bool `json:"quoteLikes" editable:"true"` + SoundTrackLikes bool `json:"soundTrackLikes" editable:"true"` +} + +// EditorSettings ... +type EditorSettings struct { + Filter EditorFilterSettings `json:"filter"` +} + +// EditorFilterSettings ... +type EditorFilterSettings struct { + Year string `json:"year" editable:"true"` + Season string `json:"season" editable:"true"` + Status string `json:"status" editable:"true"` + Type string `json:"type" editable:"true"` +} + +// Suffix returns the URL suffix. +func (filter *EditorFilterSettings) Suffix() string { + year := filter.Year + status := filter.Status + season := filter.Season + typ := filter.Type + + if year == "" { + year = "any" + } + + if season == "" { + season = "any" + } + + if status == "" { + status = "any" + } + + if typ == "" { + typ = "any" + } + + return fmt.Sprintf("/%s/%s/%s/%s", year, season, status, typ) +} + +// FormatSettings ... +type FormatSettings struct { + RatingsPrecision int `json:"ratingsPrecision" editable:"true"` +} + +// ServiceProviders ... +type ServiceProviders struct { + Anime string `json:"anime"` +} + +// AvatarSettings ... +type AvatarSettings struct { + Source string `json:"source" editable:"true"` + SourceURL string `json:"sourceUrl" editable:"true"` +} + +// CalendarSettings ... +type CalendarSettings struct { + ShowAddedAnimeOnly bool `json:"showAddedAnimeOnly" editable:"true"` +} + +// NewSettings ... +func NewSettings(user *User) *Settings { + return &Settings{ + UserID: user.ID, + SortBy: SortByAiringDate, + TitleLanguage: TitleLanguageCanonical, + Providers: ServiceProviders{ + Anime: "", + }, + Avatar: AvatarSettings{ + Source: "", + SourceURL: "", + }, + Format: FormatSettings{ + RatingsPrecision: 1, + }, + Privacy: PrivacySettings{ + ShowLocation: true, + }, + Calendar: CalendarSettings{ + ShowAddedAnimeOnly: false, + }, + Notification: DefaultNotificationSettings(), + Theme: "light", + } +} + +// DefaultNotificationSettings returns the default notification settings. +func DefaultNotificationSettings() NotificationSettings { + return NotificationSettings{ + Email: "", + NewFollowers: true, + AnimeEpisodeReleases: true, + AnimeFinished: false, + ForumLikes: true, + GroupPostLikes: true, + QuoteLikes: true, + SoundTrackLikes: true, + } +} + +// GetSettings ... +func GetSettings(userID UserID) (*Settings, error) { + obj, err := DB.Get("Settings", userID) + + if err != nil { + return nil, err + } + + return obj.(*Settings), nil +} + +// User returns the user object for the settings. +func (settings *Settings) User() *User { + user, _ := GetUser(settings.UserID) + return user +} diff --git a/arn/SettingsAPI.go b/arn/SettingsAPI.go new file mode 100644 index 00000000..b916dccc --- /dev/null +++ b/arn/SettingsAPI.go @@ -0,0 +1,58 @@ +package arn + +import ( + "errors" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ api.Editable = (*Settings)(nil) + _ api.Filter = (*Settings)(nil) +) + +// Authorize returns an error if the given API POST request is not authorized. +func (settings *Settings) Authorize(ctx aero.Context, action string) error { + return AuthorizeIfLoggedInAndOwnData(ctx, "id") +} + +// Edit updates the settings object. +func (settings *Settings) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (bool, error) { + // nolint:gocritic (because this should stay as a switch statement) + switch key { + case "Theme": + if settings.User().IsPro() { + settings.Theme = newValue.String() + } else { + return true, errors.New("PRO accounts only") + } + + return true, nil + } + + return false, nil +} + +// Filter removes privacy critical fields from the settings object. +func (settings *Settings) Filter() { + settings.Notification.Email = "" +} + +// ShouldFilter tells whether data needs to be filtered in the given context. +func (settings *Settings) ShouldFilter(ctx aero.Context) bool { + ctxUser := GetUserFromContext(ctx) + + if ctxUser != nil && ctxUser.Role == "admin" { + return false + } + + return true +} + +// Save saves the settings in the database. +func (settings *Settings) Save() { + DB.Set("Settings", settings.UserID, settings) +} diff --git a/arn/ShopItem.go b/arn/ShopItem.go new file mode 100644 index 00000000..ace98e53 --- /dev/null +++ b/arn/ShopItem.go @@ -0,0 +1,69 @@ +package arn + +import "github.com/aerogo/nano" + +const ( + // ShopItemRarityCommon ... + ShopItemRarityCommon = "common" + + // ShopItemRaritySuperior ... + ShopItemRaritySuperior = "superior" + + // ShopItemRarityRare ... + ShopItemRarityRare = "rare" + + // ShopItemRarityUnique ... + ShopItemRarityUnique = "unique" + + // ShopItemRarityLegendary ... + ShopItemRarityLegendary = "legendary" +) + +// ShopItem is a purchasable item in the shop. +type ShopItem struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Price uint `json:"price"` + Icon string `json:"icon"` + Rarity string `json:"rarity"` + Order int `json:"order"` + Consumable bool `json:"consumable"` +} + +// GetShopItem ... +func GetShopItem(id string) (*ShopItem, error) { + obj, err := DB.Get("ShopItem", id) + + if err != nil { + return nil, err + } + + return obj.(*ShopItem), nil +} + +// StreamShopItems returns a stream of all shop items. +func StreamShopItems() <-chan *ShopItem { + channel := make(chan *ShopItem, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("ShopItem") { + channel <- obj.(*ShopItem) + } + + close(channel) + }() + + return channel +} + +// AllShopItems returns a slice of all items. +func AllShopItems() ([]*ShopItem, error) { + all := make([]*ShopItem, 0, DB.Collection("ShopItem").Count()) + + for obj := range StreamShopItems() { + all = append(all, obj) + } + + return all, nil +} diff --git a/arn/ShopItemAPI.go b/arn/ShopItemAPI.go new file mode 100644 index 00000000..8a770fdf --- /dev/null +++ b/arn/ShopItemAPI.go @@ -0,0 +1,6 @@ +package arn + +// Save saves the item in the database. +func (item *ShopItem) Save() { + DB.Set("ShopItem", item.ID, item) +} diff --git a/arn/SoundTrack.go b/arn/SoundTrack.go new file mode 100644 index 00000000..5053b2e1 --- /dev/null +++ b/arn/SoundTrack.go @@ -0,0 +1,397 @@ +package arn + +import ( + "errors" + "os" + "os/exec" + "path" + "sort" + "strings" + + "github.com/aerogo/nano" + "github.com/akyoto/color" + "github.com/animenotifier/notify.moe/arn/autocorrect" +) + +// SoundTrack is a soundtrack used in one or multiple anime. +type SoundTrack struct { + Title SoundTrackTitle `json:"title" editable:"true"` + Media []*ExternalMedia `json:"media" editable:"true"` + Links []*Link `json:"links" editable:"true"` + Lyrics SoundTrackLyrics `json:"lyrics" editable:"true"` + Tags []string `json:"tags" editable:"true" tooltip:"