v0.8/Initial - Working Downloads and Novel Processing
This commit is contained in:
parent
ef1c533009
commit
a0b4b4361f
9 changed files with 1063 additions and 0 deletions
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
@ -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
|
9
.idea/kavita-helper-go.iml
generated
Normal file
9
.idea/kavita-helper-go.iml
generated
Normal file
|
@ -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>
|
12
.idea/material_theme_project_new.xml
generated
Normal file
12
.idea/material_theme_project_new.xml
generated
Normal file
|
@ -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>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
@ -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>
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
@ -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>
|
5
go.mod
Normal file
5
go.mod
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
module kavita-helper-go
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require github.com/beevik/etree v1.5.0
|
2
go.sum
Normal file
2
go.sum
Normal file
|
@ -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=
|
417
jnc/jnc.go
Normal file
417
jnc/jnc.go
Normal file
|
@ -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
|
||||||
|
}
|
596
main.go
Normal file
596
main.go
Normal file
|
@ -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)
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue