diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/kavita-helper-go.iml b/.idea/kavita-helper-go.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/kavita-helper-go.iml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<module type="WEB_MODULE" version="4"> + <component name="Go" enabled="true" /> + <component name="NewModuleRootManager"> + <content url="file://$MODULE_DIR$" /> + <orderEntry type="inheritedJdk" /> + <orderEntry type="sourceFolder" forTests="false" /> + </component> +</module> \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..9deb3c9 --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="MaterialThemeProjectNewConfig"> + <option name="metadata"> + <MTProjectMetadataState> + <option name="migrated" value="true" /> + <option name="pristineConfig" value="false" /> + <option name="userId" value="-349a7812:1900e527711:-7ffe" /> + </MTProjectMetadataState> + </option> + </component> +</project> \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..6cc55c5 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="ProjectModuleManager"> + <modules> + <module fileurl="file://$PROJECT_DIR$/.idea/kavita-helper-go.iml" filepath="$PROJECT_DIR$/.idea/kavita-helper-go.iml" /> + </modules> + </component> +</project> \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="VcsDirectoryMappings"> + <mapping directory="$PROJECT_DIR$" vcs="Git" /> + </component> +</project> \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..755b411 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module kavita-helper-go + +go 1.23 + +require github.com/beevik/etree v1.5.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..92e3ab9 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= diff --git a/jnc/jnc.go b/jnc/jnc.go new file mode 100644 index 0000000..5f0a094 --- /dev/null +++ b/jnc/jnc.go @@ -0,0 +1,417 @@ +package jnc + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "sort" + "strconv" + "strings" + "time" +) + +const BaseUrl = "https://labs.j-novel.club/" +const ApiV1Url = BaseUrl + "app/v1/" +const ApiV2Url = BaseUrl + "app/v2/" +const FeedUrl = BaseUrl + "feed/" + +type ApiFormat int + +const ( + JSON = iota + TEXT + PROTOBUF +) + +var ApiFormatParam = map[ApiFormat]string{ + JSON: "format=json", + TEXT: "format=text", + PROTOBUF: "", +} + +type BookStatus string +type PublishingStatus string +type SeriesFormat string +type DownloadType string + +type AuthBody struct { + Login string `json:"login"` + Password string `json:"password"` + Slim bool `json:"slim"` +} + +type JWT struct { + Token string `json:"id"` + TTL string `json:"ttl"` + Created string `json:"created"` +} + +type Api struct { + _auth Auth + _format ApiFormat + _library Library + _series map[string]SerieAugmented +} + +type Auth struct { + _username string + _password string + _jwt JWT +} + +type Library struct { + Books []Book `json:"books"` + Pagination Pagination `json:"pagination"` +} + +type Book struct { + Id string `json:"id"` + LegacyId string `json:"legacyId"` + Volume Volume `json:"volume"` + Serie Serie `json:"serie"` + Purchased string `json:"purchased"` + LastDownload string `json:"lastDownload"` + LastUpdated string `json:"lastUpdated"` + Downloads []Download `json:"downloads"` + Status BookStatus `json:"status"` +} + +type Volume struct { + Id string `json:"id"` + LegacyId string `json:"legacyId"` + Title string `json:"title"` + ShortTitle string `json:"shortTitle"` + OriginalTitle string `json:"originalTitle"` + Slug string `json:"slug"` + Number int `json:"number"` + OriginalPublisher string `json:"originalPublisher"` + Label string `json:"label"` + Creators []string `json:"creators"` // TODO: check, might not be correct + hidden bool + ForumTopicId int `json:"forumTopicId"` + Created string `json:"created"` + Publishing string `json:"publishing"` + Description string `json:"description"` + ShortDescription string `json:"shortDescription"` + Cover Cover `json:"cover"` + Owned bool `json:"owned"` + PremiumExtras string `json:"premiumExtras"` + NoEbook bool `json:"noEbook"` + TotalParts int `json:"totalParts"` + OnSale bool `json:"onSale"` +} + +type VolumeAugmented struct { + Info Volume + Downloads []Download + updated string + downloaded string +} + +func (volume *VolumeAugmented) UpdateAvailable() bool { + if volume.downloaded == "" && len(volume.Downloads) != 0 { + return true // The file has never been downloaded but at least one is available + } + + downloadedTime, err := time.Parse(time.RFC3339, volume.downloaded) + if err != nil { + panic(err) + } + + updatedTime, err := time.Parse(time.RFC3339, volume.updated) + if err != nil { + panic(err) + } + + if downloadedTime.Before(updatedTime) { + return true + } else { + return false + } +} + +type Cover struct { + OriginalUrl string `json:"originalUrl"` + CoverUrl string `json:"coverUrl"` + ThumbnailUrl string `json:"thumbnail"` +} + +type Serie struct { + Id string `json:"id"` + LegacyId string `json:"legacyId"` + Type SeriesFormat `json:"type"` + Status PublishingStatus `json:"status"` + Title string `json:"title"` + ShortTitle string `json:"shortTitle"` + OriginalTitle string `json:"originalTitle"` + Slug string `json:"slug"` + Hidden bool `json:"hidden"` + Created string `json:"created"` + Description string `json:"description"` + ShortDescription string `json:"shortDescription"` + Tags []string `json:"tags"` + Cover Cover `json:"cover"` + Following bool `json:"following"` + Catchup bool `json:"catchup"` + Rentals bool `json:"rentals"` + TopicId int `json:"topicId"` + AgeRating int `json:"ageRating"` +} + +type SerieAugmented struct { + Info Serie `json:"series"` + Volumes []VolumeAugmented `json:"volumes"` +} + +type Download struct { + Link string `json:"link"` + Type DownloadType `json:"type"` + Label string `json:"label"` +} + +type Pagination struct { + Limit int `json:"limit"` + Skip int `json:"skip"` + LastPage bool `json:"lastPage"` +} + +func NewJNC() Api { + jncApi := Api{ + _auth: Auth{ + _username: "", + _password: "", + _jwt: JWT{}, + }, + _library: Library{}, + _series: map[string]SerieAugmented{}, + } + return jncApi +} + +func (jncApi *Api) ReturnFormat() string { + return ApiFormatParam[jncApi._format] +} + +// Login attempts a login using the username and password set with SetUsername and SetPassword +func (jncApi *Api) Login() error { + fmt.Println("Logging in...") + if jncApi._auth._username == "" { + return errors.New("username not specified") + } + if jncApi._auth._password == "" { + return errors.New("password not specified") + } + + authBody := AuthBody{ + Login: jncApi._auth._username, + Password: jncApi._auth._password, + Slim: true, + } + + jsonAuthBody, err := json.Marshal(authBody) + if err != nil { + return err + } + + res, err := http.Post(ApiV2Url+"auth/login?"+jncApi.ReturnFormat(), "application/json", bytes.NewBuffer(jsonAuthBody)) + if err != nil { + return err + } + if res.StatusCode != 200 && res.StatusCode != 201 { + return errors.New(res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + err = json.Unmarshal(body, &jncApi._auth._jwt) + if err != nil { + return err + } + return nil +} + +// LoginOTP starts an OTP based login using the username provided with SetUsername. NOTE: Currently not implemented +func (jncApi *Api) LoginOTP() error { + if jncApi._auth._username == "" { + return errors.New("username not specified") + } + + return errors.New("not implemented") +} + +func (jncApi *Api) AuthParam() string { + return "access_token=" + jncApi._auth._jwt.Token +} + +// Logout invalidates the auth token and resets the jnc instance to a blank slate. No information remains after calling. +func (jncApi *Api) Logout() error { + fmt.Println("Logging out...") + res, err := http.Post(ApiV2Url+"auth/logout?"+jncApi.ReturnFormat()+"&"+jncApi.AuthParam(), "application/json", nil) + if err != nil { + panic(err) + } + + if res.StatusCode != 204 { + return errors.New(res.Status) + } + + *jncApi = Api{ + _auth: Auth{}, + _format: JSON, + _library: Library{}, + _series: map[string]SerieAugmented{}, + } + + return nil +} + +func (jncApi *Api) SetPassword(password string) { + jncApi._auth._password = password +} + +func (jncApi *Api) SetUsername(username string) { + jncApi._auth._username = username +} + +// FetchLibrary retrieves the list of Book's in the logged-in User's J-Novel Club library +func (jncApi *Api) FetchLibrary() error { + fmt.Println("Fetching library contents...") + res, err := http.Get(ApiV2Url + "me/library?" + jncApi.ReturnFormat() + "&" + jncApi.AuthParam()) + if err != nil { + return err + } + + if res.StatusCode != 200 { + return errors.New(res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + err = json.Unmarshal(body, &jncApi._library) + if err != nil { + return err + } + + for i := range jncApi._library.Books { + book := &jncApi._library.Books[i] + + data, ok := jncApi._series[book.Serie.Id] + if ok { + data.Volumes = append(data.Volumes, VolumeAugmented{ + Info: book.Volume, + Downloads: book.Downloads, + updated: book.LastUpdated, + downloaded: book.LastDownload, + }) + sort.Slice(data.Volumes, func(i, j int) bool { + return data.Volumes[i].Info.Number < data.Volumes[j].Info.Number + }) + jncApi._series[book.Serie.Id] = data + continue + } + jncApi._series[book.Serie.Id] = SerieAugmented{ + Info: book.Serie, + Volumes: []VolumeAugmented{ + { + Info: book.Volume, + Downloads: book.Downloads, + updated: book.LastUpdated, + downloaded: book.LastDownload, + }, + }, + } + } + + return nil +} + +// FetchLibrarySeries is only needed because for whatever reason the Endpoint used in FetchLibrary does not return the Series Tags +func (jncApi *Api) FetchLibrarySeries() error { + progress := 1 + fmt.Printf("Fetching Series Info: 0/%s - 0%", strconv.Itoa(len(jncApi._series))) + for i := range jncApi._series { + fmt.Printf("\rFetching Series Info: %s/%s - %s%%", strconv.Itoa(progress), strconv.Itoa(len(jncApi._series)), strconv.FormatFloat(float64(progress)/float64(len(jncApi._series))*float64(100), 'f', 2, 32)) + serie := jncApi._series[i] + + res, err := http.Get(ApiV2Url + "series/" + serie.Info.Id + "?" + jncApi.ReturnFormat() + "&" + jncApi.AuthParam()) + + if err != nil { + return err + } + + if res.StatusCode != 200 { + return errors.New(res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + err = json.Unmarshal(body, &serie) + if err != nil { + jncApi._series[i] = serie + } + + progress++ + } + + return nil +} + +// GetLibrarySeries returns a list of unique series found in the User's library. Tags are only included if FetchLibrarySeries was previously called. +func (jncApi *Api) GetLibrarySeries() (seriesList []SerieAugmented, err error) { + arr := make([]SerieAugmented, 0, len(jncApi._series)) + for s := range jncApi._series { + arr = append(arr, jncApi._series[s]) + } + + sort.Slice(arr, func(i, j int) bool { + return arr[i].Info.Title < arr[j].Info.Title + }) + + return arr, nil +} + +func (jncApi *Api) Download(link string, targetDir string) (name string, err error) { + fmt.Printf("Downloading %s...", link) + res, err := http.Get(link) + if err != nil { + return "", err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(res.Body) + + if res.StatusCode != 200 { + return "", errors.New(res.Status) + } + + filePath := targetDir + strings.Trim(strings.Split(res.Header["Content-Disposition"][0], "=")[1], "\"") + + file, err := os.Create(filePath) + if err != nil { + return "", err + } + defer func(file *os.File) { + err := file.Close() + if err != nil { + panic(err) + } + }(file) + + _, err = io.Copy(file, res.Body) + if err != nil { + return "", err + } + + return filePath, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f05a8ad --- /dev/null +++ b/main.go @@ -0,0 +1,596 @@ +package main + +import ( + "archive/zip" + "bufio" + "container/list" + "fmt" + "github.com/beevik/etree" + "io" + "kavita-helper-go/jnc" + "os" + "os/exec" + "regexp" + "slices" + "strconv" + "strings" +) + +type Chapter struct { + numberMain int8 + numberSub int8 // uses 0 for regular main chapters + chDisplay string + title string + firstPage string + lastPage string +} + +type FileWrapper struct { + fileName string + file zip.File + fileSet bool +} + +func NewFileWrapper() FileWrapper { + newFileWrapper := FileWrapper{} + newFileWrapper.fileSet = false + newFileWrapper.fileName = "" + return newFileWrapper +} + +// TODO's +// [ ] Create Chapter .cbz's based on given Information (no ComicInfo.xml yet) +// [ ] Sort Into Volume Folders (requires following step) +// [ ] Get ComicInfo.xml data from the J-Novel Club API +// [ ] Get the Manga from the J-Novel Club API + +func GetUserInput(prompt string) string { + reader := bufio.NewScanner(os.Stdin) + fmt.Print(prompt) + reader.Scan() + err := reader.Err() + if err != nil { + panic(err) + } + return reader.Text() +} + +func ClearScreen() { + cmd := exec.Command("clear") + cmd.Stdout = os.Stdout + if err := cmd.Run(); err != nil { + fmt.Println("[ERROR] - ", err) + } +} + +func main() { + downloadDir := "/home/neshura/Documents/Go Workspaces/Kavita Helper/" + + // initialize the J-Novel Club Instance + jnovel := jnc.NewJNC() + + var username string + if slices.Contains(os.Args, "-u") { + idx := slices.Index(os.Args, "-u") + username = os.Args[idx+1] + } else { + username = GetUserInput("Enter Username: ") + } + jnovel.SetUsername(username) + + var password string + if slices.Contains(os.Args, "-p") { + idx := slices.Index(os.Args, "-p") + password = os.Args[idx+1] + } else { + password = GetUserInput("Enter Password: ") + } + jnovel.SetPassword(password) + + err := jnovel.Login() + if err != nil { + panic(err) + } + defer func(jnovel *jnc.Api) { + err := jnovel.Logout() + if err != nil { + panic(err) + } + }(&jnovel) + + err = jnovel.FetchLibrary() + if err != nil { + panic(err) + } + + err = jnovel.FetchLibrarySeries() + if err != nil { + panic(err) + } + + seriesList, err := jnovel.GetLibrarySeries() + if err != nil { + panic(err) + } + + ClearScreen() + + fmt.Println("[1] - Download Single Volume") + fmt.Println("[2] - Download Entire Series") + fmt.Println("[3] - Download Entire Library") + fmt.Println("[4] - Download Entire Library [MANGA only]") + fmt.Println("[5] - Download Entire Library [NOVEL only]") + mode := GetUserInput("Select Mode: ") + + ClearScreen() + + if mode == "3" || mode == "4" || mode == "5" { + updatedOnly := GetUserInput("Download Only Where Newer Files Are Available? [Y/N]: ") + + for s := range seriesList { + serie := seriesList[s] + if mode == "3" || mode == "4" { + if serie.Info.Type == "MANGA" { + HandleSeries(jnovel, serie, downloadDir, updatedOnly == "Y" || updatedOnly == "y") + } + } + if mode == "3" || mode == "5" { + if serie.Info.Type == "NOVEL" { + HandleSeries(jnovel, serie, downloadDir, updatedOnly == "Y" || updatedOnly == "y") + } + } + } + fmt.Println("Done") + } else { + fmt.Println("\n###[Series Selection]###") + for s := range seriesList { + serie := seriesList[s].Info + + fmt.Printf("[%s] - [%s] - %s\n", strconv.Itoa(s+1), serie.Type, serie.Title) + } + + msg := "Enter Series Number: " + var seriesNumber int + for { + selection := GetUserInput(msg) + seriesNumber, err = strconv.Atoi(selection) + if err != nil { + msg = "\rInvalid. Enter VALID Series Number: " + } + break + } + + serie := seriesList[seriesNumber-1] + + if mode == "2" { + HandleSeries(jnovel, serie, downloadDir, false) + } else { + ClearScreen() + fmt.Println("\n###[Volume Selection]###") + for i := range serie.Volumes { + vol := serie.Volumes[i] + fmt.Printf("[%s] - %s\n", strconv.Itoa(i+1), vol.Info.Title) + } + + msg = "Enter Volume Number: " + var volumeNumber int + for { + selection := GetUserInput(msg) + volumeNumber, err = strconv.Atoi(selection) + if err != nil { + msg = "\rInvalid. Enter VALID Volume Number: " + } + break + } + + volume := serie.Volumes[volumeNumber-1] + HandleVolume(jnovel, serie, volume, downloadDir) + } + } +} + +func HandleSeries(jnovel jnc.Api, serie jnc.SerieAugmented, downloadDir string, updatedOnly bool) { + for v := range serie.Volumes { + volume := serie.Volumes[v] + if len(volume.Downloads) != 0 && volume.UpdateAvailable() { + downloadDir = PrepareSerieDirectory(serie, volume, downloadDir) + HandleVolume(jnovel, serie, volume, downloadDir) + } + } +} + +func PrepareSerieDirectory(serie jnc.SerieAugmented, volume jnc.VolumeAugmented, downloadDir string) string { + splits := strings.Split(downloadDir, "/") + // last element of this split is always empty due to the trailing slash + if splits[len(splits)-2] != serie.Info.Title { + if serie.Info.Title == "Ascendance of a Bookworm" { + partSplits := strings.Split(volume.Info.ShortTitle, " ") + part := " " + partSplits[0] + " " + partSplits[1] + + if strings.Split(splits[len(splits)-2], " ")[0] == "Ascendance" { + splits[len(splits)-2] = serie.Info.Title + part + } else { + splits[len(splits)-1] = serie.Info.Title + part + splits = append(splits, "") + } + downloadDir = strings.Join(splits, "/") + } else { + downloadDir += serie.Info.Title + "/" + } + + _, err := os.Stat(downloadDir) + if err != nil { + err = os.Mkdir(downloadDir, 0755) + if err != nil { + panic(err) + } + } + } + + return downloadDir +} + +func HandleVolume(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc.VolumeAugmented, downloadDir string) { + var downloadLink string + if len(volume.Downloads) == 0 { + fmt.Printf("Volume %s currently has no downloads available. Skipping \n", volume.Info.Title) + return + } else if len(volume.Downloads) > 1 { + fmt.Println("TODO: implement download selection/default select highest quality") + downloadLink = volume.Downloads[len(volume.Downloads)-1].Link + } else { + downloadLink = volume.Downloads[0].Link + } + + DownloadAndProcessEpub(jnovel, serie, volume, downloadLink, downloadDir) +} + +func DownloadAndProcessEpub(jnovel jnc.Api, serie jnc.SerieAugmented, volume jnc.VolumeAugmented, downloadLink string, downloadDirectory string) { + FormatNavigationFile := map[string]string{ + "manga": "item/nav.ncx", + "novel": "OEBPS/toc.ncx", + } + + FormatMetadataName := map[string]string{ + "item/comic.opf": "manga", + "OEBPS/content.opf": "novel", + } + + MetaInf := "META-INF/container.xml" + file, err := jnovel.Download(downloadLink, downloadDirectory) + if err != nil { + panic(err) + } + + epub, err := zip.OpenReader(file) + if err != nil { + fmt.Println(err) + } + + metadata := NewFileWrapper() + navigation := NewFileWrapper() + + for i := range epub.Reader.File { + file := epub.Reader.File[i] + + if file.Name == MetaInf { + doc := etree.NewDocument() + reader, err := file.Open() + if err != nil { + fmt.Println(err) + } + + if _, err := doc.ReadFrom(reader); err != nil { + _ = reader.Close() + panic(err) + } + + metadata.fileName = doc.FindElement("//container/rootfiles/rootfile").SelectAttr("full-path").Value + navigation.fileName = FormatNavigationFile[FormatMetadataName[metadata.fileName]] + } + + if len(metadata.fileName) != 0 && file.Name == metadata.fileName { + metadata.file = *file + metadata.fileSet = true + } + + if len(navigation.fileName) != 0 && file.Name == navigation.fileName { + navigation.file = *file + navigation.fileSet = true + } + + if metadata.fileSet && navigation.fileSet { + println("breaking") + break + } + } + + // Switch based on metadata file + + switch FormatMetadataName[metadata.fileName] { + case "manga": + { + chapterList := GenerateMangaChapterList(navigation) + + for ch := chapterList.Front(); ch != nil; ch = ch.Next() { + chap := ch.Value.(Chapter) + fmt.Printf("[%s] %s: %s - %s\n", chap.chDisplay, chap.title, chap.firstPage, chap.lastPage) + } + } + case "novel": + { + CalibreSeriesAttr := "calibre:series" + CalibreSeriesIndexAttr := "calibre:series_index" + EpubTitleTag := "//package/metadata" + + metafile, err := metadata.file.Open() + if err != nil { + fmt.Println(err) + } + + doc := etree.NewDocument() + if _, err = doc.ReadFrom(metafile); err != nil { + fmt.Println(err) + } + + idx := doc.FindElement(EpubTitleTag + "/dc:title").Index() + + seriesTitle := etree.NewElement("meta") + seriesTitle.CreateAttr("name", CalibreSeriesAttr) + var title string + var number string + if serie.Info.Title == "Ascendance of a Bookworm" { + splits := strings.Split(volume.Info.ShortTitle, " ") + title = serie.Info.Title + " " + splits[0] + " " + splits[1] + number = splits[3] + } else { + title = serie.Info.Title + number = strconv.Itoa(volume.Info.Number) + } + + seriesTitle.CreateAttr("content", title) + + seriesNumber := etree.NewElement("meta") + seriesNumber.CreateAttr("name", CalibreSeriesIndexAttr) + seriesNumber.CreateAttr("content", number) + + doc.FindElement(EpubTitleTag).InsertChildAt(idx+1, seriesTitle) + doc.FindElement(EpubTitleTag).InsertChildAt(idx+2, seriesNumber) + doc.Indent(2) + + // Open the zip file for writing + zipfile, err := os.OpenFile(file+".new", os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + panic(err) + } + defer func(zipfile *os.File) { + err := zipfile.Close() + if err != nil { + panic(err) + } + }(zipfile) + + zipWriter := zip.NewWriter(zipfile) + + str, err := doc.WriteToString() + if err != nil { + panic(err) + } + + metawriter, err := zipWriter.Create(metadata.fileName) + if err != nil { + panic(err) + } + + _, err = metawriter.Write([]byte(str)) + if err != nil { + panic(err) + } + + for _, f := range epub.File { + if f.Name != metadata.fileName { + writer, err := zipWriter.Create(f.Name) + if err != nil { + panic(err) + } + reader, err := f.Open() + if err != nil { + panic(err) + } + + _, err = io.Copy(writer, reader) + if err != nil { + _ = reader.Close() + panic(err) + } + } + } + + epub.Close() + zipWriter.Close() + source, _ := os.Open(file + ".new") + dest, _ := os.Create(file) + io.Copy(dest, source) + os.Remove(file + ".new") + } + } +} + +func FinalizeChapters(chapters *list.List, pageList []*etree.Element) *list.List { + fmt.Println("Finalizing Chapters") + + lastPage := pageList[len(pageList)-1].SelectAttr("src").Value + + sortedChapters := list.New() + + for ch := chapters.Back(); ch != nil; ch = ch.Prev() { + var newChapterData Chapter + currentChapter := ch.Value.(Chapter) + + if currentChapter.numberMain != -1 { + newChapterData.title = currentChapter.title + newChapterData.firstPage = currentChapter.firstPage + } else { + mainChapter := GetLastValidChapterNumber(ch) + for sch := sortedChapters.Front(); sch != nil; sch = sch.Next() { + if sch.Value.(Chapter).title == mainChapter.title { + newChapterData = sch.Value.(Chapter) + newChapterData.firstPage = currentChapter.firstPage + sch.Value = newChapterData + break + } + } + continue + } + + if currentChapter.numberSub != 0 { + numberMain, numberSub := AnalyzeMainChapter(ch) + + newChapterData.numberMain = numberMain + newChapterData.numberSub = numberSub + } else { + newChapterData.numberMain = currentChapter.numberMain + newChapterData.numberSub = currentChapter.numberSub + } + + if ch.Next() == nil { + newChapterData.lastPage = lastPage + } else { + var lastChapterPage string + for p := range pageList { + page := pageList[p] + if page.SelectAttr("src").Value == ch.Next().Value.(Chapter).firstPage { + lastChapterPage = pageList[p-1].SelectAttr("src").Value + } + } + newChapterData.lastPage = lastChapterPage + } + + if newChapterData.numberSub != 0 { + newChapterData.chDisplay = strconv.FormatInt(int64(newChapterData.numberMain), 10) + "." + strconv.FormatInt(int64(newChapterData.numberSub), 10) + } else { + newChapterData.chDisplay = strconv.FormatInt(int64(newChapterData.numberMain), 10) + } + + sortedChapters.PushFront(newChapterData) + } + + return sortedChapters +} + +func AnalyzeMainChapter(currentChapter *list.Element) (int8, int8) { + var currentChapterNumber int8 + subChapterCount := 1 + + if currentChapter.Next() != nil { + ch := currentChapter.Next() + for { + if ch.Value.(Chapter).numberSub != 0 { + subChapterCount++ + if ch.Next() != nil { + ch = ch.Next() + } else { + break + } + } else { + break + } + } + } + + if currentChapter.Prev() != nil { + ch := currentChapter.Prev() + // Then Get the Current Main Chapter + for { + if ch.Value.(Chapter).numberSub != 0 { + subChapterCount++ + if ch.Prev() != nil { + ch = ch.Prev() + } else { + break + } + } else { + subChapterCount++ // actual Chapter also counts in this case + currentChapterNumber = ch.Value.(Chapter).numberMain + break + } + } + } + + // Calculate integer sub number based on total subChapterC + subChapterNumber := int8((float32(currentChapter.Value.(Chapter).numberSub) / float32(subChapterCount)) * 10) + + // return main Chapter Number + CurrentChapter Sub Number + return currentChapterNumber, subChapterNumber +} + +func GetLastValidChapterNumber(currentChapter *list.Element) Chapter { + for { + if currentChapter.Next() != nil { + currentChapter = currentChapter.Next() + chapterData := currentChapter.Value.(Chapter) + if chapterData.numberMain != -1 && chapterData.numberSub == 0 { + return chapterData + } + } + break + } + return currentChapter.Value.(Chapter) +} + +func GenerateMangaChapterList(navigation FileWrapper) *list.List { + reader, err := navigation.file.Open() + if err != nil { + fmt.Println(err) + } + + doc := etree.NewDocument() + if _, err = doc.ReadFrom(reader); err != nil { + fmt.Println(err) + } + + navMap := doc.FindElement("//ncx/navMap") + navChapters := navMap.FindElements("//navPoint") + chapters := list.New() + + pageList := doc.FindElements("//ncx/pageList/pageTarget/content") + + lastMainChapter := int8(-1) + subChapter := int8(0) + + for c := range len(navChapters) { + navChapter := navChapters[c] + label := navChapter.FindElement("./navLabel/text").Text() + page := navChapter.FindElement("./content") + + regex := "Chapter ([0-9]*)" + match, _ := regexp.MatchString(regex, label) + + if match { + r, _ := regexp.Compile(regex) + num := r.FindStringSubmatch(label)[1] + parse, _ := strconv.ParseInt(num, 10, 8) + lastMainChapter = int8(parse) + subChapter = int8(0) + } else { + if lastMainChapter == -1 { + subChapter -= 1 + } else { + subChapter += 1 + } + } + + chapterData := Chapter{ + numberMain: lastMainChapter, + numberSub: subChapter, + chDisplay: "", + title: label, + firstPage: page.SelectAttr("src").Value, + lastPage: "", + } + + chapters.PushBack(chapterData) + } + + return FinalizeChapters(chapters, pageList) +}