13 Commits
0.3.0 ... 0.4.0

31 changed files with 505 additions and 440 deletions

View File

@@ -3,7 +3,7 @@ FROM node:lts AS buildfront
COPY front .
RUN npm install && npm run build
FROM golang:1.25 AS build
FROM golang:1.26 AS build
WORKDIR /src
COPY . .
COPY --from=buildfront ./dist front/dist

View File

@@ -28,7 +28,7 @@ Or with a volume, for example if you created a volume named `bibliomane_data`:
`--add-user` or `-a` can be used to create an account on startup. It requires a string following htpasswd format `[username]:[bcrypt hashed password]`.
The password can be generated using `htpasswd -nB [username]`.
The password can be generated using `htpasswd -nBC10 [username]`.
For example, to create an user account `demo`:

View File

@@ -69,7 +69,7 @@ INSERT INTO user_books(created_at, user_id, book_id, read, rating) VALUES ('NOW'
INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Le petit bleu de la côte Ouest',(SELECT id FROM authors WHERE name = 'Jean-Patrick Manchette'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'le-petit-bleu-de-la-cote-ouest.jpg'));
INSERT INTO user_books(created_at, user_id, book_id, want_read, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'Le petit bleu de la côte Ouest'), true,0);
INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'D''un château l''autre',(SELECT id FROM authors WHERE name = 'Louis-Ferdinand Céline'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'dunchateaulautre.jpg'));
INSERT INTO user_books(created_at, user_id, book_id, read, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'D''un château l''autre'), true,10);
INSERT INTO user_books(created_at, user_id, book_id, read, rating, review) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'D''un château l''autre'), true,10, "Lorem ipsum dolor sit amet, consectetur adipisci elit, sed eiusmod tempor incidunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur. Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint obcaecat cupiditat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.");
INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Les dieux ont soif',(SELECT id FROM authors WHERE name = 'Anatole France'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'lesdieuxontsoif.jpg'));
INSERT INTO user_books(created_at, user_id, book_id, read, start_read_date, end_read_date, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'Les dieux ont soif'), true,'2026-01-30 00:00:00+00:00','2026-02-13 00:00:00+00:00',7);
INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Rigodon',(SELECT id FROM authors WHERE name = 'Louis-Ferdinand Céline'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'rigodon.jpg'));

View File

@@ -1,6 +1,6 @@
{
"name": "bibliomane",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"engines": {

View File

@@ -47,13 +47,8 @@ onMounted(() => {
<template>
<nav class="navbar">
<div class="navbar-brand">
<RouterLink
to="/"
class="navbar-item"
:title="'bibliomane v' + appVersion"
activeClass="is-active"
>
<img src="/image/logo.svg" />
<RouterLink to="/" class="navbar-item" :title="'bibliomane v' + appVersion">
<img class="ml-3" src="/image/logo.svg" />
</RouterLink>
<div class="navbar-item is-hidden-desktop">
<a

View File

@@ -3,9 +3,7 @@ import { ref, computed } from 'vue'
import {
getBook,
getImagePathOrDefault,
putReadBook,
putWantReadBook,
putRateBook,
putUpdateBook,
putStartReadDate,
putStartReadDateUnset,
putEndReadDate,
@@ -15,6 +13,7 @@ import {
import { useRouter, onBeforeRouteUpdate } from 'vue-router'
import { VRating } from 'vuetify/components/VRating'
import BookFormIcons from './BookFormIcons.vue'
import ReviewWidget from './ReviewWidget.vue'
const router = useRouter()
const props = defineProps({
@@ -37,7 +36,12 @@ function onRatingUpdate(rating) {
data.value.read = true
data.value.wantread = false
}
putRateBook(props.id, { rating: data.value.rating })
putUpdateBook(props.id, { rating: data.value.rating })
}
function onReviewUpdate(review) {
data.value.review = review
putUpdateBook(props.id, { review: data.value.review })
}
async function onReadIconClick() {
@@ -53,7 +57,7 @@ async function onReadIconClick() {
function onWantReadIconClick() {
data.value.wantread = !data.value.wantread
putWantReadBook(props.id, { wantread: data.value.wantread })
putUpdateBook(props.id, { wantread: data.value.wantread })
}
async function onStartReadIconClick() {
@@ -100,17 +104,6 @@ function goToAuthor() {
<figure class="image">
<img v-bind:src="imagePathOrDefault" v-bind:alt="data.title" />
</figure>
<VRating
half-increments
hover
:length="5"
size="x-large"
density="compact"
:model-value="data.rating / 2"
@update:modelValue="onRatingUpdate"
active-color="bulma-body-color"
class="centered"
/>
</div>
<div class="column">
<h3 class="title">{{ data.title }}</h3>
@@ -119,6 +112,12 @@ function goToAuthor() {
<div class="my-5" v-if="data.isbn">ISBN: {{ data.isbn }}</div>
<div class="my-5" v-if="data.inventaireid">Inventaire ID: {{ data.inventaireid }}</div>
<div class="my-5" v-if="data.openlibraryid">OLID: {{ data.openlibraryid }}</div>
<ReviewWidget
:reviewtext="data.review"
:rating="data.rating"
@on-review-update="onReviewUpdate"
@on-rating-update="onRatingUpdate"
/>
</div>
<div class="column">
<BookFormIcons
@@ -141,12 +140,6 @@ img {
width: auto;
}
.centered {
display: flex;
justify-content: center;
align-items: center;
}
@media (min-width: 1024px) {
.left-panel {
margin-left: 3rem;

View File

@@ -59,7 +59,7 @@ async function onStartReadIconClick() {
>
<BigIcon
icon="BIconBook"
:legend="$t('bookform.wantread')"
:legend="$t('bookform.startread')"
:is-set="isStartReadExpanded()"
@click="onStartReadIconClick"
/>

View File

@@ -39,4 +39,13 @@ const today = new Date().toISOString().slice(0, 10)
font-size: 26px;
border-radius: 5px;
}
@media (max-width: 1024px) {
.datelabel {
font-size: 18px;
}
.datepicker {
font-size: 18px;
}
}
</style>

134
front/src/ReviewWidget.vue Normal file
View File

@@ -0,0 +1,134 @@
<script setup>
import { ref } from 'vue'
import { VRating } from 'vuetify/components/VRating'
const props = defineProps({
rating: Number,
reviewtext: String,
})
const isTextareaExpanded = ref(false)
const isTextareaTransitionEnabled = ref(true)
defineEmits('onRatingUpdate', 'onReviewUpdate')
function computeTextareaClass() {
let classAttr =
isTextareaExpanded && isTextareaExpanded.value ? 'textarea-expanded' : 'textarea-normal'
if (isTextareaTransitionEnabled && isTextareaTransitionEnabled.value) {
classAttr += ' transition-height'
}
return classAttr
}
function onTextAreaFocus() {
isTextareaExpanded.value = true
setTimeout(() => {
isTextareaTransitionEnabled.value = false
}, 500)
}
</script>
<template>
<div class="maincontainer py-5">
<div class="widget-header mb-5 full-width">
<div class="widget-title ml-3">
<h2>{{ $t('review.title') }}</h2>
<span class="ml-3">
<b-icon-pen />
</span>
</div>
<VRating
half-increments
hover
:length="5"
size="x-large"
density="compact"
:model-value="rating / 2"
@update:modelValue="(r) => $emit('onRatingUpdate', r)"
active-color="bulma-body-color"
class="widget-rating centered"
/>
</div>
<div class="full-width centered">
<textarea
:placeholder="$t('review.textplaceholder')"
class="widget-textarea mx-4"
@change="(e) => $emit('onReviewUpdate', e.target.value)"
@focus="onTextAreaFocus"
:class="computeTextareaClass()"
>{{ reviewtext }}</textarea
>
</div>
</div>
</template>
<style scoped>
.maincontainer {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
border: solid;
border-radius: 20px;
padding: 10px;
}
.widget-header {
display: flex;
}
.widget-title {
flex: 2;
font-size: 2em;
display: flex;
justify-content: center;
align-items: center;
}
.widget-title h2 {
font-weight: bold;
}
.widget-rating {
flex: 2;
}
.widget-textarea {
color: var(--bulma-body-color);
background-color: var(--bulma-text-20);
width: 95%;
border-radius: 30px;
border: none;
padding: 15px;
}
.textarea-normal {
height: 80px;
resize: none;
}
.textarea-expanded {
height: 350px;
resize: vertical;
}
.transition-height {
transition: height 0.5s;
}
.full-width {
width: 100%;
}
@media (max-width: 1024px) {
.widget-header {
display: flex;
flex-wrap: wrap;
}
.widget-title {
font-size: 1.5em;
width: 100%;
margin-bottom: 10px;
}
}
</style>

View File

@@ -99,35 +99,31 @@ export async function postImportBook(id, language) {
}
export async function putReadBook(bookId) {
return genericPayloadCall('/ws/book/' + bookId + '/read', { read: true }, 'PUT')
return genericPayloadCall('/ws/book/' + bookId, { read: true }, 'PUT')
}
export async function putUnreadBook(bookId) {
return genericPayloadCall('/ws/book/' + bookId + '/read', { read: false }, 'PUT')
return genericPayloadCall('/ws/book/' + bookId, { read: false }, 'PUT')
}
export async function putEndReadDate(bookId, enddate) {
return genericPayloadCall('/ws/book/' + bookId + '/read', { read: true, endDate: enddate }, 'PUT')
return genericPayloadCall('/ws/book/' + bookId, { read: true, endDate: enddate }, 'PUT')
}
export async function putEndReadDateUnset(bookId) {
return genericPayloadCall('/ws/book/' + bookId + '/read', { read: true, endDate: 'null' }, 'PUT')
return genericPayloadCall('/ws/book/' + bookId, { read: true, endDate: 'null' }, 'PUT')
}
export async function putStartReadDateUnset(bookId) {
return genericPayloadCall('/ws/book/' + bookId + '/startread', { startDate: 'null' }, 'PUT')
return genericPayloadCall('/ws/book/' + bookId, { startDate: 'null' }, 'PUT')
}
export async function putStartReadDate(bookId, startdate) {
return genericPayloadCall('/ws/book/' + bookId + '/startread', { startDate: startdate }, 'PUT')
return genericPayloadCall('/ws/book/' + bookId, { startDate: startdate }, 'PUT')
}
export async function putWantReadBook(bookId, payload) {
return genericPayloadCall('/ws/book/' + bookId + '/wantread', payload, 'PUT')
}
export async function putRateBook(bookId, payload) {
return genericPayloadCall('/ws/book/' + bookId + '/rate', payload, 'PUT')
export async function putUpdateBook(bookId, payload) {
return genericPayloadCall('/ws/book/' + bookId, payload, 'PUT')
}
export function postLogin(user) {

View File

@@ -59,6 +59,7 @@
},
"bookform": {
"error": "Error when loading book: {error}",
"reviewbtn": "My review",
"read": "Read",
"startread": "Started",
"wantread": "Interested"
@@ -80,5 +81,9 @@
"releasedate": "Release date:",
"publisher": "Publisher:",
"importing": "Importing..."
},
"review": {
"title": "My review",
"textplaceholder": "Write my review..."
}
}

View File

@@ -59,6 +59,7 @@
},
"bookform": {
"error": "Erreur pendant le chargement du livre: {error}",
"reviewbtn": "Ma critique",
"read": "Lu",
"startread": "Commencé",
"wantread": "À lire"
@@ -80,5 +81,9 @@
"releasedate": "Date de publication : ",
"publisher": "Maison d'édition : ",
"importing": "Import en cours..."
},
"review": {
"title": "Ma critique",
"textplaceholder": "Écrire ma critique..."
}
}

View File

@@ -1,3 +1,9 @@
.clickable {
cursor: pointer;
}
.centered {
display: flex;
justify-content: center;
align-items: center;
}

View File

@@ -22,6 +22,7 @@ func TestGetBook_Ok(t *testing.T) {
Rating: 10,
Read: true,
CoverPath: "/static/bookcover/dunchateaulautre.jpg",
Review: "Lorem ipsum dolor sit amet, consectetur adipisci elit, sed eiusmod tempor incidunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur. Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint obcaecat cupiditat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
}, book)
}

View File

@@ -1,87 +0,0 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPutRatingUserBooksHandler_UpdateRating(t *testing.T) {
payload :=
`{
"rating": 5
}`
bookId := "17"
testPutRateUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, 5, book.Rating)
assert.Equal(t, true, book.Read)
}
func TestPutRatingUserBooksHandler_RateNewBookMakeItRead(t *testing.T) {
payload :=
`{
"rating": 7
}`
bookId := "18"
testPutRateUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, 7, book.Rating)
assert.Equal(t, true, book.Read)
assert.Equal(t, false, book.WantRead)
}
func TestPutRatingUserBooksHandler_RateWantedBook(t *testing.T) {
payload :=
`{
"rating": 6
}`
bookId := "2"
testPutRateUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, 6, book.Rating)
assert.Equal(t, true, book.Read)
assert.Equal(t, false, book.WantRead)
}
func TestPutRatingUserBooksHandler_RatingTypeWrong(t *testing.T) {
payload :=
`{
"rating": "bad"
}`
bookId := "18"
testPutRateUserBooks(t, payload, bookId, http.StatusInternalServerError)
}
func TestPutRatingUserBooksHandler_RatingMin(t *testing.T) {
payload :=
`{
"rating": -3
}`
bookId := "18"
testPutRateUserBooks(t, payload, bookId, http.StatusBadRequest)
}
func TestPutRatingUserBooksHandler_RatingMax(t *testing.T) {
payload :=
`{
"rating": 15
}`
bookId := "18"
testPutRateUserBooks(t, payload, bookId, http.StatusBadRequest)
}
func TestPutRatingUserBooksHandler_BadBookId(t *testing.T) {
payload :=
`{
"rating": 15
}`
bookId := "18574"
testPutRateUserBooks(t, payload, bookId, http.StatusNotFound)
}
func testPutRateUserBooks(t *testing.T, payload string, bookId string, expectedCode int) {
testutils.TestBookPutCallWithDemoPayload(t, payload, bookId, expectedCode, "/ws/book/"+bookId+"/rate")
}

View File

@@ -1,63 +0,0 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPutReadUserBooks_NewReadOk(t *testing.T) {
payload :=
`{
"read": true
}`
bookId := "21"
testPutReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, true, book.Read)
assert.Equal(t, false, book.WantRead)
}
func TestPutReadUserBooks_NewReadDateOk(t *testing.T) {
payload :=
`{
"read": true,
"endDate": "2025-10-20"
}`
bookId := "9"
testPutReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, true, book.Read)
assert.Equal(t, "2025-10-20", book.EndReadDate)
}
func TestPutReadUserBooks_UnsetEndDate(t *testing.T) {
payload :=
`{
"read": true,
"endDate": "null"
}`
bookId := "9"
testPutReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, true, book.Read)
assert.Equal(t, "", book.EndReadDate)
}
func TestPutReadUserBooks_UnsetReadOk(t *testing.T) {
payload :=
`{
"read": false
}`
bookId := "9"
testPutReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, false, book.Read)
assert.Equal(t, "", book.EndReadDate)
}
func testPutReadUserBooks(t *testing.T, payload string, bookId string, expectedCode int) {
testutils.TestBookPutCallWithDemoPayload(t, payload, bookId, expectedCode, "/ws/book/"+bookId+"/read")
}

View File

@@ -1,53 +0,0 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPutStartReadUserBooks_NoDate(t *testing.T) {
payload :=
`{
"date": "2025-11-19"
}`
bookId := "6"
testPutStartReadUserBooks(t, payload, bookId, http.StatusBadRequest)
}
func TestPutStartReadUserBooks_WrongDateFormat(t *testing.T) {
payload :=
`{
"startDate": "19/11/2025"
}`
bookId := "6"
testPutStartReadUserBooks(t, payload, bookId, http.StatusInternalServerError)
}
func TestPutStartReadUserBooks_NewReadOk(t *testing.T) {
payload :=
`{
"startDate": "2025-11-19"
}`
bookId := "6"
testPutStartReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "2025-11-19", book.StartReadDate)
}
func TestPutStartReadUserBooks_Unset(t *testing.T) {
payload :=
`{
"startDate": "null"
}`
bookId := "6"
testPutStartReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "", book.StartReadDate)
}
func testPutStartReadUserBooks(t *testing.T, payload string, bookId string, expectedCode int) {
testutils.TestBookPutCallWithDemoPayload(t, payload, bookId, expectedCode, "/ws/book/"+bookId+"/startread")
}

View File

@@ -0,0 +1,190 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPutRatingUserBooksHandler_UpdateRating(t *testing.T) {
payload :=
`{
"rating": 5
}`
bookId := "17"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, 5, book.Rating)
assert.Equal(t, true, book.Read)
}
func TestPutRatingUserBooksHandler_RateNewBookMakeItRead(t *testing.T) {
payload :=
`{
"rating": 7
}`
bookId := "18"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, 7, book.Rating)
assert.Equal(t, true, book.Read)
assert.Equal(t, false, book.WantRead)
}
func TestPutRatingUserBooksHandler_RateWantedBook(t *testing.T) {
payload :=
`{
"rating": 6
}`
bookId := "2"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, 6, book.Rating)
assert.Equal(t, true, book.Read)
assert.Equal(t, false, book.WantRead)
}
func TestPutRatingUserBooksHandler_RatingTypeWrong(t *testing.T) {
payload :=
`{
"rating": "bad"
}`
bookId := "18"
testPutUserBooks(t, payload, bookId, http.StatusInternalServerError)
}
func TestPutRatingUserBooksHandler_RatingMin(t *testing.T) {
payload :=
`{
"rating": -3
}`
bookId := "18"
testPutUserBooks(t, payload, bookId, http.StatusBadRequest)
}
func TestPutRatingUserBooksHandler_RatingMax(t *testing.T) {
payload :=
`{
"rating": 15
}`
bookId := "18"
testPutUserBooks(t, payload, bookId, http.StatusBadRequest)
}
func TestPutRatingUserBooksHandler_BadBookId(t *testing.T) {
payload :=
`{
"rating": 15
}`
bookId := "18574"
testPutUserBooks(t, payload, bookId, http.StatusNotFound)
}
func TestPutReadUserBooks_NewReadOk(t *testing.T) {
payload :=
`{
"read": true
}`
bookId := "21"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, true, book.Read)
assert.Equal(t, false, book.WantRead)
}
func TestPutReadUserBooks_NewReadDateOk(t *testing.T) {
payload :=
`{
"read": true,
"endDate": "2025-10-20"
}`
bookId := "9"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, true, book.Read)
assert.Equal(t, "2025-10-20", book.EndReadDate)
}
func TestPutReadUserBooks_UnsetEndDate(t *testing.T) {
payload :=
`{
"read": true,
"endDate": "null"
}`
bookId := "9"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, true, book.Read)
assert.Equal(t, "", book.EndReadDate)
}
func TestPutReadUserBooks_UnsetReadOk(t *testing.T) {
payload :=
`{
"read": false
}`
bookId := "9"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, false, book.Read)
assert.Equal(t, "", book.EndReadDate)
}
func TestPutStartReadUserBooks_WrongDateFormat(t *testing.T) {
payload :=
`{
"startDate": "19/11/2025"
}`
bookId := "6"
testPutUserBooks(t, payload, bookId, http.StatusInternalServerError)
}
func TestPutStartReadUserBooks_NewReadOk(t *testing.T) {
payload :=
`{
"startDate": "2025-11-19"
}`
bookId := "6"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "2025-11-19", book.StartReadDate)
}
func TestPutStartReadUserBooks_Unset(t *testing.T) {
payload :=
`{
"startDate": "null"
}`
bookId := "6"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "", book.StartReadDate)
}
func TestPutWantRead_SetTrue(t *testing.T) {
payload :=
`{
"wantread": true
}`
bookId := "17"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, true, book.WantRead)
}
func TestPutWantRead_SetFalse(t *testing.T) {
payload :=
`{
"wantread": false
}`
bookId := "2"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, false, book.WantRead)
}
func testPutUserBooks(t *testing.T, payload string, bookId string, expectedCode int) {
testutils.TestBookPutCallWithDemoPayload(t, payload, bookId, expectedCode, "/ws/book/"+bookId)
}

View File

@@ -1,35 +0,0 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPutWantRead_SetTrue(t *testing.T) {
payload :=
`{
"wantread": true
}`
bookId := "17"
testPutWantReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, true, book.WantRead)
}
func TestPutWantRead_SetFalse(t *testing.T) {
payload :=
`{
"wantread": false
}`
bookId := "2"
testPutWantReadUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, false, book.WantRead)
}
func testPutWantReadUserBooks(t *testing.T, payload string, bookId string, expectedCode int) {
testutils.TestBookPutCallWithDemoPayload(t, payload, bookId, expectedCode, "/ws/book/"+bookId+"/wantread")
}

View File

@@ -21,6 +21,15 @@ type BookPostImport struct {
Lang string `json:"lang" binding:"required,max=5"`
}
type UserBookPutUpdate struct {
Read *bool `json:"read"`
EndDate *string `json:"endDate"`
WantRead *bool `json:"wantread"`
Rating *int `json:"rating"`
StartDate *string `json:"startDate"`
Review *string `json:"review"`
}
type FileInfoPost struct {
FileID uint `json:"fileId"`
FilePath string `json:"filepath"`

View File

@@ -14,6 +14,7 @@ type BookGet struct {
InventaireId string `json:"inventaireid"`
OpenLibraryId string `json:"openlibraryid"`
Summary string `json:"summary"`
Review string `json:"review"`
Rating int `json:"rating"`
Read bool `json:"read"`
WantRead bool `json:"wantread"`

View File

@@ -132,6 +132,27 @@ func TestCallInventaireEdition(t *testing.T) {
result)
}
func TestCalInventaireEditionNoAuthor(t *testing.T) {
result, err := CallInventaireEdition(getBaseInventaireUrl(), "isbn:9782226487162", "fr")
if err != nil {
t.Error(err)
}
assert.Equal(t,
InventaireEditionDetailedSingleResult{
Id: "isbn:9782226487162",
Title: "Les Yeux de Mona",
Author: nil,
Description: "",
ISBN: "978-2-226-48716-2",
Publisher: "éditions Albin Michel",
ReleaseDate: "2024-02-01",
Image: "https://inventaire.io/img/entities/3ca857913983d694be03dee712bb2af9e2c51747",
Lang: "fr",
},
result)
}
func TestCallInventaireEditionFromISBN(t *testing.T) {
result, err := CallInventaireFromISBN(getBaseInventaireUrl(), "9782070379248", "fr")
if err != nil {

View File

@@ -2,6 +2,7 @@ package inventaire
import (
"math"
"slices"
"sort"
"git.artlef.fr/bibliomane/internal/callapiutils"
@@ -32,14 +33,15 @@ func CallInventaireEditionFromWork(inventaireUrl string, workId string, lang str
if err != nil {
return queryResult, err
}
queryResult.Count = int64(len(uris.Uris))
sort.Strings(uris.Uris)
limitedUris := uris.Uris
listUris := slices.Compact(uris.Uris)
queryResult.Count = int64(len(listUris))
limitedUris := listUris
if limit != 0 {
l := len(uris.Uris)
l := len(listUris)
startIndex := int(math.Min(float64(offset), float64(l)))
endIndex := int(math.Min(float64(limit+offset), float64(l)))
limitedUris = uris.Uris[startIndex:endIndex]
limitedUris = listUris[startIndex:endIndex]
}
editionEntities, err := callInventaireEditionEntities(inventaireUrl, limitedUris)

View File

@@ -15,6 +15,7 @@ type UserBook struct {
Rating int
Read bool
WantRead bool
Review string
StartReadDate *time.Time
EndReadDate *time.Time
}

View File

@@ -79,6 +79,8 @@ func computeValidationMessage(ac *appcontext.AppContext, fe *validator.FieldErro
return i18nresource.GetTranslatedMessage(ac, "ValidationRequired")
case "min":
return fmt.Sprintf(i18nresource.GetTranslatedMessage(ac, "ValidationTooShort"), (*fe).Param())
case "gte":
return fmt.Sprintf("Should be greater than %s", (*fe).Param())
case "max":
return fmt.Sprintf(i18nresource.GetTranslatedMessage(ac, "ValidationTooLong"), (*fe).Param())
default:

View File

@@ -11,7 +11,7 @@ func FetchBookGet(db *gorm.DB, userId uint, bookId uint64) (dto.BookGet, error)
var book dto.BookGet
query := db.Model(&model.Book{})
selectQueryString := "books.title, authors.name as author, authors.id as author_id, books.isbn, books.inventaire_id, books.open_library_id, books.summary, " +
"user_books.rating, user_books.read, user_books.want_read, " +
"user_books.review, user_books.rating, user_books.read, user_books.want_read, " +
"DATE(user_books.start_read_date) as start_read_date, " +
"DATE(user_books.end_read_date) AS end_read_date, " +
selectStaticFilesPath()

View File

@@ -40,18 +40,21 @@ func PostImportBookHandler(ac appcontext.AppContext) {
}
func saveInventaireBookToDb(ac appcontext.AppContext, inventaireEdition inventaire.InventaireEditionDetailedSingleResult, user *model.User) (*model.Book, error) {
author, err := fetchOrCreateInventaireAuthor(ac, inventaireEdition.Author)
if err != nil {
return nil, err
}
book := model.Book{
Title: inventaireEdition.Title,
SmallDescription: inventaireEdition.Description,
InventaireID: inventaireEdition.Id,
Author: *author,
AddedBy: *user,
}
if inventaireEdition.Author != nil {
author, err := fetchOrCreateInventaireAuthor(ac, inventaireEdition.Author)
if err != nil {
return nil, err
}
book.Author = *author
}
if inventaireEdition.Image != "" {
cover, err := fileutils.DownloadFile(ac, inventaireEdition.Image)
if err != nil {
@@ -59,7 +62,7 @@ func saveInventaireBookToDb(ac appcontext.AppContext, inventaireEdition inventai
}
book.Cover = cover
}
err = ac.Db.Save(&book).Error
err := ac.Db.Save(&book).Error
return &book, err
}

View File

@@ -7,21 +7,35 @@ import (
"time"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"gorm.io/gorm"
)
func PutReadUserBookHandler(ac appcontext.AppContext) {
data, err := retrieveDataFromContext(ac)
func PutUserBookHandler(ac appcontext.AppContext) {
bookId64, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
bookId := uint(bookId64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
bookId := data.BookId
user := data.User
var read userbookPutRead
err = ac.C.ShouldBindJSON(&read)
err = myvalidator.ValidateId(ac.Db, bookId, &model.Book{})
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var userBookPut dto.UserBookPutUpdate
err = ac.C.ShouldBindJSON(&userBookPut)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
@@ -32,14 +46,56 @@ func PutReadUserBookHandler(ac appcontext.AppContext) {
return
}
userbook.Read = read.Read
if read.EndDate != "" {
d, err := parseDate(read.EndDate)
if userBookPut.Read != nil {
err = updateReadStatus(&userbook, &userBookPut)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
}
if userBookPut.WantRead != nil {
userbook.WantRead = *userBookPut.WantRead
}
if userBookPut.StartDate != nil {
d, err := parseDate(*userBookPut.StartDate)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
userbook.StartReadDate = d
}
if userBookPut.Rating != nil {
err = validateRating(*userBookPut.Rating)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
}
updateRating(&userbook, &userBookPut)
}
if userBookPut.Review != nil {
userbook.Review = *userBookPut.Review
}
ac.Db.Save(&userbook)
ac.C.String(http.StatusOK, "Success")
}
func validateRating(rating int) error {
//struct used for validation
var ratingStruct struct {
Rating int `validate:"gte=0,lte=10"`
}
ratingStruct.Rating = rating
validate := validator.New()
return validate.Struct(ratingStruct)
}
func updateReadStatus(userbook *model.UserBook, userBookPut *dto.UserBookPutUpdate) error {
userbook.Read = *userBookPut.Read
if userBookPut.EndDate != nil && *userBookPut.EndDate != "" {
d, err := parseDate(*userBookPut.EndDate)
if err != nil {
return err
}
userbook.EndReadDate = d
}
@@ -52,84 +108,11 @@ func PutReadUserBookHandler(ac appcontext.AppContext) {
if !userbook.Read {
userbook.EndReadDate = nil
}
ac.Db.Save(&userbook)
ac.C.String(http.StatusOK, "Success")
return nil
}
func PutWantReadUserBookHandler(ac appcontext.AppContext) {
data, err := retrieveDataFromContext(ac)
if err != nil {
return
}
bookId := data.BookId
user := data.User
var wantread userbookPutWantRead
err = ac.C.ShouldBindJSON(&wantread)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
userbook, err := fetchOrCreateUserBook(ac, bookId, &user)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
userbook.WantRead = wantread.WantRead
ac.Db.Save(&userbook)
ac.C.String(http.StatusOK, "Success")
}
func PutStartReadUserBookHandler(ac appcontext.AppContext) {
data, err := retrieveDataFromContext(ac)
if err != nil {
return
}
bookId := data.BookId
user := data.User
var startDateToParse userbookPutStartRead
err = ac.C.ShouldBindJSON(&startDateToParse)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
userbook, err := fetchOrCreateUserBook(ac, bookId, &user)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
d, err := parseDate(startDateToParse.StartDate)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
userbook.StartReadDate = d
ac.Db.Save(&userbook)
ac.C.String(http.StatusOK, "Success")
}
func PutRateUserBookHandler(ac appcontext.AppContext) {
data, err := retrieveDataFromContext(ac)
if err != nil {
return
}
bookId := data.BookId
user := data.User
var rating userbookPutRating
err = ac.C.ShouldBindJSON(&rating)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
userbook, err := fetchOrCreateUserBook(ac, bookId, &user)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
userbook.Rating = rating.Rating
func updateRating(userbook *model.UserBook, userBookPut *dto.UserBookPutUpdate) {
userbook.Rating = *userBookPut.Rating
//if rated, set to "read" (a rating = 0 means unrated)
if userbook.Rating > 0 {
@@ -137,30 +120,6 @@ func PutRateUserBookHandler(ac appcontext.AppContext) {
//if set to read, remove want read
userbook.WantRead = false
}
ac.Db.Save(&userbook)
ac.C.String(http.StatusOK, "Success")
}
type userbookPutRead struct {
Read bool `json:"read"`
EndDate string `json:"endDate"`
}
type userbookPutWantRead struct {
WantRead bool `json:"wantread"`
}
type userbookPutRating struct {
Rating int `json:"rating" binding:"min=0,max=10"`
}
type userbookPutStartRead struct {
StartDate string `json:"startDate" binding:"required"`
}
type apiCallData struct {
BookId uint
User model.User
}
func parseDate(dateToParse string) (*time.Time, error) {
@@ -173,27 +132,6 @@ func parseDate(dateToParse string) (*time.Time, error) {
}
}
func retrieveDataFromContext(ac appcontext.AppContext) (apiCallData, error) {
bookId64, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
bookId := uint(bookId64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return apiCallData{}, err
}
err = myvalidator.ValidateId(ac.Db, bookId, &model.Book{})
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return apiCallData{}, err
}
user, fetchUserErr := ac.GetAuthenticatedUser()
if fetchUserErr != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return apiCallData{}, fetchUserErr
}
return apiCallData{BookId: bookId, User: user}, nil
}
func fetchOrCreateUserBook(ac appcontext.AppContext, bookId uint, user *model.User) (model.UserBook, error) {
var userbook model.UserBook
res := ac.Db.Where("user_id = ? AND book_id = ?", user.ID, bookId).First(&userbook)

View File

@@ -58,17 +58,8 @@ func Setup(config *config.Config) *gin.Engine {
ws.GET("/book/:id", func(c *gin.Context) {
routes.GetBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.PUT("/book/:id/read", func(c *gin.Context) {
routes.PutReadUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.PUT("/book/:id/wantread", func(c *gin.Context) {
routes.PutWantReadUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.PUT("/book/:id/startread", func(c *gin.Context) {
routes.PutStartReadUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.PUT("/book/:id/rate", func(c *gin.Context) {
routes.PutRateUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
ws.PUT("/book/:id", func(c *gin.Context) {
routes.PutUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.POST("/book", func(c *gin.Context) {
routes.PostBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})

View File

@@ -12,7 +12,6 @@ import (
"git.artlef.fr/bibliomane/internal/config"
"git.artlef.fr/bibliomane/internal/setup"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestSetup() *gin.Engine {
@@ -62,5 +61,7 @@ func TestBookPutCallWithDemoPayload(t *testing.T, payload string, bookId string,
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, expectedCode, w.Code)
if w.Code != expectedCode {
t.Errorf("%s", w.Body.String())
}
}

View File

@@ -6,7 +6,7 @@ import (
)
func main() {
applicationVersion := "0.3.0"
applicationVersion := "0.4.0"
c := config.LoadConfig(applicationVersion)
r := setup.Setup(&c)
r.Run(":" + c.Port)