Initial Ebook Support (#623)

* generate ebook

* serve ebook file

* Update ebook.go not download same image twice anymore

* download ebook request api/ui part

* fix typo

* add stylesheet

* update hasEbook status

* download link update after ebook generate

update bookmark command in ui

* download ebook with bookmark title

* Apply suggestions from code review for better error handling

Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com>

* Update internal/view/js/page/home.js fix typo

Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com>

* import error lib and retuen missing error

* move ebook download action to update cache

* replace io/ioutil with io

* add missing error handling

* update Archive now always update ebook

* replace panic error with 404

* remove ebook with delete action

* add download ebook link to content page

* remove tags that not work correctly right now

* if file is pdf not generate ebook

* update style.css

* Revert "update style.css"

This reverts commit 519e10d6ce.

* remove download limit for api

* fix missing fmt.Errorf and change to errors.Wrap

* fix double panic

* return 404 if bookmark not exist

* change function name to GenerateEbook

* not isFatalErr anymore

* add unit test

* remove uneeded field for unit test

---------

Co-authored-by: Felipe Martin <812088+fmartingr@users.noreply.github.com>
This commit is contained in:
Monirzadeh 2023-07-09 09:59:32 +03:30 committed by GitHub
parent ec86febdaa
commit 249f4b89c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 829 additions and 5 deletions

252
internal/core/ebook.go Normal file
View file

@ -0,0 +1,252 @@
package core
import (
"archive/zip"
"fmt"
"io"
"log"
"net/http"
"os"
fp "path/filepath"
"regexp"
"strconv"
"strings"
"github.com/go-shiori/shiori/internal/model"
"github.com/pkg/errors"
)
func GenerateEbook(req ProcessRequest) (book model.Bookmark, err error) {
// variable for store generated html code
var html string
book = req.Bookmark
// Make sure bookmark ID is defined
if book.ID == 0 {
return book, errors.New("bookmark ID is not valid")
}
// cheak archive and thumb
strID := strconv.Itoa(book.ID)
imagePath := fp.Join(req.DataDir, "thumb", fmt.Sprintf("%d", book.ID))
archivePath := fp.Join(req.DataDir, "archive", fmt.Sprintf("%d", book.ID))
if _, err := os.Stat(imagePath); err == nil {
book.ImageURL = fp.Join("/", "bookmark", strID, "thumb")
}
if _, err := os.Stat(archivePath); err == nil {
book.HasArchive = true
}
ebookfile := fp.Join(req.DataDir, "ebook", fmt.Sprintf("%d.epub", book.ID))
// if epub exist finish prosess else continue
if _, err := os.Stat(ebookfile); err == nil {
book.HasEbook = true
return book, nil
}
contentType := req.ContentType
if strings.Contains(contentType, "application/pdf") {
return book, errors.New("can't create ebook for pdf")
}
ebookDir := fp.Join(req.DataDir, "ebook")
// check if directory not exsist create that
if _, err := os.Stat(ebookDir); os.IsNotExist(err) {
err := os.MkdirAll(ebookDir, model.DataDirPerm)
if err != nil {
return book, errors.Wrap(err, "can't create ebook directory")
}
}
// create epub file
epubFile, err := os.Create(ebookfile)
if err != nil {
return book, errors.Wrap(err, "can't create ebook")
}
defer epubFile.Close()
// Create zip archive
epubWriter := zip.NewWriter(epubFile)
defer epubWriter.Close()
// Create the mimetype file
mimetypeWriter, err := epubWriter.Create("mimetype")
if err != nil {
return book, errors.Wrap(err, "can't create mimetype")
}
_, err = mimetypeWriter.Write([]byte("application/epub+zip"))
if err != nil {
return book, errors.Wrap(err, "can't write into mimetype file")
}
// Create the container.xml file
containerWriter, err := epubWriter.Create("META-INF/container.xml")
if err != nil {
return book, errors.Wrap(err, "can't create container.xml")
}
_, err = containerWriter.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`))
if err != nil {
return book, errors.Wrap(err, "can't write into container.xml file")
}
contentOpfWriter, err := epubWriter.Create("OEBPS/content.opf")
if err != nil {
return book, errors.Wrap(err, "can't create content.opf")
}
_, err = contentOpfWriter.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="BookId">
<metadata>
<dc:title>` + book.Title + `</dc:title>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="content" href="content.html" media-type="application/xhtml+xml"/>
<item id="id" href="../style.css" media-type="text/css"/>
</manifest>
<spine toc="ncx">
<itemref idref="content"/>
</spine>
</package>`))
if err != nil {
return book, errors.Wrap(err, "can't write into container.opf file")
}
// Create the style.css file
styleWriter, err := epubWriter.Create("style.css")
if err != nil {
return book, errors.Wrap(err, "can't create content.xml")
}
_, err = styleWriter.Write([]byte(`content {
display: block;
font-size: 1em;
line-height: 1.2;
padding-left: 0;
padding-right: 0;
text-align: justify;
margin: 0 5pt
}
img {
margin: auto;
display: block;
}`))
if err != nil {
return book, errors.Wrap(err, "can't write into style.css file")
}
// Create the toc.ncx file
tocNcxWriter, err := epubWriter.Create("OEBPS/toc.ncx")
if err != nil {
return book, errors.Wrap(err, "can't create toc.ncx")
}
_, err = tocNcxWriter.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN"
"http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="urn:uuid:12345678-1234-5678-1234-567812345678"/>
<meta name="dtb:depth" content="1"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle>
<text>` + book.Title + `</text>
</docTitle>
<navMap>
<navPoint id="navPoint-1" playOrder="1">
<navLabel>
<text >` + book.Title + `</text>
</navLabel>
<content src="content.html"/>
</navPoint>
</navMap>
</ncx>`))
if err != nil {
return book, errors.Wrap(err, "can't write into toc.ncx file")
}
// get list of images tag in html
imageList, _ := GetImages(book.HTML)
imgRegex := regexp.MustCompile(`<img.*?src="([^"]*)".*?>`)
// Create a set to store unique image URLs
imageSet := make(map[string]bool)
// Download image in html file and generate new html
html = book.HTML
for _, match := range imgRegex.FindAllStringSubmatch(book.HTML, -1) {
imageURL := match[1]
if _, ok := imageList[imageURL]; ok && !imageSet[imageURL] {
// Add the image URL to the set
imageSet[imageURL] = true
// Download the image
resp, err := http.Get(imageURL)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
// Get the image data
imageData, err := io.ReadAll(resp.Body)
if err != nil {
return book, errors.Wrap(err, "can't get image from the internet")
}
fileName := fp.Base(imageURL)
filePath := "images/" + fileName
imageWriter, err := epubWriter.Create(filePath)
if err != nil {
log.Fatal(err)
}
// Write the image to the file
_, err = imageWriter.Write(imageData)
if err != nil {
return book, errors.Wrap(err, "can't create image file")
}
// Replace the image tag with the new downloaded image
html = strings.ReplaceAll(html, match[0], fmt.Sprintf(`<img src="../%s"/>`, filePath))
}
}
// Create the content.html file
contentHtmlWriter, err := epubWriter.Create("OEBPS/content.html")
if err != nil {
return book, errors.Wrap(err, "can't create content.xml")
}
_, err = contentHtmlWriter.Write([]byte("<?xml version='1.0' encoding='utf-8'?>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\n<head>\n\t<title>" + book.Title + "</title>\n\t<link href=\"../style.css\" rel=\"stylesheet\" type=\"text/css\"/>\n</head>\n<body>\n\t<h1 dir=\"auto\">" + book.Title + "</h1>" + "\n<content dir=\"auto\">\n" + html + "\n</content>" + "\n</body></html>"))
if err != nil {
return book, errors.Wrap(err, "can't write into content.html")
}
book.HasEbook = true
return book, nil
}
// function get html and return list of image url inside html file
func GetImages(html string) (map[string]string, error) {
// Regular expression to match image tags and their URLs
imageTagRegex := regexp.MustCompile(`<img.*?src="(.*?)".*?>`)
// Find all matches in the HTML string
imageTagMatches := imageTagRegex.FindAllStringSubmatch(html, -1)
// Create a dictionary to store the image URLs
images := make(map[string]string)
// Check if there are any matches
if len(imageTagMatches) == 0 {
return nil, nil
}
// Loop through all the matches and add them to the dictionary
for _, match := range imageTagMatches {
imageURL := match[1]
images[imageURL] = match[0]
}
return images, nil
}

288
internal/core/ebook_test.go Normal file
View file

@ -0,0 +1,288 @@
package core_test
import (
"errors"
"fmt"
"os"
fp "path/filepath"
"testing"
"github.com/go-shiori/shiori/internal/core"
"github.com/go-shiori/shiori/internal/model"
"github.com/stretchr/testify/assert"
)
func TestGenerateEbook_ValidBookmarkID_ReturnsBookmarkWithHasEbookTrue(t *testing.T) {
tempDir := t.TempDir()
defer os.RemoveAll(tempDir)
mockRequest := core.ProcessRequest{
Bookmark: model.Bookmark{
ID: 1,
Title: "Example Bookmark",
HTML: "<html><body>Example HTML</body></html>",
HasEbook: false,
},
DataDir: tempDir,
ContentType: "text/html",
}
bookmark, err := core.GenerateEbook(mockRequest)
assert.True(t, bookmark.HasEbook)
assert.NoError(t, err)
}
func TestGenerateEbook_InvalidBookmarkID_ReturnsError(t *testing.T) {
tempDir := t.TempDir()
defer os.RemoveAll(tempDir)
mockRequest := core.ProcessRequest{
Bookmark: model.Bookmark{
ID: 0,
HasEbook: false,
},
DataDir: tempDir,
ContentType: "text/html",
}
bookmark, err := core.GenerateEbook(mockRequest)
assert.Equal(t, model.Bookmark{
ID: 0,
HasEbook: false,
}, bookmark)
assert.Error(t, err)
}
func TestGenerateEbook_ValidBookmarkID_EbookExist_EbookExist_ReturnWithHasEbookTrue(t *testing.T) {
tempDir := t.TempDir()
defer os.RemoveAll(tempDir)
mockRequest := core.ProcessRequest{
Bookmark: model.Bookmark{
ID: 1,
HasEbook: false,
},
DataDir: tempDir,
ContentType: "text/html",
}
// Create the ebook directory
ebookDir := fp.Join(mockRequest.DataDir, "ebook")
err := os.MkdirAll(ebookDir, os.ModePerm)
if err != nil {
t.Fatal(err)
}
// Create the ebook file
ebookfile := fp.Join(mockRequest.DataDir, "ebook", fmt.Sprintf("%d.epub", mockRequest.Bookmark.ID))
file, err := os.Create(ebookfile)
if err != nil {
t.Fatal(err)
}
defer file.Close()
bookmark, err := core.GenerateEbook(mockRequest)
assert.True(t, bookmark.HasEbook)
assert.NoError(t, err)
}
func TestGenerateEbook_ValidBookmarkID_EbookExist_ImagePathExist_ReturnWithHasEbookTrue(t *testing.T) {
tempDir := t.TempDir()
defer os.RemoveAll(tempDir)
mockRequest := core.ProcessRequest{
Bookmark: model.Bookmark{
ID: 1,
HasEbook: false,
},
DataDir: tempDir,
ContentType: "text/html",
}
// Create the image directory
imageDir := fp.Join(mockRequest.DataDir, "thumb")
err := os.MkdirAll(imageDir, os.ModePerm)
if err != nil {
t.Fatal(err)
}
// Create the image file
imagePath := fp.Join(mockRequest.DataDir, "thumb", fmt.Sprintf("%d", mockRequest.Bookmark.ID))
file, err := os.Create(imagePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
bookmark, err := core.GenerateEbook(mockRequest)
expectedimagePath := "/bookmark/1/thumb"
if expectedimagePath != bookmark.ImageURL {
t.Errorf("Expected imageURL %s, but got %s", bookmark.ImageURL, expectedimagePath)
}
assert.True(t, bookmark.HasEbook)
assert.NoError(t, err)
}
func TestGenerateEbook_ValidBookmarkID_EbookExist_ReturnWithHasArchiveTrue(t *testing.T) {
tempDir := t.TempDir()
defer os.RemoveAll(tempDir)
mockRequest := core.ProcessRequest{
Bookmark: model.Bookmark{
ID: 1,
HasEbook: false,
},
DataDir: tempDir,
ContentType: "text/html",
}
// Create the archive directory
archiveDir := fp.Join(mockRequest.DataDir, "archive")
err := os.MkdirAll(archiveDir, os.ModePerm)
if err != nil {
t.Fatal(err)
}
// Create the archive file
archivePath := fp.Join(mockRequest.DataDir, "archive", fmt.Sprintf("%d", mockRequest.Bookmark.ID))
file, err := os.Create(archivePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
bookmark, err := core.GenerateEbook(mockRequest)
assert.True(t, bookmark.HasArchive)
assert.NoError(t, err)
}
func TestGenerateEbook_ValidBookmarkID_RetuenError_PDF(t *testing.T) {
tempDir := t.TempDir()
defer os.RemoveAll(tempDir)
mockRequest := core.ProcessRequest{
Bookmark: model.Bookmark{
ID: 1,
HasEbook: false,
},
DataDir: tempDir,
ContentType: "application/pdf",
}
bookmark, err := core.GenerateEbook(mockRequest)
assert.False(t, bookmark.HasEbook)
assert.Error(t, err)
assert.Contains(t, err.Error(), "can't create ebook for pdf")
}
func TestGenerateEbook_CreateEbookDirectoryNotWritable(t *testing.T) {
// Create a temporary directory to use as the parent directory
parentDir := t.TempDir()
// Create a child directory with read-only permissions
ebookDir := fp.Join(parentDir, "ebook")
err := os.Mkdir(ebookDir, 0444)
if err != nil {
t.Fatalf("could not create ebook directory: %s", err)
}
mockRequest := core.ProcessRequest{
Bookmark: model.Bookmark{
ID: 1,
HasEbook: false,
},
DataDir: ebookDir,
ContentType: "text/html",
}
// Call GenerateEbook to create the ebook directory
bookmark, err := core.GenerateEbook(mockRequest)
if err == nil {
t.Fatal("GenerateEbook succeeded even though MkdirAll should have failed")
}
if !errors.Is(err, os.ErrPermission) {
t.Fatalf("unexpected error: expected os.ErrPermission, got %v", err)
}
// Check if the ebook directory still exists and has read-only permissions
info, err := os.Stat(ebookDir)
if err != nil {
t.Fatalf("could not retrieve ebook directory info: %s", err)
}
if !info.IsDir() {
t.Errorf("ebook directory is not a directory")
}
if info.Mode().Perm() != 0444 {
t.Errorf("ebook directory has incorrect permissions: expected 0444, got %o", info.Mode().Perm())
}
assert.False(t, bookmark.HasEbook)
}
// Add more unit tests for other scenarios that missing specialy
// can't create ebook directory and can't write situatuin
// writing inside zip file
// html variable that not export and image download loop
func TestGetImages(t *testing.T) {
// Test case 1: HTML with no image tags
html1 := `<html><body><h1>Hello, World!</h1></body></html>`
expected1 := make(map[string]string)
result1, err1 := core.GetImages(html1)
if err1 != nil {
t.Errorf("Unexpected error: %v", err1)
}
if len(result1) != len(expected1) {
t.Errorf("Expected %d images, but got %d", len(expected1), len(result1))
}
// Test case 2: HTML with one image tag
html2 := `<html><body><img src="image1.jpg"></body></html>`
expected2 := map[string]string{"image1.jpg": "<img src=\"image1.jpg\">"}
result2, err2 := core.GetImages(html2)
if err2 != nil {
t.Errorf("Unexpected error: %v", err2)
}
if len(result2) != len(expected2) {
t.Errorf("Expected %d images, but got %d", len(expected2), len(result2))
}
for key, value := range expected2 {
if result2[key] != value {
t.Errorf("Expected image URL %s with tag %s, but got %s", key, value, result2[key])
}
}
// Test case 3: HTML with multiple image tags
html3 := `<html><body><img src="image1.jpg"><img src="image2.jpg"></body></html>`
expected3 := map[string]string{
"image1.jpg": "<img src=\"image1.jpg\">",
"image2.jpg": "<img src=\"image2.jpg\">",
}
result3, err3 := core.GetImages(html3)
if err3 != nil {
t.Errorf("Unexpected error: %v", err3)
}
if len(result3) != len(expected3) {
t.Errorf("Expected %d images, but got %d", len(expected3), len(result3))
}
for key, value := range expected3 {
if result3[key] != value {
t.Errorf("Expected image URL %s with tag %s, but got %s", key, value, result3[key])
}
}
// Test case 4: HTML with multiple image tags with duplicayr
html4 := `<html><body><img src="image1.jpg"><img src="image2.jpg"><img src="image2.jpg"></body></html>`
expected4 := map[string]string{
"image1.jpg": "<img src=\"image1.jpg\">",
"image2.jpg": "<img src=\"image2.jpg\">",
}
result4, err4 := core.GetImages(html4)
if err4 != nil {
t.Errorf("Unexpected error: %v", err4)
}
if len(result4) != len(expected4) {
t.Errorf("Expected %d images, but got %d", len(expected4), len(result4))
}
for key, value := range expected4 {
if result4[key] != value {
t.Errorf("Expected image URL %s with tag %s, but got %s", key, value, result4[key])
}
}
}

View file

@ -20,6 +20,7 @@ import (
"github.com/go-shiori/go-readability"
"github.com/go-shiori/shiori/internal/model"
"github.com/go-shiori/warc"
"github.com/pkg/errors"
// Add support for png
_ "image/png"
@ -125,6 +126,22 @@ func ProcessBookmark(req ProcessRequest) (book model.Bookmark, isFatalErr bool,
}
}
// If needed, create ebook as well
if book.CreateEbook {
ebookPath := fp.Join(req.DataDir, "ebook", fmt.Sprintf("%d.epub", book.ID))
os.Remove(ebookPath)
if strings.Contains(contentType, "application/pdf") {
return book, false, errors.Wrap(err, "can't create ebook from pdf")
} else {
_, err = GenerateEbook(req)
if err != nil {
return book, true, errors.Wrap(err, "failed to create ebook")
}
book.HasEbook = true
}
}
// If needed, create offline archive as well
if book.CreateArchive {
archivePath := fp.Join(req.DataDir, "archive", fmt.Sprintf("%d", book.ID))

View file

@ -22,8 +22,10 @@ type Bookmark struct {
ImageURL string `db:"image_url" json:"imageURL"`
HasContent bool `db:"has_content" json:"hasContent"`
HasArchive bool `json:"hasArchive"`
HasEbook bool `json:"hasEbook"`
Tags []Tag `json:"tags"`
CreateArchive bool `json:"createArchive"`
CreateEbook bool `json:"createEbook"`
}
// Account is person that allowed to access web interface.

View file

@ -33,6 +33,9 @@
$$if .Book.HasArchive$$
<a href="bookmark/$$.Book.ID$$/archive">View Archive</a>
$$end$$
$$if .Book.HasEbook$$
<a href="bookmark/$$.Book.ID$$/ebook" download="$$.Book.Title$$.epub">Download Ebook</a>
$$end$$
</div>
</div>
<div id="content" dir="auto" v-pre>

View file

@ -32,6 +32,9 @@ var template = `
<a title="Update archive" @click="updateBookmark">
<i class="fas fa-fw fa-cloud-download-alt"></i>
</a>
<a v-if="hasEbook" title="Download book" @click="downloadebook">
<i class="fas fa-fw fa-book"></i>
</a>
</template>
</div>
</div>`;
@ -47,6 +50,7 @@ export default {
imageURL: String,
hasContent: Boolean,
hasArchive: Boolean,
hasEbook: Boolean,
index: Number,
showId: Boolean,
editMode: Boolean,
@ -72,6 +76,13 @@ export default {
return this.url;
}
},
ebookURL() {
if (this.hasEbook) {
return new URL(`bookmark/${this.id}/ebook`, document.baseURI);
} else {
return null;
}
},
hostnameURL() {
var url = new URL(this.url);
return url.hostname.replace(/^www\./, "");
@ -112,6 +123,14 @@ export default {
},
updateBookmark() {
this.$emit("update", this.eventItem);
}
},
downloadebook() {
const id = this.id;
const ebook_url = new URL(`bookmark/${id}/ebook`, document.baseURI);
const downloadLink = document.createElement("a");
downloadLink.href = ebook_url.toString();
downloadLink.download = `${this.title}.epub`;
downloadLink.click();
},
}
}

View file

@ -22,6 +22,7 @@ export default {
keepMetadata: false,
useArchive: false,
createEbook: false,
makePublic: false,
};
}
@ -111,4 +112,4 @@ export default {
});
},
}
}
}

View file

@ -26,6 +26,9 @@ var template = `
<a title="Update archives" @click="showDialogUpdateCache(selection)">
<i class="fas fa-fw fa-cloud-download-alt"></i>
</a>
<a title="Download ebooks" @click="ebookGenerate(selection)">
<i class="fas fa-fw fa-book"></i>
</a>
<a title="Cancel" @click="toggleEditMode">
<i class="fas fa-fw fa-times"></i>
</a>
@ -47,6 +50,7 @@ var template = `
:imageURL="book.imageURL"
:hasContent="book.hasContent"
:hasArchive="book.hasArchive"
:hasEbook="book.hasEbook"
:tags="book.tags"
:index="index"
:key="book.id"
@ -61,6 +65,7 @@ var template = `
@tag-clicked="bookmarkTagClicked"
@edit="showDialogEdit"
@delete="showDialogDelete"
@generate-ebook="ebookGenerate"
@update="showDialogUpdateCache">
</bookmark-item>
<pagination-box v-if="maxPage > 1"
@ -583,6 +588,61 @@ export default {
}
});
},
ebookGenerate(items) {
// Check and filter items
if (typeof items !== "object") return;
if (!Array.isArray(items)) items = [items];
items = items.filter(item => {
var id = (typeof item.id === "number") ? item.id : 0,
index = (typeof item.index === "number") ? item.index : -1;
return id > 0 && index > -1;
});
if (items.length === 0) return;
// define variable and send request
var ids = items.map(item => item.id);
var data = {
ids: ids,
};
this.loading = true;
fetch(new URL("api/ebook", document.baseURI), {
method: "put",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
}).then(response => {
if (!response.ok) throw response;
return response.json();
}).then(json => {
this.selection = [];
this.editMode = false;
json.forEach(book => {
// download ebooks
const id = book.id;
if (book.hasEbook){
const ebook_url = new URL(`bookmark/${id}/ebook`, document.baseURI);
const downloadLink = document.createElement("a");
downloadLink.href = ebook_url.toString();
downloadLink.download = `${book.title}.epub`;
downloadLink.click();
}
var item = items.find(el => el.id === book.id);
this.bookmarks.splice(item.index, 1, book);
});
}).catch(err => {
this.selection = [];
this.editMode = false;
this.getErrorMessage(err).then(msg => {
this.showErrorDialog(msg);
})
})
.finally(() => {
this.loading = false;
});
},
showDialogUpdateCache(items) {
// Check and filter items
if (typeof items !== "object") return;
@ -613,7 +673,12 @@ export default {
label: "Update archive as well",
type: "check",
value: this.appOptions.useArchive,
}],
}, {
name: "createEbook",
label: "Update Ebook as well",
type: "check",
value: this.appOptions.createEbook,
}],
mainText: "Yes",
secondText: "No",
mainClick: (data) => {
@ -621,6 +686,7 @@ export default {
ids: ids,
createArchive: data.createArchive,
keepMetadata: data.keepMetadata,
createEbook: data.createEbook,
};
this.dialog.loading = true;
@ -834,4 +900,4 @@ export default {
this.loadData(false, true);
}
}
}

View file

@ -203,6 +203,7 @@ func (h *handler) apiGetBookmarks(w http.ResponseWriter, r *http.Request, ps htt
strID := strconv.Itoa(bookmarks[i].ID)
imgPath := fp.Join(h.DataDir, "thumb", strID)
archivePath := fp.Join(h.DataDir, "archive", strID)
ebookPath := fp.Join(h.DataDir, "ebook", strID+".epub")
if fileExists(imgPath) {
bookmarks[i].ImageURL = path.Join(h.RootPath, "bookmark", strID, "thumb")
@ -211,6 +212,9 @@ func (h *handler) apiGetBookmarks(w http.ResponseWriter, r *http.Request, ps htt
if fileExists(archivePath) {
bookmarks[i].HasArchive = true
}
if fileExists(ebookPath) {
bookmarks[i].HasEbook = true
}
}
// Return JSON response
@ -373,9 +377,11 @@ func (h *handler) apiDeleteBookmark(w http.ResponseWriter, r *http.Request, ps h
strID := strconv.Itoa(id)
imgPath := fp.Join(h.DataDir, "thumb", strID)
archivePath := fp.Join(h.DataDir, "archive", strID)
ebookPath := fp.Join(h.DataDir, "ebook", strID+".epub")
os.Remove(imgPath)
os.Remove(archivePath)
os.Remove(ebookPath)
}
fmt.Fprint(w, 1)
@ -458,6 +464,104 @@ func (h *handler) apiUpdateBookmark(w http.ResponseWriter, r *http.Request, ps h
checkError(err)
}
// apiDownloadEbook is handler for PUT /api/ebook
func (h *handler) apiDownloadEbook(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
request := struct {
IDs []int `json:"ids"`
}{}
err = json.NewDecoder(r.Body).Decode(&request)
checkError(err)
// Get existing bookmark from database
filter := database.GetBookmarksOptions{
IDs: request.IDs,
WithContent: true,
}
bookmarks, err := h.DB.GetBookmarks(ctx, filter)
checkError(err)
if len(bookmarks) == 0 {
http.NotFound(w, r)
return
}
// Fetch data from internet
mx := sync.RWMutex{}
wg := sync.WaitGroup{}
chDone := make(chan struct{})
chProblem := make(chan int, 10)
semaphore := make(chan struct{}, 10)
for i, book := range bookmarks {
wg.Add(1)
go func(i int, book model.Bookmark) {
// Make sure to finish the WG
defer wg.Done()
// Register goroutine to semaphore
semaphore <- struct{}{}
defer func() {
<-semaphore
}()
// Download data from internet
content, contentType, err := core.DownloadBookmark(book.URL)
if err != nil {
chProblem <- book.ID
return
}
request := core.ProcessRequest{
DataDir: h.DataDir,
Bookmark: book,
Content: content,
ContentType: contentType,
}
book, err = core.GenerateEbook(request)
content.Close()
if err != nil {
chProblem <- book.ID
return
}
// Update list of bookmarks
mx.Lock()
bookmarks[i] = book
mx.Unlock()
}(i, book)
}
// Receive all problematic bookmarks
idWithProblems := []int{}
go func() {
for {
select {
case <-chDone:
return
case id := <-chProblem:
idWithProblems = append(idWithProblems, id)
}
}
}()
// Wait until all download finished
wg.Wait()
close(chDone)
w.Header().Set("Content-Type", "application1/json")
err = json.NewEncoder(w).Encode(&bookmarks)
checkError(err)
}
// apiUpdateCache is handler for PUT /api/cache
func (h *handler) apiUpdateCache(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
@ -471,6 +575,7 @@ func (h *handler) apiUpdateCache(w http.ResponseWriter, r *http.Request, ps http
IDs []int `json:"ids"`
KeepMetadata bool `json:"keepMetadata"`
CreateArchive bool `json:"createArchive"`
CreateEbook bool `json:"createEbook"`
}{}
err = json.NewDecoder(r.Body).Decode(&request)
@ -506,8 +611,9 @@ func (h *handler) apiUpdateCache(w http.ResponseWriter, r *http.Request, ps http
for i, book := range bookmarks {
wg.Add(1)
// Mark whether book will be archived
// Mark whether book will be archived or ebook generate request
book.CreateArchive = request.CreateArchive
book.CreateEbook = request.CreateEbook
go func(i int, book model.Bookmark, keepMetadata bool) {
// Make sure to finish the WG

View file

@ -119,6 +119,10 @@ func (h *handler) serveBookmarkContent(w http.ResponseWriter, r *http.Request, p
}
// Check if it has archive.
ebookPath := fp.Join(h.DataDir, "ebook", strID+".epub")
if fileExists(ebookPath) {
bookmark.HasEbook = true
}
archivePath := fp.Join(h.DataDir, "archive", strID)
if fileExists(archivePath) {
bookmark.HasArchive = true
@ -356,3 +360,67 @@ func (h *handler) serveBookmarkArchive(w http.ResponseWriter, r *http.Request, p
log.Printf("error writting response: %s", err)
}
}
// serveEbook is handler for GET /bookmark/:id/ebook
func (h *handler) serveBookmarkEbook(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
ctx := r.Context()
// Get bookmark ID from URL
strID := ps.ByName("id")
id, err := strconv.Atoi(strID)
checkError(err)
// Get bookmark in database
bookmark, exist, err := h.DB.GetBookmark(ctx, id, "")
checkError(err)
if !exist {
http.Error(w, "bookmark not found", http.StatusNotFound)
return
}
// If it's not public, make sure session still valid
if bookmark.Public != 1 {
err = h.validateSession(r)
if err != nil {
newPath := path.Join(h.RootPath, "/login")
redirectURL := createRedirectURL(newPath, r.URL.String())
redirectPage(w, r, redirectURL)
return
}
}
// Check if it has ebook.
ebookPath := fp.Join(h.DataDir, "ebook", strID+".epub")
if !fileExists(ebookPath) {
http.Error(w, "ebook not found", http.StatusNotFound)
return
}
epub, err := os.Open(ebookPath)
if err != nil {
http.Error(w, "Internal server error", http.StatusNotFound)
return
}
defer epub.Close()
// Set content type
w.Header().Set("Content-Type", "application/epub+zip")
// Set cache value
info, err := epub.Stat()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
etag := fmt.Sprintf(`W/"%x-%x"`, info.ModTime().Unix(), info.Size())
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "max-age=86400")
// Serve epub file
if _, err := epub.Seek(0, 0); err != nil {
log.Printf("error during epub seek: %s", err)
}
_, err = io.Copy(w, epub)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}

View file

@ -183,6 +183,7 @@ func ServeApp(cfg Config) error {
router.GET(jp("/login"), withLogging(hdl.serveLoginPage))
router.GET(jp("/bookmark/:id/thumb"), withLogging(hdl.serveThumbnailImage))
router.GET(jp("/bookmark/:id/content"), withLogging(hdl.serveBookmarkContent))
router.GET(jp("/bookmark/:id/ebook"), withLogging(hdl.serveBookmarkEbook))
router.GET(jp("/bookmark/:id/archive/*filepath"), withLogging(hdl.serveBookmarkArchive))
router.POST(jp("/api/login"), withLogging(hdl.apiLogin))
@ -194,6 +195,7 @@ func ServeApp(cfg Config) error {
router.DELETE(jp("/api/bookmarks"), withLogging(hdl.apiDeleteBookmark))
router.PUT(jp("/api/bookmarks"), withLogging(hdl.apiUpdateBookmark))
router.PUT(jp("/api/cache"), withLogging(hdl.apiUpdateCache))
router.PUT(jp("/api/ebook"), withLogging(hdl.apiDownloadEbook))
router.PUT(jp("/api/bookmarks/tags"), withLogging(hdl.apiUpdateBookmarkTags))
router.POST(jp("/api/bookmarks/ext"), withLogging(hdl.apiInsertViaExtension))
router.DELETE(jp("/api/bookmarks/ext"), withLogging(hdl.apiDeleteViaExtension))