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 + +| [![Cedric Fung](https://avatars3.githubusercontent.com/u/2269238?s=70&v=4)](https://github.com/cedricfung) | [![Scott Rayapoullé](https://avatars3.githubusercontent.com/u/11772084?s=70&v=4)](https://github.com/soulcramer) | [![Eduard Urbach](https://avatars3.githubusercontent.com/u/438936?s=70&v=4)](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:""` + File string `json:"file"` + + hasID + hasPosts + hasCreator + hasEditor + hasLikes + hasDraft +} + +// Link returns the permalink for the track. +func (track *SoundTrack) Link() string { + return "/soundtrack/" + track.ID +} + +// TitleByUser returns the preferred title for the given user. +func (track *SoundTrack) TitleByUser(user *User) string { + return track.Title.ByUser(user) +} + +// MediaByService returns a slice of all media by the given service. +func (track *SoundTrack) MediaByService(service string) []*ExternalMedia { + filtered := []*ExternalMedia{} + + for _, media := range track.Media { + if media.Service == service { + filtered = append(filtered, media) + } + } + + return filtered +} + +// HasMediaByService returns true if the track has media by the given service. +func (track *SoundTrack) HasMediaByService(service string) bool { + for _, media := range track.Media { + if media.Service == service { + return true + } + } + + return false +} + +// HasTag returns true if it contains the given tag. +func (track *SoundTrack) HasTag(search string) bool { + for _, tag := range track.Tags { + if tag == search { + return true + } + } + + return false +} + +// HasLyrics returns true if the track has lyrics in any language. +func (track *SoundTrack) HasLyrics() bool { + return track.Lyrics.Native != "" || track.Lyrics.Romaji != "" +} + +// Anime fetches all tagged anime of the sound track. +func (track *SoundTrack) Anime() []*Anime { + var animeList []*Anime + + for _, tag := range track.Tags { + if strings.HasPrefix(tag, "anime:") { + animeID := strings.TrimPrefix(tag, "anime:") + anime, err := GetAnime(animeID) + + if err != nil { + if !track.IsDraft { + color.Red("Error fetching anime: %v", err) + } + + continue + } + + animeList = append(animeList, anime) + } + } + + return animeList +} + +// OsuBeatmaps returns all osu beatmap IDs of the sound track. +func (track *SoundTrack) OsuBeatmaps() []string { + return FilterIDTags(track.Tags, "osu-beatmap") +} + +// EtternaBeatmaps returns all Etterna song IDs of the sound track. +func (track *SoundTrack) EtternaBeatmaps() []string { + return FilterIDTags(track.Tags, "etterna") +} + +// MainAnime ... +func (track *SoundTrack) MainAnime() *Anime { + allAnime := track.Anime() + + if len(allAnime) == 0 { + return nil + } + + return allAnime[0] +} + +// TypeName returns the type name. +func (track *SoundTrack) TypeName() string { + return "SoundTrack" +} + +// Self returns the object itself. +func (track *SoundTrack) Self() Loggable { + return track +} + +// EditedByUser returns the user who edited this track last. +func (track *SoundTrack) EditedByUser() *User { + user, _ := GetUser(track.EditedBy) + return user +} + +// OnLike is called when the soundtrack receives a like. +func (track *SoundTrack) OnLike(likedBy *User) { + if likedBy.ID == track.CreatedBy { + return + } + + if !track.Creator().Settings().Notification.SoundTrackLikes { + return + } + + go func() { + track.Creator().SendNotification(&PushNotification{ + Title: likedBy.Nick + " liked your soundtrack " + track.Title.ByUser(track.Creator()), + Message: likedBy.Nick + " liked your soundtrack " + track.Title.ByUser(track.Creator()) + ".", + Icon: "https:" + likedBy.AvatarLink("large"), + Link: "https://notify.moe" + likedBy.Link(), + Type: NotificationTypeLike, + }) + }() +} + +// Publish ... +func (track *SoundTrack) Publish() error { + // No media added + if len(track.Media) == 0 { + return errors.New("No media specified (at least 1 media source is required)") + } + + animeFound := false + + for _, tag := range track.Tags { + tag = autocorrect.Tag(tag) + + if strings.HasPrefix(tag, "anime:") { + animeID := strings.TrimPrefix(tag, "anime:") + _, err := GetAnime(animeID) + + if err != nil { + return errors.New("Invalid anime ID") + } + + animeFound = true + } + } + + // No anime found + if !animeFound { + return errors.New("Need to specify at least one anime") + } + + // No tags + if len(track.Tags) < 1 { + return errors.New("Need to specify at least one tag") + } + + // Publish + err := publish(track) + + if err != nil { + return err + } + + // Start download in the background + go func() { + err := track.Download() + + if err == nil { + track.Save() + } + }() + + return nil +} + +// Unpublish ... +func (track *SoundTrack) Unpublish() error { + draftIndex, err := GetDraftIndex(track.CreatedBy) + + if err != nil { + return err + } + + if draftIndex.SoundTrackID != "" { + return errors.New("You still have an unfinished draft") + } + + track.IsDraft = true + draftIndex.SoundTrackID = track.ID + draftIndex.Save() + return nil +} + +// Download downloads the track. +func (track *SoundTrack) Download() error { + if track.IsDraft { + return errors.New("Track is a draft") + } + + youtubeVideos := track.MediaByService("Youtube") + + if len(youtubeVideos) == 0 { + return errors.New("No Youtube ID") + } + + youtubeID := youtubeVideos[0].ServiceID + + // Check for existing file + if track.File != "" { + stat, err := os.Stat(path.Join(Root, "audio", track.File)) + + if err == nil && !stat.IsDir() && stat.Size() > 0 { + return errors.New("Already downloaded") + } + } + + audioDirectory := path.Join(Root, "audio") + baseName := track.ID + "|" + youtubeID + + // Check if it exists on the file system + fullPath := FindFileWithExtension(baseName, audioDirectory, []string{ + ".opus", + ".webm", + ".ogg", + ".m4a", + ".mp3", + ".flac", + ".wav", + }) + + // In case we added the file but didn't register it in database + if fullPath != "" { + extension := path.Ext(fullPath) + track.File = baseName + extension + return nil + } + + filePath := path.Join(audioDirectory, baseName) + + // Use full URL to avoid problems with Youtube IDs that start with a hyphen + url := "https://youtube.com/watch?v=" + youtubeID + + // Download + cmd := exec.Command( + "youtube-dl", + "--no-check-certificate", + "--extract-audio", + "--audio-quality", "0", + "--output", filePath+".%(ext)s", + url, + ) + + err := cmd.Start() + + if err != nil { + return err + } + + err = cmd.Wait() + + if err != nil { + return err + } + + // Find downloaded file + fullPath = FindFileWithExtension(baseName, audioDirectory, []string{ + ".opus", + ".webm", + ".ogg", + ".m4a", + ".mp3", + ".flac", + ".wav", + }) + + extension := path.Ext(fullPath) + track.File = baseName + extension + return nil +} + +// String implements the default string serialization. +func (track *SoundTrack) String() string { + return track.Title.ByUser(nil) +} + +// SortSoundTracksLatestFirst ... +func SortSoundTracksLatestFirst(tracks []*SoundTrack) { + sort.Slice(tracks, func(i, j int) bool { + return tracks[i].Created > tracks[j].Created + }) +} + +// SortSoundTracksPopularFirst ... +func SortSoundTracksPopularFirst(tracks []*SoundTrack) { + sort.Slice(tracks, func(i, j int) bool { + aLikes := len(tracks[i].Likes) + bLikes := len(tracks[j].Likes) + + if aLikes == bLikes { + return tracks[i].Created > tracks[j].Created + } + + return aLikes > bLikes + }) +} + +// GetSoundTrack ... +func GetSoundTrack(id string) (*SoundTrack, error) { + track, err := DB.Get("SoundTrack", id) + + if err != nil { + return nil, err + } + + return track.(*SoundTrack), nil +} + +// StreamSoundTracks returns a stream of all soundtracks. +func StreamSoundTracks() <-chan *SoundTrack { + channel := make(chan *SoundTrack, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("SoundTrack") { + channel <- obj.(*SoundTrack) + } + + close(channel) + }() + + return channel +} + +// AllSoundTracks ... +func AllSoundTracks() []*SoundTrack { + all := make([]*SoundTrack, 0, DB.Collection("SoundTrack").Count()) + + for obj := range StreamSoundTracks() { + all = append(all, obj) + } + + return all +} + +// FilterSoundTracks filters all soundtracks by a custom function. +func FilterSoundTracks(filter func(*SoundTrack) bool) []*SoundTrack { + var filtered []*SoundTrack + + for obj := range StreamSoundTracks() { + if filter(obj) { + filtered = append(filtered, obj) + } + } + + return filtered +} diff --git a/arn/SoundTrackAPI.go b/arn/SoundTrackAPI.go new file mode 100644 index 00000000..ac296cf9 --- /dev/null +++ b/arn/SoundTrackAPI.go @@ -0,0 +1,154 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + "strings" + + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ Publishable = (*SoundTrack)(nil) + _ Likeable = (*SoundTrack)(nil) + _ LikeEventReceiver = (*SoundTrack)(nil) + _ PostParent = (*SoundTrack)(nil) + _ fmt.Stringer = (*SoundTrack)(nil) + _ api.Newable = (*SoundTrack)(nil) + _ api.Editable = (*SoundTrack)(nil) + _ api.Deletable = (*SoundTrack)(nil) + _ api.ArrayEventListener = (*SoundTrack)(nil) +) + +// Actions +func init() { + API.RegisterActions("SoundTrack", []*api.Action{ + // Publish + PublishAction(), + + // Unpublish + UnpublishAction(), + + // Like + LikeAction(), + + // Unlike + UnlikeAction(), + }) +} + +// Create sets the data for a new track with data we received from the API request. +func (track *SoundTrack) Create(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + if user == nil { + return errors.New("Not logged in") + } + + track.ID = GenerateID("SoundTrack") + track.Created = DateTimeUTC() + track.CreatedBy = user.ID + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "create", "SoundTrack", track.ID, "", "", "") + logEntry.Save() + + return track.Unpublish() +} + +// Edit updates the external media object. +func (track *SoundTrack) 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", "SoundTrack", track.ID, key, fmt.Sprint(value.Interface()), fmt.Sprint(newValue.Interface())) + logEntry.Save() + + // Verify service name + if strings.HasPrefix(key, "Media[") && strings.HasSuffix(key, ".Service") { + newService := newValue.String() + found := false + + for _, option := range DataLists["media-services"] { + if option.Label == newService { + found = true + break + } + } + + if !found { + return true, errors.New("Invalid service name") + } + + value.SetString(newService) + return true, nil + } + + return false, nil +} + +// OnAppend saves a log entry. +func (track *SoundTrack) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(track, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (track *SoundTrack) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(track, ctx, key, index, obj) +} + +// DeleteInContext deletes the track in the given context. +func (track *SoundTrack) DeleteInContext(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "delete", "SoundTrack", track.ID, "", fmt.Sprint(track), "") + logEntry.Save() + + return track.Delete() +} + +// Delete deletes the object from the database. +func (track *SoundTrack) Delete() error { + if track.IsDraft { + draftIndex := track.Creator().DraftIndex() + draftIndex.SoundTrackID = "" + draftIndex.Save() + } + + for _, post := range track.Posts() { + err := post.Delete() + + if err != nil { + return err + } + } + + DB.Delete("SoundTrack", track.ID) + return nil +} + +// Authorize returns an error if the given API POST request is not authorized. +func (track *SoundTrack) 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 soundtrack object in the database. +func (track *SoundTrack) Save() { + DB.Set("SoundTrack", track.ID, track) +} diff --git a/arn/SoundTrackLyrics.go b/arn/SoundTrackLyrics.go new file mode 100644 index 00000000..a51581d0 --- /dev/null +++ b/arn/SoundTrackLyrics.go @@ -0,0 +1,8 @@ +package arn + +// SoundTrackLyrics represents song lyrics. +type SoundTrackLyrics struct { + Romaji string `json:"romaji" editable:"true" type:"textarea"` + Native string `json:"native" editable:"true" type:"textarea"` + // English string `json:"english" editable:"true" type:"textarea"` +} diff --git a/arn/SoundTrackTitle.go b/arn/SoundTrackTitle.go new file mode 100644 index 00000000..30261be9 --- /dev/null +++ b/arn/SoundTrackTitle.go @@ -0,0 +1,35 @@ +package arn + +// SoundTrackTitle represents a song title. +type SoundTrackTitle struct { + Canonical string `json:"canonical" editable:"true"` + Native string `json:"native" editable:"true"` +} + +// String is the default representation of the title. +func (title *SoundTrackTitle) String() string { + return title.ByUser(nil) +} + +// ByUser returns the preferred title for the given user. +func (title *SoundTrackTitle) 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/SoundTrackUtils.go b/arn/SoundTrackUtils.go new file mode 100644 index 00000000..7cad1a47 --- /dev/null +++ b/arn/SoundTrackUtils.go @@ -0,0 +1,26 @@ +package arn + +import ( + "errors" + "regexp" +) + +var youtubeIDRegex = regexp.MustCompile(`youtu(?:.*\/v\/|.*v=|\.be\/)([A-Za-z0-9_-]{11})`) + +// GetYoutubeMedia returns an ExternalMedia object for the given Youtube link. +func GetYoutubeMedia(url string) (*ExternalMedia, error) { + matches := youtubeIDRegex.FindStringSubmatch(url) + + if len(matches) < 2 { + return nil, errors.New("Invalid Youtube URL") + } + + videoID := matches[1] + + media := &ExternalMedia{ + Service: "Youtube", + ServiceID: videoID, + } + + return media, nil +} diff --git a/arn/Spoiler.go b/arn/Spoiler.go new file mode 100644 index 00000000..9254677a --- /dev/null +++ b/arn/Spoiler.go @@ -0,0 +1,11 @@ +package arn + +// Spoiler represents a text that can spoil a future event. +type Spoiler struct { + Text string `json:"text" editable:"true" type:"textarea"` +} + +// String returns the containing text. +func (spoiler *Spoiler) String() string { + return spoiler.Text +} diff --git a/arn/StatisticsCategory.go b/arn/StatisticsCategory.go new file mode 100644 index 00000000..65ea3f61 --- /dev/null +++ b/arn/StatisticsCategory.go @@ -0,0 +1,100 @@ +package arn + +import "fmt" + +// StatisticsCategory ... +type StatisticsCategory struct { + Name string `json:"name"` + PieCharts []*PieChart `json:"pieCharts"` +} + +// PieChart ... +type PieChart struct { + Title string `json:"title"` + Slices []*PieChartSlice `json:"slices"` +} + +// PieChartSlice ... +type PieChartSlice struct { + From float64 `json:"from"` + To float64 `json:"to"` + Title string `json:"title"` + Color string `json:"color"` +} + +// NewPieChart ... +func NewPieChart(title string, data map[string]float64) *PieChart { + return &PieChart{ + Title: title, + Slices: ToPieChartSlices(data), + } +} + +// ToPieChartSlices ... +func ToPieChartSlices(data map[string]float64) []*PieChartSlice { + if len(data) == 0 { + return nil + } + + dataSorted := []*AnalyticsItem{} + sum := 0.0 + + for key, value := range data { + sum += value + + item := &AnalyticsItem{ + Key: key, + Value: value, + } + + if len(dataSorted) == 0 { + dataSorted = append(dataSorted, item) + continue + } + + found := false + + for i := 0; i < len(dataSorted); i++ { + if value >= dataSorted[i].Value { + // Append empty element + dataSorted = append(dataSorted, nil) + + // Move all elements after index "i" 1 position up + copy(dataSorted[i+1:], dataSorted[i:]) + + // Set value for index "i" + dataSorted[i] = item + + // Set flag + found = true + + // Leave loop + break + } + } + + if !found { + dataSorted = append(dataSorted, item) + } + } + + slices := []*PieChartSlice{} + current := 0.0 + hueOffset := 230.0 + hueScaling := -30.0 + + for i, item := range dataSorted { + percentage := item.Value / sum + + slices = append(slices, &PieChartSlice{ + From: current, + To: current + percentage, + Title: fmt.Sprintf("%s (%d%%)", item.Key, int(percentage*100+0.5)), + Color: fmt.Sprintf("hsl(%.2f, 75%%, 50%%)", float64(i)*hueScaling+hueOffset), + }) + + current += percentage + } + + return slices +} diff --git a/arn/StatisticsItem.go b/arn/StatisticsItem.go new file mode 100644 index 00000000..176fa643 --- /dev/null +++ b/arn/StatisticsItem.go @@ -0,0 +1,7 @@ +package arn + +// AnalyticsItem ... +type AnalyticsItem struct { + Key string + Value float64 +} diff --git a/arn/Thread.go b/arn/Thread.go new file mode 100644 index 00000000..b79a112c --- /dev/null +++ b/arn/Thread.go @@ -0,0 +1,186 @@ +package arn + +import ( + "sort" + + "github.com/aerogo/markdown" + "github.com/aerogo/nano" +) + +// Thread is a forum thread. +type Thread struct { + Title string `json:"title" editable:"true"` + Sticky int `json:"sticky" editable:"true"` + Tags []string `json:"tags" editable:"true"` + Edited string `json:"edited"` + + hasID + hasText + hasPosts + hasCreator + hasLikes + hasLocked + + html string +} + +// Link returns the relative URL of the thread. +func (thread *Thread) Link() string { + return "/thread/" + thread.ID +} + +// HTML returns the HTML representation of the thread. +func (thread *Thread) HTML() string { + if thread.html != "" { + return thread.html + } + + thread.html = markdown.Render(thread.Text) + return thread.html +} + +// String implements the default string serialization. +func (thread *Thread) String() string { + return thread.Title +} + +// Parent always returns nil for threads. +func (thread *Thread) Parent() PostParent { + return nil +} + +// GetParentID always returns an empty string for threads. +func (thread *Thread) GetParentID() string { + return "" +} + +// TypeName returns the type name. +func (thread *Thread) TypeName() string { + return "Thread" +} + +// Self returns the object itself. +func (thread *Thread) Self() Loggable { + return thread +} + +// OnLike is called when the thread receives a like. +func (thread *Thread) OnLike(likedBy *User) { + if !thread.Creator().Settings().Notification.ForumLikes { + return + } + + go func() { + thread.Creator().SendNotification(&PushNotification{ + Title: likedBy.Nick + " liked your thread", + Message: likedBy.Nick + " liked your thread \"" + thread.Title + "\".", + Icon: "https:" + likedBy.AvatarLink("large"), + Link: "https://notify.moe" + likedBy.Link(), + Type: NotificationTypeLike, + }) + }() +} + +// OnLock is called when the thread is locked. +func (thread *Thread) OnLock(user *User) { + logEntry := NewEditLogEntry(user.ID, "edit", "Thread", thread.ID, "Locked", "false", "true") + logEntry.Save() +} + +// OnUnlock is called when the thread is unlocked. +func (thread *Thread) OnUnlock(user *User) { + logEntry := NewEditLogEntry(user.ID, "edit", "Thread", thread.ID, "Locked", "true", "false") + logEntry.Save() +} + +// TitleByUser returns the title of the thread, +// regardless of the user language settings +// because threads are bound to one language. +func (thread *Thread) TitleByUser(user *User) string { + return thread.Title +} + +// GetThread ... +func GetThread(id string) (*Thread, error) { + obj, err := DB.Get("Thread", id) + + if err != nil { + return nil, err + } + + return obj.(*Thread), nil +} + +// GetThreadsByTag ... +func GetThreadsByTag(tag string) []*Thread { + var threads []*Thread + allTags := (tag == "" || tag == "") + + for thread := range StreamThreads() { + if (allTags && !Contains(thread.Tags, "update")) || Contains(thread.Tags, tag) { + threads = append(threads, thread) + } + } + + return threads +} + +// GetThreadsByUser ... +func GetThreadsByUser(user *User) []*Thread { + var threads []*Thread + + for thread := range StreamThreads() { + if thread.CreatedBy == user.ID { + threads = append(threads, thread) + } + } + + return threads +} + +// StreamThreads ... +func StreamThreads() <-chan *Thread { + channel := make(chan *Thread, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("Thread") { + channel <- obj.(*Thread) + } + + close(channel) + }() + + return channel +} + +// AllThreads ... +func AllThreads() []*Thread { + all := make([]*Thread, 0, DB.Collection("Thread").Count()) + + for obj := range StreamThreads() { + all = append(all, obj) + } + + return all +} + +// SortThreads sorts a slice of threads for the forum view (stickies first). +func SortThreads(threads []*Thread) { + sort.Slice(threads, func(i, j int) bool { + a := threads[i] + b := threads[j] + + if a.Sticky != b.Sticky { + return a.Sticky > b.Sticky + } + + return a.Created > b.Created + }) +} + +// SortThreadsLatestFirst sorts a slice of threads by creation date. +func SortThreadsLatestFirst(threads []*Thread) { + sort.Slice(threads, func(i, j int) bool { + return threads[i].Created > threads[j].Created + }) +} diff --git a/arn/ThreadAPI.go b/arn/ThreadAPI.go new file mode 100644 index 00000000..d93c3703 --- /dev/null +++ b/arn/ThreadAPI.go @@ -0,0 +1,187 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + + "github.com/aerogo/aero" + "github.com/aerogo/api" + "github.com/aerogo/markdown" + "github.com/animenotifier/notify.moe/arn/autocorrect" +) + +// Force interface implementations +var ( + _ Postable = (*Thread)(nil) + _ Likeable = (*Thread)(nil) + _ LikeEventReceiver = (*Thread)(nil) + _ Lockable = (*Thread)(nil) + _ LockEventReceiver = (*Thread)(nil) + _ PostParent = (*Thread)(nil) + _ fmt.Stringer = (*Thread)(nil) + _ api.Newable = (*Thread)(nil) + _ api.Editable = (*Thread)(nil) + _ api.Actionable = (*Thread)(nil) + _ api.Deletable = (*Thread)(nil) +) + +// Actions +func init() { + API.RegisterActions("Thread", []*api.Action{ + // Like thread + LikeAction(), + + // Unlike thread + UnlikeAction(), + + // Lock thread + LockAction(), + + // Unlock thread + UnlockAction(), + }) +} + +// Authorize returns an error if the given API POST request is not authorized. +func (thread *Thread) 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 thread.CreatedBy != user.ID && user.Role != "admin" { + return errors.New("Can't edit the threads of other users") + } + } + + return nil +} + +// Create sets the data for a new thread with data we received from the API request. +func (thread *Thread) Create(ctx aero.Context) error { + data, err := ctx.Request().Body().JSONObject() + + if err != nil { + return err + } + + userID, ok := ctx.Session().Get("userId").(string) + + if !ok || userID == "" { + return errors.New("Not logged in") + } + + user, err := GetUser(userID) + + if err != nil { + return err + } + + thread.ID = GenerateID("Thread") + thread.Title, _ = data["title"].(string) + thread.Text, _ = data["text"].(string) + thread.CreatedBy = user.ID + thread.Sticky, _ = data["sticky"].(int) + thread.Created = DateTimeUTC() + thread.Edited = "" + + // Post-process text + thread.Title = autocorrect.ThreadTitle(thread.Title) + thread.Text = autocorrect.PostText(thread.Text) + + // Tags + tags, _ := data["tags"].([]interface{}) + thread.Tags = make([]string, len(tags)) + + for i := range thread.Tags { + thread.Tags[i] = tags[i].(string) + } + + if len(tags) < 1 { + return errors.New("Need to specify at least one tag") + } + + if len(thread.Title) < 10 { + return errors.New("Title too short: Should be at least 10 characters") + } + + if len(thread.Text) < 10 { + return errors.New("Text too short: Should be at least 10 characters") + } + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "create", "Thread", thread.ID, "", "", "") + logEntry.Save() + + // Create activity + activity := NewActivityCreate("Thread", thread.ID, user.ID) + activity.Save() + + return nil +} + +// Edit creates an edit log entry. +func (thread *Thread) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (consumed bool, err error) { + return edit(thread, ctx, key, value, newValue) +} + +// OnAppend saves a log entry. +func (thread *Thread) OnAppend(ctx aero.Context, key string, index int, obj interface{}) { + onAppend(thread, ctx, key, index, obj) +} + +// OnRemove saves a log entry. +func (thread *Thread) OnRemove(ctx aero.Context, key string, index int, obj interface{}) { + onRemove(thread, ctx, key, index, obj) +} + +// AfterEdit sets the edited date on the thread object. +func (thread *Thread) AfterEdit(ctx aero.Context) error { + thread.Edited = DateTimeUTC() + thread.html = markdown.Render(thread.Text) + return nil +} + +// Save saves the thread object in the database. +func (thread *Thread) Save() { + DB.Set("Thread", thread.ID, thread) +} + +// DeleteInContext deletes the thread in the given context. +func (thread *Thread) DeleteInContext(ctx aero.Context) error { + user := GetUserFromContext(ctx) + + // Write log entry + logEntry := NewEditLogEntry(user.ID, "delete", "Thread", thread.ID, "", fmt.Sprint(thread), "") + logEntry.Save() + + return thread.Delete() +} + +// Delete deletes the thread and its posts from the database. +func (thread *Thread) Delete() error { + for _, post := range thread.Posts() { + err := post.Delete() + + if err != nil { + return err + } + } + + // Remove activities + for activity := range StreamActivityCreates() { + if activity.ObjectID == thread.ID && activity.ObjectType == "Thread" { + err := activity.Delete() + + if err != nil { + return err + } + } + } + + DB.Delete("Thread", thread.ID) + return nil +} diff --git a/arn/TwitterToUser.go b/arn/TwitterToUser.go new file mode 100644 index 00000000..cbb6c2b6 --- /dev/null +++ b/arn/TwitterToUser.go @@ -0,0 +1,4 @@ +package arn + +// TwitterToUser stores the user ID by Twitter user ID. +type TwitterToUser GoogleToUser diff --git a/arn/UpcomingEpisode.go b/arn/UpcomingEpisode.go new file mode 100644 index 00000000..07b61aef --- /dev/null +++ b/arn/UpcomingEpisode.go @@ -0,0 +1,7 @@ +package arn + +// UpcomingEpisode is used in the user schedule. +type UpcomingEpisode struct { + Anime *Anime + Episode *AnimeEpisode +} diff --git a/arn/User.go b/arn/User.go new file mode 100644 index 00000000..5bcf282a --- /dev/null +++ b/arn/User.go @@ -0,0 +1,575 @@ +package arn + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/aerogo/aero" + + "github.com/aerogo/http/client" + "github.com/animenotifier/notify.moe/arn/autocorrect" + "github.com/animenotifier/notify.moe/arn/validate" + "github.com/animenotifier/ffxiv" + "github.com/animenotifier/osu" + "github.com/animenotifier/overwatch" + gravatar "github.com/ungerik/go-gravatar" +) + +var setNickMutex sync.Mutex +var setEmailMutex sync.Mutex + +// Register data lists. +func init() { + DataLists["genders"] = []*Option{ + // &Option{"", "Prefer not to say"}, + {"male", "Male"}, + {"female", "Female"}, + } +} + +// UserID represents a user ID. +type UserID = string + +// User is a registered person. +type User struct { + ID UserID `json:"id"` + Nick string `json:"nick" editable:"true"` + FirstName string `json:"firstName" private:"true"` + LastName string `json:"lastName" private:"true"` + Email string `json:"email" editable:"true" private:"true"` + Role string `json:"role"` + Registered string `json:"registered"` + LastLogin string `json:"lastLogin" private:"true"` + LastSeen string `json:"lastSeen" private:"true"` + ProExpires string `json:"proExpires" editable:"true"` + Gender string `json:"gender" editable:"true" private:"true" datalist:"genders"` + Language string `json:"language"` + Introduction string `json:"introduction" editable:"true" type:"textarea"` + Website string `json:"website" editable:"true"` + BirthDay string `json:"birthDay" editable:"true" private:"true"` + IP string `json:"ip" private:"true"` + UserAgent string `json:"agent" private:"true"` + Balance int `json:"balance" private:"true"` + Avatar UserAvatar `json:"avatar"` + Cover UserCover `json:"cover"` + Accounts UserAccounts `json:"accounts" private:"true"` + Browser UserBrowser `json:"browser" private:"true"` + OS UserOS `json:"os" private:"true"` + Location *Location `json:"location" private:"true"` + + hasPosts + + eventStreams struct { + sync.Mutex + value []*aero.EventStream + } +} + +// NewUser creates an empty user object with a unique ID. +func NewUser() *User { + user := &User{ + ID: GenerateID("User"), + + // Avoid nil value fields + Location: &Location{}, + } + + return user +} + +// RegisterUser registers a new user in the database and sets up all the required references. +func RegisterUser(user *User) { + user.Registered = DateTimeUTC() + user.LastLogin = user.Registered + user.LastSeen = user.Registered + + // Save nick in NickToUser collection + DB.Set("NickToUser", user.Nick, &NickToUser{ + Nick: user.Nick, + UserID: user.ID, + }) + + // Save email in EmailToUser collection + if user.Email != "" { + DB.Set("EmailToUser", user.Email, &EmailToUser{ + Email: user.Email, + UserID: user.ID, + }) + } + + // Create default settings + NewSettings(user).Save() + + // Add empty anime list + DB.Set("AnimeList", user.ID, &AnimeList{ + UserID: user.ID, + Items: []*AnimeListItem{}, + }) + + // Add empty inventory + NewInventory(user.ID).Save() + + // Add draft index + NewDraftIndex(user.ID).Save() + + // Add empty push subscriptions + DB.Set("PushSubscriptions", user.ID, &PushSubscriptions{ + UserID: user.ID, + Items: []*PushSubscription{}, + }) + + // Add empty follow list + NewUserFollows(user.ID).Save() + + // Add empty notifications list + NewUserNotifications(user.ID).Save() + + // Fetch gravatar + if user.Email != "" && !IsDevelopment() { + gravatarURL := gravatar.Url(user.Email) + "?s=" + fmt.Sprint(AvatarMaxSize) + "&d=404&r=pg" + gravatarURL = strings.Replace(gravatarURL, "http://", "https://", 1) + + response, err := client.Get(gravatarURL).End() + + if err == nil && response.StatusCode() == http.StatusOK { + data := response.Bytes() + err = user.SetAvatarBytes(data) + + if err != nil { + fmt.Println(err) + } + } + } +} + +// SendNotification accepts a PushNotification and generates a new Notification object. +// The notification is then sent to all registered push devices. +func (user *User) SendNotification(pushNotification *PushNotification) { + // Don't ever send notifications in development mode + if IsDevelopment() && user.ID != "4J6qpK1ve" { + return + } + + // Save notification in database + notification := NewNotification(user.ID, pushNotification) + notification.Save() + + userNotifications := user.Notifications() + err := userNotifications.Add(notification.ID) + + if err != nil { + fmt.Println(err) + return + } + + userNotifications.Save() + + // Send push notification + subs := user.PushSubscriptions() + expired := []*PushSubscription{} + + for _, sub := range subs.Items { + resp, err := sub.SendNotification(pushNotification) + + if resp != nil && resp.StatusCode == http.StatusGone { + expired = append(expired, sub) + continue + } + + // Print errors + if err != nil { + fmt.Println(err) + continue + } + + // Print bad status codes + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := ioutil.ReadAll(resp.Body) + fmt.Println(resp.StatusCode, string(body)) + continue + } + + sub.LastSuccess = DateTimeUTC() + } + + // Remove expired items + if len(expired) > 0 { + for _, sub := range expired { + subs.Remove(sub.ID()) + } + } + + // Save changes + subs.Save() + + // Send an event to the user's open tabs + user.BroadcastEvent(&aero.Event{ + Name: "notificationCount", + Data: userNotifications.CountUnseen(), + }) +} + +// RealName returns the real name of the user. +func (user *User) RealName() string { + if user.LastName == "" { + return user.FirstName + } + + if user.FirstName == "" { + return user.LastName + } + + return user.FirstName + " " + user.LastName +} + +// RegisteredTime returns the time the user registered his account. +func (user *User) RegisteredTime() time.Time { + reg, _ := time.Parse(time.RFC3339, user.Registered) + return reg +} + +// LastSeenTime returns the time the user was last seen on the site. +func (user *User) LastSeenTime() time.Time { + lastSeen, _ := time.Parse(time.RFC3339, user.LastSeen) + return lastSeen +} + +// AgeInYears returns the user's age in years. +func (user *User) AgeInYears() int { + return AgeInYears(user.BirthDay) +} + +// IsActive tells you whether the user is active. +func (user *User) IsActive() bool { + lastSeen, _ := time.Parse(time.RFC3339, user.LastSeen) + twoWeeksAgo := time.Now().Add(-14 * 24 * time.Hour) + + if lastSeen.Unix() < twoWeeksAgo.Unix() { + return false + } + + if len(user.AnimeList().Items) == 0 { + return false + } + + return true +} + +// IsPro returns whether the user is a PRO user or not. +func (user *User) IsPro() bool { + if user.ProExpires == "" { + return false + } + + return DateTimeUTC() < user.ProExpires +} + +// ExtendProDuration extends the PRO account duration by the given duration. +func (user *User) ExtendProDuration(duration time.Duration) { + now := time.Now().UTC() + expires, _ := time.Parse(time.RFC3339, user.ProExpires) + + // If the user never had a PRO account yet, + // or if it already expired, + // use the current time as the start time. + if user.ProExpires == "" || now.Unix() > expires.Unix() { + expires = now + } + + user.ProExpires = expires.Add(duration).Format(time.RFC3339) +} + +// TimeSinceRegistered returns the duration since the user registered his account. +func (user *User) TimeSinceRegistered() time.Duration { + registered, _ := time.Parse(time.RFC3339, user.Registered) + return time.Since(registered) +} + +// HasNick returns whether the user has a custom nickname. +func (user *User) HasNick() bool { + return !strings.HasPrefix(user.Nick, "g") && !strings.HasPrefix(user.Nick, "fb") && !strings.HasPrefix(user.Nick, "t") && user.Nick != "" +} + +// WebsiteURL adds https:// to the URL. +func (user *User) WebsiteURL() string { + return "https://" + user.WebsiteShortURL() +} + +// WebsiteShortURL returns the user website without the protocol. +func (user *User) WebsiteShortURL() string { + return strings.Replace(strings.Replace(user.Website, "https://", "", 1), "http://", "", 1) +} + +// Link returns the URI to the user page. +func (user *User) Link() string { + return "/+" + user.Nick +} + +// GetID returns the ID. +func (user *User) GetID() string { + return user.ID +} + +// HasAvatar tells you whether the user has an avatar or not. +func (user *User) HasAvatar() bool { + return user.Avatar.Extension != "" +} + +// AvatarLink returns the URL to the user avatar. +// Expects "small" (50 x 50) or "large" (560 x 560) for the size parameter. +func (user *User) AvatarLink(size string) string { + if user.HasAvatar() { + return fmt.Sprintf("//%s/images/avatars/%s/%s%s?%v", MediaHost, size, user.ID, user.Avatar.Extension, user.Avatar.LastModified) + } + + return fmt.Sprintf("//%s/images/elements/no-avatar.svg", MediaHost) +} + +// CoverLink ... +func (user *User) CoverLink(size string) string { + if user.Cover.Extension != "" && user.IsPro() { + return fmt.Sprintf("//%s/images/covers/%s/%s%s?%v", MediaHost, size, user.ID, user.Cover.Extension, user.Cover.LastModified) + } + + return "/images/elements/default-cover.jpg" +} + +// Gravatar returns the URL to the gravatar if an email has been registered. +func (user *User) Gravatar() string { + if user.Email == "" { + return "" + } + + return gravatar.SecureUrl(user.Email) + "?s=" + fmt.Sprint(AvatarMaxSize) +} + +// EditorScore returns the editor score. +func (user *User) EditorScore() int { + ignoreDifferences := FilterIgnoreAnimeDifferences(func(entry *IgnoreAnimeDifference) bool { + return entry.CreatedBy == user.ID + }) + + score := len(ignoreDifferences) * IgnoreAnimeDifferenceEditorScore + + logEntries := FilterEditLogEntries(func(entry *EditLogEntry) bool { + return entry.UserID == user.ID + }) + + for _, entry := range logEntries { + score += entry.EditorScore() + } + + return score +} + +// ActivateItemEffect activates an item in the user inventory by the given item ID. +func (user *User) ActivateItemEffect(itemID string) error { + month := 30 * 24 * time.Hour + + switch itemID { + case "pro-account-1": + user.ExtendProDuration(1 * month) + user.Save() + return nil + + case "pro-account-3": + user.ExtendProDuration(3 * month) + user.Save() + return nil + + case "pro-account-6": + user.ExtendProDuration(6 * month) + user.Save() + return nil + + case "pro-account-12": + user.ExtendProDuration(12 * month) + user.Save() + return nil + + case "pro-account-24": + user.ExtendProDuration(24 * month) + user.Save() + return nil + + default: + return errors.New("Can't activate unknown item: " + itemID) + } +} + +// SetNick changes the user's nickname safely. +func (user *User) SetNick(newName string) error { + setNickMutex.Lock() + defer setNickMutex.Unlock() + + newName = autocorrect.UserNick(newName) + + if !validate.Nick(newName) { + return errors.New("Invalid nickname") + } + + if newName == user.Nick { + return nil + } + + // Make sure the nickname doesn't exist already + _, err := GetUserByNick(newName) + + // If there was no error: the username exists. + // If "not found" is not included in the error message it's a different error type. + if err == nil || !strings.Contains(err.Error(), "not found") { + return errors.New("Username '" + newName + "' is taken already") + } + + user.ForceSetNick(newName) + return nil +} + +// ForceSetNick forces a nickname overwrite. +func (user *User) ForceSetNick(newName string) { + // Delete old nick reference + DB.Delete("NickToUser", user.Nick) + + // Set new nick + user.Nick = newName + + DB.Set("NickToUser", user.Nick, &NickToUser{ + Nick: user.Nick, + UserID: user.ID, + }) +} + +// CleanNick only returns the nickname if it was set by user, otherwise empty string. +func (user *User) CleanNick() string { + if user.HasNick() { + return user.Nick + } + + return "" +} + +// Creator needs to be implemented for the PostParent interface. +func (user *User) Creator() *User { + return user +} + +// CreatorID needs to be implemented for the PostParent interface. +func (user *User) CreatorID() UserID { + return user.ID +} + +// TitleByUser returns the username. +func (user *User) TitleByUser(viewUser *User) string { + return user.Nick +} + +// SetEmail changes the user's email safely. +func (user *User) SetEmail(newEmail string) error { + setEmailMutex.Lock() + defer setEmailMutex.Unlock() + + if !validate.Email(newEmail) { + return errors.New("Invalid email address") + } + + // Delete old email reference + DB.Delete("EmailToUser", user.Email) + + // Set new email + user.Email = newEmail + + DB.Set("EmailToUser", user.Email, &EmailToUser{ + Email: user.Email, + UserID: user.ID, + }) + + return nil +} + +// HasBasicInfo returns true if the user has a username, an avatar and an introduction. +func (user *User) HasBasicInfo() bool { + return user.HasAvatar() && user.HasNick() && user.Introduction != "" +} + +// TypeName returns the type name. +func (user *User) TypeName() string { + return "User" +} + +// Self returns the object itself. +func (user *User) Self() Loggable { + return user +} + +// RefreshOsuInfo refreshes a user's Osu information. +func (user *User) RefreshOsuInfo() error { + if user.Accounts.Osu.Nick == "" { + return nil + } + + osu, err := osu.GetUser(user.Accounts.Osu.Nick) + + if err != nil { + return err + } + + user.Accounts.Osu.PP, _ = strconv.ParseFloat(osu.PPRaw, 64) + user.Accounts.Osu.Level, _ = strconv.ParseFloat(osu.Level, 64) + user.Accounts.Osu.Accuracy, _ = strconv.ParseFloat(osu.Accuracy, 64) + + return nil +} + +// RefreshFFXIVInfo refreshes a user's FFXIV information. +func (user *User) RefreshFFXIVInfo() error { + if user.Accounts.FinalFantasyXIV.Nick == "" || user.Accounts.FinalFantasyXIV.Server == "" { + return nil + } + + characterID, err := ffxiv.GetCharacterID(user.Accounts.FinalFantasyXIV.Nick, user.Accounts.FinalFantasyXIV.Server) + + if err != nil { + return err + } + + character, err := ffxiv.GetCharacter(characterID) + + if err != nil { + return err + } + + user.Accounts.FinalFantasyXIV.Class = character.Class + user.Accounts.FinalFantasyXIV.Level = character.Level + user.Accounts.FinalFantasyXIV.ItemLevel = character.ItemLevel + + return nil +} + +// RefreshOverwatchInfo refreshes a user's Overwatch information. +func (user *User) RefreshOverwatchInfo() error { + if user.Accounts.Overwatch.BattleTag == "" { + return nil + } + + stats, err := overwatch.GetPlayerStats(user.Accounts.Overwatch.BattleTag) + + if err != nil { + return err + } + + skillRating, tier := stats.HighestSkillRating() + + // Only show career highest skill rating + if skillRating > user.Accounts.Overwatch.SkillRating { + user.Accounts.Overwatch.SkillRating = skillRating + user.Accounts.Overwatch.Tier = tier + } + + return nil +} diff --git a/arn/UserAPI.go b/arn/UserAPI.go new file mode 100644 index 00000000..f32b01cf --- /dev/null +++ b/arn/UserAPI.go @@ -0,0 +1,247 @@ +package arn + +import ( + "errors" + "fmt" + "reflect" + "strings" + + "github.com/animenotifier/notify.moe/arn/validate" + + "github.com/aerogo/aero" + "github.com/aerogo/api" + "github.com/aerogo/http/client" + "github.com/akyoto/color" + "github.com/animenotifier/notify.moe/arn/autocorrect" +) + +// Force interface implementations +var ( + _ PostParent = (*User)(nil) + _ api.Editable = (*User)(nil) +) + +// Authorize returns an error if the given API POST request is not authorized. +func (user *User) Authorize(ctx aero.Context, action string) error { + editor := GetUserFromContext(ctx) + + if editor == nil { + return errors.New("Not authorized") + } + + if editor.ID != ctx.Get("id") && editor.Role != "admin" { + return errors.New("Can not modify data from other users") + } + + return nil +} + +// Edit updates the user object. +func (user *User) Edit(ctx aero.Context, key string, value reflect.Value, newValue reflect.Value) (bool, error) { + switch key { + case "Nick": + newNick := newValue.String() + err := user.SetNick(newNick) + return true, err + + case "Email": + newEmail := newValue.String() + err := user.SetEmail(newEmail) + return true, err + + case "Gender": + newGender := newValue.String() + + if newGender != "male" && newGender != "female" { + return true, errors.New("Invalid gender") + } + + user.Gender = newGender + return true, nil + + case "Website": + newSite := newValue.String() + + if newSite == "" { + user.Website = newSite + return true, nil + } + + if autocorrect.IsTrackerLink(newSite) { + return true, errors.New("Not an actual personal website or homepage") + } + + newSite = autocorrect.Website(newSite) + + if !validate.URI("https://" + newSite) { + return true, errors.New("Not a valid website link") + } + + response, err := client.Get("https://" + newSite).End() + + if err != nil || response.StatusCode() >= 400 { + return true, fmt.Errorf("https://%s seems to be inaccessible", newSite) + } + + user.Website = newSite + return true, nil + + case "BirthDay": + newBirthDay := newValue.String() + + if AgeInYears(newBirthDay) <= 0 { + return true, errors.New("Invalid birthday (make sure to use YYYY-MM-DD format, e.g. 2000-01-17)") + } + + user.BirthDay = newBirthDay + return true, nil + + case "ProExpires": + user := GetUserFromContext(ctx) + + if user == nil || user.Role != "admin" { + return true, errors.New("Not authorized to edit") + } + + case "Accounts.Discord.Nick": + newNick := newValue.String() + + if newNick == "" { + value.SetString(newNick) + user.Accounts.Discord.Verified = false + return true, nil + } + + if !validate.DiscordNick(newNick) { + return true, errors.New("Discord username must include your name and the 4-digit Discord tag (e.g. Yandere#1234)") + } + + // Trim spaces + parts := strings.Split(newNick, "#") + parts[0] = strings.TrimSpace(parts[0]) + parts[1] = strings.TrimSpace(parts[1]) + newNick = strings.Join(parts, "#") + + if value.String() != newNick { + value.SetString(newNick) + user.Accounts.Discord.Verified = false + } + + return true, nil + + case "Accounts.Overwatch.BattleTag": + newBattleTag := newValue.String() + value.SetString(newBattleTag) + + if newBattleTag == "" { + user.Accounts.Overwatch.SkillRating = 0 + user.Accounts.Overwatch.Tier = "" + } else { + // Refresh Overwatch info if the battletag changed + go func() { + err := user.RefreshOverwatchInfo() + + if err != nil { + color.Red("Error refreshing Overwatch info of user '%s' with Overwatch battle tag '%s': %v", user.Nick, newBattleTag, err) + return + } + + color.Green("Refreshed Overwatch info of user '%s' with Overwatch battle tag '%s': %v", user.Nick, newBattleTag, user.Accounts.Overwatch.SkillRating) + user.Save() + }() + } + + return true, nil + + case "Accounts.FinalFantasyXIV.Nick", "Accounts.FinalFantasyXIV.Server": + newValue := newValue.String() + value.SetString(newValue) + + if newValue == "" { + user.Accounts.FinalFantasyXIV.Class = "" + user.Accounts.FinalFantasyXIV.Level = 0 + user.Accounts.FinalFantasyXIV.ItemLevel = 0 + } else if user.Accounts.FinalFantasyXIV.Nick != "" && user.Accounts.FinalFantasyXIV.Server != "" { + // Refresh FinalFantasyXIV info if the name or server changed + go func() { + err := user.RefreshFFXIVInfo() + + if err != nil { + color.Red("Error refreshing FinalFantasy XIV info of user '%s' with nick '%s' on server '%s': %v", user.Nick, user.Accounts.FinalFantasyXIV.Nick, user.Accounts.FinalFantasyXIV.Server, err) + return + } + + user.Save() + }() + } + + return true, nil + } + + // Automatically correct account nicks + if strings.HasPrefix(key, "Accounts.") && strings.HasSuffix(key, ".Nick") { + newNick := newValue.String() + newNick = autocorrect.AccountNick(newNick) + value.SetString(newNick) + + // Refresh osu info if the name changed + if key == "Accounts.Osu.Nick" { + if newNick == "" { + user.Accounts.Osu.PP = 0 + user.Accounts.Osu.Level = 0 + user.Accounts.Osu.Accuracy = 0 + } else { + go func() { + err := user.RefreshOsuInfo() + + if err != nil { + color.Red("Error refreshing osu info of user '%s' with osu nick '%s': %v", user.Nick, newNick, err) + return + } + + color.Green("Refreshed osu info of user '%s' with osu nick '%s': %v", user.Nick, newNick, user.Accounts.Osu.PP) + user.Save() + }() + } + } + + return true, nil + } + + return false, nil +} + +// Save saves the user object in the database. +func (user *User) Save() { + DB.Set("User", user.ID, user) +} + +// Filter removes privacy critical fields from the user object. +func (user *User) Filter() { + user.Email = "" + user.Gender = "" + user.FirstName = "" + user.LastName = "" + user.IP = "" + user.UserAgent = "" + user.LastLogin = "" + user.LastSeen = "" + user.Accounts.Facebook.ID = "" + user.Accounts.Google.ID = "" + user.Accounts.Twitter.ID = "" + user.BirthDay = "" + user.Location = &Location{} + user.Browser = UserBrowser{} + user.OS = UserOS{} +} + +// ShouldFilter tells whether data needs to be filtered in the given context. +func (user *User) ShouldFilter(ctx aero.Context) bool { + ctxUser := GetUserFromContext(ctx) + + if ctxUser != nil && ctxUser.Role == "admin" { + return false + } + + return true +} diff --git a/arn/UserAccounts.go b/arn/UserAccounts.go new file mode 100644 index 00000000..c54d6911 --- /dev/null +++ b/arn/UserAccounts.go @@ -0,0 +1,132 @@ +package arn + +// Register a list of gaming servers. +func init() { + DataLists["ffxiv-servers"] = []*Option{ + {"", ""}, + {"Adamantoise", "Adamantoise"}, + {"Aegis", "Aegis"}, + {"Alexander", "Alexander"}, + {"Anima", "Anima"}, + {"Asura", "Asura"}, + {"Atomos", "Atomos"}, + {"Bahamut", "Bahamut"}, + {"Balmung", "Balmung"}, + {"Behemoth", "Behemoth"}, + {"Belias", "Belias"}, + {"Brynhildr", "Brynhildr"}, + {"Cactuar", "Cactuar"}, + {"Carbuncle", "Carbuncle"}, + {"Cerberus", "Cerberus"}, + {"Chocobo", "Chocobo"}, + {"Coeurl", "Coeurl"}, + {"Diabolos", "Diabolos"}, + {"Durandal", "Durandal"}, + {"Excalibur", "Excalibur"}, + {"Exodus", "Exodus"}, + {"Faerie", "Faerie"}, + {"Famfrit", "Famfrit"}, + {"Fenrir", "Fenrir"}, + {"Garuda", "Garuda"}, + {"Gilgamesh", "Gilgamesh"}, + {"Goblin", "Goblin"}, + {"Gungnir", "Gungnir"}, + {"Hades", "Hades"}, + {"Hyperion", "Hyperion"}, + {"Ifrit", "Ifrit"}, + {"Ixion", "Ixion"}, + {"Jenova", "Jenova"}, + {"Kujata", "Kujata"}, + {"Lamia", "Lamia"}, + {"Leviathan", "Leviathan"}, + {"Lich", "Lich"}, + {"Louisoix", "Louisoix"}, + {"Malboro", "Malboro"}, + {"Mandragora", "Mandragora"}, + {"Masamune", "Masamune"}, + {"Mateus", "Mateus"}, + {"Midgardsormr", "Midgardsormr"}, + {"Moogle", "Moogle"}, + {"Odin", "Odin"}, + {"Omega", "Omega"}, + {"Pandaemonium", "Pandaemonium"}, + {"Phoenix", "Phoenix"}, + {"Ragnarok", "Ragnarok"}, + {"Ramuh", "Ramuh"}, + {"Ridill", "Ridill"}, + {"Sargatanas", "Sargatanas"}, + {"Shinryu", "Shinryu"}, + {"Shiva", "Shiva"}, + {"Siren", "Siren"}, + {"Tiamat", "Tiamat"}, + {"Titan", "Titan"}, + {"Tonberry", "Tonberry"}, + {"Typhon", "Typhon"}, + {"Ultima", "Ultima"}, + {"Ultros", "Ultros"}, + {"Unicorn", "Unicorn"}, + {"Valefor", "Valefor"}, + {"Yojimbo", "Yojimbo"}, + {"Zalera", "Zalera"}, + {"Zeromus", "Zeromus"}, + {"Zodiark", "Zodiark"}, + } +} + +// UserAccounts represents a user's accounts on external services. +type UserAccounts struct { + Facebook struct { + ID string `json:"id" private:"true"` + } `json:"facebook"` + + Google struct { + ID string `json:"id" private:"true"` + } `json:"google"` + + Twitter struct { + ID string `json:"id" private:"true"` + Nick string `json:"nick" private:"true"` + } `json:"twitter"` + + Discord struct { + Nick string `json:"nick" editable:"true"` + Verified bool `json:"verified"` + } `json:"discord"` + + Osu struct { + Nick string `json:"nick" editable:"true"` + PP float64 `json:"pp"` + Accuracy float64 `json:"accuracy"` + Level float64 `json:"level"` + } `json:"osu"` + + Overwatch struct { + BattleTag string `json:"battleTag" editable:"true"` + SkillRating int `json:"skillRating"` + Tier string `json:"tier"` + } `json:"overwatch"` + + FinalFantasyXIV struct { + Nick string `json:"nick" editable:"true"` + Server string `json:"server" editable:"true" datalist:"ffxiv-servers"` + Class string `json:"class"` + Level int `json:"level"` + ItemLevel int `json:"itemLevel"` + } `json:"ffxiv"` + + AniList struct { + Nick string `json:"nick" editable:"true"` + } `json:"anilist"` + + AnimePlanet struct { + Nick string `json:"nick" editable:"true"` + } `json:"animeplanet"` + + MyAnimeList struct { + Nick string `json:"nick" editable:"true"` + } `json:"myanimelist"` + + Kitsu struct { + Nick string `json:"nick" editable:"true"` + } `json:"kitsu"` +} diff --git a/arn/UserAvatar.go b/arn/UserAvatar.go new file mode 100644 index 00000000..88592280 --- /dev/null +++ b/arn/UserAvatar.go @@ -0,0 +1,100 @@ +package arn + +import ( + "bytes" + "image" + "path" + "time" + + "github.com/akyoto/imageserver" +) + +const ( + // AvatarSmallSize is the minimum size in pixels of an avatar. + AvatarSmallSize = 100 + + // AvatarMaxSize is the maximum size in pixels of an avatar. + AvatarMaxSize = 560 + + // AvatarWebPQuality is the WebP quality of avatars. + AvatarWebPQuality = 80 + + // AvatarJPEGQuality is the JPEG quality of avatars. + AvatarJPEGQuality = 80 +) + +// Define the avatar outputs +var avatarOutputs = []imageserver.Output{ + // Original - Large + &imageserver.OriginalFile{ + Directory: path.Join(Root, "images/avatars/large/"), + Width: AvatarMaxSize, + Height: AvatarMaxSize, + Quality: AvatarJPEGQuality, + }, + + // Original - Small + &imageserver.OriginalFile{ + Directory: path.Join(Root, "images/avatars/small/"), + Width: AvatarSmallSize, + Height: AvatarSmallSize, + Quality: AvatarJPEGQuality, + }, + + // WebP - Large + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/avatars/large/"), + Width: AvatarMaxSize, + Height: AvatarMaxSize, + Quality: AvatarWebPQuality, + }, + + // WebP - Small + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/avatars/small/"), + Width: AvatarSmallSize, + Height: AvatarSmallSize, + Quality: AvatarWebPQuality, + }, +} + +// UserAvatar ... +type UserAvatar struct { + Extension string `json:"extension"` + Source string `json:"source"` + LastModified int64 `json:"lastModified"` +} + +// SetAvatarBytes accepts a byte buffer that represents an image file and updates the avatar. +func (user *User) SetAvatarBytes(data []byte) error { + // Decode + img, format, err := image.Decode(bytes.NewReader(data)) + + if err != nil { + return err + } + + return user.SetAvatar(&imageserver.MetaImage{ + Image: img, + Format: format, + Data: data, + }) +} + +// SetAvatar sets the avatar to the given MetaImage. +func (user *User) SetAvatar(avatar *imageserver.MetaImage) error { + var lastError error + + // Save the different image formats and sizes + for _, output := range avatarOutputs { + err := output.Save(avatar, user.ID) + + if err != nil { + lastError = err + } + } + + user.Avatar.Extension = avatar.Extension() + user.Avatar.LastModified = time.Now().Unix() + return lastError +} diff --git a/arn/UserConnectAccounts.go b/arn/UserConnectAccounts.go new file mode 100644 index 00000000..087794c0 --- /dev/null +++ b/arn/UserConnectAccounts.go @@ -0,0 +1,43 @@ +package arn + +// ConnectGoogle connects the user's account with a Google account. +func (user *User) ConnectGoogle(googleID string) { + if googleID == "" { + return + } + + user.Accounts.Google.ID = googleID + + DB.Set("GoogleToUser", googleID, &GoogleToUser{ + ID: googleID, + UserID: user.ID, + }) +} + +// ConnectFacebook connects the user's account with a Facebook account. +func (user *User) ConnectFacebook(facebookID string) { + if facebookID == "" { + return + } + + user.Accounts.Facebook.ID = facebookID + + DB.Set("FacebookToUser", facebookID, &FacebookToUser{ + ID: facebookID, + UserID: user.ID, + }) +} + +// ConnectTwitter connects the user's account with a Twitter account. +func (user *User) ConnectTwitter(twtterID string) { + if twtterID == "" { + return + } + + user.Accounts.Twitter.ID = twtterID + + DB.Set("TwitterToUser", twtterID, &TwitterToUser{ + ID: twtterID, + UserID: user.ID, + }) +} diff --git a/arn/UserCover.go b/arn/UserCover.go new file mode 100644 index 00000000..2d42595e --- /dev/null +++ b/arn/UserCover.go @@ -0,0 +1,105 @@ +package arn + +import ( + "bytes" + "image" + "path" + "time" + + "github.com/akyoto/imageserver" +) + +const ( + // CoverMaxWidth is the maximum size for covers. + CoverMaxWidth = 1920 + + // CoverMaxHeight is the maximum height for covers. + CoverMaxHeight = 450 + + // CoverSmallWidth is the width used for mobile phones. + CoverSmallWidth = 640 + + // CoverSmallHeight is the height used for mobile phones. + CoverSmallHeight = 640 + + // CoverWebPQuality is the WebP quality of cover images. + CoverWebPQuality = AvatarWebPQuality + + // CoverJPEGQuality is the JPEG quality of cover images. + CoverJPEGQuality = CoverWebPQuality +) + +// Define the cover image outputs +var coverImageOutputs = []imageserver.Output{ + // JPEG - Large + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/covers/large/"), + Width: CoverMaxWidth, + Height: CoverMaxHeight, + Quality: CoverJPEGQuality, + }, + + // JPEG - Small + &imageserver.JPEGFile{ + Directory: path.Join(Root, "images/covers/small/"), + Width: CoverSmallWidth, + Height: CoverSmallHeight, + Quality: CoverJPEGQuality, + }, + + // WebP - Large + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/covers/large/"), + Width: CoverMaxWidth, + Height: CoverMaxHeight, + Quality: CoverWebPQuality, + }, + + // WebP - Small + &imageserver.WebPFile{ + Directory: path.Join(Root, "images/covers/small/"), + Width: CoverSmallWidth, + Height: CoverSmallHeight, + Quality: CoverWebPQuality, + }, +} + +// UserCover ... +type UserCover struct { + Extension string `json:"extension"` + LastModified int64 `json:"lastModified"` +} + +// SetCoverBytes accepts a byte buffer that represents an image file and updates the cover image. +func (user *User) SetCoverBytes(data []byte) error { + // Decode + img, format, err := image.Decode(bytes.NewReader(data)) + + if err != nil { + return err + } + + return user.SetCover(&imageserver.MetaImage{ + Image: img, + Format: format, + Data: data, + }) +} + +// SetCover sets the cover image to the given MetaImage. +func (user *User) SetCover(cover *imageserver.MetaImage) error { + var lastError error + + // Save the different image formats and sizes + for _, output := range coverImageOutputs { + err := output.Save(cover, user.ID) + + if err != nil { + lastError = err + } + } + + user.Cover.Extension = ".jpg" + user.Cover.LastModified = time.Now().Unix() + return lastError +} diff --git a/arn/UserEvents.go b/arn/UserEvents.go new file mode 100644 index 00000000..90cbc0a5 --- /dev/null +++ b/arn/UserEvents.go @@ -0,0 +1,46 @@ +package arn + +import "github.com/aerogo/aero" + +// // EventStreams returns the user's active event streams. +// func (user *User) EventStreams() []*aero.EventStream { +// return user.eventStreams +// } + +// AddEventStream adds an event stream to the given user. +func (user *User) AddEventStream(stream *aero.EventStream) { + user.eventStreams.Lock() + defer user.eventStreams.Unlock() + + user.eventStreams.value = append(user.eventStreams.value, stream) +} + +// RemoveEventStream removes an event stream from the given user +// and returns true if it was removed, otherwise false. +func (user *User) RemoveEventStream(stream *aero.EventStream) bool { + user.eventStreams.Lock() + defer user.eventStreams.Unlock() + + for index, element := range user.eventStreams.value { + if element == stream { + user.eventStreams.value = append(user.eventStreams.value[:index], user.eventStreams.value[index+1:]...) + return true + } + } + + return false +} + +// BroadcastEvent sends the given event to all event streams for the given user. +func (user *User) BroadcastEvent(event *aero.Event) { + user.eventStreams.Lock() + defer user.eventStreams.Unlock() + + for _, stream := range user.eventStreams.value { + // Non-blocking send because we don't know if our listeners are still active. + select { + case stream.Events <- event: + default: + } + } +} diff --git a/arn/UserFollows.go b/arn/UserFollows.go new file mode 100644 index 00000000..4d0cfdb7 --- /dev/null +++ b/arn/UserFollows.go @@ -0,0 +1,158 @@ +package arn + +import ( + "errors" + + "github.com/aerogo/nano" +) + +// UserFollows is a list including IDs to users you follow. +type UserFollows struct { + UserID UserID `json:"userId"` + Items []string `json:"items"` +} + +// NewUserFollows creates a new UserFollows list. +func NewUserFollows(userID UserID) *UserFollows { + return &UserFollows{ + UserID: userID, + Items: []string{}, + } +} + +// Add adds an user to the list if it hasn't been added yet. +func (list *UserFollows) Add(userID UserID) error { + if userID == list.UserID { + return errors.New("You can't follow yourself") + } + + if list.Contains(userID) { + return errors.New("User " + userID + " has already been added") + } + + list.Items = append(list.Items, userID) + + // Send notification + user, err := GetUser(userID) + + if err == nil { + if !user.Settings().Notification.NewFollowers { + return nil + } + + follower, err := GetUser(list.UserID) + + if err == nil { + user.SendNotification(&PushNotification{ + Title: "You have a new follower!", + Message: follower.Nick + " started following you.", + Icon: "https:" + follower.AvatarLink("large"), + Link: "https://notify.moe" + follower.Link(), + Type: NotificationTypeFollow, + }) + } + } + + return nil +} + +// Remove removes the user ID from the list. +func (list *UserFollows) Remove(userID UserID) bool { + for index, item := range list.Items { + if item == userID { + list.Items = append(list.Items[:index], list.Items[index+1:]...) + return true + } + } + + return false +} + +// Contains checks if the list contains the user ID already. +func (list *UserFollows) Contains(userID UserID) bool { + for _, item := range list.Items { + if item == userID { + return true + } + } + + return false +} + +// Users returns a slice of all the users you are following. +func (list *UserFollows) Users() []*User { + followsObj := DB.GetMany("User", list.Items) + follows := make([]*User, len(followsObj)) + + for i, obj := range followsObj { + follows[i] = obj.(*User) + } + + return follows +} + +// UsersWhoFollowBack returns a slice of all the users you are following that also follow you. +func (list *UserFollows) UsersWhoFollowBack() []*User { + followsObj := DB.GetMany("User", list.Items) + friends := make([]*User, 0, len(followsObj)) + + for _, obj := range followsObj { + friend := obj.(*User) + + if Contains(friend.Follows().Items, list.UserID) { + friends = append(friends, friend) + } + } + + return friends +} + +// UserFollowerCountMap returns a map of user ID keys and their corresping number of followers as the value. +func UserFollowerCountMap() map[string]int { + followCount := map[string]int{} + + for list := range StreamUserFollows() { + for _, followUserID := range list.Items { + followCount[followUserID]++ + } + } + + return followCount +} + +// GetUserFollows ... +func GetUserFollows(id UserID) (*UserFollows, error) { + obj, err := DB.Get("UserFollows", id) + + if err != nil { + return nil, err + } + + return obj.(*UserFollows), nil +} + +// StreamUserFollows returns a stream of all user follows. +func StreamUserFollows() <-chan *UserFollows { + channel := make(chan *UserFollows, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("UserFollows") { + channel <- obj.(*UserFollows) + } + + close(channel) + }() + + return channel +} + +// AllUserFollows returns a slice of all user follows. +func AllUserFollows() ([]*UserFollows, error) { + all := make([]*UserFollows, 0, DB.Collection("UserFollows").Count()) + + for obj := range StreamUserFollows() { + all = append(all, obj) + } + + return all, nil +} diff --git a/arn/UserFollowsAPI.go b/arn/UserFollowsAPI.go new file mode 100644 index 00000000..a90a2f64 --- /dev/null +++ b/arn/UserFollowsAPI.go @@ -0,0 +1,33 @@ +package arn + +import ( + "github.com/aerogo/aero" + "github.com/aerogo/api" +) + +// Force interface implementations +var ( + _ IDCollection = (*UserFollows)(nil) + _ api.Editable = (*UserFollows)(nil) +) + +// Actions +func init() { + API.RegisterActions("UserFollows", []*api.Action{ + // Add follow + AddAction(), + + // Remove follow + RemoveAction(), + }) +} + +// Authorize returns an error if the given API request is not authorized. +func (list *UserFollows) Authorize(ctx aero.Context, action string) error { + return AuthorizeIfLoggedInAndOwnData(ctx, "id") +} + +// Save saves the follow list in the database. +func (list *UserFollows) Save() { + DB.Set("UserFollows", list.UserID, list) +} diff --git a/arn/UserHelper.go b/arn/UserHelper.go new file mode 100644 index 00000000..687fcd75 --- /dev/null +++ b/arn/UserHelper.go @@ -0,0 +1,166 @@ +package arn + +import ( + "errors" + "sort" + + "github.com/aerogo/nano" +) + +// GetUser fetches the user with the given ID from the database. +func GetUser(id UserID) (*User, error) { + obj, err := DB.Get("User", id) + + if err != nil { + return nil, err + } + + return obj.(*User), nil +} + +// GetUserByNick fetches the user with the given nick from the database. +func GetUserByNick(nick string) (*User, error) { + obj, err := DB.Get("NickToUser", nick) + + if err != nil { + return nil, err + } + + userID := obj.(*NickToUser).UserID + user, err := GetUser(userID) + + return user, err +} + +// GetUserByEmail fetches the user with the given email from the database. +func GetUserByEmail(email string) (*User, error) { + if email == "" { + return nil, errors.New("Email is empty") + } + + obj, err := DB.Get("EmailToUser", email) + + if err != nil { + return nil, err + } + + userID := obj.(*EmailToUser).UserID + user, err := GetUser(userID) + + return user, err +} + +// GetUserByFacebookID fetches the user with the given Facebook ID from the database. +func GetUserByFacebookID(facebookID string) (*User, error) { + obj, err := DB.Get("FacebookToUser", facebookID) + + if err != nil { + return nil, err + } + + userID := obj.(*FacebookToUser).UserID + user, err := GetUser(userID) + + return user, err +} + +// GetUserByTwitterID fetches the user with the given Twitter ID from the database. +func GetUserByTwitterID(twitterID string) (*User, error) { + obj, err := DB.Get("TwitterToUser", twitterID) + + if err != nil { + return nil, err + } + + userID := obj.(*TwitterToUser).UserID + user, err := GetUser(userID) + + return user, err +} + +// GetUserByGoogleID fetches the user with the given Google ID from the database. +func GetUserByGoogleID(googleID string) (*User, error) { + obj, err := DB.Get("GoogleToUser", googleID) + + if err != nil { + return nil, err + } + + userID := obj.(*GoogleToUser).UserID + user, err := GetUser(userID) + + return user, err +} + +// StreamUsers returns a stream of all users. +func StreamUsers() <-chan *User { + channel := make(chan *User, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("User") { + channel <- obj.(*User) + } + + close(channel) + }() + + return channel +} + +// AllUsers returns a slice of all users. +func AllUsers() ([]*User, error) { + all := make([]*User, 0, DB.Collection("User").Count()) + + for obj := range StreamUsers() { + all = append(all, obj) + } + + return all, nil +} + +// FilterUsers filters all users by a custom function. +func FilterUsers(filter func(*User) bool) []*User { + var filtered []*User + + for obj := range StreamUsers() { + if filter(obj) { + filtered = append(filtered, obj) + } + } + + return filtered +} + +// SortUsersLastSeenFirst sorts a list of users by their last seen date. +func SortUsersLastSeenFirst(users []*User) { + sort.Slice(users, func(i, j int) bool { + return users[i].LastSeen > users[j].LastSeen + }) +} + +// SortUsersLastSeenLast sorts a list of users by their last seen date. +func SortUsersLastSeenLast(users []*User) { + sort.Slice(users, func(i, j int) bool { + return users[i].LastSeen < users[j].LastSeen + }) +} + +// SortUsersFollowers sorts a list of users by their number of followers. +func SortUsersFollowers(users []*User) { + followCount := UserFollowerCountMap() + + sort.Slice(users, func(i, j int) bool { + if users[i].HasAvatar() != users[j].HasAvatar() { + return users[i].HasAvatar() + } + + followersA := followCount[users[i].ID] + followersB := followCount[users[j].ID] + + if followersA == followersB { + return users[i].Nick < users[j].Nick + } + + return followersA > followersB + }) +} diff --git a/arn/UserJoins.go b/arn/UserJoins.go new file mode 100644 index 00000000..1d8504c0 --- /dev/null +++ b/arn/UserJoins.go @@ -0,0 +1,102 @@ +package arn + +// Threads ... +func (user *User) Threads() []*Thread { + threads := GetThreadsByUser(user) + return threads +} + +// // Posts ... +// func (user *User) Posts() []*Post { +// posts, _ := GetPostsByUser(user) +// return posts +// } + +// Settings ... +func (user *User) Settings() *Settings { + settings, _ := GetSettings(user.ID) + return settings +} + +// Analytics ... +func (user *User) Analytics() *Analytics { + analytics, _ := GetAnalytics(user.ID) + return analytics +} + +// AnimeList ... +func (user *User) AnimeList() *AnimeList { + animeList, _ := GetAnimeList(user.ID) + return animeList +} + +// PushSubscriptions ... +func (user *User) PushSubscriptions() *PushSubscriptions { + subs, _ := GetPushSubscriptions(user.ID) + return subs +} + +// Inventory ... +func (user *User) Inventory() *Inventory { + inventory, _ := GetInventory(user.ID) + return inventory +} + +// Follows returns the list of user follows. +func (user *User) Follows() *UserFollows { + follows, _ := GetUserFollows(user.ID) + return follows +} + +// Notifications returns the list of user notifications. +func (user *User) Notifications() *UserNotifications { + notifications, _ := GetUserNotifications(user.ID) + return notifications +} + +// Followers ... +func (user *User) Followers() []*User { + var followerIDs []string + + for list := range StreamUserFollows() { + if list.Contains(user.ID) { + followerIDs = append(followerIDs, list.UserID) + } + } + + usersObj := DB.GetMany("User", followerIDs) + users := make([]*User, len(usersObj)) + + for i, obj := range usersObj { + users[i] = obj.(*User) + } + + return users +} + +// FollowersCount ... +func (user *User) FollowersCount() int { + count := 0 + + for list := range StreamUserFollows() { + if list.Contains(user.ID) { + count++ + } + } + + return count +} + +// DraftIndex ... +func (user *User) DraftIndex() *DraftIndex { + draftIndex, _ := GetDraftIndex(user.ID) + return draftIndex +} + +// SoundTracks returns the soundtracks posted by the user. +func (user *User) SoundTracks() []*SoundTrack { + tracks := FilterSoundTracks(func(track *SoundTrack) bool { + return !track.IsDraft && len(track.Media) > 0 && track.CreatedBy == user.ID + }) + return tracks +} diff --git a/arn/UserNotifications.go b/arn/UserNotifications.go new file mode 100644 index 00000000..d021438e --- /dev/null +++ b/arn/UserNotifications.go @@ -0,0 +1,121 @@ +package arn + +import ( + "errors" + + "github.com/aerogo/nano" +) + +// UserNotifications is a list including IDs to your notifications. +type UserNotifications struct { + UserID UserID `json:"userId"` + Items []string `json:"items"` +} + +// NewUserNotifications creates a new UserNotifications list. +func NewUserNotifications(userID UserID) *UserNotifications { + return &UserNotifications{ + UserID: userID, + Items: []string{}, + } +} + +// CountUnseen returns the number of unseen notifications. +func (list *UserNotifications) CountUnseen() int { + notifications := list.Notifications() + unseen := 0 + + for _, notification := range notifications { + if notification.Seen == "" { + unseen++ + } + } + + return unseen +} + +// Add adds an user to the list if it hasn't been added yet. +func (list *UserNotifications) Add(notificationID string) error { + if list.Contains(notificationID) { + return errors.New("Notification " + notificationID + " has already been added") + } + + list.Items = append(list.Items, notificationID) + return nil +} + +// Remove removes the notification ID from the list. +func (list *UserNotifications) Remove(notificationID string) bool { + for index, item := range list.Items { + if item == notificationID { + list.Items = append(list.Items[:index], list.Items[index+1:]...) + return true + } + } + + return false +} + +// Contains checks if the list contains the notification ID already. +func (list *UserNotifications) Contains(notificationID string) bool { + for _, item := range list.Items { + if item == notificationID { + return true + } + } + + return false +} + +// Notifications returns a slice of all the notifications. +func (list *UserNotifications) Notifications() []*Notification { + notificationsObj := DB.GetMany("Notification", list.Items) + notifications := []*Notification{} + + for _, obj := range notificationsObj { + notification, ok := obj.(*Notification) + + if ok { + notifications = append(notifications, notification) + } + } + + return notifications +} + +// GetUserNotifications ... +func GetUserNotifications(id UserID) (*UserNotifications, error) { + obj, err := DB.Get("UserNotifications", id) + + if err != nil { + return nil, err + } + + return obj.(*UserNotifications), nil +} + +// StreamUserNotifications returns a stream of all user notifications. +func StreamUserNotifications() <-chan *UserNotifications { + channel := make(chan *UserNotifications, nano.ChannelBufferSize) + + go func() { + for obj := range DB.All("UserNotifications") { + channel <- obj.(*UserNotifications) + } + + close(channel) + }() + + return channel +} + +// AllUserNotifications returns a slice of all user notifications. +func AllUserNotifications() ([]*UserNotifications, error) { + all := make([]*UserNotifications, 0, DB.Collection("UserNotifications").Count()) + + for obj := range StreamUserNotifications() { + all = append(all, obj) + } + + return all, nil +} diff --git a/arn/UserNotificationsAPI.go b/arn/UserNotificationsAPI.go new file mode 100644 index 00000000..21aea41e --- /dev/null +++ b/arn/UserNotificationsAPI.go @@ -0,0 +1,11 @@ +package arn + +// Force interface implementations +var ( + _ IDCollection = (*UserNotifications)(nil) +) + +// Save saves the notification list in the database. +func (list *UserNotifications) Save() { + DB.Set("UserNotifications", list.UserID, list) +} diff --git a/arn/UserSubTypes.go b/arn/UserSubTypes.go new file mode 100644 index 00000000..de452e3e --- /dev/null +++ b/arn/UserSubTypes.go @@ -0,0 +1,42 @@ +package arn + +// UserBrowser ... +type UserBrowser struct { + Name string `json:"name" private:"true"` + Version string `json:"version" private:"true"` + IsMobile bool `json:"isMobile" private:"true"` +} + +// UserOS ... +type UserOS struct { + Name string `json:"name" private:"true"` + Version string `json:"version" private:"true"` +} + +// UserListProviders ... +type UserListProviders struct { + AniList ListProviderConfig `json:"AniList"` + AnimePlanet ListProviderConfig `json:"AnimePlanet"` + HummingBird ListProviderConfig `json:"HummingBird"` + MyAnimeList ListProviderConfig `json:"MyAnimeList"` +} + +// ListProviderConfig ... +type ListProviderConfig struct { + UserName string `json:"userName"` +} + +// PushEndpoint ... +type PushEndpoint struct { + Registered string `json:"registered"` + Keys struct { + P256DH string `json:"p256dh" private:"true"` + Auth string `json:"auth" private:"true"` + } `json:"keys"` +} + +// CSSPosition ... +type CSSPosition struct { + X string `json:"x"` + Y string `json:"y"` +} diff --git a/arn/User_test.go b/arn/User_test.go new file mode 100644 index 00000000..ac926a5b --- /dev/null +++ b/arn/User_test.go @@ -0,0 +1,25 @@ +package arn_test + +import ( + "strings" + "testing" + + "github.com/animenotifier/notify.moe/arn" + "github.com/stretchr/testify/assert" +) + +func TestNewUser(t *testing.T) { + user := arn.NewUser() + + assert.NotNil(t, user) + assert.NotEmpty(t, user.ID) +} + +func TestDatabaseErrorMessages(t *testing.T) { + _, err := arn.GetUser("NON EXISTENT USER ID") + + // We need to make sure that non-existent records return "not found" in the error message. + assert.NotNil(t, err) + assert.NotEmpty(t, err.Error()) + assert.NotEqual(t, -1, strings.Index(err.Error(), "not found")) +} diff --git a/arn/Utils.go b/arn/Utils.go new file mode 100644 index 00000000..85d5d6bd --- /dev/null +++ b/arn/Utils.go @@ -0,0 +1,379 @@ +package arn + +import ( + "errors" + "flag" + "fmt" + "os" + "path" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "github.com/aerogo/aero" + "github.com/aerogo/mirror" + "github.com/akyoto/color" + "github.com/animenotifier/kitsu" + "github.com/animenotifier/mal" + jsoniter "github.com/json-iterator/go" + shortid "github.com/ventu-io/go-shortid" +) + +var ( + // MediaHost is the host we use to link image files. + MediaHost = "media.notify.moe" + + // Regular expressions + stripTagsRegex = regexp.MustCompile(`<[^>]*>`) + sourceRegex = regexp.MustCompile(`\(Source: (.*?)\)`) + writtenByRegex = regexp.MustCompile(`\[Written by (.*?)\]`) +) + +// GenerateID generates a unique ID for a given collection. +func GenerateID(collection string) string { + id, _ := shortid.Generate() + + // Retry until we find an unused ID + retry := 0 + + for { + _, err := DB.Get(collection, id) + + if err != nil && strings.Contains(err.Error(), "not found") { + return id + } + + retry++ + + if retry > 10 { + panic(errors.New("Can't generate unique ID")) + } + + id, _ = shortid.Generate() + } +} + +// GetUserFromContext returns the logged in user for the given context. +func GetUserFromContext(ctx aero.Context) *User { + if !ctx.HasSession() { + return nil + } + + userID := ctx.Session().GetString("userId") + + if userID == "" { + return nil + } + + user, err := GetUser(userID) + + if err != nil { + return nil + } + + return user +} + +// GetObjectTitle ... +func GetObjectTitle(typeName string, id string) string { + obj, err := DB.Get(typeName, id) + + if err != nil { + return fmt.Sprintf("", id) + } + + return fmt.Sprint(obj) +} + +// GetObjectLink ... +func GetObjectLink(typeName string, id string) string { + obj, err := DB.Get(typeName, id) + + if err != nil { + return fmt.Sprintf("", id) + } + + linkable, ok := obj.(Linkable) + + if ok { + return linkable.Link() + } + + return "/" + strings.ToLower(typeName) + "/" + id +} + +// FilterIDTags returns all IDs of the given type in the tag list. +func FilterIDTags(tags []string, idType string) []string { + var idList []string + prefix := idType + ":" + + for _, tag := range tags { + if strings.HasPrefix(tag, prefix) { + id := strings.TrimPrefix(tag, prefix) + idList = append(idList, id) + } + } + + return idList +} + +// AgeInYears returns the person's age in years. +func AgeInYears(birthDayString string) int { + birthDay, err := time.Parse("2006-01-02", birthDayString) + + if err != nil { + return 0 + } + + now := time.Now() + years := now.Year() - birthDay.Year() + + if now.YearDay() < birthDay.YearDay() { + years-- + } + + return years +} + +// JSON turns the object into a JSON string. +func JSON(obj interface{}) string { + data, err := jsoniter.Marshal(obj) + + if err == nil { + return string(data) + } + + return err.Error() +} + +// SetObjectProperties updates the object with the given map[string]interface{} +func SetObjectProperties(rootObj interface{}, updates map[string]interface{}) error { + for key, value := range updates { + field, _, v, err := mirror.GetField(rootObj, key) + + if err != nil { + return err + } + + // Is somebody attempting to edit fields that aren't editable? + if field.Tag.Get("editable") != "true" { + return errors.New("Field " + key + " is not editable") + } + + newValue := reflect.ValueOf(value) + + // Implement special data type cases here + if v.Kind() == reflect.Int { + x := int64(newValue.Float()) + + if !v.OverflowInt(x) { + v.SetInt(x) + } + } else { + v.Set(newValue) + } + } + + return nil +} + +// GetGenreIDByName ... +func GetGenreIDByName(genre string) string { + genre = strings.Replace(genre, "-", "", -1) + genre = strings.Replace(genre, " ", "", -1) + genre = strings.ToLower(genre) + return genre +} + +// FixAnimeDescription ... +func FixAnimeDescription(description string) string { + description = stripTagsRegex.ReplaceAllString(description, "") + description = sourceRegex.ReplaceAllString(description, "") + description = writtenByRegex.ReplaceAllString(description, "") + return strings.TrimSpace(description) +} + +// FixGender ... +func FixGender(gender string) string { + if gender != "male" && gender != "female" { + return "" + } + + return gender +} + +// DateToSeason returns the season of the year for the given date. +func DateToSeason(date time.Time) string { + month := date.Month() + + if month >= 4 && month <= 6 { + return "spring" + } + + if month >= 7 && month <= 9 { + return "summer" + } + + if month >= 10 && month <= 12 { + return "autumn" + } + + if month >= 1 && month < 4 { + return "winter" + } + + return "" +} + +// BroadcastEvent sends the given event to the event streams of all users. +func BroadcastEvent(event *aero.Event) { + for user := range StreamUsers() { + user.BroadcastEvent(event) + } +} + +// AnimeRatingStars displays the rating in Unicode stars. +func AnimeRatingStars(rating float64) string { + stars := int(rating/20 + 0.5) + return strings.Repeat("★", stars) + strings.Repeat("☆", 5-stars) +} + +// EpisodesToString shows a question mark if the episode count is zero. +func EpisodesToString(episodes int) string { + if episodes == 0 { + return "?" + } + + return fmt.Sprint(episodes) +} + +// EpisodeCountMax is used for the max value of number input on episodes. +func EpisodeCountMax(episodes int) string { + if episodes == 0 { + return "" + } + + return strconv.Itoa(episodes) +} + +// DateTimeUTC returns the current UTC time in RFC3339 format. +func DateTimeUTC() string { + return time.Now().UTC().Format(time.RFC3339) +} + +// OverallRatingName returns Overall in general, but Hype when episodes watched is zero. +func OverallRatingName(episodes int) string { + if episodes == 0 { + return "Hype" + } + + return "Overall" +} + +// IsIPv6 tells you whether the given address is IPv6 encoded. +func IsIPv6(ip string) bool { + for i := 0; i < len(ip); i++ { + if ip[i] == ':' { + return true + } + } + + return false +} + +// MyAnimeListStatusToARNStatus ... +func MyAnimeListStatusToARNStatus(status int) string { + switch status { + case mal.AnimeListStatusCompleted: + return AnimeListStatusCompleted + case mal.AnimeListStatusWatching: + return AnimeListStatusWatching + case mal.AnimeListStatusPlanned: + return AnimeListStatusPlanned + case mal.AnimeListStatusHold: + return AnimeListStatusHold + case mal.AnimeListStatusDropped: + return AnimeListStatusDropped + default: + return "" + } +} + +// KitsuStatusToARNStatus ... +func KitsuStatusToARNStatus(status string) string { + switch status { + case kitsu.AnimeListStatusCompleted: + return AnimeListStatusCompleted + case kitsu.AnimeListStatusWatching: + return AnimeListStatusWatching + case kitsu.AnimeListStatusPlanned: + return AnimeListStatusPlanned + case kitsu.AnimeListStatusHold: + return AnimeListStatusHold + case kitsu.AnimeListStatusDropped: + return AnimeListStatusDropped + default: + return "" + } +} + +// ListItemStatusName ... +func ListItemStatusName(status string) string { + switch status { + case AnimeListStatusWatching: + return "Watching" + case AnimeListStatusCompleted: + return "Completed" + case AnimeListStatusPlanned: + return "Planned" + case AnimeListStatusHold: + return "On hold" + case AnimeListStatusDropped: + return "Dropped" + default: + return "" + } +} + +// IsTest returns true if the program is currently running in the "go test" tool. +func IsTest() bool { + return flag.Lookup("test.v") != nil +} + +// PanicOnError will panic if the error is not nil. +func PanicOnError(err error) { + if err != nil { + panic(err) + } +} + +// deleteImages deletes images in the given folder. +func deleteImages(folderName string, id string, originalExtension string) { + if originalExtension == "" { + return + } + + err := os.Remove(path.Join(Root, "images", folderName, "original", id+originalExtension)) + + 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", folderName, "large", id+".jpg")) + os.Remove(path.Join(Root, "images", folderName, "large", id+"@2.jpg")) + os.Remove(path.Join(Root, "images", folderName, "large", id+".webp")) + os.Remove(path.Join(Root, "images", folderName, "large", id+"@2.webp")) + os.Remove(path.Join(Root, "images", folderName, "medium", id+".jpg")) + os.Remove(path.Join(Root, "images", folderName, "medium", id+"@2.jpg")) + os.Remove(path.Join(Root, "images", folderName, "medium", id+".webp")) + os.Remove(path.Join(Root, "images", folderName, "medium", id+"@2.webp")) + os.Remove(path.Join(Root, "images", folderName, "small", id+".jpg")) + os.Remove(path.Join(Root, "images", folderName, "small", id+"@2.jpg")) + os.Remove(path.Join(Root, "images", folderName, "small", id+".webp")) + os.Remove(path.Join(Root, "images", folderName, "small", id+"@2.webp")) +} diff --git a/arn/autocorrect/AutoCorrect.go b/arn/autocorrect/AutoCorrect.go new file mode 100644 index 00000000..ccf591a4 --- /dev/null +++ b/arn/autocorrect/AutoCorrect.go @@ -0,0 +1,119 @@ +package autocorrect + +import ( + "regexp" + "strings" +) + +const maxNickLength = 25 + +var fixNickRegex = regexp.MustCompile(`[\W\s\d]`) + +var accountNickRegexes = []*regexp.Regexp{ + regexp.MustCompile(`anilist.co/user/(.*)`), + regexp.MustCompile(`anilist.co/animelist/(.*)`), + regexp.MustCompile(`kitsu.io/users/(.*?)/library`), + regexp.MustCompile(`kitsu.io/users/(.*)`), + regexp.MustCompile(`anime-planet.com/users/(.*?)/anime`), + regexp.MustCompile(`anime-planet.com/users/(.*)`), + regexp.MustCompile(`myanimelist.net/profile/(.*)`), + regexp.MustCompile(`myanimelist.net/animelist/(.*?)\?`), + regexp.MustCompile(`myanimelist.net/animelist/(.*)`), + regexp.MustCompile(`myanimelist.net/(.*)`), + regexp.MustCompile(`myanimelist.com/(.*)`), + regexp.MustCompile(`twitter.com/(.*)`), + regexp.MustCompile(`osu.ppy.sh/u/(.*)`), +} + +var animeLinkRegex = regexp.MustCompile(`notify.moe/anime/(\d+)`) +var osuBeatmapRegex = regexp.MustCompile(`osu.ppy.sh/s/(\d+)`) + +// Tag converts links to correct tags automatically. +func Tag(tag string) string { + tag = strings.TrimSpace(tag) + tag = strings.TrimSuffix(tag, "/") + + // Anime + matches := animeLinkRegex.FindStringSubmatch(tag) + + if len(matches) > 1 { + return "anime:" + matches[1] + } + + // Osu beatmap + matches = osuBeatmapRegex.FindStringSubmatch(tag) + + if len(matches) > 1 { + return "osu-beatmap:" + matches[1] + } + + return tag +} + +// UserNick automatically corrects a username. +func UserNick(nick string) string { + nick = fixNickRegex.ReplaceAllString(nick, "") + + if nick == "" { + return nick + } + + nick = strings.Trim(nick, "_") + + if nick == "" { + return "" + } + + if len(nick) > maxNickLength { + nick = nick[:maxNickLength] + } + + return strings.ToUpper(string(nick[0])) + nick[1:] +} + +// AccountNick automatically corrects the username/nick of an account. +func AccountNick(nick string) string { + for _, regex := range accountNickRegexes { + matches := regex.FindStringSubmatch(nick) + + if len(matches) > 1 { + nick = matches[1] + return nick + } + } + + return nick +} + +// PostText fixes common mistakes in post texts. +func PostText(text string) string { + text = strings.Replace(text, "http://", "https://", -1) + text = strings.TrimSpace(text) + return text +} + +// ThreadTitle ... +func ThreadTitle(title string) string { + return strings.TrimSpace(title) +} + +// Website fixed common website mistakes. +func Website(url string) string { + // Disallow links that aren't actual websites, + // just tracker links. + if IsTrackerLink(url) { + return "" + } + + url = strings.TrimSpace(url) + url = strings.TrimPrefix(url, "http://") + url = strings.TrimPrefix(url, "https://") + url = strings.TrimSuffix(url, "/") + + return url +} + +// IsTrackerLink returns true if the URL is a tracker link. +func IsTrackerLink(url string) bool { + return strings.Contains(url, "myanimelist.net/") || strings.Contains(url, "anilist.co/") || strings.Contains(url, "kitsu.io/") || strings.Contains(url, "kissanime.") +} diff --git a/arn/autocorrect/AutoCorrect_test.go b/arn/autocorrect/AutoCorrect_test.go new file mode 100644 index 00000000..b023d32f --- /dev/null +++ b/arn/autocorrect/AutoCorrect_test.go @@ -0,0 +1,43 @@ +package autocorrect_test + +import ( + "testing" + + "github.com/animenotifier/notify.moe/arn/autocorrect" + "github.com/stretchr/testify/assert" +) + +func TestFixUserNick(t *testing.T) { + // Nickname autocorrect + assert.True(t, autocorrect.UserNick("Akyoto") == "Akyoto") + assert.True(t, autocorrect.UserNick("Tsundere") == "Tsundere") + assert.True(t, autocorrect.UserNick("akyoto") == "Akyoto") + assert.True(t, autocorrect.UserNick("aky123oto") == "Akyoto") + assert.True(t, autocorrect.UserNick("__aky123oto%$§") == "Akyoto") + assert.True(t, autocorrect.UserNick("__aky123oto%$§__") == "Akyoto") + assert.True(t, autocorrect.UserNick("123%&/(__%") == "") +} + +func TestFixAccountNick(t *testing.T) { + // Nickname autocorrect + assert.True(t, autocorrect.AccountNick("UserName") == "UserName") + assert.True(t, autocorrect.AccountNick("anilist.co/user/UserName") == "UserName") + assert.True(t, autocorrect.AccountNick("https://anilist.co/user/UserName") == "UserName") + assert.True(t, autocorrect.AccountNick("osu.ppy.sh/u/UserName") == "UserName") + assert.True(t, autocorrect.AccountNick("kitsu.io/users/UserName/library") == "UserName") +} + +func TestFixTag(t *testing.T) { + // Nickname autocorrect + assert.Equal(t, autocorrect.Tag("general"), "general") + assert.Equal(t, autocorrect.Tag("https://notify.moe/anime/244"), "anime:244") + assert.Equal(t, autocorrect.Tag("https://notify.moe/anime/244/"), "anime:244") + assert.Equal(t, autocorrect.Tag("https://osu.ppy.sh/s/320118"), "osu-beatmap:320118") +} + +func TestFixWebsite(t *testing.T) { + // Website autocorrect + assert.Equal(t, autocorrect.Website("http://websi.te"), "websi.te") + assert.Equal(t, autocorrect.Website("https://websi.te"), "websi.te") + assert.Equal(t, autocorrect.Website("http://myanimelist.net/profile/patcho"), "") +} diff --git a/arn/autodocs/Type.go b/arn/autodocs/Type.go new file mode 100644 index 00000000..93864ee4 --- /dev/null +++ b/arn/autodocs/Type.go @@ -0,0 +1,80 @@ +package autodocs + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// Type represents a type in a Go source file. +type Type struct { + Name string + Comment string + LineNumber int +} + +// Endpoint returns the REST endpoint for that type. +func (typ *Type) Endpoint() string { + return "/api/" + strings.ToLower(typ.Name) + "/" +} + +// GitHubLink returns link to display the type in GitHub. +func (typ *Type) GitHubLink() string { + return fmt.Sprintf("https://github.com/animenotifier/notify.moe/tree/go/arn/blob/go/%s.go#L%d", typ.Name, typ.LineNumber) +} + +// GetTypeDocumentation tries to gather documentation about the given type. +func GetTypeDocumentation(typeName string, filePath string) (*Type, error) { + typ := &Type{ + Name: typeName, + } + + file, err := os.Open(filePath) + + if err != nil { + return typ, err + } + + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNumber := 0 + + var comments []string + + for scanner.Scan() { + lineNumber++ + + line := scanner.Text() + line = strings.TrimSpace(line) + isComment := strings.HasPrefix(line, "// ") + + if isComment { + comment := strings.TrimPrefix(line, "// ") + comments = append(comments, comment) + continue + } + + if strings.HasPrefix(line, "type ") { + definedTypeName := strings.TrimPrefix(line, "type ") + space := strings.Index(definedTypeName, " ") + definedTypeName = definedTypeName[:space] + + if definedTypeName == typeName { + typ.Comment = strings.Join(comments, " ") + typ.LineNumber = lineNumber + } + } + + if !isComment { + comments = nil + } + } + + if err := scanner.Err(); err != nil { + return typ, err + } + + return typ, nil +} diff --git a/arn/mailer/mailer.go b/arn/mailer/mailer.go new file mode 100644 index 00000000..3b71cede --- /dev/null +++ b/arn/mailer/mailer.go @@ -0,0 +1,20 @@ +package mailer + +import ( + "github.com/animenotifier/notify.moe/arn" + gomail "gopkg.in/gomail.v2" +) + +// SendEmailNotification sends an e-mail notification. +func SendEmailNotification(email string, notification *arn.PushNotification) error { + m := gomail.NewMessage() + m.SetHeader("From", arn.APIKeys.SMTP.Address) + m.SetHeader("To", email) + m.SetHeader("Subject", notification.Title) + m.SetBody("text/html", "

"+notification.Message+"

Anime cover image

") + + d := gomail.NewDialer(arn.APIKeys.SMTP.Server, 587, arn.APIKeys.SMTP.Address, arn.APIKeys.SMTP.Password) + + // Send the email + return d.DialAndSend(m) +} diff --git a/arn/osutils/osutils.go b/arn/osutils/osutils.go new file mode 100644 index 00000000..682263f3 --- /dev/null +++ b/arn/osutils/osutils.go @@ -0,0 +1,20 @@ +package osutils + +import ( + "os" +) + +// Exists tells you whether the given file or directory exists. +func Exists(filePath string) bool { + _, err := os.Stat(filePath) + return !os.IsNotExist(err) +} + +// // WaitExists will wait until the given file path exists. +// func WaitExists(filePath string, timeout time.Duration, pollingInterval time.Duration) { +// start := time.Now() + +// for !Exists(filePath) && time.Since(start) < timeout { +// time.Sleep(pollingInterval) +// } +// } diff --git a/arn/search/AMVs.go b/arn/search/AMVs.go new file mode 100644 index 00000000..9dc51b29 --- /dev/null +++ b/arn/search/AMVs.go @@ -0,0 +1,66 @@ +package search + +import ( + "sort" + "strings" + + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" +) + +// AMVs searches all anime music videos. +func AMVs(originalTerm string, maxLength int) []*arn.AMV { + term := strings.ToLower(stringutils.RemoveSpecialCharacters(originalTerm)) + results := make([]*Result, 0, maxLength) + + for amv := range arn.StreamAMVs() { + if amv.ID == originalTerm { + return []*arn.AMV{amv} + } + + if amv.IsDraft { + continue + } + + text := strings.ToLower(amv.Title.Canonical) + similarity := stringutils.AdvancedStringSimilarity(term, text) + + if similarity >= MinimumStringSimilarity { + results = append(results, &Result{ + obj: amv, + similarity: similarity, + }) + continue + } + + text = strings.ToLower(amv.Title.Native) + similarity = stringutils.AdvancedStringSimilarity(term, text) + + if similarity >= MinimumStringSimilarity { + results = append(results, &Result{ + obj: amv, + similarity: similarity, + }) + continue + } + } + + // Sort + sort.Slice(results, func(i, j int) bool { + return results[i].similarity > results[j].similarity + }) + + // Limit + if len(results) >= maxLength { + results = results[:maxLength] + } + + // Final list + final := make([]*arn.AMV, len(results)) + + for i, result := range results { + final[i] = result.obj.(*arn.AMV) + } + + return final +} diff --git a/arn/search/All.go b/arn/search/All.go new file mode 100644 index 00000000..3e85a5f5 --- /dev/null +++ b/arn/search/All.go @@ -0,0 +1,56 @@ +package search + +import ( + "github.com/aerogo/flow" + "github.com/animenotifier/notify.moe/arn" +) + +// MinimumStringSimilarity is the minimum JaroWinkler distance we accept for search results. +const MinimumStringSimilarity = 0.89 + +// popularityDamping reduces the factor of popularity in search results. +const popularityDamping = 0.0009 + +// Result ... +type Result struct { + obj interface{} + similarity float64 +} + +// All is a fuzzy search. +func All(term string, maxUsers, maxAnime, maxPosts, maxThreads, maxTracks, maxCharacters, maxAMVs, maxCompanies int) ([]*arn.User, []*arn.Anime, []*arn.Post, []*arn.Thread, []*arn.SoundTrack, []*arn.Character, []*arn.AMV, []*arn.Company) { + if term == "" { + return nil, nil, nil, nil, nil, nil, nil, nil + } + + var ( + userResults []*arn.User + animeResults []*arn.Anime + postResults []*arn.Post + threadResults []*arn.Thread + trackResults []*arn.SoundTrack + characterResults []*arn.Character + amvResults []*arn.AMV + companyResults []*arn.Company + ) + + flow.Parallel(func() { + userResults = Users(term, maxUsers) + }, func() { + animeResults = Anime(term, maxAnime) + }, func() { + postResults = Posts(term, maxPosts) + }, func() { + threadResults = Threads(term, maxThreads) + }, func() { + trackResults = SoundTracks(term, maxTracks) + }, func() { + characterResults = Characters(term, maxCharacters) + }, func() { + amvResults = AMVs(term, maxAMVs) + }, func() { + companyResults = Companies(term, maxCompanies) + }) + + return userResults, animeResults, postResults, threadResults, trackResults, characterResults, amvResults, companyResults +} diff --git a/arn/search/Anime.go b/arn/search/Anime.go new file mode 100644 index 00000000..b8e43bcf --- /dev/null +++ b/arn/search/Anime.go @@ -0,0 +1,101 @@ +package search + +import ( + "sort" + "strings" + + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" +) + +// Anime searches all anime. +func Anime(originalTerm string, maxLength int) []*arn.Anime { + term := strings.ToLower(stringutils.RemoveSpecialCharacters(originalTerm)) + results := make([]*Result, 0, maxLength) + + check := func(text string) float64 { + if text == "" { + return 0 + } + + return stringutils.AdvancedStringSimilarity(term, strings.ToLower(stringutils.RemoveSpecialCharacters(text))) + } + + add := func(anime *arn.Anime, similarity float64) { + similarity += float64(anime.Popularity.Total()) * popularityDamping + + results = append(results, &Result{ + obj: anime, + similarity: similarity, + }) + } + + for anime := range arn.StreamAnime() { + if anime.ID == originalTerm { + return []*arn.Anime{anime} + } + + // Canonical title + similarity := check(anime.Title.Canonical) + + if similarity >= MinimumStringSimilarity { + add(anime, similarity) + continue + } + + // English + similarity = check(anime.Title.English) + + if similarity >= MinimumStringSimilarity { + add(anime, similarity) + continue + } + + // Romaji + similarity = check(anime.Title.Romaji) + + if similarity >= MinimumStringSimilarity { + add(anime, similarity) + continue + } + + // Synonyms + for _, synonym := range anime.Title.Synonyms { + similarity := check(synonym) + + if similarity >= MinimumStringSimilarity { + add(anime, similarity) + goto nextAnime + } + } + + // Japanese + similarity = check(anime.Title.Japanese) + + if similarity >= MinimumStringSimilarity { + add(anime, similarity) + continue + } + + nextAnime: + } + + // Sort + sort.Slice(results, func(i, j int) bool { + return results[i].similarity > results[j].similarity + }) + + // Limit + if len(results) >= maxLength { + results = results[:maxLength] + } + + // Final list + final := make([]*arn.Anime, len(results)) + + for i, result := range results { + final[i] = result.obj.(*arn.Anime) + } + + return final +} diff --git a/arn/search/Anime_test.go b/arn/search/Anime_test.go new file mode 100644 index 00000000..936e6dba --- /dev/null +++ b/arn/search/Anime_test.go @@ -0,0 +1,55 @@ +package search_test + +import ( + "testing" + + "github.com/animenotifier/notify.moe/arn/search" + "github.com/stretchr/testify/assert" +) + +// Run these search terms and expect the +// anime ID on the right as first result. +var tests = map[string]string{ + "lucky star": "Pg9BcFmig", // Lucky☆Star + "dragn bll": "hbih5KmmR", // Dragon Ball + "dragon ball": "hbih5KmmR", // Dragon Ball + "dragon ball z": "ir-05Fmmg", // Dragon Ball Z + "masotan": "grdNhFiiR", // Hisone to Maso-tan + "akame": "iEaTpFiig", // Akame ga Kill! + "kimi": "7VjCpFiiR", // Kimi no Na wa. + "working": "0iIgtFimg", // Working!! + "k on": "LP8j5Kmig", // K-On! + "ko n": "LP8j5Kmig", // K-On! + "kon": "LP8j5Kmig", // K-On! + "danmachi": "LTTPtKmiR", // Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka + "sword oratoria": "ifGetFmig", // Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka Gaiden: Sword Oratoria + "gint": "QAZ1cKmig", // Gintama + "k": "EDSOtKmig", // K + "champloo": "0ER25Fiig", // Samurai Champloo + "one peace": "jdZp5KmiR", // One Piece + "howl": "CpmTcFmig", // Howl's Moving Castle + "howl's": "CpmTcFmig", // Howl's Moving Castle + "howls": "CpmTcFmig", // Howl's Moving Castle + "fate stay": "74y2cFiiR", // Fate/stay night + "fate night": "74y2cFiiR", // Fate/stay night + "stay night": "74y2cFiiR", // Fate/stay night + "re zero": "Un9XpFimg", // Re:Zero kara Hajimeru Isekai Seikatsu +} + +func TestAnimeSearch(t *testing.T) { + for term, expectedAnimeID := range tests { + results := search.Anime(term, 1) + assert.Len(t, results, 1, "%s -> %s", term, expectedAnimeID) + assert.Equal(t, expectedAnimeID, results[0].ID, "%s -> %s", term, expectedAnimeID) + } +} + +func BenchmarkAnimeSearch(b *testing.B) { + b.ReportAllocs() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + search.Anime("drgon bll", 1) + } + }) +} diff --git a/arn/search/Characters.go b/arn/search/Characters.go new file mode 100644 index 00000000..c2fff3da --- /dev/null +++ b/arn/search/Characters.go @@ -0,0 +1,112 @@ +package search + +import ( + "sort" + "strings" + + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" +) + +// Characters searches all characters. +func Characters(originalTerm string, maxLength int) []*arn.Character { + if maxLength == 0 { + return nil + } + + term := strings.ToLower(stringutils.RemoveSpecialCharacters(originalTerm)) + termHasUnicode := stringutils.ContainsUnicodeLetters(term) + results := make([]*Result, 0, maxLength) + + for character := range arn.StreamCharacters() { + if character.ID == originalTerm { + return []*arn.Character{character} + } + + if character.Image.Extension == "" { + continue + } + + // Canonical + text := strings.ToLower(stringutils.RemoveSpecialCharacters(character.Name.Canonical)) + + if text == term { + results = append(results, &Result{ + obj: character, + similarity: float64(20 + len(character.Likes)), + }) + continue + } + + spaceCount := 0 + start := 0 + found := false + + for i := 0; i <= len(text); i++ { + if i == len(text) || text[i] == ' ' { + part := text[start:i] + + if part == term { + results = append(results, &Result{ + obj: character, + similarity: float64(10 - spaceCount*5 + len(character.Likes)), + }) + + found = true + break + } + + start = i + 1 + spaceCount++ + } + } + + if found { + continue + } + + // Japanese + if termHasUnicode { + if strings.Contains(character.Name.Japanese, term) { + results = append(results, &Result{ + obj: character, + similarity: float64(len(character.Likes)), + }) + continue + } + } + } + + // Sort + sort.Slice(results, func(i, j int) bool { + similarityA := results[i].similarity + similarityB := results[j].similarity + + if similarityA == similarityB { + characterA := results[i].obj.(*arn.Character) + characterB := results[j].obj.(*arn.Character) + + if characterA.Name.Canonical == characterB.Name.Canonical { + return characterA.ID < characterB.ID + } + + return characterA.Name.Canonical < characterB.Name.Canonical + } + + return similarityA > similarityB + }) + + // Limit + if len(results) >= maxLength { + results = results[:maxLength] + } + + // Final list + final := make([]*arn.Character, len(results)) + + for i, result := range results { + final[i] = result.obj.(*arn.Character) + } + + return final +} diff --git a/arn/search/Companies.go b/arn/search/Companies.go new file mode 100644 index 00000000..6c6fdd3c --- /dev/null +++ b/arn/search/Companies.go @@ -0,0 +1,54 @@ +package search + +import ( + "sort" + "strings" + + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" +) + +// Companies searches all companies. +func Companies(originalTerm string, maxLength int) []*arn.Company { + term := strings.ToLower(stringutils.RemoveSpecialCharacters(originalTerm)) + results := make([]*Result, 0, maxLength) + + for company := range arn.StreamCompanies() { + if company.ID == originalTerm { + return []*arn.Company{company} + } + + if company.IsDraft { + continue + } + + text := strings.ToLower(stringutils.RemoveSpecialCharacters(company.Name.English)) + similarity := stringutils.AdvancedStringSimilarity(term, text) + + if similarity >= MinimumStringSimilarity { + results = append(results, &Result{ + obj: company, + similarity: similarity, + }) + } + } + + // Sort + sort.Slice(results, func(i, j int) bool { + return results[i].similarity > results[j].similarity + }) + + // Limit + if len(results) >= maxLength { + results = results[:maxLength] + } + + // Final list + final := make([]*arn.Company, len(results)) + + for i, result := range results { + final[i] = result.obj.(*arn.Company) + } + + return final +} diff --git a/arn/search/Posts.go b/arn/search/Posts.go new file mode 100644 index 00000000..93054ceb --- /dev/null +++ b/arn/search/Posts.go @@ -0,0 +1,41 @@ +package search + +import ( + "sort" + "strings" + + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" +) + +// Posts searches all posts. +func Posts(originalTerm string, maxLength int) []*arn.Post { + term := strings.ToLower(stringutils.RemoveSpecialCharacters(originalTerm)) + results := make([]*arn.Post, 0, maxLength) + + for post := range arn.StreamPosts() { + if post.ID == originalTerm { + return []*arn.Post{post} + } + + text := strings.ToLower(post.Text) + + if !strings.Contains(text, term) { + continue + } + + results = append(results, post) + } + + // Sort + sort.Slice(results, func(i, j int) bool { + return results[i].Created > results[j].Created + }) + + // Limit + if len(results) >= maxLength { + results = results[:maxLength] + } + + return results +} diff --git a/arn/search/SoundTracks.go b/arn/search/SoundTracks.go new file mode 100644 index 00000000..d2c212dc --- /dev/null +++ b/arn/search/SoundTracks.go @@ -0,0 +1,66 @@ +package search + +import ( + "sort" + "strings" + + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" +) + +// SoundTracks searches all soundtracks. +func SoundTracks(originalTerm string, maxLength int) []*arn.SoundTrack { + term := strings.ToLower(stringutils.RemoveSpecialCharacters(originalTerm)) + results := make([]*Result, 0, maxLength) + + for track := range arn.StreamSoundTracks() { + if track.ID == originalTerm { + return []*arn.SoundTrack{track} + } + + if track.IsDraft { + continue + } + + text := strings.ToLower(track.Title.Canonical) + similarity := stringutils.AdvancedStringSimilarity(term, text) + + if similarity >= MinimumStringSimilarity { + results = append(results, &Result{ + obj: track, + similarity: similarity, + }) + continue + } + + text = strings.ToLower(track.Title.Native) + similarity = stringutils.AdvancedStringSimilarity(term, text) + + if similarity >= MinimumStringSimilarity { + results = append(results, &Result{ + obj: track, + similarity: similarity, + }) + continue + } + } + + // Sort + sort.Slice(results, func(i, j int) bool { + return results[i].similarity > results[j].similarity + }) + + // Limit + if len(results) >= maxLength { + results = results[:maxLength] + } + + // Final list + final := make([]*arn.SoundTrack, len(results)) + + for i, result := range results { + final[i] = result.obj.(*arn.SoundTrack) + } + + return final +} diff --git a/arn/search/Threads.go b/arn/search/Threads.go new file mode 100644 index 00000000..12967bb4 --- /dev/null +++ b/arn/search/Threads.go @@ -0,0 +1,47 @@ +package search + +import ( + "sort" + "strings" + + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" +) + +// Threads searches all threads. +func Threads(originalTerm string, maxLength int) []*arn.Thread { + term := strings.ToLower(stringutils.RemoveSpecialCharacters(originalTerm)) + results := make([]*arn.Thread, 0, maxLength) + + for thread := range arn.StreamThreads() { + if thread.ID == originalTerm { + return []*arn.Thread{thread} + } + + text := strings.ToLower(thread.Text) + + if strings.Contains(text, term) { + results = append(results, thread) + continue + } + + text = strings.ToLower(thread.Title) + + if strings.Contains(text, term) { + results = append(results, thread) + continue + } + } + + // Sort + sort.Slice(results, func(i, j int) bool { + return results[i].Created > results[j].Created + }) + + // Limit + if len(results) >= maxLength { + results = results[:maxLength] + } + + return results +} diff --git a/arn/search/Users.go b/arn/search/Users.go new file mode 100644 index 00000000..50aaf768 --- /dev/null +++ b/arn/search/Users.go @@ -0,0 +1,54 @@ +package search + +import ( + "sort" + "strings" + + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" +) + +// Users searches all users. +func Users(originalTerm string, maxLength int) []*arn.User { + term := strings.ToLower(stringutils.RemoveSpecialCharacters(originalTerm)) + results := make([]*Result, 0, maxLength) + + for user := range arn.StreamUsers() { + if user.ID == originalTerm { + return []*arn.User{user} + } + + text := strings.ToLower(user.Nick) + + // Similarity check + similarity := stringutils.AdvancedStringSimilarity(term, text) + + if similarity < MinimumStringSimilarity { + continue + } + + results = append(results, &Result{ + obj: user, + similarity: similarity, + }) + } + + // Sort + sort.Slice(results, func(i, j int) bool { + return results[i].similarity > results[j].similarity + }) + + // Limit + if len(results) >= maxLength { + results = results[:maxLength] + } + + // Final list + final := make([]*arn.User, len(results)) + + for i, result := range results { + final[i] = result.obj.(*arn.User) + } + + return final +} diff --git a/arn/stringutils/StringUtils.go b/arn/stringutils/StringUtils.go new file mode 100644 index 00000000..8d998cb8 --- /dev/null +++ b/arn/stringutils/StringUtils.go @@ -0,0 +1,111 @@ +package stringutils + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" + + jsoniter "github.com/json-iterator/go" + "github.com/xrash/smetrics" +) + +var whitespace = rune(' ') + +// RemoveSpecialCharacters ... +func RemoveSpecialCharacters(s string) string { + return strings.Map( + func(r rune) rune { + if r == '-' { + return -1 + } + + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return whitespace + } + + return r + }, + s, + ) +} + +// AdvancedStringSimilarity is like StringSimilarity but boosts the value if a appears directly in b. +func AdvancedStringSimilarity(a string, b string) float64 { + if a == b { + return 10000000 + } + + normalizedA := strings.Map(keepLettersAndDigits, a) + normalizedB := strings.Map(keepLettersAndDigits, b) + + if normalizedA == normalizedB { + return 100000 + } + + s := StringSimilarity(a, b) + + if strings.Contains(normalizedB, normalizedA) { + s += 0.6 + + if strings.HasPrefix(b, a) { + s += 5.0 + } + } + + return s +} + +// StringSimilarity returns 1.0 if the strings are equal and goes closer to 0 when they are different. +func StringSimilarity(a string, b string) float64 { + return smetrics.JaroWinkler(a, b, 0.7, 4) +} + +// Capitalize returns the string with the first letter capitalized. +func Capitalize(s string) string { + if s == "" { + return "" + } + + r, n := utf8.DecodeRuneInString(s) + return string(unicode.ToUpper(r)) + s[n:] +} + +// Plural returns the number concatenated to the proper pluralization of the word. +func Plural(count int, singular string) string { + if count == 1 || count == -1 { + return fmt.Sprintf("%d %s", count, singular) + } + + switch singular { + case "activity": + singular = "activitie" + case "company": + singular = "companie" + } + + return fmt.Sprintf("%d %ss", count, singular) +} + +// ContainsUnicodeLetters tells you if unicode characters are inside the string. +func ContainsUnicodeLetters(s string) bool { + return len(s) != len([]rune(s)) +} + +// PrettyPrint prints the object as indented JSON data on the console. +func PrettyPrint(obj interface{}) { + // Currently, MarshalIndent doesn't support tabs. + // Change this back to using \t when it's implemented. + // See: https://github.com/json-iterator/go/pull/273 + pretty, _ := jsoniter.MarshalIndent(obj, "", " ") + fmt.Println(string(pretty)) +} + +// keepLettersAndDigits removes everything but letters and digits when used in strings.Map. +func keepLettersAndDigits(r rune) rune { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return -1 + } + + return r +} diff --git a/arn/stringutils/StringUtils_test.go b/arn/stringutils/StringUtils_test.go new file mode 100644 index 00000000..322e4c4d --- /dev/null +++ b/arn/stringutils/StringUtils_test.go @@ -0,0 +1,26 @@ +package stringutils_test + +import ( + "testing" + + "github.com/animenotifier/notify.moe/arn/stringutils" + "github.com/stretchr/testify/assert" +) + +func TestRemoveSpecialCharacters(t *testing.T) { + assert.Equal(t, stringutils.RemoveSpecialCharacters("Hello World"), "Hello World") + assert.Equal(t, stringutils.RemoveSpecialCharacters("Aldnoah.Zero 2"), "Aldnoah Zero 2") + assert.Equal(t, stringutils.RemoveSpecialCharacters("Working!"), "Working ") + assert.Equal(t, stringutils.RemoveSpecialCharacters("Working!!"), "Working ") + assert.Equal(t, stringutils.RemoveSpecialCharacters("Working!!!"), "Working ") + assert.Equal(t, stringutils.RemoveSpecialCharacters("Lucky☆Star"), "Lucky Star") + assert.Equal(t, stringutils.RemoveSpecialCharacters("ChäoS;Child"), "ChäoS Child") + assert.Equal(t, stringutils.RemoveSpecialCharacters("K-On!"), "KOn ") + assert.Equal(t, stringutils.RemoveSpecialCharacters("僕だけがいない街"), "僕だけがいない街") +} + +func TestContainsUnicodeLetters(t *testing.T) { + assert.False(t, stringutils.ContainsUnicodeLetters("hello")) + assert.True(t, stringutils.ContainsUnicodeLetters("こんにちは")) + assert.True(t, stringutils.ContainsUnicodeLetters("hello こんにちは")) +} diff --git a/arn/validate/Validate.go b/arn/validate/Validate.go new file mode 100644 index 00000000..604d7f16 --- /dev/null +++ b/arn/validate/Validate.go @@ -0,0 +1,68 @@ +package validate + +import ( + "net/url" + "regexp" + "strings" + "time" + + "github.com/animenotifier/notify.moe/arn/autocorrect" +) + +const ( + // DateFormat is the format used for short dates that don't include the time. + DateFormat = "2006-01-02" + + // DateTimeFormat is the format used for long dates that include the time. + DateTimeFormat = time.RFC3339 +) + +var ( + discordNickRegex = regexp.MustCompile(`^([^#]{2,32})#(\d{4})$`) +) + +// Nick tests if the given nickname is valid. +func Nick(nick string) bool { + if len(nick) < 2 { + return false + } + + return nick == autocorrect.UserNick(nick) +} + +// DiscordNick tests if the given Discord nickname is valid. +func DiscordNick(nick string) bool { + return discordNickRegex.MatchString(nick) +} + +// DateTime tells you whether the datetime is valid. +func DateTime(date string) bool { + if date == "" || strings.HasPrefix(date, "0001") { + return false + } + + _, err := time.Parse(DateTimeFormat, date) + return err == nil +} + +// Date tells you whether the datetime is valid. +func Date(date string) bool { + if date == "" || strings.HasPrefix(date, "0001") { + return false + } + + _, err := time.Parse(DateFormat, date) + return err == nil +} + +// Email tests if the given email address is valid. +func Email(email string) bool { + // TODO: Add email check + return email != "" +} + +// URI validates a URI. +func URI(uri string) bool { + _, err := url.ParseRequestURI(uri) + return err == nil +} diff --git a/arn/validate/Validate_test.go b/arn/validate/Validate_test.go new file mode 100644 index 00000000..02593290 --- /dev/null +++ b/arn/validate/Validate_test.go @@ -0,0 +1,49 @@ +package validate_test + +import ( + "testing" + + "github.com/animenotifier/notify.moe/arn/validate" + "github.com/stretchr/testify/assert" +) + +func TestIsValidNick(t *testing.T) { + // Invalid nicknames + assert.False(t, validate.Nick("")) + assert.False(t, validate.Nick("A")) + assert.False(t, validate.Nick("AB CD")) + assert.False(t, validate.Nick("A123")) + assert.False(t, validate.Nick("A!§$%&/()=?`")) + assert.False(t, validate.Nick("__")) + assert.False(t, validate.Nick("Tsun.Dere")) + assert.False(t, validate.Nick("Tsun Dere")) + assert.False(t, validate.Nick("さとう")) + + // Valid nicknames + assert.True(t, validate.Nick("Tsundere")) + assert.True(t, validate.Nick("TsunDere")) + assert.True(t, validate.Nick("Tsun_Dere")) + assert.True(t, validate.Nick("Akyoto")) +} + +func TestIsValidEmail(t *testing.T) { + assert.False(t, validate.Email("")) + assert.True(t, validate.Email("support@notify.moe")) +} + +func TestIsValidDate(t *testing.T) { + assert.False(t, validate.DateTime("")) + assert.False(t, validate.DateTime("0001-01-01T01:01:00Z")) + assert.False(t, validate.DateTime("292277026596-12-04T15:30:07Z")) + assert.True(t, validate.DateTime("2017-03-09T10:25:00Z")) +} + +func TestIsValidURI(t *testing.T) { + assert.False(t, validate.URI("")) + assert.False(t, validate.URI("a")) + assert.False(t, validate.URI("google.com")) + assert.True(t, validate.URI("https://google.com")) + assert.True(t, validate.URI("https://google.com/")) + assert.True(t, validate.URI("https://google.com/images")) + assert.True(t, validate.URI("https://google.com/images/")) +} diff --git a/arn/video/Info.go b/arn/video/Info.go new file mode 100644 index 00000000..2e564950 --- /dev/null +++ b/arn/video/Info.go @@ -0,0 +1,87 @@ +package video + +import ( + "os" + "strings" + "time" + + "github.com/akyoto/go-matroska/matroska" +) + +// Info includes some general information about the video file. +type Info struct { + Duration time.Duration `json:"duration"` + FileSize int64 `json:"fileSize"` + Video videoInfo `json:"video"` + Audio audioInfo `json:"audio"` +} + +type videoInfo struct { + Width int `json:"width"` + Height int `json:"height"` + FPS float64 `json:"fps"` + Codec string `json:"codec"` +} + +type audioInfo struct { + BitDepth int `json:"bitDepth"` + SamplingFrequency float64 `json:"samplingFrequency"` + Channels int `json:"channels"` + Codec string `json:"codec"` +} + +// GetInfo returns the information about the given video file. +func GetInfo(file string) (*Info, error) { + stat, err := os.Stat(file) + + if err != nil { + return nil, err + } + + doc, err := matroska.Decode(file) + + if err != nil { + return nil, err + } + + video := doc.Segment.Tracks[0].Entries[0] + audio := doc.Segment.Tracks[0].Entries[1] + frameDuration := video.DefaultDuration + fps := 1.0 / frameDuration.Seconds() + duration := time.Duration(doc.Segment.Info[0].Duration) * doc.Segment.Info[0].TimecodeScale + videoCodecID := normalizeCodecID(video.CodecID) + audioCodecID := normalizeCodecID(audio.CodecID) + + info := &Info{ + Duration: duration, + FileSize: stat.Size(), + + Video: videoInfo{ + Width: video.Video.Width, + Height: video.Video.Height, + FPS: fps, + Codec: videoCodecID, + }, + + Audio: audioInfo{ + BitDepth: audio.Audio.BitDepth, + SamplingFrequency: audio.Audio.SamplingFreq, + Channels: audio.Audio.Channels, + Codec: audioCodecID, + }, + } + + return info, nil +} + +func normalizeCodecID(codecID string) string { + if strings.HasPrefix(codecID, "V_") { + codecID = strings.TrimPrefix(codecID, "V_") + } + + if strings.HasPrefix(codecID, "A_") { + codecID = strings.TrimPrefix(codecID, "A_") + } + + return strings.ToLower(codecID) +} diff --git a/assets/assets.go b/assets/assets.go index 308a474d..97bdc2cc 100644 --- a/assets/assets.go +++ b/assets/assets.go @@ -8,7 +8,7 @@ import ( "github.com/aerogo/manifest" "github.com/aerogo/sitemap" "github.com/akyoto/stringutils/unsafe" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components/css" "github.com/animenotifier/notify.moe/components/js" ) diff --git a/auth/facebook.go b/auth/facebook.go index 2e0b2f2d..d617ca03 100644 --- a/auth/facebook.go +++ b/auth/facebook.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/utils" jsoniter "github.com/json-iterator/go" diff --git a/auth/google.go b/auth/google.go index 990569e3..d8b60299 100644 --- a/auth/google.go +++ b/auth/google.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/utils" jsoniter "github.com/json-iterator/go" diff --git a/auth/twitter.go b/auth/twitter.go index 2752773f..e23dac88 100644 --- a/auth/twitter.go +++ b/auth/twitter.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/utils" "github.com/gomodule/oauth1/oauth" diff --git a/benchmarks/Components_test.go b/benchmarks/Components_test.go index fc0106ee..28dbf780 100644 --- a/benchmarks/Components_test.go +++ b/benchmarks/Components_test.go @@ -3,7 +3,7 @@ package benchmarks import ( "testing" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" ) diff --git a/benchmarks/DB_AnimeList_test.go b/benchmarks/DB_AnimeList_test.go index ffeb891d..4b00ded7 100644 --- a/benchmarks/DB_AnimeList_test.go +++ b/benchmarks/DB_AnimeList_test.go @@ -3,7 +3,7 @@ package benchmarks import ( "testing" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func BenchmarkDatabaseGetAnimeList(b *testing.B) { diff --git a/bots/discord/commands/AnimeSearch.go b/bots/discord/commands/AnimeSearch.go index 87921bd1..7119c477 100644 --- a/bots/discord/commands/AnimeSearch.go +++ b/bots/discord/commands/AnimeSearch.go @@ -3,7 +3,7 @@ package commands import ( "strings" - "github.com/animenotifier/arn/search" + "github.com/animenotifier/notify.moe/arn/search" "github.com/bwmarrin/discordgo" ) diff --git a/bots/discord/commands/RandomQuote.go b/bots/discord/commands/RandomQuote.go index 1ecfd890..941d165a 100644 --- a/bots/discord/commands/RandomQuote.go +++ b/bots/discord/commands/RandomQuote.go @@ -3,7 +3,7 @@ package commands import ( "math/rand" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/bwmarrin/discordgo" ) diff --git a/bots/discord/commands/Verify.go b/bots/discord/commands/Verify.go index 09ceb41d..efe92d20 100644 --- a/bots/discord/commands/Verify.go +++ b/bots/discord/commands/Verify.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/pariz/gountries" "github.com/bwmarrin/discordgo" diff --git a/bots/discord/discord.go b/bots/discord/discord.go index 1092c6da..3f282f50 100644 --- a/bots/discord/discord.go +++ b/bots/discord/discord.go @@ -6,7 +6,7 @@ import ( "os/signal" "syscall" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/bwmarrin/discordgo" ) diff --git a/go.mod b/go.mod index 8cfeb36e..33bfd232 100644 --- a/go.mod +++ b/go.mod @@ -8,22 +8,30 @@ require ( github.com/aerogo/aero v1.3.10 github.com/aerogo/api v0.2.0 github.com/aerogo/crawler v0.2.5 + github.com/aerogo/flow v0.1.4 github.com/aerogo/graphql v0.4.0 github.com/aerogo/http v1.0.6 github.com/aerogo/log v0.2.5 github.com/aerogo/manifest v0.1.4 github.com/aerogo/markdown v0.1.8 + github.com/aerogo/mirror v0.2.3 github.com/aerogo/nano v0.3.2 github.com/aerogo/session-store-nano v0.1.5 github.com/aerogo/sitemap v0.1.3 github.com/akyoto/cache v1.0.2 github.com/akyoto/color v1.8.5 + github.com/akyoto/go-matroska v0.1.1 github.com/akyoto/hash v0.4.0 + github.com/akyoto/imageserver v0.3.6 github.com/akyoto/stringutils v0.2.1 + github.com/akyoto/webpush-go v0.1.2 github.com/animenotifier/anilist v0.2.3 - github.com/animenotifier/arn v1.2.0 + github.com/animenotifier/ffxiv v0.2.1 + github.com/animenotifier/japanese v0.2.3 github.com/animenotifier/kitsu v0.2.3 github.com/animenotifier/mal v0.2.3 + github.com/animenotifier/osu v0.1.1 + github.com/animenotifier/overwatch v0.1.2 github.com/animenotifier/shoboi v0.2.3 github.com/animenotifier/twist v0.2.3 github.com/bwmarrin/discordgo v0.19.0 @@ -44,10 +52,18 @@ require ( github.com/smartystreets/assertions v1.0.0 // indirect github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a // indirect github.com/stretchr/testify v1.3.0 + github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf // indirect + github.com/ungerik/go-gravatar v0.0.0-20120802094239-6ab22628222a + github.com/ventu-io/go-shortid v0.0.0-20171029131806-771a37caa5cf + github.com/xrash/smetrics v0.0.0-20170218160415-a3153f7040e9 + golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 // indirect + golang.org/x/net v0.0.0-20190522155817-f3200d17e092 // indirect golang.org/x/oauth2 v0.0.0-20190523182746-aaccbc9213b0 golang.org/x/text v0.3.2 // indirect google.golang.org/appengine v1.6.0 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/ini.v1 v1.42.0 // indirect gopkg.in/yaml.v2 v2.2.2 // indirect ) diff --git a/go.sum b/go.sum index e85caf2f..34ef3d5f 100644 --- a/go.sum +++ b/go.sum @@ -85,8 +85,6 @@ github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRy github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/animenotifier/anilist v0.2.3 h1:409h1m4m59EBTQHc/F2U5PGY3lIWlvD/kRXxY1oTl5Q= github.com/animenotifier/anilist v0.2.3/go.mod h1:WmivLHBTIs+zmqENjiVXH66laTYB8vT5d+8q1yzLX9I= -github.com/animenotifier/arn v1.2.0 h1:BsWB6CXUFRgPKeh8MtNtgi8TPwDoqpAkgV8ah+bDTR8= -github.com/animenotifier/arn v1.2.0/go.mod h1:1wyusZbu0AbbjPn60TR20xwL8alLPo41oysxsttEP2A= github.com/animenotifier/ffxiv v0.2.1 h1:gV5h47skizAWLJQb+M3CmExy1hlqDuKmNxkOpn3JwF0= github.com/animenotifier/ffxiv v0.2.1/go.mod h1:9p0z9iQIT8nIlwH4xHUvdo0qFvJ4pVnFbBQ0G/JiY0k= github.com/animenotifier/japanese v0.2.3 h1:fGX3CcX5lGzRC+JkokDCwJqRniPOmM44FLm7aqdnOEo= @@ -228,6 +226,7 @@ github.com/zeebo/xxh3 v0.0.0-20190402181837-148601fe83bd/go.mod h1:+RhiatAQMOV+F go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= golang.org/x/arch v0.0.0-20181203225421-5a4828bb7045/go.mod h1:cYlCBUl1MsqxdiKgmc4uh7TxZfWSFLOGSRR090WDxt8= golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529 h1:iMGN4xG0cnqj3t+zOM8wUB0BiPKHEwSxEZCvzcbZuvk= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/graphql/graphql.go b/graphql/graphql.go index 1b267208..cd2458d4 100644 --- a/graphql/graphql.go +++ b/graphql/graphql.go @@ -9,7 +9,7 @@ import ( "github.com/aerogo/aero" "github.com/aerogo/graphql" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) var ( diff --git a/jobs/anime-characters/anime-characters.go b/jobs/anime-characters/anime-characters.go index d8993418..4663712f 100644 --- a/jobs/anime-characters/anime-characters.go +++ b/jobs/anime-characters/anime-characters.go @@ -5,7 +5,7 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/jobs/anime-ratings/anime-ratings.go b/jobs/anime-ratings/anime-ratings.go index 56921382..fce281fc 100644 --- a/jobs/anime-ratings/anime-ratings.go +++ b/jobs/anime-ratings/anime-ratings.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) var ratings = map[string][]arn.AnimeListItemRating{} diff --git a/jobs/download-character-images/download-character-images.go b/jobs/download-character-images/download-character-images.go index 9537351e..7f42e2e6 100644 --- a/jobs/download-character-images/download-character-images.go +++ b/jobs/download-character-images/download-character-images.go @@ -11,7 +11,7 @@ import ( "github.com/aerogo/http/client" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/jobs/jobs.go b/jobs/jobs.go index 421a5fa1..80a3f480 100644 --- a/jobs/jobs.go +++ b/jobs/jobs.go @@ -11,7 +11,7 @@ import ( "github.com/aerogo/log" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) var colorPool = []*color.Color{ diff --git a/jobs/kitsu-characters-parse/kitsu-characters-parse.go b/jobs/kitsu-characters-parse/kitsu-characters-parse.go index 28d88a1e..e961e341 100644 --- a/jobs/kitsu-characters-parse/kitsu-characters-parse.go +++ b/jobs/kitsu-characters-parse/kitsu-characters-parse.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/kitsu" ) diff --git a/jobs/kitsu-import-anime/kitsu-import-anime.go b/jobs/kitsu-import-anime/kitsu-import-anime.go index 564cc870..edc33955 100644 --- a/jobs/kitsu-import-anime/kitsu-import-anime.go +++ b/jobs/kitsu-import-anime/kitsu-import-anime.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/kitsu" ) diff --git a/jobs/kitsu-import-anime/shell.go b/jobs/kitsu-import-anime/shell.go index 0c61eaef..36935e12 100644 --- a/jobs/kitsu-import-anime/shell.go +++ b/jobs/kitsu-import-anime/shell.go @@ -4,7 +4,7 @@ import ( "errors" "flag" - "github.com/animenotifier/arn/stringutils" + "github.com/animenotifier/notify.moe/arn/stringutils" "github.com/animenotifier/kitsu" ) diff --git a/jobs/kitsu-import-mappings/kitsu-import-mappings.go b/jobs/kitsu-import-mappings/kitsu-import-mappings.go index ca2f872d..231537a5 100644 --- a/jobs/kitsu-import-mappings/kitsu-import-mappings.go +++ b/jobs/kitsu-import-mappings/kitsu-import-mappings.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/kitsu" ) diff --git a/jobs/mal-download/mal-download.go b/jobs/mal-download/mal-download.go index 57a93763..d13eb890 100644 --- a/jobs/mal-download/mal-download.go +++ b/jobs/mal-download/mal-download.go @@ -5,10 +5,10 @@ import ( "os" "time" - "github.com/animenotifier/arn/osutils" + "github.com/animenotifier/notify.moe/arn/osutils" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/aerogo/crawler" ) diff --git a/jobs/mal-download/shell.go b/jobs/mal-download/shell.go index 1d5a5068..59396487 100644 --- a/jobs/mal-download/shell.go +++ b/jobs/mal-download/shell.go @@ -4,7 +4,7 @@ import ( "flag" "github.com/aerogo/crawler" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Shell parameters diff --git a/jobs/mal-parse/anime.go b/jobs/mal-parse/anime.go index 7457055d..f163e1de 100644 --- a/jobs/mal-parse/anime.go +++ b/jobs/mal-parse/anime.go @@ -6,7 +6,7 @@ import ( "os" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/mal" malparser "github.com/animenotifier/mal/parser" ) diff --git a/jobs/mal-parse/character.go b/jobs/mal-parse/character.go index 3f0b1d2f..30fa4d27 100644 --- a/jobs/mal-parse/character.go +++ b/jobs/mal-parse/character.go @@ -6,7 +6,7 @@ import ( "os" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" malparser "github.com/animenotifier/mal/parser" ) diff --git a/jobs/mal-parse/mal-parse.go b/jobs/mal-parse/mal-parse.go index 8a269b4f..74ecc750 100644 --- a/jobs/mal-parse/mal-parse.go +++ b/jobs/mal-parse/mal-parse.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/jobs/mal-parse/shell.go b/jobs/mal-parse/shell.go index b8e05538..d89c960f 100644 --- a/jobs/mal-parse/shell.go +++ b/jobs/mal-parse/shell.go @@ -4,7 +4,7 @@ import ( "flag" "path" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Shell parameters diff --git a/jobs/mal-sync/character.go b/jobs/mal-sync/character.go index 48b722f9..0d4f34af 100644 --- a/jobs/mal-sync/character.go +++ b/jobs/mal-sync/character.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func parseCharacterDescription(input string) (output string, attributes []*arn.CharacterAttribute) { diff --git a/jobs/mal-sync/mal-sync.go b/jobs/mal-sync/mal-sync.go index ab243276..1c79b09a 100644 --- a/jobs/mal-sync/mal-sync.go +++ b/jobs/mal-sync/mal-sync.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/mal" ) diff --git a/jobs/mal-sync/shell.go b/jobs/mal-sync/shell.go index 9ce12215..bbffbce9 100644 --- a/jobs/mal-sync/shell.go +++ b/jobs/mal-sync/shell.go @@ -3,7 +3,7 @@ package main import ( "flag" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Shell parameters diff --git a/jobs/mal-sync/sync.go b/jobs/mal-sync/sync.go index 93b079f5..66696e46 100644 --- a/jobs/mal-sync/sync.go +++ b/jobs/mal-sync/sync.go @@ -5,7 +5,7 @@ import ( "github.com/aerogo/http/client" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/mal" ) diff --git a/jobs/refresh-episodes/refresh-episodes.go b/jobs/refresh-episodes/refresh-episodes.go index 69717e10..12c0b313 100644 --- a/jobs/refresh-episodes/refresh-episodes.go +++ b/jobs/refresh-episodes/refresh-episodes.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/jobs/refresh-episodes/shell.go b/jobs/refresh-episodes/shell.go index 2e2a1435..84b0c3e6 100644 --- a/jobs/refresh-episodes/shell.go +++ b/jobs/refresh-episodes/shell.go @@ -3,7 +3,7 @@ package main import ( "flag" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Shell parameters diff --git a/jobs/refresh-games/ffxiv.go b/jobs/refresh-games/ffxiv.go index 3e276cfa..125ee02f 100644 --- a/jobs/refresh-games/ffxiv.go +++ b/jobs/refresh-games/ffxiv.go @@ -5,8 +5,8 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/stringutils" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" ) var tickerFFXIV = time.NewTicker(1100 * time.Millisecond) diff --git a/jobs/refresh-games/osu.go b/jobs/refresh-games/osu.go index 359ad131..08267423 100644 --- a/jobs/refresh-games/osu.go +++ b/jobs/refresh-games/osu.go @@ -5,8 +5,8 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/stringutils" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" ) var tickerOsu = time.NewTicker(500 * time.Millisecond) diff --git a/jobs/refresh-games/overwatch.go b/jobs/refresh-games/overwatch.go index 316d0989..c1fac9aa 100644 --- a/jobs/refresh-games/overwatch.go +++ b/jobs/refresh-games/overwatch.go @@ -5,8 +5,8 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/stringutils" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" ) var tickerOW = time.NewTicker(1100 * time.Millisecond) diff --git a/jobs/refresh-games/refresh-games.go b/jobs/refresh-games/refresh-games.go index ee633e8d..936eb75b 100644 --- a/jobs/refresh-games/refresh-games.go +++ b/jobs/refresh-games/refresh-games.go @@ -2,7 +2,7 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/jobs/soundtrack-download/soundtrack-download.go b/jobs/soundtrack-download/soundtrack-download.go index db5f24a6..34c84e8b 100644 --- a/jobs/soundtrack-download/soundtrack-download.go +++ b/jobs/soundtrack-download/soundtrack-download.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/jobs/sync-media-relations/sync-media-relations.go b/jobs/sync-media-relations/sync-media-relations.go index f7a684c5..9ccfcd8c 100644 --- a/jobs/sync-media-relations/sync-media-relations.go +++ b/jobs/sync-media-relations/sync-media-relations.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/akyoto/color" "github.com/animenotifier/kitsu" diff --git a/jobs/sync-shoboi/sync-shoboi.go b/jobs/sync-shoboi/sync-shoboi.go index e0ec3328..20a01320 100644 --- a/jobs/sync-shoboi/sync-shoboi.go +++ b/jobs/sync-shoboi/sync-shoboi.go @@ -4,7 +4,7 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/shoboi" ) diff --git a/jobs/test/test.go b/jobs/test/test.go index cd56a4d1..172554a9 100644 --- a/jobs/test/test.go +++ b/jobs/test/test.go @@ -4,14 +4,14 @@ import ( "os/exec" "sync" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/akyoto/color" ) var packages = []string{ "github.com/animenotifier/notify.moe", - "github.com/animenotifier/arn", + "github.com/animenotifier/notify.moe/arn", "github.com/animenotifier/kitsu", "github.com/animenotifier/anilist", "github.com/animenotifier/mal", diff --git a/jobs/twist/twist.go b/jobs/twist/twist.go index dcf22560..e6544f6e 100644 --- a/jobs/twist/twist.go +++ b/jobs/twist/twist.go @@ -6,7 +6,7 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/twist" ) diff --git a/main.go b/main.go index 0b6bbe17..ae2b3a98 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,7 @@ import ( "github.com/aerogo/aero" nanostore "github.com/aerogo/session-store-nano" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/auth" "github.com/animenotifier/notify.moe/graphql" diff --git a/main_test.go b/main_test.go index dbc06813..fc80d036 100644 --- a/main_test.go +++ b/main_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/utils/routetests" "github.com/stretchr/testify/assert" ) diff --git a/middleware/Layout.go b/middleware/Layout.go index 099e4ee4..45ca0b83 100644 --- a/middleware/Layout.go +++ b/middleware/Layout.go @@ -5,7 +5,7 @@ import ( "github.com/aerogo/aero" "github.com/akyoto/stringutils/unsafe" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" ) diff --git a/middleware/OpenGraph.go b/middleware/OpenGraph.go index 0c98b3c5..a009d853 100644 --- a/middleware/OpenGraph.go +++ b/middleware/OpenGraph.go @@ -2,7 +2,7 @@ package middleware import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // OpenGraphContext is a context with open graph data. diff --git a/middleware/UserInfo.go b/middleware/UserInfo.go index b49c9148..8372b2c5 100644 --- a/middleware/UserInfo.go +++ b/middleware/UserInfo.go @@ -8,7 +8,7 @@ import ( "github.com/aerogo/aero" "github.com/aerogo/http/client" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/utils" "github.com/mssola/user_agent" ) diff --git a/pages/activity/activity.go b/pages/activity/activity.go index 5854be99..414b9dd3 100644 --- a/pages/activity/activity.go +++ b/pages/activity/activity.go @@ -2,7 +2,7 @@ package activity import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/activity/render.go b/pages/activity/render.go index 3eedb23f..b560a236 100644 --- a/pages/activity/render.go +++ b/pages/activity/render.go @@ -2,7 +2,7 @@ package activity import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/admin/clienterrors.go b/pages/admin/clienterrors.go index cf1fa50c..2c3f861b 100644 --- a/pages/admin/clienterrors.go +++ b/pages/admin/clienterrors.go @@ -4,7 +4,7 @@ import ( "sort" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" ) diff --git a/pages/admin/payments.go b/pages/admin/payments.go index 9e0553d9..d4e415af 100644 --- a/pages/admin/payments.go +++ b/pages/admin/payments.go @@ -5,7 +5,7 @@ import ( "sort" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/admin/purchases.go b/pages/admin/purchases.go index 2ff51d34..90183092 100644 --- a/pages/admin/purchases.go +++ b/pages/admin/purchases.go @@ -5,7 +5,7 @@ import ( "sort" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/admin/registrations.go b/pages/admin/registrations.go index 7d59753f..faa4ec50 100644 --- a/pages/admin/registrations.go +++ b/pages/admin/registrations.go @@ -6,7 +6,7 @@ import ( "github.com/aerogo/aero" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/amv/amv.go b/pages/amv/amv.go index 5a35772b..e6ff744d 100644 --- a/pages/amv/amv.go +++ b/pages/amv/amv.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/amv/edit.go b/pages/amv/edit.go index 00b2d928..7784c561 100644 --- a/pages/amv/edit.go +++ b/pages/amv/edit.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/editform" diff --git a/pages/amv/history.go b/pages/amv/history.go index f8d6ec0d..a4bf24a7 100644 --- a/pages/amv/history.go +++ b/pages/amv/history.go @@ -1,7 +1,7 @@ package amv import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils/history" ) diff --git a/pages/amv/opengraph.go b/pages/amv/opengraph.go index 02992755..d84aa99d 100644 --- a/pages/amv/opengraph.go +++ b/pages/amv/opengraph.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" ) diff --git a/pages/amvs/fetch.go b/pages/amvs/fetch.go index 15971de8..24873e43 100644 --- a/pages/amvs/fetch.go +++ b/pages/amvs/fetch.go @@ -1,6 +1,6 @@ package amvs -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" func fetchAll() []*arn.AMV { return arn.FilterAMVs(func(amv *arn.AMV) bool { diff --git a/pages/amvs/render.go b/pages/amvs/render.go index 7eed6354..1cb5bb26 100644 --- a/pages/amvs/render.go +++ b/pages/amvs/render.go @@ -2,7 +2,7 @@ package amvs import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/anime/anime.go b/pages/anime/anime.go index 95d92e09..c7c02252 100644 --- a/pages/anime/anime.go +++ b/pages/anime/anime.go @@ -5,7 +5,7 @@ import ( "sort" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" diff --git a/pages/anime/characters.go b/pages/anime/characters.go index 743d91f0..d4ab6b82 100644 --- a/pages/anime/characters.go +++ b/pages/anime/characters.go @@ -8,7 +8,7 @@ import ( "github.com/animenotifier/notify.moe/components" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Characters ... diff --git a/pages/anime/comments.go b/pages/anime/comments.go index 10dbaad4..22a52a63 100644 --- a/pages/anime/comments.go +++ b/pages/anime/comments.go @@ -7,7 +7,7 @@ import ( "github.com/animenotifier/notify.moe/utils" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Comments ... diff --git a/pages/anime/editanime/editanime.go b/pages/anime/editanime/editanime.go index e12d373e..28a91fca 100644 --- a/pages/anime/editanime/editanime.go +++ b/pages/anime/editanime/editanime.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/editform" diff --git a/pages/anime/editanime/history.go b/pages/anime/editanime/history.go index 3b2f8866..024e5940 100644 --- a/pages/anime/editanime/history.go +++ b/pages/anime/editanime/history.go @@ -1,7 +1,7 @@ package editanime import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils/history" ) diff --git a/pages/anime/episodes.go b/pages/anime/episodes.go index daa6c259..e64b3e43 100644 --- a/pages/anime/episodes.go +++ b/pages/anime/episodes.go @@ -7,7 +7,7 @@ import ( "github.com/animenotifier/notify.moe/utils" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Episodes ... diff --git a/pages/anime/redirect.go b/pages/anime/redirect.go index 381aee0b..34f7eb7a 100644 --- a/pages/anime/redirect.go +++ b/pages/anime/redirect.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/anime/relations.go b/pages/anime/relations.go index df10e882..f27325f5 100644 --- a/pages/anime/relations.go +++ b/pages/anime/relations.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/anime/tracks.go b/pages/anime/tracks.go index 9d07bdfb..aa5a4a28 100644 --- a/pages/anime/tracks.go +++ b/pages/anime/tracks.go @@ -8,7 +8,7 @@ import ( "github.com/animenotifier/notify.moe/components" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Tracks ... diff --git a/pages/animeimport/deletekitsu.go b/pages/animeimport/deletekitsu.go index fa46ef23..000e7eb2 100644 --- a/pages/animeimport/deletekitsu.go +++ b/pages/animeimport/deletekitsu.go @@ -3,7 +3,7 @@ package animeimport import ( "net/http" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/aerogo/aero" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/animeimport/kitsu.go b/pages/animeimport/kitsu.go index 81a971df..98eac556 100644 --- a/pages/animeimport/kitsu.go +++ b/pages/animeimport/kitsu.go @@ -7,7 +7,7 @@ import ( "github.com/akyoto/color" "github.com/animenotifier/kitsu" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/aerogo/aero" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/animelist/animelist.go b/pages/animelist/animelist.go index 09ed70e4..8cda4d44 100644 --- a/pages/animelist/animelist.go +++ b/pages/animelist/animelist.go @@ -5,7 +5,7 @@ import ( "strconv" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" diff --git a/pages/animelistitem/animelistitem.go b/pages/animelistitem/animelistitem.go index 778dd01d..dd0526dc 100644 --- a/pages/animelistitem/animelistitem.go +++ b/pages/animelistitem/animelistitem.go @@ -5,7 +5,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/apiview/api.go b/pages/apiview/api.go index d091458a..9e812253 100644 --- a/pages/apiview/api.go +++ b/pages/apiview/api.go @@ -6,8 +6,8 @@ import ( "github.com/aerogo/aero" "github.com/akyoto/color" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/autodocs" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/autodocs" "github.com/animenotifier/notify.moe/components" ) @@ -20,7 +20,7 @@ func Get(ctx aero.Context) error { continue } - typ, err := autodocs.GetTypeDocumentation(typeName, path.Join(arn.Root, "..", "arn", typeName+".go")) + typ, err := autodocs.GetTypeDocumentation(typeName, path.Join(arn.Root, "arn", typeName+".go")) types = append(types, typ) if err != nil { diff --git a/pages/apiview/apidocs/apidocs.go b/pages/apiview/apidocs/apidocs.go index 77e7a066..10e096ae 100644 --- a/pages/apiview/apidocs/apidocs.go +++ b/pages/apiview/apidocs/apidocs.go @@ -6,7 +6,7 @@ import ( "unicode" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/routetests" diff --git a/pages/calendar/calendar.go b/pages/calendar/calendar.go index 570df7ce..02e02796 100644 --- a/pages/calendar/calendar.go +++ b/pages/calendar/calendar.go @@ -5,8 +5,8 @@ import ( "time" "github.com/aerogo/aero" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/validate" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/validate" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/character/character.go b/pages/character/character.go index 03078cea..10679474 100644 --- a/pages/character/character.go +++ b/pages/character/character.go @@ -6,7 +6,7 @@ import ( "github.com/aerogo/aero" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" diff --git a/pages/character/edit.go b/pages/character/edit.go index 04518f3f..dfe4e008 100644 --- a/pages/character/edit.go +++ b/pages/character/edit.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/editform" diff --git a/pages/character/history.go b/pages/character/history.go index 723d63b1..eb6ca579 100644 --- a/pages/character/history.go +++ b/pages/character/history.go @@ -1,7 +1,7 @@ package character import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils/history" ) diff --git a/pages/character/ranking.go b/pages/character/ranking.go index 7187c1cf..d54b8b89 100644 --- a/pages/character/ranking.go +++ b/pages/character/ranking.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Ranking returns the ranking information for the character via the API. diff --git a/pages/characters/best.go b/pages/characters/best.go index 08e9e5fb..2a59f026 100644 --- a/pages/characters/best.go +++ b/pages/characters/best.go @@ -2,7 +2,7 @@ package characters import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Best characters. diff --git a/pages/characters/fetch.go b/pages/characters/fetch.go index 8d16d668..cb0d453d 100644 --- a/pages/characters/fetch.go +++ b/pages/characters/fetch.go @@ -1,6 +1,6 @@ package characters -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" func fetchAll() []*arn.Character { return arn.FilterCharacters(func(character *arn.Character) bool { diff --git a/pages/characters/render.go b/pages/characters/render.go index c2f0f814..342ecddd 100644 --- a/pages/characters/render.go +++ b/pages/characters/render.go @@ -2,7 +2,7 @@ package characters import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/companies/all.go b/pages/companies/all.go index b4ac64ee..475ed8da 100644 --- a/pages/companies/all.go +++ b/pages/companies/all.go @@ -6,7 +6,7 @@ import ( "unicode" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/companies/fetch.go b/pages/companies/fetch.go index 642c2051..c19767c4 100644 --- a/pages/companies/fetch.go +++ b/pages/companies/fetch.go @@ -1,6 +1,6 @@ package companies -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" // fetchAll returns all companies func fetchAll() []*arn.Company { diff --git a/pages/companies/popular.go b/pages/companies/popular.go index fabd230b..889f82b8 100644 --- a/pages/companies/popular.go +++ b/pages/companies/popular.go @@ -2,7 +2,7 @@ package companies import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/company/company.go b/pages/company/company.go index 664b228f..f10ed8ef 100644 --- a/pages/company/company.go +++ b/pages/company/company.go @@ -5,7 +5,7 @@ import ( "sort" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" diff --git a/pages/company/edit.go b/pages/company/edit.go index 61dee65c..4f3a3198 100644 --- a/pages/company/edit.go +++ b/pages/company/edit.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" diff --git a/pages/company/history.go b/pages/company/history.go index 9b054c0f..168cb39b 100644 --- a/pages/company/history.go +++ b/pages/company/history.go @@ -1,7 +1,7 @@ package company import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils/history" ) diff --git a/pages/compare/animelist.go b/pages/compare/animelist.go index 0ec0cee0..53e5e3f2 100644 --- a/pages/compare/animelist.go +++ b/pages/compare/animelist.go @@ -8,7 +8,7 @@ import ( "github.com/animenotifier/notify.moe/utils" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // AnimeList ... diff --git a/pages/editlog/editlog.go b/pages/editlog/editlog.go index 66d14164..9494ad39 100644 --- a/pages/editlog/editlog.go +++ b/pages/editlog/editlog.go @@ -3,7 +3,7 @@ package editlog import ( "net/http" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/editor/diffs.go b/pages/editor/diffs.go index e064c269..bb759b99 100644 --- a/pages/editor/diffs.go +++ b/pages/editor/diffs.go @@ -2,7 +2,7 @@ package editor import ( "github.com/akyoto/hash" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/mal" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/animediff" diff --git a/pages/editor/editor.go b/pages/editor/editor.go index 17a32cdf..21beb95a 100644 --- a/pages/editor/editor.go +++ b/pages/editor/editor.go @@ -5,7 +5,7 @@ import ( "strconv" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/editor/filteranime/all.go b/pages/editor/filteranime/all.go index 793c219c..c4396b3e 100644 --- a/pages/editor/filteranime/all.go +++ b/pages/editor/filteranime/all.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // All ... diff --git a/pages/editor/filteranime/anilist.go b/pages/editor/filteranime/anilist.go index e7a784f5..2b38f8cd 100644 --- a/pages/editor/filteranime/anilist.go +++ b/pages/editor/filteranime/anilist.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // AniList ... diff --git a/pages/editor/filteranime/characters.go b/pages/editor/filteranime/characters.go index cf494d47..99419357 100644 --- a/pages/editor/filteranime/characters.go +++ b/pages/editor/filteranime/characters.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Characters ... diff --git a/pages/editor/filteranime/duplicatemappings.go b/pages/editor/filteranime/duplicatemappings.go index 2418eb19..27c6788c 100644 --- a/pages/editor/filteranime/duplicatemappings.go +++ b/pages/editor/filteranime/duplicatemappings.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // DuplicateMappings ... diff --git a/pages/editor/filteranime/episodelength.go b/pages/editor/filteranime/episodelength.go index 00c2d281..09b618e1 100644 --- a/pages/editor/filteranime/episodelength.go +++ b/pages/editor/filteranime/episodelength.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // EpisodeLength ... diff --git a/pages/editor/filteranime/genres.go b/pages/editor/filteranime/genres.go index 044df7ae..965d4d62 100644 --- a/pages/editor/filteranime/genres.go +++ b/pages/editor/filteranime/genres.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Genres ... diff --git a/pages/editor/filteranime/licensors.go b/pages/editor/filteranime/licensors.go index a3103080..b0c96ad4 100644 --- a/pages/editor/filteranime/licensors.go +++ b/pages/editor/filteranime/licensors.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Licensors ... diff --git a/pages/editor/filteranime/lowresimages.go b/pages/editor/filteranime/lowresimages.go index e437bd5b..d0943ad5 100644 --- a/pages/editor/filteranime/lowresimages.go +++ b/pages/editor/filteranime/lowresimages.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // LowResolutionAnimeImages filters anime with low resolution images. diff --git a/pages/editor/filteranime/mal.go b/pages/editor/filteranime/mal.go index 4277a832..244b0b6f 100644 --- a/pages/editor/filteranime/mal.go +++ b/pages/editor/filteranime/mal.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // MAL ... diff --git a/pages/editor/filteranime/producers.go b/pages/editor/filteranime/producers.go index 58bd6cd6..806bf8ec 100644 --- a/pages/editor/filteranime/producers.go +++ b/pages/editor/filteranime/producers.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Producers ... diff --git a/pages/editor/filteranime/relations.go b/pages/editor/filteranime/relations.go index 7a01be9a..5505e498 100644 --- a/pages/editor/filteranime/relations.go +++ b/pages/editor/filteranime/relations.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Relations ... diff --git a/pages/editor/filteranime/shoboi.go b/pages/editor/filteranime/shoboi.go index 3352e347..517ad5fd 100644 --- a/pages/editor/filteranime/shoboi.go +++ b/pages/editor/filteranime/shoboi.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Shoboi ... diff --git a/pages/editor/filteranime/source.go b/pages/editor/filteranime/source.go index 8d20cd8a..4a63227d 100644 --- a/pages/editor/filteranime/source.go +++ b/pages/editor/filteranime/source.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Source ... diff --git a/pages/editor/filteranime/startdate.go b/pages/editor/filteranime/startdate.go index e796127f..77ba63f3 100644 --- a/pages/editor/filteranime/startdate.go +++ b/pages/editor/filteranime/startdate.go @@ -4,7 +4,7 @@ import ( "time" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // StartDate ... diff --git a/pages/editor/filteranime/studios.go b/pages/editor/filteranime/studios.go index c946de3a..07ff930b 100644 --- a/pages/editor/filteranime/studios.go +++ b/pages/editor/filteranime/studios.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Studios ... diff --git a/pages/editor/filteranime/synopsis.go b/pages/editor/filteranime/synopsis.go index 9a210282..6e6aa319 100644 --- a/pages/editor/filteranime/synopsis.go +++ b/pages/editor/filteranime/synopsis.go @@ -2,7 +2,7 @@ package filteranime import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Synopsis ... diff --git a/pages/editor/filteranime/trailers.go b/pages/editor/filteranime/trailers.go index 917b910b..1a51550c 100644 --- a/pages/editor/filteranime/trailers.go +++ b/pages/editor/filteranime/trailers.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Trailers ... diff --git a/pages/editor/filteranime/utils.go b/pages/editor/filteranime/utils.go index 360fa1ad..3c74d785 100644 --- a/pages/editor/filteranime/utils.go +++ b/pages/editor/filteranime/utils.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/editor/filtercompanies/filtercompanies.go b/pages/editor/filtercompanies/filtercompanies.go index 110abf16..ca02527c 100644 --- a/pages/editor/filtercompanies/filtercompanies.go +++ b/pages/editor/filtercompanies/filtercompanies.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/editor/filtersoundtracks/file.go b/pages/editor/filtersoundtracks/file.go index 651f4ba3..ecc0d992 100644 --- a/pages/editor/filtersoundtracks/file.go +++ b/pages/editor/filtersoundtracks/file.go @@ -2,7 +2,7 @@ package filtersoundtracks import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // File shows soundtracks without an audio file. diff --git a/pages/editor/filtersoundtracks/links.go b/pages/editor/filtersoundtracks/links.go index b8ec6d54..65db3dd5 100644 --- a/pages/editor/filtersoundtracks/links.go +++ b/pages/editor/filtersoundtracks/links.go @@ -2,7 +2,7 @@ package filtersoundtracks import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Links shows soundtracks without links. diff --git a/pages/editor/filtersoundtracks/lyrics.go b/pages/editor/filtersoundtracks/lyrics.go index b4a3e604..f90de5fa 100644 --- a/pages/editor/filtersoundtracks/lyrics.go +++ b/pages/editor/filtersoundtracks/lyrics.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // MissingLyrics shows soundtracks without lyrics. diff --git a/pages/editor/filtersoundtracks/tags.go b/pages/editor/filtersoundtracks/tags.go index 0e5241f1..5f9f8748 100644 --- a/pages/editor/filtersoundtracks/tags.go +++ b/pages/editor/filtersoundtracks/tags.go @@ -2,7 +2,7 @@ package filtersoundtracks import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Tags shows soundtracks with less than 3 tags. diff --git a/pages/editor/filtersoundtracks/utils.go b/pages/editor/filtersoundtracks/utils.go index c28d04d9..66bf6af3 100644 --- a/pages/editor/filtersoundtracks/utils.go +++ b/pages/editor/filtersoundtracks/utils.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/editor/jobs/start.go b/pages/editor/jobs/start.go index 12cd34b5..fc80861d 100644 --- a/pages/editor/jobs/start.go +++ b/pages/editor/jobs/start.go @@ -4,7 +4,7 @@ import ( "net/http" "sync" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/aerogo/aero" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/editor/kitsu.go b/pages/editor/kitsu.go index e2334887..4549fbbc 100644 --- a/pages/editor/kitsu.go +++ b/pages/editor/kitsu.go @@ -4,7 +4,7 @@ import ( "sort" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/kitsu" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/editor/mal.go b/pages/editor/mal.go index 5d9919ac..dcac9e8a 100644 --- a/pages/editor/mal.go +++ b/pages/editor/mal.go @@ -6,7 +6,7 @@ import ( "github.com/animenotifier/notify.moe/utils/animediff" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/mal" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/episode/episode.go b/pages/episode/episode.go index 46e9652e..4e8bb535 100644 --- a/pages/episode/episode.go +++ b/pages/episode/episode.go @@ -6,7 +6,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" minio "github.com/minio/minio-go" diff --git a/pages/episode/subtitles.go b/pages/episode/subtitles.go index 45689b6a..f1c0ffb5 100644 --- a/pages/episode/subtitles.go +++ b/pages/episode/subtitles.go @@ -5,7 +5,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" minio "github.com/minio/minio-go" ) diff --git a/pages/explore/explore.go b/pages/explore/explore.go index 5732915a..5ae422f1 100644 --- a/pages/explore/explore.go +++ b/pages/explore/explore.go @@ -5,7 +5,7 @@ import ( "time" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/explore/explorecolor/explorecolor.go b/pages/explore/explorecolor/explorecolor.go index e8f18296..ebc39d57 100644 --- a/pages/explore/explorecolor/explorecolor.go +++ b/pages/explore/explorecolor/explorecolor.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/explore/explorerelations/sequels.go b/pages/explore/explorerelations/sequels.go index 5a9032d2..3dae2883 100644 --- a/pages/explore/explorerelations/sequels.go +++ b/pages/explore/explorerelations/sequels.go @@ -4,7 +4,7 @@ import ( "net/http" "sort" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/aerogo/aero" "github.com/animenotifier/notify.moe/components" diff --git a/pages/explore/halloffame/halloffame.go b/pages/explore/halloffame/halloffame.go index 555490dc..4f3ec743 100644 --- a/pages/explore/halloffame/halloffame.go +++ b/pages/explore/halloffame/halloffame.go @@ -5,7 +5,7 @@ import ( "time" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/forum/forum.go b/pages/forum/forum.go index a238b0bb..29498c70 100644 --- a/pages/forum/forum.go +++ b/pages/forum/forum.go @@ -2,7 +2,7 @@ package forum import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" ) diff --git a/pages/frontpage/frontpage.go b/pages/frontpage/frontpage.go index 52ae83ef..4b8d4a0c 100644 --- a/pages/frontpage/frontpage.go +++ b/pages/frontpage/frontpage.go @@ -2,7 +2,7 @@ package frontpage import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" diff --git a/pages/genre/genre.go b/pages/genre/genre.go index 8c3c52d5..215c3426 100644 --- a/pages/genre/genre.go +++ b/pages/genre/genre.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/genres/genres.go b/pages/genres/genres.go index b67d3e46..a95bec78 100644 --- a/pages/genres/genres.go +++ b/pages/genres/genres.go @@ -2,7 +2,7 @@ package genres import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/group/edit.go b/pages/group/edit.go index e460c583..5ea95147 100644 --- a/pages/group/edit.go +++ b/pages/group/edit.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/editform" diff --git a/pages/group/feed.go b/pages/group/feed.go index a22dba49..2f7bf013 100644 --- a/pages/group/feed.go +++ b/pages/group/feed.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/group/history.go b/pages/group/history.go index cb5d6c1a..5b423adc 100644 --- a/pages/group/history.go +++ b/pages/group/history.go @@ -1,7 +1,7 @@ package group import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils/history" ) diff --git a/pages/group/info.go b/pages/group/info.go index 783ba94c..27b510df 100644 --- a/pages/group/info.go +++ b/pages/group/info.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/group/members.go b/pages/group/members.go index b4ecf6ab..1868ce37 100644 --- a/pages/group/members.go +++ b/pages/group/members.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/group/opengraph.go b/pages/group/opengraph.go index d356b064..35e97b78 100644 --- a/pages/group/opengraph.go +++ b/pages/group/opengraph.go @@ -2,7 +2,7 @@ package group import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" ) diff --git a/pages/groups/fetch.go b/pages/groups/fetch.go index 5d20c5b9..3e914a06 100644 --- a/pages/groups/fetch.go +++ b/pages/groups/fetch.go @@ -1,6 +1,6 @@ package groups -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" func fetchGroups(memberID string) []*arn.Group { return arn.FilterGroups(func(group *arn.Group) bool { diff --git a/pages/groups/render.go b/pages/groups/render.go index b7c51968..83814550 100644 --- a/pages/groups/render.go +++ b/pages/groups/render.go @@ -2,7 +2,7 @@ package groups import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/index/apiroutes/apiroutes.go b/pages/index/apiroutes/apiroutes.go index a75420fb..9e1332e5 100644 --- a/pages/index/apiroutes/apiroutes.go +++ b/pages/index/apiroutes/apiroutes.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/pages/animeimport" "github.com/animenotifier/notify.moe/pages/apiview" "github.com/animenotifier/notify.moe/pages/apiview/apidocs" diff --git a/pages/index/userroutes/userroutes.go b/pages/index/userroutes/userroutes.go index fed63487..bbfd9b92 100644 --- a/pages/index/userroutes/userroutes.go +++ b/pages/index/userroutes/userroutes.go @@ -2,7 +2,7 @@ package userroutes import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/pages/animelist" "github.com/animenotifier/notify.moe/pages/animelistitem" "github.com/animenotifier/notify.moe/pages/compare" diff --git a/pages/inventory/inventory.go b/pages/inventory/inventory.go index 71c5eca8..c82dd474 100644 --- a/pages/inventory/inventory.go +++ b/pages/inventory/inventory.go @@ -3,7 +3,7 @@ package inventory import ( "net/http" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" diff --git a/pages/listimport/listimportanilist/anilist.go b/pages/listimport/listimportanilist/anilist.go index bf8800d4..e5e419b4 100644 --- a/pages/listimport/listimportanilist/anilist.go +++ b/pages/listimport/listimportanilist/anilist.go @@ -8,7 +8,7 @@ import ( "github.com/aerogo/aero" "github.com/animenotifier/anilist" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/listimport/listimportkitsu/kitsu.go b/pages/listimport/listimportkitsu/kitsu.go index 7d9e393b..472b34fb 100644 --- a/pages/listimport/listimportkitsu/kitsu.go +++ b/pages/listimport/listimportkitsu/kitsu.go @@ -6,7 +6,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/kitsu" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/listimport/listimportmyanimelist/myanimelist.go b/pages/listimport/listimportmyanimelist/myanimelist.go index c3178ab5..e540365d 100644 --- a/pages/listimport/listimportmyanimelist/myanimelist.go +++ b/pages/listimport/listimportmyanimelist/myanimelist.go @@ -7,7 +7,7 @@ import ( "strconv" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/mal" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/notifications/all.go b/pages/notifications/all.go index 93baf0d1..237578ef 100644 --- a/pages/notifications/all.go +++ b/pages/notifications/all.go @@ -5,7 +5,7 @@ import ( "sort" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" ) diff --git a/pages/notifications/api.go b/pages/notifications/api.go index b72dd646..d4f65be7 100644 --- a/pages/notifications/api.go +++ b/pages/notifications/api.go @@ -6,7 +6,7 @@ import ( "strconv" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/notifications/notifications.go b/pages/notifications/notifications.go index e9a45afb..722510a1 100644 --- a/pages/notifications/notifications.go +++ b/pages/notifications/notifications.go @@ -4,7 +4,7 @@ import ( "net/http" "sort" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/aerogo/aero" "github.com/animenotifier/notify.moe/components" diff --git a/pages/paypal/paypal.go b/pages/paypal/paypal.go index fe8631a8..26b84955 100644 --- a/pages/paypal/paypal.go +++ b/pages/paypal/paypal.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/utils" paypalsdk "github.com/logpacker/PayPal-Go-SDK" diff --git a/pages/paypal/success.go b/pages/paypal/success.go index 64539131..9320902f 100644 --- a/pages/paypal/success.go +++ b/pages/paypal/success.go @@ -6,8 +6,8 @@ import ( "strconv" "github.com/aerogo/aero" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/stringutils" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/popular/animetitles.go b/pages/popular/animetitles.go index 8b765596..c0e79ec8 100644 --- a/pages/popular/animetitles.go +++ b/pages/popular/animetitles.go @@ -4,8 +4,8 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/stringutils" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" ) // AnimeTitles returns a list of the 500 most popular anime titles. diff --git a/pages/post/editpost/editpost.go b/pages/post/editpost/editpost.go index 60a05a46..8d0c9ad8 100644 --- a/pages/post/editpost/editpost.go +++ b/pages/post/editpost/editpost.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/editform" diff --git a/pages/post/opengraph.go b/pages/post/opengraph.go index 99b3f169..0dc9f47a 100644 --- a/pages/post/opengraph.go +++ b/pages/post/opengraph.go @@ -2,7 +2,7 @@ package post import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/post/post.go b/pages/post/post.go index 108144f7..f15906f6 100644 --- a/pages/post/post.go +++ b/pages/post/post.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/post/reply-ui.go b/pages/post/reply-ui.go index 172b2c44..c782058c 100644 --- a/pages/post/reply-ui.go +++ b/pages/post/reply-ui.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/profile/followers.go b/pages/profile/followers.go index 9eee1af0..84b4bb11 100644 --- a/pages/profile/followers.go +++ b/pages/profile/followers.go @@ -4,7 +4,7 @@ package profile // "net/http" // "github.com/aerogo/aero" -// "github.com/animenotifier/arn" +// "github.com/animenotifier/notify.moe/arn" // "github.com/animenotifier/notify.moe/components" // "github.com/animenotifier/notify.moe/utils" // ) diff --git a/pages/profile/posts.go b/pages/profile/posts.go index 9650a378..9b801a02 100644 --- a/pages/profile/posts.go +++ b/pages/profile/posts.go @@ -4,7 +4,7 @@ package profile // "net/http" // "github.com/aerogo/aero" -// "github.com/animenotifier/arn" +// "github.com/animenotifier/notify.moe/arn" // "github.com/animenotifier/notify.moe/components" // "github.com/animenotifier/notify.moe/utils" // ) diff --git a/pages/profile/profile.go b/pages/profile/profile.go index 2b931160..3e68830f 100644 --- a/pages/profile/profile.go +++ b/pages/profile/profile.go @@ -5,7 +5,7 @@ import ( "time" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" diff --git a/pages/profile/profilecharacters/liked.go b/pages/profile/profilecharacters/liked.go index c9d8a4c6..45213413 100644 --- a/pages/profile/profilecharacters/liked.go +++ b/pages/profile/profilecharacters/liked.go @@ -5,7 +5,7 @@ import ( "sort" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/profile/profilequotes/added.go b/pages/profile/profilequotes/added.go index 2cc13f97..85794fea 100644 --- a/pages/profile/profilequotes/added.go +++ b/pages/profile/profilequotes/added.go @@ -2,7 +2,7 @@ package profilequotes // import ( // "github.com/aerogo/aero" -// "github.com/animenotifier/arn" +// "github.com/animenotifier/notify.moe/arn" // ) // // Added shows all quotes added by a particular user. diff --git a/pages/profile/profilequotes/liked.go b/pages/profile/profilequotes/liked.go index 8a94159d..b5064138 100644 --- a/pages/profile/profilequotes/liked.go +++ b/pages/profile/profilequotes/liked.go @@ -2,7 +2,7 @@ package profilequotes // import ( // "github.com/aerogo/aero" -// "github.com/animenotifier/arn" +// "github.com/animenotifier/notify.moe/arn" // ) // // Liked shows all quotes liked by a particular user. diff --git a/pages/profile/profilequotes/render.go b/pages/profile/profilequotes/render.go index ee39c0ab..dc2297d3 100644 --- a/pages/profile/profilequotes/render.go +++ b/pages/profile/profilequotes/render.go @@ -4,7 +4,7 @@ package profilequotes // "net/http" // "github.com/aerogo/aero" -// "github.com/animenotifier/arn" +// "github.com/animenotifier/notify.moe/arn" // "github.com/animenotifier/notify.moe/components" // "github.com/animenotifier/notify.moe/utils" // "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/profile/profiletracks/added.go b/pages/profile/profiletracks/added.go index 8cb941a1..e42080e0 100644 --- a/pages/profile/profiletracks/added.go +++ b/pages/profile/profiletracks/added.go @@ -2,7 +2,7 @@ package profiletracks // import ( // "github.com/aerogo/aero" -// "github.com/animenotifier/arn" +// "github.com/animenotifier/notify.moe/arn" // ) // // Added shows all soundtracks added by a particular user. diff --git a/pages/profile/profiletracks/liked.go b/pages/profile/profiletracks/liked.go index c6dc9121..5d159413 100644 --- a/pages/profile/profiletracks/liked.go +++ b/pages/profile/profiletracks/liked.go @@ -2,7 +2,7 @@ package profiletracks // import ( // "github.com/aerogo/aero" -// "github.com/animenotifier/arn" +// "github.com/animenotifier/notify.moe/arn" // ) // // Liked shows all soundtracks liked by a particular user. diff --git a/pages/profile/profiletracks/render.go b/pages/profile/profiletracks/render.go index e016fbc8..7e4d39d7 100644 --- a/pages/profile/profiletracks/render.go +++ b/pages/profile/profiletracks/render.go @@ -4,7 +4,7 @@ package profiletracks // "net/http" // "github.com/aerogo/aero" -// "github.com/animenotifier/arn" +// "github.com/animenotifier/notify.moe/arn" // "github.com/animenotifier/notify.moe/components" // "github.com/animenotifier/notify.moe/utils" // "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/profile/stats.go b/pages/profile/stats.go index fe2d0f49..339cc689 100644 --- a/pages/profile/stats.go +++ b/pages/profile/stats.go @@ -7,7 +7,7 @@ package profile // "time" // "github.com/aerogo/aero" -// "github.com/animenotifier/arn" +// "github.com/animenotifier/notify.moe/arn" // "github.com/animenotifier/notify.moe/components" // "github.com/animenotifier/notify.moe/utils" // ) diff --git a/pages/profile/threads.go b/pages/profile/threads.go index d4d1fcbb..36b7d455 100644 --- a/pages/profile/threads.go +++ b/pages/profile/threads.go @@ -4,7 +4,7 @@ package profile // "net/http" // "github.com/aerogo/aero" -// "github.com/animenotifier/arn" +// "github.com/animenotifier/notify.moe/arn" // "github.com/animenotifier/notify.moe/components" // "github.com/animenotifier/notify.moe/utils" // ) diff --git a/pages/quote/edit.go b/pages/quote/edit.go index 2f8ee9b4..6f7cca67 100644 --- a/pages/quote/edit.go +++ b/pages/quote/edit.go @@ -6,7 +6,7 @@ import ( "github.com/animenotifier/notify.moe/middleware" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/quote/history.go b/pages/quote/history.go index 5fe2f7e4..d8e176cc 100644 --- a/pages/quote/history.go +++ b/pages/quote/history.go @@ -1,7 +1,7 @@ package quote import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils/history" ) diff --git a/pages/quote/quote.go b/pages/quote/quote.go index 6c49fa23..a950718d 100644 --- a/pages/quote/quote.go +++ b/pages/quote/quote.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" diff --git a/pages/quotes/best.go b/pages/quotes/best.go index 4fbc960f..0c1c3e06 100644 --- a/pages/quotes/best.go +++ b/pages/quotes/best.go @@ -2,7 +2,7 @@ package quotes import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/quotes/fetch.go b/pages/quotes/fetch.go index 25dd7555..507c858b 100644 --- a/pages/quotes/fetch.go +++ b/pages/quotes/fetch.go @@ -1,6 +1,6 @@ package quotes -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" // fetchAll returns all quotes func fetchAll() []*arn.Quote { diff --git a/pages/quotes/quotes.go b/pages/quotes/quotes.go index 80233b76..a1871e2e 100644 --- a/pages/quotes/quotes.go +++ b/pages/quotes/quotes.go @@ -2,7 +2,7 @@ package quotes import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/recommended/affinity.go b/pages/recommended/affinity.go index 4a54b91b..0f656fed 100644 --- a/pages/recommended/affinity.go +++ b/pages/recommended/affinity.go @@ -3,7 +3,7 @@ package recommended import ( "math" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func getAnimeAffinity(anime *arn.Anime, animeListItem *arn.AnimeListItem, completed *arn.AnimeList, bestGenres []string) float64 { diff --git a/pages/recommended/anime.go b/pages/recommended/anime.go index 4f9551d6..244fdfea 100644 --- a/pages/recommended/anime.go +++ b/pages/recommended/anime.go @@ -5,7 +5,7 @@ import ( "sort" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/search/search.go b/pages/search/search.go index aec49a1d..c0b55da9 100644 --- a/pages/search/search.go +++ b/pages/search/search.go @@ -6,7 +6,7 @@ import ( "github.com/animenotifier/notify.moe/utils" "github.com/aerogo/aero" - "github.com/animenotifier/arn/search" + "github.com/animenotifier/notify.moe/arn/search" "github.com/animenotifier/notify.moe/components" ) diff --git a/pages/settings/settings.go b/pages/settings/settings.go index bc7f0910..3861af8d 100644 --- a/pages/settings/settings.go +++ b/pages/settings/settings.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/shop/buyitem.go b/pages/shop/buyitem.go index 7bc7b04c..7c90e956 100644 --- a/pages/shop/buyitem.go +++ b/pages/shop/buyitem.go @@ -5,7 +5,7 @@ import ( "sync" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/shop/history.go b/pages/shop/history.go index 3751ac50..d00d2429 100644 --- a/pages/shop/history.go +++ b/pages/shop/history.go @@ -5,7 +5,7 @@ import ( "sort" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/shop/shop.go b/pages/shop/shop.go index b86a3ee1..f2f7de65 100644 --- a/pages/shop/shop.go +++ b/pages/shop/shop.go @@ -4,7 +4,7 @@ import ( "net/http" "sort" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/aerogo/aero" "github.com/animenotifier/notify.moe/components" diff --git a/pages/soundtrack/download.go b/pages/soundtrack/download.go index 8a7bccbe..617b4e20 100644 --- a/pages/soundtrack/download.go +++ b/pages/soundtrack/download.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/soundtrack/edit.go b/pages/soundtrack/edit.go index 94cb12a5..a986fdf4 100644 --- a/pages/soundtrack/edit.go +++ b/pages/soundtrack/edit.go @@ -10,7 +10,7 @@ import ( "github.com/animenotifier/notify.moe/utils/editform" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Edit track. diff --git a/pages/soundtrack/history.go b/pages/soundtrack/history.go index 9f747f5e..d5b23250 100644 --- a/pages/soundtrack/history.go +++ b/pages/soundtrack/history.go @@ -4,7 +4,7 @@ import ( "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils/history" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // History of the edits. diff --git a/pages/soundtrack/lyrics.go b/pages/soundtrack/lyrics.go index bf173503..5f470cb7 100644 --- a/pages/soundtrack/lyrics.go +++ b/pages/soundtrack/lyrics.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/soundtrack/opengraph.go b/pages/soundtrack/opengraph.go index 65f11acb..2b1f5732 100644 --- a/pages/soundtrack/opengraph.go +++ b/pages/soundtrack/opengraph.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" ) diff --git a/pages/soundtrack/random.go b/pages/soundtrack/random.go index d1583000..07ecbd9a 100644 --- a/pages/soundtrack/random.go +++ b/pages/soundtrack/random.go @@ -4,7 +4,7 @@ import ( "math/rand" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Random returns a random soundtrack. diff --git a/pages/soundtrack/soundtrack.go b/pages/soundtrack/soundtrack.go index 4c09fa00..bf32ba38 100644 --- a/pages/soundtrack/soundtrack.go +++ b/pages/soundtrack/soundtrack.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/soundtracks/best.go b/pages/soundtracks/best.go index d96bb891..105be51b 100644 --- a/pages/soundtracks/best.go +++ b/pages/soundtracks/best.go @@ -2,7 +2,7 @@ package soundtracks import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Best renders the best soundtracks. diff --git a/pages/soundtracks/fetch.go b/pages/soundtracks/fetch.go index 05425fc5..3f85aabf 100644 --- a/pages/soundtracks/fetch.go +++ b/pages/soundtracks/fetch.go @@ -1,7 +1,7 @@ package soundtracks import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // fetchAll returns all soundtracks diff --git a/pages/soundtracks/latest.go b/pages/soundtracks/latest.go index bbb4a39f..d3bc90df 100644 --- a/pages/soundtracks/latest.go +++ b/pages/soundtracks/latest.go @@ -2,7 +2,7 @@ package soundtracks import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Latest renders the latest soundtracks. diff --git a/pages/soundtracks/render.go b/pages/soundtracks/render.go index 4db36a70..c0a08b27 100644 --- a/pages/soundtracks/render.go +++ b/pages/soundtracks/render.go @@ -2,7 +2,7 @@ package soundtracks import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/infinitescroll" diff --git a/pages/soundtracks/tag.go b/pages/soundtracks/tag.go index e35cb971..1d15b032 100644 --- a/pages/soundtracks/tag.go +++ b/pages/soundtracks/tag.go @@ -2,7 +2,7 @@ package soundtracks import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // FilterByTag renders the best soundtracks filtered by tag. diff --git a/pages/statistics/anime.go b/pages/statistics/anime.go index e402df9b..31a47070 100644 --- a/pages/statistics/anime.go +++ b/pages/statistics/anime.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" ) diff --git a/pages/statistics/statistics.go b/pages/statistics/statistics.go index 762496ac..a3bee925 100644 --- a/pages/statistics/statistics.go +++ b/pages/statistics/statistics.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" ) diff --git a/pages/thread/editthread/editthread.go b/pages/thread/editthread/editthread.go index f804533b..81ac098c 100644 --- a/pages/thread/editthread/editthread.go +++ b/pages/thread/editthread/editthread.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/editform" diff --git a/pages/thread/opengraph.go b/pages/thread/opengraph.go index 6a61237a..d4bed01d 100644 --- a/pages/thread/opengraph.go +++ b/pages/thread/opengraph.go @@ -2,7 +2,7 @@ package thread import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/assets" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/thread/reply-ui.go b/pages/thread/reply-ui.go index d9d18bee..24c569d8 100644 --- a/pages/thread/reply-ui.go +++ b/pages/thread/reply-ui.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/thread/thread.go b/pages/thread/thread.go index 5fca99af..851d37e4 100644 --- a/pages/thread/thread.go +++ b/pages/thread/thread.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/middleware" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/upload/amv.go b/pages/upload/amv.go index ca119aff..63f5972f 100644 --- a/pages/upload/amv.go +++ b/pages/upload/amv.go @@ -3,7 +3,7 @@ package upload import ( "net/http" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/aerogo/aero" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/upload/anime-image.go b/pages/upload/anime-image.go index 802503b0..fe85d237 100644 --- a/pages/upload/anime-image.go +++ b/pages/upload/anime-image.go @@ -3,7 +3,7 @@ package upload import ( "net/http" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/aerogo/aero" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/upload/character.go b/pages/upload/character.go index c27ebaf7..d4cc2af4 100644 --- a/pages/upload/character.go +++ b/pages/upload/character.go @@ -3,7 +3,7 @@ package upload import ( "net/http" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/aerogo/aero" "github.com/animenotifier/notify.moe/utils" diff --git a/pages/upload/group.go b/pages/upload/group.go index 5347f4c2..03f26bdb 100644 --- a/pages/upload/group.go +++ b/pages/upload/group.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/utils" ) diff --git a/pages/user/edit.go b/pages/user/edit.go index a1fc24a3..441be7da 100644 --- a/pages/user/edit.go +++ b/pages/user/edit.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/utils" "github.com/animenotifier/notify.moe/utils/editform" ) diff --git a/pages/users/users.go b/pages/users/users.go index b461e0bf..a680a7c5 100644 --- a/pages/users/users.go +++ b/pages/users/users.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/patches/activity-recreate-from-log/activity-recreate-from-log.go b/patches/activity-recreate-from-log/activity-recreate-from-log.go index 664e2302..f7898f54 100644 --- a/patches/activity-recreate-from-log/activity-recreate-from-log.go +++ b/patches/activity-recreate-from-log/activity-recreate-from-log.go @@ -1,6 +1,6 @@ package main -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" func main() { defer arn.Node.Close() diff --git a/patches/add-character-created-date/add-character-created-date.go b/patches/add-character-created-date/add-character-created-date.go index 77951e97..b821f3bd 100644 --- a/patches/add-character-created-date/add-character-created-date.go +++ b/patches/add-character-created-date/add-character-created-date.go @@ -5,7 +5,7 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/add-item/add-item.go b/patches/add-item/add-item.go index beec2c41..3a2f43bb 100644 --- a/patches/add-item/add-item.go +++ b/patches/add-item/add-item.go @@ -3,7 +3,7 @@ package main import ( "flag" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) var nick string diff --git a/patches/add-shop-items/add-shop-items.go b/patches/add-shop-items/add-shop-items.go index aea4aa76..93348c72 100644 --- a/patches/add-shop-items/add-shop-items.go +++ b/patches/add-shop-items/add-shop-items.go @@ -1,6 +1,6 @@ package main -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" var items = []*arn.ShopItem{ // 1 month diff --git a/patches/add-user-notify-email/add-user-notify-email.go b/patches/add-user-notify-email/add-user-notify-email.go index 5ef0e719..8bcdb565 100644 --- a/patches/add-user-notify-email/add-user-notify-email.go +++ b/patches/add-user-notify-email/add-user-notify-email.go @@ -1,6 +1,6 @@ package main -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" func main() { defer arn.Node.Close() diff --git a/patches/anime-episodes-sort/anime-episodes-sort.go b/patches/anime-episodes-sort/anime-episodes-sort.go index c9e7bdaf..3a5cad59 100644 --- a/patches/anime-episodes-sort/anime-episodes-sort.go +++ b/patches/anime-episodes-sort/anime-episodes-sort.go @@ -1,6 +1,6 @@ package main -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" func main() { defer arn.Node.Close() diff --git a/patches/anime-status/anime-status.go b/patches/anime-status/anime-status.go index 89e86203..25ba0f88 100644 --- a/patches/anime-status/anime-status.go +++ b/patches/anime-status/anime-status.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/animelist-remove-duplicates/animelist-remove-duplicates.go b/patches/animelist-remove-duplicates/animelist-remove-duplicates.go index d7593867..71cea89e 100644 --- a/patches/animelist-remove-duplicates/animelist-remove-duplicates.go +++ b/patches/animelist-remove-duplicates/animelist-remove-duplicates.go @@ -1,7 +1,7 @@ package main import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/change-role/change-role.go b/patches/change-role/change-role.go index 5d4e80a2..080703c1 100644 --- a/patches/change-role/change-role.go +++ b/patches/change-role/change-role.go @@ -3,7 +3,7 @@ package main import ( "flag" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Shell parameters diff --git a/patches/character-split-spoilers/character-split-spoilers.go b/patches/character-split-spoilers/character-split-spoilers.go index d52cf323..2b574da8 100644 --- a/patches/character-split-spoilers/character-split-spoilers.go +++ b/patches/character-split-spoilers/character-split-spoilers.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/character-unescape-name/character-unescape-name.go b/patches/character-unescape-name/character-unescape-name.go index 0d56d471..3be86998 100644 --- a/patches/character-unescape-name/character-unescape-name.go +++ b/patches/character-unescape-name/character-unescape-name.go @@ -5,7 +5,7 @@ import ( "html" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/christmas-notification/christmas-notification.go b/patches/christmas-notification/christmas-notification.go index 4e756c9f..c9e265db 100644 --- a/patches/christmas-notification/christmas-notification.go +++ b/patches/christmas-notification/christmas-notification.go @@ -2,7 +2,7 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/clear-anime-ratings/clear-anime-ratings.go b/patches/clear-anime-ratings/clear-anime-ratings.go index c847c56f..544c3d5a 100644 --- a/patches/clear-anime-ratings/clear-anime-ratings.go +++ b/patches/clear-anime-ratings/clear-anime-ratings.go @@ -1,6 +1,6 @@ package main -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" func main() { defer arn.Node.Close() diff --git a/patches/clear-sessions/clear-sessions.go b/patches/clear-sessions/clear-sessions.go index bafad962..c9d24601 100644 --- a/patches/clear-sessions/clear-sessions.go +++ b/patches/clear-sessions/clear-sessions.go @@ -2,7 +2,7 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/delete-amv-tags/delete-amv-tags.go b/patches/delete-amv-tags/delete-amv-tags.go index 9c8ad152..564c7693 100644 --- a/patches/delete-amv-tags/delete-amv-tags.go +++ b/patches/delete-amv-tags/delete-amv-tags.go @@ -2,7 +2,7 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/delete-balance/delete-balance.go b/patches/delete-balance/delete-balance.go index d26004f7..7f48ee60 100644 --- a/patches/delete-balance/delete-balance.go +++ b/patches/delete-balance/delete-balance.go @@ -4,7 +4,7 @@ import ( "flag" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Shell parameters diff --git a/patches/delete-old-sessions/delete-old-sessions.go b/patches/delete-old-sessions/delete-old-sessions.go index e363cbac..aa2cbf4b 100644 --- a/patches/delete-old-sessions/delete-old-sessions.go +++ b/patches/delete-old-sessions/delete-old-sessions.go @@ -5,7 +5,7 @@ import ( "github.com/aerogo/nano" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/delete-private-data/delete-private-data.go b/patches/delete-private-data/delete-private-data.go index fa02517f..0754464c 100644 --- a/patches/delete-private-data/delete-private-data.go +++ b/patches/delete-private-data/delete-private-data.go @@ -2,7 +2,7 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/delete-pro/delete-pro.go b/patches/delete-pro/delete-pro.go index b390038a..1aa113ad 100644 --- a/patches/delete-pro/delete-pro.go +++ b/patches/delete-pro/delete-pro.go @@ -4,7 +4,7 @@ import ( "flag" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Shell parameters diff --git a/patches/delete-unused-characters/delete-unused-characters.go b/patches/delete-unused-characters/delete-unused-characters.go index e40e2593..a6b17c78 100644 --- a/patches/delete-unused-characters/delete-unused-characters.go +++ b/patches/delete-unused-characters/delete-unused-characters.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/email-notification/email-notification.go b/patches/email-notification/email-notification.go index ad8df1ba..37575512 100644 --- a/patches/email-notification/email-notification.go +++ b/patches/email-notification/email-notification.go @@ -3,8 +3,8 @@ package main import ( "fmt" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/mailer" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/mailer" ) func main() { diff --git a/patches/fix-airing-dates/fix-airing-dates.go b/patches/fix-airing-dates/fix-airing-dates.go index d6b53f52..8fa0cec1 100644 --- a/patches/fix-airing-dates/fix-airing-dates.go +++ b/patches/fix-airing-dates/fix-airing-dates.go @@ -5,7 +5,7 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/fix-anime-list-item-status/fix-anime-list-item-status.go b/patches/fix-anime-list-item-status/fix-anime-list-item-status.go index 39fa4757..eb91f6b2 100644 --- a/patches/fix-anime-list-item-status/fix-anime-list-item-status.go +++ b/patches/fix-anime-list-item-status/fix-anime-list-item-status.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/fix-broken-relations/fix-broken-relations.go b/patches/fix-broken-relations/fix-broken-relations.go index 2059006b..a8d9c981 100644 --- a/patches/fix-broken-relations/fix-broken-relations.go +++ b/patches/fix-broken-relations/fix-broken-relations.go @@ -2,7 +2,7 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/fix-genres/fix-genres.go b/patches/fix-genres/fix-genres.go index 803b13aa..4bae370d 100644 --- a/patches/fix-genres/fix-genres.go +++ b/patches/fix-genres/fix-genres.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/fix-theme/fix-theme.go b/patches/fix-theme/fix-theme.go index d8d1c0d1..f2b74048 100644 --- a/patches/fix-theme/fix-theme.go +++ b/patches/fix-theme/fix-theme.go @@ -1,7 +1,7 @@ package main import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/fix-tvdb-duplicates/fix-tvdb-duplicates.go b/patches/fix-tvdb-duplicates/fix-tvdb-duplicates.go index 65366b81..834c7683 100644 --- a/patches/fix-tvdb-duplicates/fix-tvdb-duplicates.go +++ b/patches/fix-tvdb-duplicates/fix-tvdb-duplicates.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/fix-user-websites/fix-user-websites.go b/patches/fix-user-websites/fix-user-websites.go index b8178403..327c9d91 100644 --- a/patches/fix-user-websites/fix-user-websites.go +++ b/patches/fix-user-websites/fix-user-websites.go @@ -4,8 +4,8 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/autocorrect" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/autocorrect" ) func main() { diff --git a/patches/fix-wrong-ids-in-animelists/fix-wrong-ids-in-animelists.go b/patches/fix-wrong-ids-in-animelists/fix-wrong-ids-in-animelists.go index e1ce2c9f..b1360c20 100644 --- a/patches/fix-wrong-ids-in-animelists/fix-wrong-ids-in-animelists.go +++ b/patches/fix-wrong-ids-in-animelists/fix-wrong-ids-in-animelists.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/import-kitsu-mappings/import-kitsu-mappings.go b/patches/import-kitsu-mappings/import-kitsu-mappings.go index 0e114637..41c55ec6 100644 --- a/patches/import-kitsu-mappings/import-kitsu-mappings.go +++ b/patches/import-kitsu-mappings/import-kitsu-mappings.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/import-mal-companies/import-mal-companies.go b/patches/import-mal-companies/import-mal-companies.go index 85e5bddb..5c6e8c74 100644 --- a/patches/import-mal-companies/import-mal-companies.go +++ b/patches/import-mal-companies/import-mal-companies.go @@ -5,7 +5,7 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/mal" ) diff --git a/patches/kitsu-anilist/kitsu-anilist.go b/patches/kitsu-anilist/kitsu-anilist.go index 34e1f237..7fbb6cee 100644 --- a/patches/kitsu-anilist/kitsu-anilist.go +++ b/patches/kitsu-anilist/kitsu-anilist.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/kitsu-download-anime-images/kitsu-download-anime-images.go b/patches/kitsu-download-anime-images/kitsu-download-anime-images.go index d6097654..26d27d15 100644 --- a/patches/kitsu-download-anime-images/kitsu-download-anime-images.go +++ b/patches/kitsu-download-anime-images/kitsu-download-anime-images.go @@ -14,7 +14,7 @@ import ( "github.com/aerogo/http/client" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) var ticker = time.NewTicker(50 * time.Millisecond) diff --git a/patches/mark-month-as-current/mark-month-as-current.go b/patches/mark-month-as-current/mark-month-as-current.go index a523e82f..97a6e112 100644 --- a/patches/mark-month-as-current/mark-month-as-current.go +++ b/patches/mark-month-as-current/mark-month-as-current.go @@ -5,7 +5,7 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/merge-duplicate-characters/merge-duplicate-characters.go b/patches/merge-duplicate-characters/merge-duplicate-characters.go index 9f5146ab..31b1feae 100644 --- a/patches/merge-duplicate-characters/merge-duplicate-characters.go +++ b/patches/merge-duplicate-characters/merge-duplicate-characters.go @@ -5,7 +5,7 @@ import ( "sort" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/notification-delete-old/notification-delete-old.go b/patches/notification-delete-old/notification-delete-old.go index 8c0ec2f5..7034212d 100644 --- a/patches/notification-delete-old/notification-delete-old.go +++ b/patches/notification-delete-old/notification-delete-old.go @@ -2,7 +2,7 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) const maxNotificationsPerUser = 30 diff --git a/patches/notification-emails/notification-emails.go b/patches/notification-emails/notification-emails.go index 2f441d32..42a5b67f 100644 --- a/patches/notification-emails/notification-emails.go +++ b/patches/notification-emails/notification-emails.go @@ -3,7 +3,7 @@ package main import ( "fmt" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/optimize-all-webm-files/optimize-all-webm-files.go b/patches/optimize-all-webm-files/optimize-all-webm-files.go index 5c23bcee..a76626ab 100644 --- a/patches/optimize-all-webm-files/optimize-all-webm-files.go +++ b/patches/optimize-all-webm-files/optimize-all-webm-files.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/post-sort-date/post-sort-date.go b/patches/post-sort-date/post-sort-date.go index 5553b061..48716403 100644 --- a/patches/post-sort-date/post-sort-date.go +++ b/patches/post-sort-date/post-sort-date.go @@ -2,7 +2,7 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/post-texts/post-texts.go b/patches/post-texts/post-texts.go index 9d5c6e3a..ab4f390e 100644 --- a/patches/post-texts/post-texts.go +++ b/patches/post-texts/post-texts.go @@ -2,8 +2,8 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/autocorrect" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/autocorrect" ) func main() { diff --git a/patches/refresh-anime-average-color/refresh-anime-average-color.go b/patches/refresh-anime-average-color/refresh-anime-average-color.go index 6bd0e171..1b2b747e 100644 --- a/patches/refresh-anime-average-color/refresh-anime-average-color.go +++ b/patches/refresh-anime-average-color/refresh-anime-average-color.go @@ -10,7 +10,7 @@ import ( "path" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/refresh-anime-thumbnails/refresh-anime-thumbnails.go b/patches/refresh-anime-thumbnails/refresh-anime-thumbnails.go index 6c61328b..4264a23d 100644 --- a/patches/refresh-anime-thumbnails/refresh-anime-thumbnails.go +++ b/patches/refresh-anime-thumbnails/refresh-anime-thumbnails.go @@ -8,7 +8,7 @@ import ( "path" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/remove-character-borders/remove-character-borders.go b/patches/remove-character-borders/remove-character-borders.go index 21243f03..5393b859 100644 --- a/patches/remove-character-borders/remove-character-borders.go +++ b/patches/remove-character-borders/remove-character-borders.go @@ -8,7 +8,7 @@ import ( "path" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" icolor "image/color" _ "image/gif" diff --git a/patches/reset-animelist-ratings/reset-animelist-ratings.go b/patches/reset-animelist-ratings/reset-animelist-ratings.go index a7b0d356..899f97fa 100644 --- a/patches/reset-animelist-ratings/reset-animelist-ratings.go +++ b/patches/reset-animelist-ratings/reset-animelist-ratings.go @@ -1,7 +1,7 @@ package main import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/reset-inventories/reset-inventories.go b/patches/reset-inventories/reset-inventories.go index 9c236fdd..b009595a 100644 --- a/patches/reset-inventories/reset-inventories.go +++ b/patches/reset-inventories/reset-inventories.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Shell parameters diff --git a/patches/reset-notification-settings/reset-notification-settings.go b/patches/reset-notification-settings/reset-notification-settings.go index d40210d2..82eeb823 100644 --- a/patches/reset-notification-settings/reset-notification-settings.go +++ b/patches/reset-notification-settings/reset-notification-settings.go @@ -1,6 +1,6 @@ package main -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" func main() { defer arn.Node.Close() diff --git a/patches/show-kitsu-mappings/show-kitsu-mappings.go b/patches/show-kitsu-mappings/show-kitsu-mappings.go index 74bb60aa..1110222e 100644 --- a/patches/show-kitsu-mappings/show-kitsu-mappings.go +++ b/patches/show-kitsu-mappings/show-kitsu-mappings.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/show-season/show-season.go b/patches/show-season/show-season.go index 5e4a63e3..835588b1 100644 --- a/patches/show-season/show-season.go +++ b/patches/show-season/show-season.go @@ -4,7 +4,7 @@ import ( "fmt" "sort" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/show-user-languages/show-user-languages.go b/patches/show-user-languages/show-user-languages.go index edf64008..a1678d8e 100644 --- a/patches/show-user-languages/show-user-languages.go +++ b/patches/show-user-languages/show-user-languages.go @@ -3,7 +3,7 @@ package main import ( "fmt" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/thread-posts/thread-posts.go b/patches/thread-posts/thread-posts.go index e73fe2b6..3f511202 100644 --- a/patches/thread-posts/thread-posts.go +++ b/patches/thread-posts/thread-posts.go @@ -1,7 +1,7 @@ package main import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/update-amv-info/update-amv-info.go b/patches/update-amv-info/update-amv-info.go index 444f98ea..445f920d 100644 --- a/patches/update-amv-info/update-amv-info.go +++ b/patches/update-amv-info/update-amv-info.go @@ -2,8 +2,8 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" - "github.com/animenotifier/arn/stringutils" + "github.com/animenotifier/notify.moe/arn" + "github.com/animenotifier/notify.moe/arn/stringutils" ) func main() { diff --git a/patches/user-privacy-location/user-privacy-location.go b/patches/user-privacy-location/user-privacy-location.go index db0b6522..a6e016fd 100644 --- a/patches/user-privacy-location/user-privacy-location.go +++ b/patches/user-privacy-location/user-privacy-location.go @@ -2,7 +2,7 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/user-references/user-references.go b/patches/user-references/user-references.go index ce830bf3..bfa6c5bd 100644 --- a/patches/user-references/user-references.go +++ b/patches/user-references/user-references.go @@ -2,7 +2,7 @@ package main import ( "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/patches/user-set-social-id/user-set-social-id.go b/patches/user-set-social-id/user-set-social-id.go index 1f43533b..68d20191 100644 --- a/patches/user-set-social-id/user-set-social-id.go +++ b/patches/user-set-social-id/user-set-social-id.go @@ -4,7 +4,7 @@ import ( "flag" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // Shell parameters diff --git a/patches/user-show-intros/user-show-intros.go b/patches/user-show-intros/user-show-intros.go index 7b675d15..6bf4bdc8 100644 --- a/patches/user-show-intros/user-show-intros.go +++ b/patches/user-show-intros/user-show-intros.go @@ -7,7 +7,7 @@ import ( "unicode" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func main() { diff --git a/security.go b/security.go index 82ea4ad3..1d776090 100644 --- a/security.go +++ b/security.go @@ -6,7 +6,7 @@ import ( "github.com/aerogo/aero" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) func configureHTTPS(app *aero.Application) { diff --git a/utils/ActivityLink.go b/utils/ActivityLink.go index bb154474..87b6d740 100644 --- a/utils/ActivityLink.go +++ b/utils/ActivityLink.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // ActivityAPILink returns the API link for any activity. diff --git a/utils/AnimeWithRelatedAnime.go b/utils/AnimeWithRelatedAnime.go index e6a9b568..7c0d5383 100644 --- a/utils/AnimeWithRelatedAnime.go +++ b/utils/AnimeWithRelatedAnime.go @@ -1,6 +1,6 @@ package utils -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" // AnimeWithRelatedAnime ... type AnimeWithRelatedAnime struct { diff --git a/utils/Calendar.go b/utils/Calendar.go index 4a026711..f9bac0e1 100644 --- a/utils/Calendar.go +++ b/utils/Calendar.go @@ -1,6 +1,6 @@ package utils -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" // CalendarDay is a calendar day. type CalendarDay struct { diff --git a/utils/Comparison.go b/utils/Comparison.go index 4e3bd4a5..97914099 100644 --- a/utils/Comparison.go +++ b/utils/Comparison.go @@ -1,6 +1,6 @@ package utils -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" // Comparison of an anime between 2 users. type Comparison struct { diff --git a/utils/GetUser.go b/utils/GetUser.go index 2b628d39..066690c5 100644 --- a/utils/GetUser.go +++ b/utils/GetUser.go @@ -2,7 +2,7 @@ package utils import ( "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // GetUser returns the logged in user for the given context. diff --git a/utils/HallOfFameEntry.go b/utils/HallOfFameEntry.go index 6f51dd7b..4879439e 100644 --- a/utils/HallOfFameEntry.go +++ b/utils/HallOfFameEntry.go @@ -1,6 +1,6 @@ package utils -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" // HallOfFameEntry is an entry in the hall of fame. type HallOfFameEntry struct { diff --git a/utils/Intersection.go b/utils/Intersection.go index eebf6ade..e97f0699 100644 --- a/utils/Intersection.go +++ b/utils/Intersection.go @@ -1,6 +1,6 @@ package utils -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" // Intersection returns common elements of a and b. func Intersection(a []string, b []string) []string { diff --git a/utils/JobInfo.go b/utils/JobInfo.go index 82e16a1b..20843b80 100644 --- a/utils/JobInfo.go +++ b/utils/JobInfo.go @@ -6,7 +6,7 @@ import ( "time" "github.com/akyoto/color" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" ) // JobInfo gives you information about a background job. diff --git a/utils/MALComparison.go b/utils/MALComparison.go index 170507d5..3e8497c7 100644 --- a/utils/MALComparison.go +++ b/utils/MALComparison.go @@ -1,7 +1,7 @@ package utils import ( - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/mal" "github.com/animenotifier/notify.moe/utils/animediff" ) diff --git a/utils/MaxAnime.go b/utils/MaxAnime.go index d0509f5c..115852b0 100644 --- a/utils/MaxAnime.go +++ b/utils/MaxAnime.go @@ -1,6 +1,6 @@ package utils -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" // MaxAnime limits the number of anime that will maximally be returned. func MaxAnime(animes []*arn.Anime, maxLength int) []*arn.Anime { diff --git a/utils/UserList.go b/utils/UserList.go index b4a98f4d..ae57a2ba 100644 --- a/utils/UserList.go +++ b/utils/UserList.go @@ -1,6 +1,6 @@ package utils -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" // UserList is a named list of users. type UserList struct { diff --git a/utils/UserStats.go b/utils/UserStats.go index d4e5c2d7..016a84d9 100644 --- a/utils/UserStats.go +++ b/utils/UserStats.go @@ -1,7 +1,7 @@ package utils import "time" -import "github.com/animenotifier/arn" +import "github.com/animenotifier/notify.moe/arn" // UserStats ... type UserStats struct { diff --git a/utils/YenToUserCurrency.go b/utils/YenToUserCurrency.go index 8935a70e..a5bc4568 100644 --- a/utils/YenToUserCurrency.go +++ b/utils/YenToUserCurrency.go @@ -3,7 +3,7 @@ package utils import ( "fmt" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/pariz/gountries" ) diff --git a/utils/editform/editform.go b/utils/editform/editform.go index 84014cd3..79c1cd76 100644 --- a/utils/editform/editform.go +++ b/utils/editform/editform.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/aerogo/api" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) diff --git a/utils/history/history.go b/utils/history/history.go index 9fd11c78..b1bb2555 100644 --- a/utils/history/history.go +++ b/utils/history/history.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/arn" "github.com/animenotifier/notify.moe/utils" )