15 Commits
0.4.0 ... 0.5.0

Author SHA1 Message Date
57a41e0e3e Release 0.5.0 2026-03-26 22:39:00 +01:00
bc077f176e Start reading cancel read: move logic to backend 2026-03-26 20:17:21 +01:00
9c18206483 Click on "start reading" now removes "want to read" 2026-03-26 20:07:26 +01:00
d8fc7396ff Books list: make the buttons work like in the form 2026-03-26 17:07:22 +01:00
4d687e3dcb Make "start read" icon full when the book is being read in list view 2026-03-26 15:11:56 +01:00
1da482c2ad Browse books: show latest books first 2026-03-26 14:26:31 +01:00
83088c689e Apply prettier format 2026-03-25 15:58:44 +01:00
950340beed fixed issue where buttons in book list did not appear 2026-03-25 15:58:20 +01:00
315d7db56a Add a new tab to browse all books on the instance 2026-03-25 15:45:50 +01:00
9db7957ad3 Backend query module: merge two files 2026-03-25 14:53:02 +01:00
5e6715d586 Improve error messages when fetching description on babelio 2026-03-24 17:23:06 +01:00
843c5b5dbc Add a new config to scrap description from babelio 2026-03-24 17:02:43 +01:00
c4390742b3 error check: refactor code to remove warning 2026-03-18 15:18:27 +01:00
0efc3629b0 API: improve validation message greater/lower than
Add translation and return a better error for lower than
2026-03-18 15:11:29 +01:00
a77d57603f Allow to cancel a rating 2026-03-18 13:51:38 +01:00
33 changed files with 773 additions and 150 deletions

View File

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

View File

@@ -83,6 +83,9 @@ onMounted(() => {
<div id="navmenu" class="navbar-menu" :class="isMenuActive ? 'is-active' : ''">
<div class="navbar-start">
<NavBarSearch size-class="" class="is-hidden-touch" />
<RouterLink v-if="authStore.user" to="/browse" class="navbar-item" activeClass="is-active">
{{ $t('navbar.explore') }}
</RouterLink>
<RouterLink v-if="authStore.user" to="/books" class="navbar-item" activeClass="is-active">
{{ $t('navbar.mybooks') }}
</RouterLink>

View File

@@ -6,6 +6,7 @@ import {
putUpdateBook,
putStartReadDate,
putStartReadDateUnset,
putReadBook,
putEndReadDate,
putEndReadDateUnset,
putUnreadBook,
@@ -49,7 +50,7 @@ async function onReadIconClick() {
if (data.value.read) {
data.value.wantread = false
data.value.endReadDate = today
putEndReadDate(props.id, today)
putReadBook(props.id)
} else {
putUnreadBook(props.id)
}
@@ -63,6 +64,7 @@ function onWantReadIconClick() {
async function onStartReadIconClick() {
if (!data.value.startReadDate) {
data.value.startReadDate = today
data.value.wantread = false
putStartReadDate(props.id, data.value.startReadDate)
} else if (!data.value.read) {
data.value.startReadDate = null
@@ -70,7 +72,6 @@ async function onStartReadIconClick() {
}
if (data.value.read) {
data.value.read = false
putUnreadBook(props.id)
}
}

View File

@@ -1,6 +1,14 @@
<script setup>
import { ref, computed } from 'vue'
import { putReadBook, getImagePathOrDefault, postImportBook } from './api.js'
import {
putUpdateBook,
putReadBook,
putUnreadBook,
putStartRead,
putStartReadDateUnset,
getImagePathOrDefault,
postImportBook,
} from './api.js'
import { useRouter } from 'vue-router'
const router = useRouter()
@@ -14,16 +22,83 @@ const props = defineProps({
description: String,
rating: Number,
read: Boolean,
startreaddate: String,
wantread: Boolean,
coverPath: String,
})
const imagePathOrDefault = computed(() => getImagePathOrDefault(props.coverPath))
const error = ref(null)
const isWantRead = ref(props.wantread)
const currentStartReadDate = ref(props.startreaddate)
const isRead = ref(props.read)
const today = new Date().toISOString().slice(0, 10)
const isStartRead = computed(
() => currentStartReadDate && currentStartReadDate.value && isRead && !isRead.value,
)
async function onUserBookRead() {
if (!isRead.value) {
userBookRead()
} else {
userBookUnread()
}
}
async function userBookRead() {
const res = await putReadBook(props.id)
if (res.ok) {
router.push('/books')
currentStartReadDate.value = ''
isWantRead.value = false
isRead.value = true
} else {
res.json().then((json) => (error.value = json))
}
}
async function userBookUnread() {
const res = await putUnreadBook(props.id)
if (res.ok) {
isRead.value = false
} else {
res.json().then((json) => (error.value = json))
}
}
async function onUserBookWantRead() {
const res = await putUpdateBook(props.id, { wantread: !isWantRead.value })
if (res.ok) {
isWantRead.value = !isWantRead.value
} else {
res.json().then((json) => (error.value = json))
}
}
async function onUserBookStartRead() {
if (!isStartRead.value) {
userBookStartRead()
} else {
userBookCancelStartRead()
}
}
async function userBookStartRead() {
const res = await putStartRead(props.id)
if (res.ok) {
currentStartReadDate.value = today
isRead.value = false
isWantRead.value = false
} else {
res.json().then((json) => (error.value = json))
}
}
async function userBookCancelStartRead() {
const res = await putStartReadDateUnset(props.id)
if (res.ok) {
currentStartReadDate.value = ''
} else {
res.json().then((json) => (error.value = json))
}
@@ -67,21 +142,22 @@ async function importInventaireEdition(inventaireid) {
<div class="has-text-text-65 is-size-6" v-if="props.description">{{ description }}</div>
</div>
</div>
<div v-if="!inventaireid" class="column is-narrow">
<button @click="" class="button is-large verticalbutton">
<div v-if="id && id != 0" class="column is-narrow">
<button @click="onUserBookWantRead" class="button is-large verticalbutton">
<span class="icon" :title="$t('booklistelement.wantread')">
<b-icon-eye-fill v-if="props.wantread" />
<b-icon-eye-fill v-if="isWantRead" />
<b-icon-eye v-else />
</span>
</button>
<button @click="" class="button is-large verticalbutton">
<button @click="onUserBookStartRead" class="button is-large verticalbutton">
<span class="icon" :title="$t('booklistelement.startread')">
<b-icon-book />
<b-icon-book-fill v-if="isStartRead" />
<b-icon-book v-else />
</span>
</button>
<button @click="onUserBookRead" class="button is-large verticalbutton">
<span class="icon" :title="$t('booklistelement.read')">
<b-icon-check-circle-fill v-if="props.read" />
<b-icon-check-circle-fill v-if="isRead" />
<b-icon-check-circle v-else />
</span>
</button>

View File

@@ -17,8 +17,8 @@ const offset = computed(() => (pageNumber.value - 1) * limit)
let currentFilterState = ref(FilterStates.READ)
let data = ref(null)
let error = ref(null)
const data = ref(null)
const error = ref(null)
let totalBooksNumber = computed(() =>
typeof data != 'undefined' && data.value != null ? data.value['count'] : 0,

View File

@@ -0,0 +1,61 @@
<script setup>
import { ref, computed } from 'vue'
import { getAllBooks } from './api.js'
import { onBeforeRouteUpdate } from 'vue-router'
import BookListElement from './BookListElement.vue'
import Pagination from './Pagination.vue'
const limit = 5
const pageNumber = ref(1)
const offset = computed(() => (pageNumber.value - 1) * limit)
const data = ref(null)
const error = ref(null)
const pageTotal = computed(() => {
const countValue = data.value !== null ? data.value['count'] : 0
return Math.ceil(countValue / limit)
})
function fetchData() {
data.value = null
error.value = null
getAllBooks(data, error, limit, offset.value)
}
fetchData()
onBeforeRouteUpdate(async (to, from) => {
pageNumber.value = 1
fetchData()
})
function pageChange(newPageNumber) {
pageNumber.value = newPageNumber
fetchData()
}
</script>
<template>
<div>
<div>
<div v-if="error">{{ error }}</div>
<div v-else-if="data && data.books && data.books.length > 0">
<div class="booksearchlist" v-for="book in data.books" :key="book.id">
<BookListElement v-bind="book" />
</div>
</div>
<div v-else-if="data === null">{{ $t('searchbook.loading') }}</div>
<div v-else>{{ $t('searchbook.noresult') }}</div>
</div>
<Pagination
:pageNumber="pageNumber"
:pageTotal="pageTotal"
maxItemDisplayed="11"
@pageChange="pageChange"
/>
</div>
</template>
<style scoped></style>

View File

@@ -39,6 +39,7 @@ function onTextAreaFocus() {
</div>
<VRating
half-increments
clearable
hover
:length="5"
size="x-large"

View File

@@ -54,6 +54,14 @@ export function getMyBooks(data, error, arg, limit, offset) {
return useFetch(data, error, '/ws/mybooks/' + arg + '?' + queryParams.toString())
}
export function getAllBooks(data, error, limit, offset) {
const queryParams = new URLSearchParams({
limit: limit,
offset: offset,
})
return useFetch(data, error, '/ws/books' + '?' + queryParams.toString())
}
export function getSearchBooks(data, error, searchterm, lang, searchInventaire, limit, offset) {
const queryParams = new URLSearchParams({
lang: lang,
@@ -99,7 +107,7 @@ export async function postImportBook(id, language) {
}
export async function putReadBook(bookId) {
return genericPayloadCall('/ws/book/' + bookId, { read: true }, 'PUT')
return putEndReadDate(bookId, new Date().toISOString().slice(0, 10))
}
export async function putUnreadBook(bookId) {
@@ -118,6 +126,10 @@ export async function putStartReadDateUnset(bookId) {
return genericPayloadCall('/ws/book/' + bookId, { startDate: 'null' }, 'PUT')
}
export async function putStartRead(bookId) {
return putStartReadDate(bookId, new Date().toISOString().slice(0, 10))
}
export async function putStartReadDate(bookId, startdate) {
return genericPayloadCall('/ws/book/' + bookId, { startDate: startdate }, 'PUT')
}

View File

@@ -6,6 +6,7 @@
"navbar": {
"mybooks": "My Books",
"addbook": "Add Book",
"explore": "Explore",
"logout": "Log out",
"signup": "Sign up",
"search": "Search",

View File

@@ -5,6 +5,7 @@
},
"navbar": {
"mybooks": "Mes Livres",
"explore": "Explorer",
"addbook": "Ajouter Un Livre",
"logout": "Se déconnecter",
"signup": "S'inscrire",

View File

@@ -10,11 +10,13 @@ import Home from './Home.vue'
import ScanBook from './ScanBook.vue'
import SearchBook from './SearchBook.vue'
import ImportInventaire from './ImportInventaire.vue'
import InstanceBrowser from './InstanceBrowser.vue'
import { useAuthStore } from './auth.store'
const routes = [
{ path: '/', component: Home },
{ path: '/scan', component: ScanBook },
{ path: '/browse', component: InstanceBrowser },
{ path: '/books', component: BooksBrowser },
{ path: '/book/:id', component: BookForm, props: true },
{ path: '/author/:id', component: AuthorForm, props: true },

10
go.mod
View File

@@ -3,6 +3,7 @@ module git.artlef.fr/bibliomane
go 1.26
require (
github.com/PuerkitoBio/goquery v1.12.0
github.com/alecthomas/kong v1.14.0
github.com/alecthomas/kong-toml v0.4.0
github.com/gin-gonic/gin v1.11.0
@@ -11,13 +12,14 @@ require (
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/pelletier/go-toml v1.9.5
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.48.0
golang.org/x/text v0.34.0
golang.org/x/crypto v0.49.0
golang.org/x/text v0.35.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.0
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -47,8 +49,8 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

85
go.sum
View File

@@ -1,5 +1,7 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s=
@@ -8,6 +10,8 @@ github.com/alecthomas/kong-toml v0.4.0 h1:sSK/HHi2M5jqSXYTxmuxkdZcJ+ip9jhYvwcjDG
github.com/alecthomas/kong-toml v0.4.0/go.mod h1:hRVV9iGmqYsFqs17jFQgqhkjYIxiklbfy95xJ3nlpKI=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -40,6 +44,7 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -97,19 +102,83 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -10,3 +10,5 @@ image-folder-path = "/tmp"
# The port to listen on for the server.
port = "8080"
book-description-from-babelio = true

View File

@@ -0,0 +1,58 @@
package apitest
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestFetchAllBooks(t *testing.T) {
result := testFetchBooks(t, "15", "0")
assert.Equal(t, int64(31), result.Count)
assert.Equal(t, 15, len(result.Books))
}
func testFetchBooks(t *testing.T, limit string, offset string) dto.BookSearchGet {
router := testutils.TestSetup()
u, err := url.Parse("/ws/books")
if err != nil {
t.Error(err)
}
if limit != "" {
q := u.Query()
q.Set("limit", limit)
u.RawQuery = q.Encode()
}
if offset != "" {
q := u.Query()
q.Set("offset", offset)
u.RawQuery = q.Encode()
}
q := u.Query()
q.Set("lang", "fr")
u.RawQuery = q.Encode()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("GET", u.String(), nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var result dto.BookSearchGet
s := w.Body.String()
err = json.Unmarshal([]byte(s), &result)
if err != nil {
t.Error(err)
}
assert.Equal(t, 200, w.Code)
return result
}

View File

@@ -24,6 +24,10 @@ func TestPostImportBookHandler_Ok(t *testing.T) {
assert.Equal(t, "Emily Brontë", book.Author)
assert.Equal(t, "isbn:9782253004752", book.InventaireId)
assert.Equal(t, "/static/bookcover/44abbcbdc1092212c2bae66f5165019dac1e2a7b.webp", book.CoverPath)
expectedDesc := `Roman unique, à la croisée du fantastique et du romantisme, ce texte inclassable bouleverse les codes du XIXe siècle par sa violence émotionnelle, sa narration fragmentée et ses personnages à fleur de peau.
Sur les landes battues par les vents, à l'ombre des murs de Hurlevent, se joue une tragédie d'amour et de vengeance entre Catherine et Heathcliff - deux âmes tourmentées, liées par une passion aussi absolue que destructrice.
Sublimée par l'univers graphique intense d'Isabella Mazzanti, cette édition s'impose comme un objet littéraire à part, mêlant innovations narratives et force d'évocation. Les images semblent vibrer d'un souffle secret, comme si le vent y faisait surgir, en silence, le tumulte des passions.`
assert.Equal(t, expectedDesc, book.Summary)
}
func TestPostImportBookHandler_OkAuthorKey(t *testing.T) {
@@ -33,6 +37,9 @@ func TestPostImportBookHandler_OkAuthorKey(t *testing.T) {
assert.Equal(t, "Philip K. Dick", book.Author)
assert.Equal(t, "isbn:9782290033630", book.InventaireId)
assert.Equal(t, "/static/bookcover/1d1493159d031224a42b37c4417fcbb8c76b00bd.webp", book.CoverPath)
expectedDesc := `Les bombes étaient finalement tombées. Malgré l'équilibre de la terreur, un jour un homme avait été assez fou pour appuyer sur le bouton. Cependant, dans ce coin perdu de Californie, la vie continuait. Pour Bonny Keller, toujours perturbée malgré six ans d'analyse ; pour Bruno Bluthgeld, l'un des responsables de la grande catastrophe ; pour Hoppy, le phocomèle, l'ancien bébé thalidomide doté de pouvoirs supranormaux... Elle continuait aussi pour Walt Dangerfield, l'astronaute expédié sur mars, mais dont la cabine s'était satellisée autour de la terre. Là, à l'abri des radiations, il s'était transformé en une sorte de super disc-jockey dont l'écoute était devenue une drogue pour tous les survivants...
Philip K. Dick (1928-1982). A travers une œuvre imposante, il ne cessera de traiter ses thèmes de prédilection : la juxtaposition de deux niveaux de réalité — l'un "objectivement" déterminé, l'autre n'étant qu'un monde d'apparences — et la poranoïa qu'impliquent ces manipulations de la réalité dont personne ne tonnait jamais le degré exact de "virtualité".`
assert.Equal(t, expectedDesc, book.Summary)
}
func TestPostImportBookHandler_NoOLID(t *testing.T) {

View File

@@ -137,19 +137,32 @@ func TestPutStartReadUserBooks_WrongDateFormat(t *testing.T) {
`{
"startDate": "19/11/2025"
}`
bookId := "6"
bookId := "7"
testPutUserBooks(t, payload, bookId, http.StatusInternalServerError)
}
func TestPutStartReadUserBooks_NewReadOk(t *testing.T) {
func TestPutStartReadUserBooks_UnsetWantReadOk(t *testing.T) {
payload :=
`{
"startDate": "2025-11-19"
}`
bookId := "6"
bookId := "7"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "2025-11-19", book.StartReadDate)
assert.Equal(t, false, book.WantRead)
}
func TestPutStartReadUserBooks_UnsetReadOk(t *testing.T) {
payload :=
`{
"startDate": "2025-12-20"
}`
bookId := "13"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "2025-12-20", book.StartReadDate)
assert.Equal(t, false, book.Read)
}
func TestPutStartReadUserBooks_Unset(t *testing.T) {
@@ -157,7 +170,7 @@ func TestPutStartReadUserBooks_Unset(t *testing.T) {
`{
"startDate": "null"
}`
bookId := "6"
bookId := "7"
testPutUserBooks(t, payload, bookId, http.StatusOK)
book := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "", book.StartReadDate)

View File

@@ -47,12 +47,30 @@ func TestSearchBook_OneBookRead(t *testing.T) {
ID: 4,
Rating: 7,
Read: true,
StartReadDate: "2026-01-30",
WantRead: false,
CoverPath: "/static/bookcover/lesdieuxontsoif.jpg",
}},
result.Books)
}
func TestSearchBook_OneBookStartRead(t *testing.T) {
result := testSearchBook(t, "Recherches", "", "")
assert.Equal(t, int64(1), result.Count)
assert.Equal(t,
[]dto.BookSearchGetBook{{
Title: "Recherches philosophiques",
Author: "Ludwig Wittgenstein",
ID: 30,
Rating: 0,
Read: false,
StartReadDate: "2025-11-22",
WantRead: false,
CoverPath: "/static/bookcover/Recherches-philosophiques.jpg",
}},
result.Books)
}
func TestSearchBook_ISBN(t *testing.T) {
result := testSearchBook(t, "9782070337903", "", "")
assert.Equal(t, int64(1), result.Count)

128
internal/babelio/babelio.go Normal file
View File

@@ -0,0 +1,128 @@
package babelio
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.artlef.fr/bibliomane/internal/callapiutils"
"git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/PuerkitoBio/goquery"
"golang.org/x/text/encoding/charmap"
)
type babelioSearchArg struct {
Term string `json:"term"`
}
type babelioSearchResult struct {
//only parsing the url
Url string `json:"url"`
}
func GetDescriptionFromISBN(baseUrl string, isbn string) (string, error) {
url, err := searchPageIsbn(baseUrl, isbn)
if err != nil {
return "", err
}
//we either find the full summary, or we have to make another call to get it.
fullSummary, payloadToQuery, err := parseBookPage(baseUrl, url)
if err != nil {
return "", err
}
if fullSummary != "" {
return decodeAndCleanText(strings.NewReader(fullSummary)), err
} else if payloadToQuery != "" {
return queryDescription(baseUrl, payloadToQuery)
} else {
return "", nil
}
}
func searchPageIsbn(baseUrl, isbn string) (string, error) {
searchUrl, err := callapiutils.ComputeUrl(baseUrl, "aj_recherche.php")
if err != nil {
return "", err
}
term := babelioSearchArg{Term: isbn}
var searchResults []babelioSearchResult
callapiutils.FetchAndParseResultFromPost(searchUrl, &term, &searchResults)
if len(searchResults) == 0 {
return "", myvalidator.TranslatedError{Err: errors.New("ISBNNotFoundBabelio"), Arg: isbn}
}
return searchResults[0].Url, nil
}
func parseBookPage(baseUrl, bookUrl string) (string, string, error) {
url, err := callapiutils.ComputeUrl(baseUrl, bookUrl)
if err != nil {
return "", "", err
}
resp, err := http.Get(url.String())
if err != nil {
return "", "", err
}
defer resp.Body.Close()
doc, err := goquery.NewDocumentFromReader(resp.Body)
//we either find the full summary, or we have to make another call to get it.
fullsummary := ""
jsToParse := ""
doc.Find(".livre_resume").Each(func(i int, s *goquery.Selection) {
onclick, ok := s.Find("a").Attr("onclick")
if ok {
jsToParse = onclick
} else {
fullsummary = s.Text()
}
})
if fullsummary != "" {
return fullsummary, "", nil
}
typeStr, idObj, err := extractNumbersFromExpression(jsToParse)
if err != nil {
return "", "", err
}
return "", fmt.Sprintf("type=%s&id_obj=%s", typeStr, idObj), nil
}
func extractNumbersFromExpression(jsToParse string) (string, string, error) {
splitted := strings.Split(jsToParse, ",")
if len(splitted) < 3 {
return "", "", myvalidator.TranslatedError{Err: errors.New("BabelioParseError")}
}
if len(splitted[2]) < 3 {
return "", "", myvalidator.TranslatedError{Err: errors.New("BabelioParseError")}
}
return splitted[1], splitted[2][:len(splitted[2])-2], nil
}
func queryDescription(baseUrl string, payloadToQuery string) (string, error) {
url, err := callapiutils.ComputeUrl(baseUrl, "aj_voir_plus_a.php")
if err != nil {
return "", err
}
resp, err := http.Post(url.String(),
"application/x-www-form-urlencoded; charset=UTF-8",
strings.NewReader(payloadToQuery))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", myvalidator.TranslatedError{Err: fmt.Errorf("BabelioFetchDescError")}
}
return decodeAndCleanText(resp.Body), nil
}
func decodeAndCleanText(reader io.Reader) string {
tr := charmap.Windows1252.NewDecoder().Reader(reader)
var decodedString strings.Builder
io.Copy(&decodedString, tr)
return strings.TrimSpace(strings.ReplaceAll(decodedString.String(), "<br>", "\n"))
}

View File

@@ -0,0 +1,30 @@
package babelio
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetDescriptionFromISBN_Philip(t *testing.T) {
desc, err := GetDescriptionFromISBN("https://www.babelio.com", "9782290033630")
expectedDesc := `Les bombes étaient finalement tombées. Malgré l'équilibre de la terreur, un jour un homme avait été assez fou pour appuyer sur le bouton. Cependant, dans ce coin perdu de Californie, la vie continuait. Pour Bonny Keller, toujours perturbée malgré six ans d'analyse ; pour Bruno Bluthgeld, l'un des responsables de la grande catastrophe ; pour Hoppy, le phocomèle, l'ancien bébé thalidomide doté de pouvoirs supranormaux... Elle continuait aussi pour Walt Dangerfield, l'astronaute expédié sur mars, mais dont la cabine s'était satellisée autour de la terre. Là, à l'abri des radiations, il s'était transformé en une sorte de super disc-jockey dont l'écoute était devenue une drogue pour tous les survivants...
Philip K. Dick (1928-1982). A travers une œuvre imposante, il ne cessera de traiter ses thèmes de prédilection : la juxtaposition de deux niveaux de réalité — l'un "objectivement" déterminé, l'autre n'étant qu'un monde d'apparences — et la poranoïa qu'impliquent ces manipulations de la réalité dont personne ne tonnait jamais le degré exact de "virtualité".`
if err != nil {
t.Error(err)
return
}
assert.Equal(t, expectedDesc, desc)
}
func TestGetDescriptionFromISBN_Emily(t *testing.T) {
desc, err := GetDescriptionFromISBN("https://www.babelio.com", "9782253004752")
expectedDesc := `Roman unique, à la croisée du fantastique et du romantisme, ce texte inclassable bouleverse les codes du XIXe siècle par sa violence émotionnelle, sa narration fragmentée et ses personnages à fleur de peau.
Sur les landes battues par les vents, à l'ombre des murs de Hurlevent, se joue une tragédie d'amour et de vengeance entre Catherine et Heathcliff - deux âmes tourmentées, liées par une passion aussi absolue que destructrice.
Sublimée par l'univers graphique intense d'Isabella Mazzanti, cette édition s'impose comme un objet littéraire à part, mêlant innovations narratives et force d'évocation. Les images semblent vibrer d'un souffle secret, comme si le vent y faisait surgir, en silence, le tumulte des passions.`
if err != nil {
t.Error(err)
return
}
assert.Equal(t, expectedDesc, desc)
}

View File

@@ -1,6 +1,7 @@
package callapiutils
import (
"bytes"
"encoding/json"
"fmt"
"io"
@@ -20,9 +21,33 @@ func AddQueryParam(u *url.URL, paramName string, paramValue string) {
u.RawQuery = q.Encode()
}
func FetchAndParseResultFromPost[T any, J any](u *url.URL, queryArg *J, queryResult *T) error {
payloadBuf := new(bytes.Buffer)
json.NewEncoder(payloadBuf).Encode(queryArg)
req, err := http.NewRequest("POST", u.String(), payloadBuf)
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
log.Printf("Calling POST %s", u.String())
return parseApiQueryResult(u, req, queryResult)
}
func FetchAndParseResult[T any](u *url.URL, queryResult *T) error {
resp, err := DoApiQuery(u)
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return err
}
req.Header.Add("Accept", "application/json")
req.Header.Add("User-Agent", "bibliomane/0.1 (artlef@protonmail.com)")
log.Printf("Calling GET %s", u.String())
return parseApiQueryResult(u, req, queryResult)
}
func parseApiQueryResult[T any](u *url.URL, req *http.Request, queryResult *T) error {
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
@@ -42,18 +67,8 @@ func FetchAndParseResult[T any](u *url.URL, queryResult *T) error {
if err != nil {
return err
}
return err
}
func DoApiQuery(u *url.URL) (*http.Response, error) {
client := &http.Client{}
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Add("Accept", "application/json")
req.Header.Add("User-Agent", "bibliomane/0.1 (artlef@protonmail.com)")
return client.Do(req)
return err
}
func ComputeUrl(baseUrl string, paths ...string) (*url.URL, error) {

View File

@@ -29,6 +29,8 @@ type Config struct {
ImageFolderPath string `toml:"image-folder-path" short:"i" default:"img" type:"path" help:"Folder where uploaded files will be stored." comment:"Folder where uploaded files will be stored."`
Limit int `toml:"limit" default:"100" help:"A single API call will return at most this number of records." comment:"A single API call will return at most this number of records."`
InventaireUrl string `toml:"inventaire-url" default:"https://inventaire.io" help:"An inventaire.io instance URL." comment:"An inventaire.io instance URL."`
BookDescriptionFromBabelio bool `toml:"book-description-from-babelio" default:"false" help:"Activate fetching description from babelio.com." comment:"Activate fetching description from babelio.com."`
BabelioUrl string `toml:"babelio-url" default:"https://www.babelio.com" comment:"Link to babelio website."`
DisableRegistration bool `toml:"disable-registration" short:"n" default:"false" help:"Disable new account creation." comment:"Disable new account creation."`
DemoMode bool `toml:"demo-mode" short:"D" default:"false" help:"Activate demo mode." comment:"Activate demo mode: anyone connecting to the instance will be logged in as a single user."`
DemoUsername string `toml:"demo-username" default:"demo" help:"Name of the single user used for the demo." comment:"Name of the single user used for the demo."`
@@ -55,6 +57,8 @@ func defaultConfig() CLI {
ImageFolderPath: "img",
Limit: 100,
InventaireUrl: "https://inventaire.io",
BookDescriptionFromBabelio: false,
BabelioUrl: "https://www.babelio.com",
DisableRegistration: false,
DemoMode: false,
DemoUsername: "demo",

View File

@@ -53,6 +53,7 @@ type BookSearchGetBook struct {
IsInventaireEdition bool `json:"isinventaireedition"`
Rating int `json:"rating"`
Read bool `json:"read"`
StartReadDate string `json:"startreaddate"`
WantRead bool `json:"wantread"`
CoverPath string `json:"coverPath"`
}

View File

@@ -3,7 +3,12 @@ AuthenticationSuccess = "Authentication was a success."
ValidationRequired = "This field is required."
ValidationTooShort = "This field is too short. It should be at least %s characters."
ValidationTooLong = "This field is too long. It should be under %s characters."
ValidationLowerThan = "This field should be lower than %s."
ValidationGreaterThan = "This field should be greater than %s."
ValidationPropertyFail = "Validation failed for '%s' property."
RegistrationDisabled = "Registration has been disabled on this instance."
UserAlreadyExists = "An user with this name already exists."
ErrorWhenCreatingUserFromStr = "Error when creating user from string %s"
ISBNNotFoundBabelio = "ISBN %s not found on babelio."
BabelioParseError = "Error when parsing babelio."
BabelioFetchDescError = "Error when fetching description on babelio."

View File

@@ -3,7 +3,12 @@ AuthenticationSuccess = "Connexion réussie."
ValidationRequired = "Ce champ est requis."
ValidationTooShort = "Ce champ est trop court. Il devrait contenir au moins %s caractères."
ValidationTooLong = "Ce champ est trop long. Il ne devrait pas dépasser %s caractères."
ValidationLowerThan = "Ce champ devrait être inférieur à %s."
ValidationGreaterThan = "Ce champ devrait être supérieur à %s."
ValidationPropertyFail = "La validation a échoué pour la propriété '%s'."
RegistrationDisabled = "La création de nouveaux comptes a été désactivée sur cette instance."
UserAlreadyExists = "Un utilisateur avec le même nom existe déjà."
ErrorWhenCreatingUserFromStr = "Erreur lors de la création de l'utilisateur %s"
ISBNNotFoundBabelio = "L'ISBN %s n'est pas sur babelio."
BabelioParseError = "Erreur en parsant babelio."
BabelioFetchDescError = "Erreur lors de la récupération de la description sur babelio."

View File

@@ -12,6 +12,11 @@ import (
"gorm.io/gorm"
)
type TranslatedError struct {
Err error
Arg string
}
type HttpError struct {
StatusCode int
Err error
@@ -41,21 +46,32 @@ func ValidateId(db *gorm.DB, id uint, value any) error {
}
func ReturnErrorsAsJsonResponse(ac *appcontext.AppContext, err error) {
var httpError HttpError
var ve validator.ValidationErrors
if errors.As(err, &ve) {
ve, isValidationErrors := errors.AsType[validator.ValidationErrors](err)
if isValidationErrors {
ac.C.JSON(http.StatusBadRequest, getValidationErrors(ac, &ve))
} else if errors.As(err, &httpError) {
ac.C.JSON(httpError.StatusCode, gin.H{"error": httpError.Err.Error()})
} else {
ac.C.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
httpError, isHttpError := errors.AsType[HttpError](err)
if isHttpError {
ac.C.JSON(httpError.StatusCode, gin.H{"error": httpError.Err.Error()})
return
}
ac.C.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
func (h HttpError) Error() string {
return fmt.Sprintf("%d: err %v", h.StatusCode, h.Err)
}
func (e TranslatedError) Error() string {
return fmt.Sprintf("%v", e.Err)
}
func (e TranslatedError) ToTranslatedMessage(ac *appcontext.AppContext) string {
return fmt.Sprintf(i18nresource.GetTranslatedMessage(ac, e.Error()), e.Arg)
}
type apiValidationError struct {
Field string `json:"field"`
Err string `json:"error"`
@@ -79,10 +95,12 @@ 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())
case "lte":
return fmt.Sprintf(i18nresource.GetTranslatedMessage(ac, "ValidationLowerThan"), (*fe).Param())
case "gte":
return fmt.Sprintf(i18nresource.GetTranslatedMessage(ac, "ValidationGreaterThan"), (*fe).Param())
default:
return fmt.Sprintf(i18nresource.GetTranslatedMessage(ac, "ValidationPropertyFail"), tag)
}

View File

@@ -1,6 +1,9 @@
package query
import (
"regexp"
"strings"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/fileutils"
"git.artlef.fr/bibliomane/internal/model"
@@ -100,6 +103,81 @@ func fetchUserBookGet(db *gorm.DB, userId uint) *gorm.DB {
return query
}
func FetchAllBooks(db *gorm.DB, userId uint, limit int, offset int) ([]dto.BookSearchGetBook, error) {
var books []dto.BookSearchGetBook
query := fetchBookQueryBuilder(db, userId)
query = query.Limit(limit)
query = query.Offset(offset)
query = query.Order("books.id DESC")
res := query.Find(&books)
return books, res.Error
}
func FetchAllBooksCount(db *gorm.DB, userId uint) (int64, error) {
var count int64
query := fetchBookQueryBuilder(db, userId)
res := query.Count(&count)
return count, res.Error
}
func FetchBookSearchByAuthorGet(db *gorm.DB, userId uint, authorId uint64, limit int, offset int) ([]dto.BookSearchGetBook, error) {
var books []dto.BookSearchGetBook
query := fetchBookSearchByAuthorQuery(db, userId, authorId)
query = query.Limit(limit)
query = query.Offset(offset)
res := query.Find(&books)
return books, res.Error
}
func FetchBookSearchByAuthorGetCount(db *gorm.DB, userId uint, authorId uint64) (int64, error) {
var count int64
query := fetchBookSearchByAuthorQuery(db, userId, authorId)
res := query.Count(&count)
return count, res.Error
}
func fetchBookSearchByAuthorQuery(db *gorm.DB, userId uint, authorId uint64) *gorm.DB {
query := fetchBookQueryBuilder(db, userId)
return query.Where("authors.id = ?", authorId)
}
func FetchBookSearchGet(db *gorm.DB, userId uint, searchterm string, limit int, offset int) ([]dto.BookSearchGetBook, error) {
var books []dto.BookSearchGetBook
query := fetchBookSearchQuery(db, userId, searchterm)
query = query.Limit(limit)
query = query.Offset(offset)
res := query.Find(&books)
return books, res.Error
}
func FetchBookSearchGetCount(db *gorm.DB, userId uint, searchterm string) (int64, error) {
query := fetchBookSearchQuery(db, userId, searchterm)
var count int64
res := query.Count(&count)
return count, res.Error
}
func fetchBookSearchQuery(db *gorm.DB, userId uint, searchterm string) *gorm.DB {
query := fetchBookQueryBuilder(db, userId)
isIsbn, _ := regexp.Match(`\d{10,13}`, []byte(searchterm))
if isIsbn {
query = query.Where("books.isbn = ?", searchterm)
} else {
query = query.Where("LOWER(books.title) LIKE ?", "%"+strings.ToLower(searchterm)+"%")
}
return query
}
func fetchBookQueryBuilder(db *gorm.DB, userId uint) *gorm.DB {
query := db.Model(&model.Book{})
query = query.Select("books.id, books.title, authors.name as author, books.small_description as description, books.inventaire_id, user_books.rating, user_books.read, DATE(user_books.start_read_date) as start_read_date, user_books.want_read, " + selectStaticFilesPath())
query = joinAuthors(query)
query = query.Joins("left join user_books on (user_books.book_id = books.id and user_books.user_id = ?)", userId)
query = joinStaticFiles(query)
return query
}
func selectStaticFilesPath() string {
return "(CASE COALESCE(static_files.path, '') WHEN '' THEN '' ELSE concat('" + fileutils.GetWsLinkPrefix() + "', static_files.path) END) as CoverPath"
}

View File

@@ -1,68 +0,0 @@
package query
import (
"regexp"
"strings"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/model"
"gorm.io/gorm"
)
func FetchBookSearchByAuthorGet(db *gorm.DB, userId uint, authorId uint64, limit int, offset int) ([]dto.BookSearchGetBook, error) {
var books []dto.BookSearchGetBook
query := fetchBookSearchByAuthorQuery(db, userId, authorId)
query = query.Limit(limit)
query = query.Offset(offset)
res := query.Find(&books)
return books, res.Error
}
func FetchBookSearchByAuthorGetCount(db *gorm.DB, userId uint, authorId uint64) (int64, error) {
var count int64
query := fetchBookSearchByAuthorQuery(db, userId, authorId)
res := query.Count(&count)
return count, res.Error
}
func fetchBookSearchByAuthorQuery(db *gorm.DB, userId uint, authorId uint64) *gorm.DB {
query := fetchBookSearchQueryBuilder(db, userId)
return query.Where("authors.id = ?", authorId)
}
func FetchBookSearchGet(db *gorm.DB, userId uint, searchterm string, limit int, offset int) ([]dto.BookSearchGetBook, error) {
var books []dto.BookSearchGetBook
query := fetchBookSearchQuery(db, userId, searchterm)
query = query.Limit(limit)
query = query.Offset(offset)
res := query.Find(&books)
return books, res.Error
}
func FetchBookSearchGetCount(db *gorm.DB, userId uint, searchterm string) (int64, error) {
query := fetchBookSearchQuery(db, userId, searchterm)
var count int64
res := query.Count(&count)
return count, res.Error
}
func fetchBookSearchQuery(db *gorm.DB, userId uint, searchterm string) *gorm.DB {
query := fetchBookSearchQueryBuilder(db, userId)
isIsbn, _ := regexp.Match(`\d{10,13}`, []byte(searchterm))
if isIsbn {
query = query.Where("books.isbn = ?", searchterm)
} else {
query = query.Where("LOWER(books.title) LIKE ?", "%"+strings.ToLower(searchterm)+"%")
}
return query
}
func fetchBookSearchQueryBuilder(db *gorm.DB, userId uint) *gorm.DB {
query := db.Model(&model.Book{})
query = query.Select("books.id, books.title, authors.name as author, books.small_description as description, books.inventaire_id, user_books.rating, user_books.read, user_books.want_read, " + selectStaticFilesPath())
query = joinAuthors(query)
query = query.Joins("left join user_books on (user_books.book_id = books.id and user_books.user_id = ?)", userId)
query = joinStaticFiles(query)
return query
}

View File

@@ -2,8 +2,11 @@ package routes
import (
"errors"
"log"
"strings"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/babelio"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/fileutils"
"git.artlef.fr/bibliomane/internal/inventaire"
@@ -62,10 +65,40 @@ func saveInventaireBookToDb(ac appcontext.AppContext, inventaireEdition inventai
}
book.Cover = cover
}
if ac.Config.BookDescriptionFromBabelio {
isbn := findIsbn(&inventaireEdition)
if isbn != "" {
desc, err := babelio.GetDescriptionFromISBN(ac.Config.BabelioUrl, isbn)
if err != nil {
te, isTrError := errors.AsType[myvalidator.TranslatedError](err)
var errToPrint string
if isTrError {
errToPrint = te.ToTranslatedMessage(&ac)
} else {
errToPrint = err.Error()
}
log.Println(errToPrint)
} else {
book.Summary = desc
}
}
}
err := ac.Db.Save(&book).Error
return &book, err
}
func findIsbn(inventaireEdition *inventaire.InventaireEditionDetailedSingleResult) string {
if inventaireEdition.ISBN != "" {
return strings.ReplaceAll(inventaireEdition.ISBN, "-", "")
}
if strings.HasPrefix(inventaireEdition.Id, "isbn:") {
return inventaireEdition.Id[5:]
}
return ""
}
func fetchOrCreateInventaireAuthor(ac appcontext.AppContext, inventaireAuthor *inventaire.InventaireAuthorResult) (*model.Author, error) {
var author model.Author
res := ac.Db.Where("inventaire_id = ?", inventaireAuthor.ID).First(&author)

View File

@@ -0,0 +1,40 @@
package routes
import (
"net/http"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/myvalidator"
"git.artlef.fr/bibliomane/internal/query"
)
func GetBooksHandler(ac appcontext.AppContext) {
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
limit, err := ac.GetQueryLimit()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
offset, err := ac.GetQueryOffset()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
books, err := query.FetchAllBooks(ac.Db, user.ID, limit, offset)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
count, err := query.FetchAllBooksCount(ac.Db, user.ID)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.C.JSON(http.StatusOK, dto.BookSearchGet{Count: count, Inventaire: false, Books: books})
}

View File

@@ -62,12 +62,17 @@ func PutUserBookHandler(ac appcontext.AppContext) {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
if d != nil {
userbook.Read = false
userbook.WantRead = false
}
userbook.StartReadDate = d
}
if userBookPut.Rating != nil {
err = validateRating(*userBookPut.Rating)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
updateRating(&userbook, &userBookPut)
}

View File

@@ -39,7 +39,9 @@ func Setup(config *config.Config) *gin.Engine {
ws.GET("/appinfo", func(c *gin.Context) {
routes.GetAppInfo(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.GET("/books", func(c *gin.Context) {
routes.GetBooksHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.GET("/mybooks/read", func(c *gin.Context) {
routes.GetMyBooksReadHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})

View File

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