diff --git a/internal/config/config.go b/internal/config/config.go index e15320e..8ecb6f5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,16 +1,5 @@ package config -import ( - "errors" - "os" - "path/filepath" - - "github.com/RobBrazier/readflow/internal" - "github.com/adrg/xdg" - "github.com/charmbracelet/log" - "gopkg.in/yaml.v3" -) - type Config struct { Columns ColumnConfig `yaml:"columns"` Databases DatabaseConfig `yaml:"databases"` @@ -64,70 +53,3 @@ func GetTargets() []string { func GetTokens() TokenConfig { return config.Tokens } - -func GetConfigPath(override *string) string { - // Has the config flag been passed in? - if it's got a value, use it - if override != nil { - if *override != "" { - configPath = *override - } - } - - if configPath == "" { - // look in the XDG_CONFIG_HOME location - var err error - configPath, err = xdg.ConfigFile(filepath.Join(internal.NAME, "config.yaml")) - - if err != nil { - // if that doesn't work for some reason, fall back to the current dir - currentDir, err := os.Getwd() - if err != nil { - currentDir = "." - } - configPath = filepath.Join(currentDir, "readflow.yaml") - } - } - - return configPath - -} - -func LoadConfig(path string) error { - log.Debug("Loading config from", "file", path) - data, err := os.ReadFile(path) - if err != nil { - return err - } - if err := yaml.Unmarshal(data, &config); err != nil { - return err - } - log.Debug("Successfully loaded config") - return nil -} - -func SaveConfig(cfg *Config) error { - data, err := yaml.Marshal(cfg) - if err != nil { - return err - } - - if _, err = os.Stat(configPath); errors.Is(err, os.ErrNotExist) { - // file doesn't exist - lets create the folder structure required - path := filepath.Dir(configPath) - if path != "." { - err := os.MkdirAll(path, 0755) - if err != nil { - log.Error("Something went wrong when trying to create the folder structure for", "file", configPath, "path", path, "error", err) - } - } - } - - err = os.WriteFile(configPath, data, 0644) - if err != nil { - log.Error("Couldn't save config to", "file", configPath) - return err - } - log.Debug("Successfully saved config to", "file", configPath) - config = *cfg - return nil -} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..282685b --- /dev/null +++ b/internal/config/load.go @@ -0,0 +1,79 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + + "github.com/RobBrazier/readflow/internal" + "github.com/adrg/xdg" + "github.com/charmbracelet/log" + "gopkg.in/yaml.v3" +) + +func GetConfigPath(override *string) string { + // Has the config flag been passed in? - if it's got a value, use it + if override != nil { + if *override != "" { + configPath = *override + } + } + + if configPath == "" { + // look in the XDG_CONFIG_HOME location + var err error + configPath, err = xdg.ConfigFile(filepath.Join(internal.NAME, "config.yaml")) + + if err != nil { + // if that doesn't work for some reason, fall back to the current dir + currentDir, err := os.Getwd() + if err != nil { + currentDir = "." + } + configPath = filepath.Join(currentDir, "readflow.yaml") + } + } + + return configPath + +} + +func LoadConfig(path string) error { + log.Debug("Loading config from", "file", path) + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := yaml.Unmarshal(data, &config); err != nil { + return err + } + log.Debug("Successfully loaded config") + return nil +} + +func SaveConfig(cfg *Config) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + + if _, err = os.Stat(configPath); errors.Is(err, os.ErrNotExist) { + // file doesn't exist - lets create the folder structure required + path := filepath.Dir(configPath) + if path != "." { + err := os.MkdirAll(path, 0755) + if err != nil { + log.Error("Something went wrong when trying to create the folder structure for", "file", configPath, "path", path, "error", err) + } + } + } + + err = os.WriteFile(configPath, data, 0644) + if err != nil { + log.Error("Couldn't save config to", "file", configPath) + return err + } + log.Debug("Successfully saved config to", "file", configPath) + config = *cfg + return nil +} diff --git a/internal/form/form.go b/internal/form/form.go index 1e98bef..4e65a95 100644 --- a/internal/form/form.go +++ b/internal/form/form.go @@ -1,7 +1,6 @@ package form import ( - "errors" "maps" "github.com/RobBrazier/readflow/internal/source" @@ -59,25 +58,6 @@ func TargetSelect(value *[]string) *huh.MultiSelect[string] { Value(value) } -func ValidationMinValues[T comparable](min int) func([]T) error { - return func(t []T) error { - if len(t) < min { - return errors.New("You must select at least one") - } - return nil - } -} - -func ValidationRequired[T comparable]() func(T) error { - return func(t T) error { - var empty T - if t == empty { - return errors.New("This field is required") - } - return nil - } -} - func Confirm(message string, value *bool) *huh.Confirm { return huh.NewConfirm(). Title(message). diff --git a/internal/form/validation.go b/internal/form/validation.go new file mode 100644 index 0000000..1fee251 --- /dev/null +++ b/internal/form/validation.go @@ -0,0 +1,22 @@ +package form + +import "errors" + +func ValidationMinValues[T comparable](min int) func([]T) error { + return func(t []T) error { + if len(t) < min { + return errors.New("You must select at least one") + } + return nil + } +} + +func ValidationRequired[T comparable]() func(T) error { + return func(t T) error { + var empty T + if t == empty { + return errors.New("This field is required") + } + return nil + } +} diff --git a/internal/source/database.go b/internal/source/database.go index 3f2a0e8..6b4d614 100644 --- a/internal/source/database.go +++ b/internal/source/database.go @@ -25,6 +25,7 @@ type chaptersRow struct { } const CHAPTERS_COLUMN = "columns.chapter" +const QUERY_DAYS = 30 func (s *databaseSource) getReadOnlyDbString(file string) string { if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { @@ -77,7 +78,7 @@ func (s *databaseSource) getRecentReads(db *sqlx.DB) ([]Book, error) { query = fmt.Sprintf(RECENT_READS_QUERY, s.chaptersColumn) } - daysToQuery := "-7 day" + daysToQuery := fmt.Sprintf("-%d day", QUERY_DAYS) err := db.Select(&books, query, daysToQuery) if err != nil { diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 36f2e5e..cadce5e 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -40,11 +40,17 @@ func (a *syncAction) Sync() ([]SyncResult, error) { func (a *syncAction) processTarget(t target.SyncTarget, reads []source.BookContext, wg *sync.WaitGroup) { defer wg.Done() - user := t.GetCurrentUser() - log.Debug("current user for", "target", t.GetName(), "user", user) + // user := t.GetCurrentUser() + // log.Debug("current user for", "target", t.GetName(), "user", user) for _, book := range reads { - log.Info("Processing", "book", book.Current.BookName, "target", t.GetName()) + name := book.Current.BookName + log := log.With("target", t.GetName(), "book", name) + if !t.ShouldProcess(book) { + log.Debug("Skipping processing of ineligible book") + continue + } + log.Info("Processing") err := t.UpdateReadStatus(book) if err != nil { log.Error("failed to update reading status", "error", err) diff --git a/internal/target/anilist.go b/internal/target/anilist.go index 8b6d8bd..1c45b71 100644 --- a/internal/target/anilist.go +++ b/internal/target/anilist.go @@ -2,6 +2,8 @@ package target import ( "context" + "math" + "strconv" "github.com/Khan/genqlient/graphql" "github.com/RobBrazier/readflow/internal/config" @@ -21,7 +23,14 @@ type AnilistTarget struct { log *log.Logger } -func (t *AnilistTarget) Login() (string, error) { +func dereferenceDefault[T any](pointer *T, defaultValue T) T { + if pointer == nil { + return defaultValue + } + return *pointer +} + +func (t AnilistTarget) Login() (string, error) { return "https://anilist.co/api/v2/oauth/authorize?client_id=21288&response_type=token", nil } @@ -32,17 +41,112 @@ func (t *AnilistTarget) getClient() graphql.Client { return t.client } -func (t *AnilistTarget) GetToken() string { +func (t AnilistTarget) GetToken() string { return config.GetTokens().Anilist } +func (t AnilistTarget) ShouldProcess(book source.BookContext) bool { + id := book.Current.AnilistID + if id == nil { + return false + } + if *id == "" { + return false + } + return true +} + func (t *AnilistTarget) GetCurrentUser() string { response, err := anilist.GetCurrentUser(t.ctx, t.getClient()) cobra.CheckErr(err) return response.Viewer.Name } +func (t *AnilistTarget) getLocalVolumes(book source.Book, maxVolumes int) int { + // lets just assume it's volume 1 if the pointer is null (i.e. no series) + volume := dereferenceDefault(book.BookSeriesIndex, 1) + + if maxVolumes > 0 && volume > maxVolumes { + t.log.Warn("Volume number exceeds the volume count on anilist - capping value", "book", book.BookName, "volume", volume, "max", maxVolumes) + + } + + return volume +} + +func (t *AnilistTarget) getLocalChapters(book source.BookContext) (current int, previous int) { + currentVolumeChapters := dereferenceDefault(book.Current.ChapterCount, 0) + previousVolumeChapters := 0 + if len(book.Previous) > 0 { + for _, book := range book.Previous { + previousVolumeChapters += dereferenceDefault(book.ChapterCount, 0) + } + } + return currentVolumeChapters, previousVolumeChapters +} + +func (t *AnilistTarget) getEstimatedNewChapterCount(book source.BookContext, maxChapters int) int { + chapter, localPreviousChapters := t.getLocalChapters(book) + + progress := dereferenceDefault(book.Current.ProgressPercent, 0.0) / 100 + latestVolumeChapter := int(math.Round(float64(chapter) * progress)) + + estimatedChapter := localPreviousChapters + latestVolumeChapter + + t.log.Debug("Estimated current chapter", "book", book.Current.BookName, "progress", progress, "chapter", estimatedChapter) + + if maxChapters > 0 && estimatedChapter > maxChapters { + t.log.Warn("Estimated chapter exceeds the chapter count on anilist - capping value", "book", book.Current.BookName, "estimated", estimatedChapter, "max", maxChapters) + estimatedChapter = maxChapters + } + + return estimatedChapter +} + func (t *AnilistTarget) UpdateReadStatus(book source.BookContext) error { + anilistId, err := strconv.Atoi(*book.Current.AnilistID) + if err != nil { + t.log.Error("Invalid anilist id", "id", *book.Current.AnilistID) + return err + } + ctx := t.ctx + client := t.getClient() + current, err := anilist.GetUserMediaById(ctx, client, anilistId) + if err != nil { + return err + } + + bookName := book.Current.BookName + title := current.Media.Title.UserPreferred + maxVolumes := current.Media.Volumes + maxChapters := current.Media.Chapters + status := current.Media.MediaListEntry.Status + + remoteVolumes := current.Media.MediaListEntry.ProgressVolumes + remoteChapters := current.Media.MediaListEntry.Progress + + localVolumes := t.getLocalVolumes(book.Current, maxVolumes) + estimatedChapter := t.getEstimatedNewChapterCount(book, maxChapters) + + if localVolumes <= remoteVolumes && estimatedChapter <= remoteChapters { + t.log. + With("book", bookName, "title", title). + Info("Skipping update as target is already up-to-date") + return nil + } + if status == "" { + status = anilist.MediaListStatusCurrent + } + if estimatedChapter == maxChapters { + status = anilist.MediaListStatusCompleted + } + t.log.Info("Updating progress for", "book", bookName, "volume", localVolumes, "chapter", estimatedChapter) + _, err = anilist.UpdateProgress(ctx, client, anilistId, estimatedChapter, localVolumes, status) + if err != nil { + t.log.Error("error updating progress", "error", err) + return err + } + return nil } diff --git a/internal/target/anilist/generated.go b/internal/target/anilist/generated.go index 27f9cbe..8a7f3a1 100644 --- a/internal/target/anilist/generated.go +++ b/internal/target/anilist/generated.go @@ -34,6 +34,171 @@ func (v *GetCurrentUserViewerUser) GetId() int { return v.Id } // GetName returns GetCurrentUserViewerUser.Name, and is useful for accessing the field via an interface. func (v *GetCurrentUserViewerUser) GetName() string { return v.Name } +// GetUserMediaByIdMedia includes the requested fields of the GraphQL type Media. +// The GraphQL type's documentation follows. +// +// Anime or Manga +type GetUserMediaByIdMedia struct { + // The amount of volumes the manga has when complete + Volumes int `json:"volumes"` + // The amount of chapters the manga has when complete + Chapters int `json:"chapters"` + // The authenticated user's media list entry for the media + MediaListEntry GetUserMediaByIdMediaMediaListEntryMediaList `json:"mediaListEntry"` + // The official titles of the media in various languages + Title GetUserMediaByIdMediaTitle `json:"title"` +} + +// GetVolumes returns GetUserMediaByIdMedia.Volumes, and is useful for accessing the field via an interface. +func (v *GetUserMediaByIdMedia) GetVolumes() int { return v.Volumes } + +// GetChapters returns GetUserMediaByIdMedia.Chapters, and is useful for accessing the field via an interface. +func (v *GetUserMediaByIdMedia) GetChapters() int { return v.Chapters } + +// GetMediaListEntry returns GetUserMediaByIdMedia.MediaListEntry, and is useful for accessing the field via an interface. +func (v *GetUserMediaByIdMedia) GetMediaListEntry() GetUserMediaByIdMediaMediaListEntryMediaList { + return v.MediaListEntry +} + +// GetTitle returns GetUserMediaByIdMedia.Title, and is useful for accessing the field via an interface. +func (v *GetUserMediaByIdMedia) GetTitle() GetUserMediaByIdMediaTitle { return v.Title } + +// GetUserMediaByIdMediaMediaListEntryMediaList includes the requested fields of the GraphQL type MediaList. +// The GraphQL type's documentation follows. +// +// List of anime or manga +type GetUserMediaByIdMediaMediaListEntryMediaList struct { + // The amount of volumes read by the user + ProgressVolumes int `json:"progressVolumes"` + // The amount of episodes/chapters consumed by the user + Progress int `json:"progress"` + // The watching/reading status + Status MediaListStatus `json:"status"` +} + +// GetProgressVolumes returns GetUserMediaByIdMediaMediaListEntryMediaList.ProgressVolumes, and is useful for accessing the field via an interface. +func (v *GetUserMediaByIdMediaMediaListEntryMediaList) GetProgressVolumes() int { + return v.ProgressVolumes +} + +// GetProgress returns GetUserMediaByIdMediaMediaListEntryMediaList.Progress, and is useful for accessing the field via an interface. +func (v *GetUserMediaByIdMediaMediaListEntryMediaList) GetProgress() int { return v.Progress } + +// GetStatus returns GetUserMediaByIdMediaMediaListEntryMediaList.Status, and is useful for accessing the field via an interface. +func (v *GetUserMediaByIdMediaMediaListEntryMediaList) GetStatus() MediaListStatus { return v.Status } + +// GetUserMediaByIdMediaTitle includes the requested fields of the GraphQL type MediaTitle. +// The GraphQL type's documentation follows. +// +// The official titles of the media in various languages +type GetUserMediaByIdMediaTitle struct { + // The currently authenticated users preferred title language. Default romaji for non-authenticated + UserPreferred string `json:"userPreferred"` +} + +// GetUserPreferred returns GetUserMediaByIdMediaTitle.UserPreferred, and is useful for accessing the field via an interface. +func (v *GetUserMediaByIdMediaTitle) GetUserPreferred() string { return v.UserPreferred } + +// GetUserMediaByIdResponse is returned by GetUserMediaById on success. +type GetUserMediaByIdResponse struct { + // Media query + Media GetUserMediaByIdMedia `json:"Media"` +} + +// GetMedia returns GetUserMediaByIdResponse.Media, and is useful for accessing the field via an interface. +func (v *GetUserMediaByIdResponse) GetMedia() GetUserMediaByIdMedia { return v.Media } + +// Media list watching/reading status enum. +type MediaListStatus string + +const ( + // Currently watching/reading + MediaListStatusCurrent MediaListStatus = "CURRENT" + // Planning to watch/read + MediaListStatusPlanning MediaListStatus = "PLANNING" + // Finished watching/reading + MediaListStatusCompleted MediaListStatus = "COMPLETED" + // Stopped watching/reading before completing + MediaListStatusDropped MediaListStatus = "DROPPED" + // Paused watching/reading + MediaListStatusPaused MediaListStatus = "PAUSED" + // Re-watching/reading + MediaListStatusRepeating MediaListStatus = "REPEATING" +) + +// UpdateProgressResponse is returned by UpdateProgress on success. +type UpdateProgressResponse struct { + // Create or update a media list entry + SaveMediaListEntry UpdateProgressSaveMediaListEntryMediaList `json:"SaveMediaListEntry"` +} + +// GetSaveMediaListEntry returns UpdateProgressResponse.SaveMediaListEntry, and is useful for accessing the field via an interface. +func (v *UpdateProgressResponse) GetSaveMediaListEntry() UpdateProgressSaveMediaListEntryMediaList { + return v.SaveMediaListEntry +} + +// UpdateProgressSaveMediaListEntryMediaList includes the requested fields of the GraphQL type MediaList. +// The GraphQL type's documentation follows. +// +// List of anime or manga +type UpdateProgressSaveMediaListEntryMediaList struct { + // The id of the list entry + Id int `json:"id"` + // The id of the media + MediaId int `json:"mediaId"` + // The amount of episodes/chapters consumed by the user + Progress int `json:"progress"` + // The amount of volumes read by the user + ProgressVolumes int `json:"progressVolumes"` + // The watching/reading status + Status MediaListStatus `json:"status"` +} + +// GetId returns UpdateProgressSaveMediaListEntryMediaList.Id, and is useful for accessing the field via an interface. +func (v *UpdateProgressSaveMediaListEntryMediaList) GetId() int { return v.Id } + +// GetMediaId returns UpdateProgressSaveMediaListEntryMediaList.MediaId, and is useful for accessing the field via an interface. +func (v *UpdateProgressSaveMediaListEntryMediaList) GetMediaId() int { return v.MediaId } + +// GetProgress returns UpdateProgressSaveMediaListEntryMediaList.Progress, and is useful for accessing the field via an interface. +func (v *UpdateProgressSaveMediaListEntryMediaList) GetProgress() int { return v.Progress } + +// GetProgressVolumes returns UpdateProgressSaveMediaListEntryMediaList.ProgressVolumes, and is useful for accessing the field via an interface. +func (v *UpdateProgressSaveMediaListEntryMediaList) GetProgressVolumes() int { + return v.ProgressVolumes +} + +// GetStatus returns UpdateProgressSaveMediaListEntryMediaList.Status, and is useful for accessing the field via an interface. +func (v *UpdateProgressSaveMediaListEntryMediaList) GetStatus() MediaListStatus { return v.Status } + +// __GetUserMediaByIdInput is used internally by genqlient +type __GetUserMediaByIdInput struct { + MediaId int `json:"mediaId"` +} + +// GetMediaId returns __GetUserMediaByIdInput.MediaId, and is useful for accessing the field via an interface. +func (v *__GetUserMediaByIdInput) GetMediaId() int { return v.MediaId } + +// __UpdateProgressInput is used internally by genqlient +type __UpdateProgressInput struct { + MediaId int `json:"mediaId"` + Progress int `json:"progress"` + ProgressVolumes int `json:"progressVolumes"` + Status MediaListStatus `json:"status"` +} + +// GetMediaId returns __UpdateProgressInput.MediaId, and is useful for accessing the field via an interface. +func (v *__UpdateProgressInput) GetMediaId() int { return v.MediaId } + +// GetProgress returns __UpdateProgressInput.Progress, and is useful for accessing the field via an interface. +func (v *__UpdateProgressInput) GetProgress() int { return v.Progress } + +// GetProgressVolumes returns __UpdateProgressInput.ProgressVolumes, and is useful for accessing the field via an interface. +func (v *__UpdateProgressInput) GetProgressVolumes() int { return v.ProgressVolumes } + +// GetStatus returns __UpdateProgressInput.Status, and is useful for accessing the field via an interface. +func (v *__UpdateProgressInput) GetStatus() MediaListStatus { return v.Status } + // The query or mutation executed by GetCurrentUser. const GetCurrentUser_Operation = ` query GetCurrentUser { @@ -65,3 +230,92 @@ func GetCurrentUser( return &data_, err_ } + +// The query or mutation executed by GetUserMediaById. +const GetUserMediaById_Operation = ` +query GetUserMediaById ($mediaId: Int) { + Media(id: $mediaId, type: MANGA) { + volumes + chapters + mediaListEntry { + progressVolumes + progress + status + } + title { + userPreferred + } + } +} +` + +func GetUserMediaById( + ctx_ context.Context, + client_ graphql.Client, + mediaId int, +) (*GetUserMediaByIdResponse, error) { + req_ := &graphql.Request{ + OpName: "GetUserMediaById", + Query: GetUserMediaById_Operation, + Variables: &__GetUserMediaByIdInput{ + MediaId: mediaId, + }, + } + var err_ error + + var data_ GetUserMediaByIdResponse + resp_ := &graphql.Response{Data: &data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return &data_, err_ +} + +// The query or mutation executed by UpdateProgress. +const UpdateProgress_Operation = ` +mutation UpdateProgress ($mediaId: Int, $progress: Int, $progressVolumes: Int, $status: MediaListStatus) { + SaveMediaListEntry(progress: $progress, progressVolumes: $progressVolumes, mediaId: $mediaId, status: $status) { + id + mediaId + progress + progressVolumes + status + } +} +` + +func UpdateProgress( + ctx_ context.Context, + client_ graphql.Client, + mediaId int, + progress int, + progressVolumes int, + status MediaListStatus, +) (*UpdateProgressResponse, error) { + req_ := &graphql.Request{ + OpName: "UpdateProgress", + Query: UpdateProgress_Operation, + Variables: &__UpdateProgressInput{ + MediaId: mediaId, + Progress: progress, + ProgressVolumes: progressVolumes, + Status: status, + }, + } + var err_ error + + var data_ UpdateProgressResponse + resp_ := &graphql.Response{Data: &data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return &data_, err_ +} diff --git a/internal/target/errors.go b/internal/target/errors.go deleted file mode 100644 index 973e65e..0000000 --- a/internal/target/errors.go +++ /dev/null @@ -1,5 +0,0 @@ -package target - -import "errors" - -var BookNotFound = errors.New("Book Not found in Reading List") diff --git a/internal/target/hardcover.go b/internal/target/hardcover.go index 4a750a2..233d89b 100644 --- a/internal/target/hardcover.go +++ b/internal/target/hardcover.go @@ -34,7 +34,7 @@ type hardcoverProgress struct { edition int } -func (t *HardcoverTarget) Login() (string, error) { +func (t HardcoverTarget) Login() (string, error) { return "https://hardcover.app/account/api", nil } @@ -45,7 +45,7 @@ func (t *HardcoverTarget) getClient() graphql.Client { return t.client } -func (t *HardcoverTarget) GetToken() string { +func (t HardcoverTarget) GetToken() string { return config.GetTokens().Hardcover } @@ -55,6 +55,17 @@ func (t *HardcoverTarget) GetCurrentUser() string { return response.GetMe()[0].GetUsername() } +func (t HardcoverTarget) ShouldProcess(book source.BookContext) bool { + id := book.Current.HardcoverID + if id == nil { + return false + } + if *id == "" { + return false + } + return true +} + // Yes this is absolutely horrible, but the generated code is horrible too... func (t *HardcoverTarget) getCurrentBookProgress(slug string) (*hardcoverProgress, error) { current, err := hardcover.GetUserBooksBySlug(t.ctx, t.getClient(), slug) @@ -137,9 +148,6 @@ func (t *HardcoverTarget) startProgress(id, pages, edition, status int) error { func (t *HardcoverTarget) UpdateReadStatus(book source.BookContext) error { slug := book.Current.HardcoverID - if slug == nil { - return BookNotFound - } localProgressPointer := book.Current.ProgressPercent if localProgressPointer == nil { // no error, but nothing to update as we have no progress @@ -152,11 +160,12 @@ func (t *HardcoverTarget) UpdateReadStatus(book source.BookContext) error { } // round to 0 decimal places to match the source progress remoteProgress := math.Round(float64(bookProgress.progress)) + log := t.log.With("book", book.Current.BookName) - t.log.Info("Got book data", "book", book.Current.BookName, "localProgress", localProgress, "remoteProgress", remoteProgress) + log.Info("Retrieved book data", "localProgress", localProgress, "remoteProgress", remoteProgress) if localProgress <= remoteProgress { - t.log.Info("Progress already up-to-date, skipping") + log.Info("Skipping update as target is already up-to-date") return nil } pages := float64(bookProgress.pages) @@ -164,7 +173,7 @@ func (t *HardcoverTarget) UpdateReadStatus(book source.BookContext) error { newPagesCount := int(math.Round(pages * progress)) if bookProgress.readId != nil { - t.log.Info("Updating progress for", "book", book.Current.BookName, "pages", newPagesCount) + log.Info("Updating progress for", "pages", newPagesCount) startTime := time.Now() if bookProgress.startTime != nil { startTime = *bookProgress.startTime @@ -172,16 +181,16 @@ func (t *HardcoverTarget) UpdateReadStatus(book source.BookContext) error { if progress == 100.0 { err := t.finishProgress(*bookProgress.readId, bookProgress.bookId, newPagesCount, bookProgress.edition, startTime) if err != nil { - t.log.Error("error finishing book", "error", err) + log.Error("error finishing book", "error", err) } } else { err := t.updateProgress(*bookProgress.readId, bookProgress.bookId, newPagesCount, bookProgress.edition, bookProgress.status, startTime) if err != nil { - t.log.Error("error updating progress", "error", err) + log.Error("error updating progress", "error", err) } } } else { - log.Info("Starting progress for", "book", book.Current.BookName, "pages", newPagesCount) + log.Info("Starting progress for", "pages", newPagesCount) err := t.startProgress(bookProgress.bookId, newPagesCount, bookProgress.edition, bookProgress.status) if err != nil { t.log.Error("error starting progress", "error", err) diff --git a/internal/target/target.go b/internal/target/target.go index 0523d57..dedfcaf 100644 --- a/internal/target/target.go +++ b/internal/target/target.go @@ -38,6 +38,7 @@ type SyncTarget interface { GetToken() string GetName() string GetCurrentUser() string + ShouldProcess(book source.BookContext) bool UpdateReadStatus(book source.BookContext) error } @@ -54,7 +55,7 @@ func (g *GraphQLTarget) getClient(url, token string) graphql.Client { wrapped: http.DefaultTransport, }, } - retryClient.Logger = slog.New(log.Default()) + retryClient.Logger = slog.New(log.WithPrefix("graphql")) httpClient := retryClient.StandardClient() return graphql.NewClient(url, httpClient) } diff --git a/schemas/anilist/genqlient.yaml b/schemas/anilist/genqlient.yaml index 5728a7b..9a69093 100644 --- a/schemas/anilist/genqlient.yaml +++ b/schemas/anilist/genqlient.yaml @@ -1,4 +1,5 @@ schema: schema.gql operations: - queries.gql + - mutations.gql generated: ../../internal/target/anilist/generated.go diff --git a/schemas/anilist/mutations.gql b/schemas/anilist/mutations.gql new file mode 100644 index 0000000..dc630cb --- /dev/null +++ b/schemas/anilist/mutations.gql @@ -0,0 +1,9 @@ +mutation UpdateProgress($mediaId: Int, $progress: Int, $progressVolumes: Int, $status: MediaListStatus) { + SaveMediaListEntry(progress: $progress, progressVolumes: $progressVolumes, mediaId: $mediaId, status: $status) { + id + mediaId + progress + progressVolumes + status + } +} diff --git a/schemas/anilist/queries.gql b/schemas/anilist/queries.gql index 8c5f5d8..6976e8c 100644 --- a/schemas/anilist/queries.gql +++ b/schemas/anilist/queries.gql @@ -4,3 +4,19 @@ query GetCurrentUser { name } } + + +query GetUserMediaById($mediaId: Int) { + Media(id: $mediaId, type: MANGA) { + volumes + chapters + mediaListEntry { + progressVolumes + progress + status + } + title { + userPreferred + } + } +}