Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57a41e0e3e | |||
| bc077f176e | |||
| 9c18206483 | |||
| d8fc7396ff | |||
| 4d687e3dcb | |||
| 1da482c2ad | |||
| 83088c689e | |||
| 950340beed | |||
| 315d7db56a | |||
| 9db7957ad3 | |||
| 5e6715d586 | |||
| 843c5b5dbc | |||
| c4390742b3 | |||
| 0efc3629b0 | |||
| a77d57603f | |||
| a023c97618 | |||
| 67c475f14c | |||
| be5be81cbd | |||
| bc75334590 | |||
| 7fdadf4b0b | |||
| 97198efb1c | |||
| 2d0bce143a | |||
| 524e517066 | |||
| d07f18d380 | |||
| f32bb49972 | |||
| 8290f77889 | |||
| ce8145a42e | |||
| 3064235a80 | |||
| 17068aa28c | |||
| aee6fbaf73 | |||
| 0d591c0fa9 | |||
| 898846c654 | |||
| 65127c2273 | |||
| 55e80181df | |||
| f01dfa01cb | |||
| d398de1b47 | |||
| 8a707610bf | |||
| 2a1d8e13c8 | |||
| 93757126e1 | |||
| e8e2df3c43 | |||
| 28e86e5032 |
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -69,9 +69,9 @@ 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, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'Les dieux ont soif'), true,7);
|
||||
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'));
|
||||
INSERT INTO user_books(created_at, user_id, book_id, read, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo2'),(SELECT id FROM books WHERE title = 'Rigodon'),true, 10);
|
||||
INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Un barrage contre le Pacifique',(SELECT id FROM authors WHERE name = 'Marguerite Duras'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'Un_barrage_contre_le_Pacifique.jpg'));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/css/bulma.min.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Bibliomane</title>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bibliomane",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
15
front/public/favicon.svg
Normal file
15
front/public/favicon.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg width="125.95975mm" height="132.42345mm" viewBox="0 0 125.95975 132.42345" version="1.1" id="svg1008" inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)" sodipodi:docname="logo_carré.svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview id="namedview1010" pagecolor="#505050" bordercolor="#ffffff" borderopacity="1" inkscape:pageshadow="0" inkscape:pageopacity="0" inkscape:pagecheckerboard="1" inkscape:document-units="mm" showgrid="false" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0" inkscape:zoom="0.77771465" inkscape:cx="80.363666" inkscape:cy="297.66702" inkscape:window-width="2560" inkscape:window-height="1369" inkscape:window-x="1912" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="layer1"/>
|
||||
<defs id="defs1005">
|
||||
<linearGradient inkscape:collect="always" xlink:href="#linearGradient29626-5" id="linearGradient41043" gradientUnits="userSpaceOnUse" x1="-274.68173" y1="86.602715" x2="-136.50122" y2="141.29916" gradientTransform="matrix(0,-0.975265,0.97958512,0,44.404193,-64.657592)"/>
|
||||
<linearGradient id="linearGradient29626-5" inkscape:swatch="gradient">
|
||||
<stop style="stop-color:#cd82d1;stop-opacity:1;" offset="0" id="stop29622"/>
|
||||
<stop style="stop-color:#45e4be;stop-opacity:1" offset="1" id="stop29624"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(-83.64899,-69.870748)">
|
||||
<path id="rect33397" style="fill:url(#linearGradient41043);stroke-width:14.6613;stroke-linecap:round;paint-order:markers fill stroke" d="m 123.59901,69.870748 c -22.13223,0 -39.95002,17.817785 -39.95002,39.950012 v 52.52341 c 0,22.13223 17.81779,39.95003 39.95002,39.95003 h 46.05972 c 22.13224,0 39.95001,-17.8178 39.95001,-39.95003 v -52.52341 c 0,-22.132227 -17.81777,-39.950012 -39.95001,-39.950012 z m 16.04451,10.09246 c 2.89943,-0.025 5.27029,2.304907 5.2958,5.204333 l 0.093,10.535274 c 0.0259,2.899434 -2.30336,5.270995 -5.20278,5.297355 -2.89984,0.0256 -5.27133,-2.304505 -5.29684,-5.204338 l -0.093,-10.536825 c -0.025,-2.89923 2.30459,-5.270008 5.20381,-5.295799 z m 22.37162,9.874874 c 1.39244,-0.02341 2.73714,0.507288 3.73828,1.475362 2.08486,2.015053 2.14177,5.338588 0.12712,7.423838 l -8.88628,9.193748 c -2.01549,2.08523 -5.33978,2.14171 -7.42487,0.12609 -2.08423,-2.01528 -2.14067,-5.33841 -0.12609,-7.42332 l 8.8868,-9.194266 c 0.96744,-1.001274 2.29295,-1.577317 3.68504,-1.601452 z m -36.12428,8.403542 c 0.077,-0.0011 0.15435,-0.0011 0.23151,5.3e-4 0.30864,0.006 0.61792,0.03233 0.92604,0.08113 2.46499,0.38998 4.30183,1.812396 5.71283,3.422536 l 0.03,0.0341 9.57203,11.31249 h 19.29081 c 5.4245,0 10.14805,2.49331 13.13356,6.02857 2.98552,3.53526 4.46133,7.94487 4.60799,12.361 0.13508,4.06728 -0.94484,8.23683 -3.47576,11.72228 1.71448,1.01562 3.30442,2.15465 4.61729,3.52227 4.26817,4.44617 6.19882,10.33751 6.02082,16.07241 -0.35602,11.46979 -9.7863,22.94071 -24.73595,22.94071 h -24.1298 l -18.22317,-18.95904 0.063,0.063 c -11.56249,-11.62674 -15.08369,-24.70477 -13.06949,-34.38395 1.0071,-4.83959 3.55434,-9.28947 8.20829,-11.16469 0.37422,-0.15075 0.77822,-0.13389 1.1622,-0.23771 -0.63978,-3.80679 -0.50295,-7.69555 0.10542,-11.12801 0.64248,-3.62486 1.54593,-6.92763 4.57957,-9.60974 1.13761,-1.005806 2.85512,-1.845387 4.68291,-2.037607 v 5.29e-4 c 0.22848,-0.02416 0.45874,-0.03788 0.68989,-0.04082 z m -0.38396,11.934156 c -0.60269,0.009 -1.13008,0.50778 -1.28416,1.3069 -1.14194,5.72294 -0.28389,9.83155 3.99252,14.50919 0,0 5.3e-4,7.9e-4 0.001,10e-4 l 0.077,0.0739 0.0119,0.0124 6.21357,6.22855 -0.01,-12.24059 -7.90029,-9.33742 c -0.35161,-0.388 -0.74031,-0.55941 -1.10174,-0.55397 z m 19.50372,13.41779 v 15.93959 h 16.68012 0.0574 c 3.0387,0.0385 4.41726,-0.84879 5.48907,-2.13579 1.07456,-1.29032 1.73633,-3.34125 1.66243,-5.56658 -0.0739,-2.22533 -0.90166,-4.47159 -2.13579,-5.93297 -1.23413,-1.46138 -2.64641,-2.30425 -5.10976,-2.30425 z m -25.58914,8.07651 c -0.20944,2.6e-4 -0.43094,0.0238 -0.66714,0.0734 -1.64588,0.45199 -2.71995,2.05325 -3.14503,4.38784 -1.47473,6.99382 0.018,11.95115 11.36778,23.30814 l 0.0315,0.0315 7.50031,7.80211 v -20.75429 l -12.90825,-13.80742 c -0.54958,-0.56585 -1.16751,-0.97593 -1.97352,-1.03405 -0.0672,-0.005 -0.13586,-0.007 -0.20568,-0.007 z m 25.58914,18.36425 v 25.20363 H 161.822 c 9.92523,0 14.04184,-6.38226 14.23995,-12.7646 0.0991,-3.19116 -0.94119,-6.225 -3.09955,-8.47338 -2.15835,-2.24838 -5.52091,-3.96565 -11.1404,-3.96565 h -0.2434 z" sodipodi:nodetypes="sssssssssccccccccccsccccssccccssscssscccsscssccssccccccccsccccsssscscssccccssccssssccscs"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
17
front/public/image/logo.svg
Normal file
17
front/public/image/logo.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg width="80.642197mm" height="104.19308mm" viewBox="0 0 80.642197 104.19308" version="1.1" id="svg863" sodipodi:docname="logo_trait.svg" inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview id="namedview865" pagecolor="#505050" bordercolor="#ffffff" borderopacity="1" inkscape:pageshadow="0" inkscape:pageopacity="0" inkscape:pagecheckerboard="1" inkscape:document-units="mm" showgrid="false" inkscape:zoom="1.0998546" inkscape:cx="111.37836" inkscape:cy="219.57448" inkscape:window-width="2560" inkscape:window-height="1369" inkscape:window-x="1912" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="layer1" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0"/>
|
||||
<defs id="defs860">
|
||||
<linearGradient inkscape:collect="always" xlink:href="#linearGradient29626-5" id="linearGradient29653" gradientUnits="userSpaceOnUse" x1="180.15134" y1="639.77362" x2="387.26251" y2="162.77214" gradientTransform="matrix(0.18523379,0,0,0.18523379,50.465238,49.46306)"/>
|
||||
<linearGradient id="linearGradient29626-5" inkscape:swatch="gradient">
|
||||
<stop style="stop-color:#cd82d1;stop-opacity:1;" offset="0" id="stop29622"/>
|
||||
<stop style="stop-color:#45e4be;stop-opacity:1" offset="1" id="stop29624"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(-76.709803,-68.336456)">
|
||||
<path id="path11547" style="color:#000000;fill:url(#linearGradient29653);fill-opacity:1;fill-rule:nonzero;stroke-width:0.700097;-inkscape-stroke:none" d="m 96.905008,85.032291 c -0.30864,-0.0061 -0.61647,0.0081 -0.9211,0.04016 v -3.61e-4 c -1.82779,0.192211 -3.54569,1.031778 -4.6833,2.037572 -3.03364,2.682116 -3.937,5.984852 -4.57947,9.609724 -0.60837,3.432464 -0.74506,7.320994 -0.10528,11.127784 -0.38397,0.10382 -0.78784,0.0873 -1.16206,0.23805 -4.65395,1.87521 -7.20143,6.32509 -8.20853,11.16468 -2.01421,9.67918 1.50698,22.75728 13.06947,34.38402 l -0.063,-0.063 18.223102,18.95861 h 24.12996 c 14.94966,0 24.37993,-11.47061 24.73595,-22.94041 0.178,-5.7349 -1.75266,-11.62612 -6.02083,-16.07229 -1.31287,-1.36763 -2.90298,-2.50673 -4.61746,-3.52233 2.53092,-3.48546 3.61075,-7.65527 3.47567,-11.72255 -0.14666,-4.41613 -1.62253,-8.82548 -4.60805,-12.36074 -2.98551,-3.53526 -7.70901,-6.028784 -13.13351,-6.028784 h -19.29073 l -9.57173,-11.312289 -0.03,-0.03437 c -1.411,-1.610146 -3.24796,-3.032503 -5.712952,-3.422483 -0.30812,-0.04875 -0.61753,-0.07496 -0.92617,-0.08104 z m 0.48588,12.487005 7.900662,9.338024 0.01,12.24061 -6.213462,-6.22873 -0.0123,-0.0123 -0.0767,-0.0738 c -3.7e-4,-3.6e-4 -0.001,-0.001 -0.001,-0.001 -4.27641,-4.67764 -5.13453,-8.78607 -3.9926,-14.509014 0.24652,-1.27859 1.44807,-1.7891 2.38607,-0.75336 z m 18.402182,12.864344 h 16.64355 c 2.46335,0 3.87571,0.84282 5.10984,2.3042 1.23413,1.46139 2.06207,3.70758 2.13598,5.93291 0.0739,2.22533 -0.5882,4.27646 -1.66276,5.56678 -1.07181,1.28701 -2.44994,2.17408 -5.48864,2.13562 h -0.0575 -16.68045 z m -26.256232,8.15022 c 1.25974,-0.2642 2.11382,0.21302 2.84659,0.96748 l 12.908122,13.80751 v 20.75451 l -7.500162,-7.80225 -0.0315,-0.0315 c -11.34978,-11.35699 -12.84262,-16.31443 -11.3679,-23.30824 0.42508,-2.33459 1.49895,-3.93552 3.14483,-4.38751 z m 26.256232,18.29082 h 16.5683 0.24348 c 5.61949,0 8.98172,1.71714 11.14007,3.96552 2.15836,2.24838 3.19883,5.2822 3.09978,8.47336 -0.19811,6.38234 -4.31462,12.76449 -14.23985,12.76449 h -16.81178 z" sodipodi:nodetypes="cccssccscccssscssscccsccccccccccccssssccccscccccscccssssccc"/>
|
||||
<path style="color:#000000;fill:#60d1c2;fill-opacity:1;stroke-width:0.653215;stroke-linecap:round;-inkscape-stroke:none" d="m 111.75634,68.336639 a 4.8991144,4.8991144 0 0 0 -4.85575,4.941216 l 0.0868,9.831401 a 4.8991144,4.8991144 0 0 0 4.9425,4.855737 4.8991144,4.8991144 0 0 0 4.85445,-4.942493 l -0.0868,-9.830124 a 4.8991144,4.8991144 0 0 0 -4.94122,-4.855737 z" id="path23529"/>
|
||||
<path style="color:#000000;fill:#60d1c2;fill-opacity:1;stroke-width:0.606037;stroke-linecap:round;-inkscape-stroke:none" d="m 132.27231,78.931592 a 4.442115,4.6508356 0 0 0 -3.16127,1.307975 l -7.77474,7.875191 a 4.442115,4.6508356 0 0 0 -0.10429,6.5754 4.442115,4.6508356 0 0 0 6.28146,0.109238 l 7.77474,-7.87519 a 4.442115,4.6508356 0 0 0 0.10314,-6.57544 4.442115,4.6508356 0 0 0 -3.11904,-1.417174 z" id="path23531"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -13,7 +13,7 @@ const router = useRouter()
|
||||
const isMenuActive = ref(false)
|
||||
const isSearchBarShown = ref(false)
|
||||
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION;
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION
|
||||
|
||||
function logout() {
|
||||
authStore.logout()
|
||||
@@ -47,7 +47,9 @@ onMounted(() => {
|
||||
<template>
|
||||
<nav class="navbar">
|
||||
<div class="navbar-brand">
|
||||
<RouterLink to="/" class="navbar-item" :title="'bibliomane v' + appVersion" activeClass="is-active"> B </RouterLink>
|
||||
<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
|
||||
@click="isSearchBarShown = !isSearchBarShown"
|
||||
@@ -81,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>
|
||||
|
||||
@@ -5,16 +5,13 @@ const props = defineProps({
|
||||
icon: String,
|
||||
legend: String,
|
||||
isSet: Boolean,
|
||||
isReadonly: Boolean,
|
||||
})
|
||||
|
||||
const hovered = ref(false)
|
||||
const isOnMobile = ref(computeIsOnMobile())
|
||||
|
||||
const computedIcon = computed(
|
||||
() =>
|
||||
props.icon +
|
||||
(!props.isReadonly && ((hovered.value && !isOnMobile.value) || props.isSet) ? 'Fill' : ''),
|
||||
() => props.icon + ((hovered.value && !isOnMobile.value) || props.isSet ? 'Fill' : ''),
|
||||
)
|
||||
|
||||
function computeIsOnMobile() {
|
||||
@@ -34,8 +31,7 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bigiconandlegend"
|
||||
:class="props.isReadonly ? '' : 'showcanclick'"
|
||||
class="bigiconandlegend showcanclick"
|
||||
@mouseover="hovered = true"
|
||||
@mouseout="hovered = false"
|
||||
>
|
||||
@@ -91,7 +87,7 @@ onUnmounted(() => {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
padding-bottom: 0px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.bigiconandlegend {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import BigIcon from './BigIcon.vue'
|
||||
import DateWidget from './DateWidget.vue'
|
||||
|
||||
const props = defineProps({
|
||||
icon: String,
|
||||
@@ -8,7 +9,6 @@ const props = defineProps({
|
||||
startReadDate: String,
|
||||
endReadDate: String,
|
||||
isExpanded: Boolean,
|
||||
isReadonly: Boolean,
|
||||
useEndDate: Boolean,
|
||||
lastWidget: Boolean,
|
||||
})
|
||||
@@ -26,9 +26,6 @@ function computeParentClasses() {
|
||||
} else {
|
||||
classNames += ' border-radius-right'
|
||||
}
|
||||
if (props.isReadonly) {
|
||||
classNames += ' widget-readonly'
|
||||
}
|
||||
return classNames
|
||||
}
|
||||
</script>
|
||||
@@ -37,35 +34,24 @@ function computeParentClasses() {
|
||||
<div :class="computeParentClasses()">
|
||||
<BigIcon
|
||||
:icon="props.icon"
|
||||
:is-readonly="props.isReadonly"
|
||||
:legend="props.legend"
|
||||
:isSet="props.isExpanded"
|
||||
@click="props.isReadonly ? null : $emit('onIconClick')"
|
||||
@click="$emit('onIconClick')"
|
||||
/>
|
||||
<div v-if="props.isExpanded" class="inputdate">
|
||||
<div class="ontopofinput">
|
||||
<label class="datelabel" for="startread">
|
||||
{{ $t('bookdatewidget.started') }}
|
||||
</label>
|
||||
<input
|
||||
class="datepicker has-background-dark has-text-light"
|
||||
id="startread"
|
||||
type="date"
|
||||
@change="(e) => $emit('onStartDateChange', e.target.value)"
|
||||
:value="props.startReadDate"
|
||||
:max="today"
|
||||
<DateWidget
|
||||
dateinputid="startread"
|
||||
dateinputlabel="bookdatewidget.started"
|
||||
:initdate="props.startReadDate"
|
||||
@onDateChange="(d) => $emit('onStartDateChange', d)"
|
||||
/>
|
||||
<div v-if="props.useEndDate">
|
||||
<label class="datelabel" for="endread">
|
||||
{{ $t('bookdatewidget.finished') }}
|
||||
</label>
|
||||
<input
|
||||
class="datepicker has-background-dark has-text-light"
|
||||
id="endread"
|
||||
type="date"
|
||||
@change="(e) => $emit('onEndDateChange', e.target.value)"
|
||||
:value="props.endReadDate"
|
||||
:max="today"
|
||||
<DateWidget
|
||||
dateinputid="endread"
|
||||
dateinputlabel="bookdatewidget.finished"
|
||||
:initdate="props.endReadDate"
|
||||
@onDateChange="(d) => $emit('onEndDateChange', d)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,24 +79,6 @@ function computeParentClasses() {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.datelabel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 26px;
|
||||
border: none;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.datepicker {
|
||||
font-size: 26px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.widget-readonly {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.bookdatewidget {
|
||||
flex: 1;
|
||||
|
||||
@@ -3,19 +3,18 @@ import { ref, computed } from 'vue'
|
||||
import {
|
||||
getBook,
|
||||
getImagePathOrDefault,
|
||||
putReadBook,
|
||||
putWantReadBook,
|
||||
putRateBook,
|
||||
putUpdateBook,
|
||||
putStartReadDate,
|
||||
putStartReadDateUnset,
|
||||
putReadBook,
|
||||
putEndReadDate,
|
||||
putEndReadDateUnset,
|
||||
putUnreadBook,
|
||||
} from './api.js'
|
||||
import { useRouter, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { VRating } from 'vuetify/components/VRating'
|
||||
import BigIcon from './BigIcon.vue'
|
||||
import BookDateWidget from './BookDateWidget.vue'
|
||||
import BookFormIcons from './BookFormIcons.vue'
|
||||
import ReviewWidget from './ReviewWidget.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
@@ -38,15 +37,20 @@ 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 onReadIconClick() {
|
||||
function onReviewUpdate(review) {
|
||||
data.value.review = review
|
||||
putUpdateBook(props.id, { review: data.value.review })
|
||||
}
|
||||
|
||||
async function onReadIconClick() {
|
||||
data.value.read = !data.value.read
|
||||
if (data.value.read) {
|
||||
data.value.wantread = false
|
||||
data.value.endReadDate = today
|
||||
putEndReadDate(props.id, today)
|
||||
putReadBook(props.id)
|
||||
} else {
|
||||
putUnreadBook(props.id)
|
||||
}
|
||||
@@ -54,17 +58,21 @@ function onReadIconClick() {
|
||||
|
||||
function onWantReadIconClick() {
|
||||
data.value.wantread = !data.value.wantread
|
||||
putWantReadBook(props.id, { wantread: data.value.wantread })
|
||||
putUpdateBook(props.id, { wantread: data.value.wantread })
|
||||
}
|
||||
|
||||
function onStartReadIconClick() {
|
||||
async function onStartReadIconClick() {
|
||||
if (!data.value.startReadDate) {
|
||||
data.value.startReadDate = today
|
||||
data.value.wantread = false
|
||||
putStartReadDate(props.id, data.value.startReadDate)
|
||||
} else {
|
||||
} else if (!data.value.read) {
|
||||
data.value.startReadDate = null
|
||||
putStartReadDateUnset(props.id)
|
||||
}
|
||||
if (data.value.read) {
|
||||
data.value.read = false
|
||||
}
|
||||
}
|
||||
|
||||
function onStartReadDateChange(d) {
|
||||
@@ -85,12 +93,6 @@ function onEndReadDateChange(d) {
|
||||
}
|
||||
}
|
||||
|
||||
function isStartReadExpanded() {
|
||||
let isStartReadDateSet = data.value.startReadDate ? true : false
|
||||
let isReadUnset = !data.value.read ? true : false
|
||||
return isStartReadDateSet && isReadUnset
|
||||
}
|
||||
|
||||
function goToAuthor() {
|
||||
router.push('/author/' + data.value.authorId)
|
||||
}
|
||||
@@ -103,17 +105,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>
|
||||
@@ -122,39 +113,22 @@ 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">
|
||||
<div class="iconscontainer" :class="data.read ? 'remove-border-bottom' : ''">
|
||||
<div class="bigiconcontainer">
|
||||
<BigIcon
|
||||
icon="BIconEye"
|
||||
:legend="$t('bookform.wantread')"
|
||||
:isSet="data.wantread"
|
||||
@click="onWantReadIconClick"
|
||||
/>
|
||||
</div>
|
||||
<BookDateWidget
|
||||
icon="BIconBook"
|
||||
:legend="$t('bookform.startread')"
|
||||
:start-read-date="data.startReadDate"
|
||||
:is-expanded="isStartReadExpanded()"
|
||||
:is-readonly="data.read"
|
||||
@onStartDateChange="onStartReadDateChange"
|
||||
@onIconClick="onStartReadIconClick"
|
||||
/>
|
||||
<BookDateWidget
|
||||
icon="BIconCheckCircle"
|
||||
:legend="$t('bookform.read')"
|
||||
:start-read-date="data.startReadDate"
|
||||
use-end-date
|
||||
last-widget
|
||||
:endReadDate="data.endReadDate"
|
||||
:isExpanded="data.read"
|
||||
@onStartDateChange="onStartReadDateChange"
|
||||
@onEndDateChange="onEndReadDateChange"
|
||||
@onIconClick="onReadIconClick"
|
||||
/>
|
||||
</div>
|
||||
<BookFormIcons
|
||||
v-bind="data"
|
||||
@on-want-read-icon-click="onWantReadIconClick"
|
||||
@on-start-read-icon-click="onStartReadIconClick"
|
||||
@on-read-icon-click="onReadIconClick"
|
||||
@on-start-read-date-change="onStartReadDateChange"
|
||||
@on-end-read-date-change="onEndReadDateChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -167,22 +141,6 @@ img {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconscontainer {
|
||||
border: solid;
|
||||
border-radius: 50px;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.remove-border-bottom {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.left-panel {
|
||||
margin-left: 3rem;
|
||||
@@ -193,10 +151,7 @@ img {
|
||||
img {
|
||||
max-height: 250px;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.bigiconcontainer {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.image {
|
||||
@@ -204,10 +159,5 @@ img {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconscontainer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
174
front/src/BookFormIcons.vue
Normal file
174
front/src/BookFormIcons.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, nextTick } from 'vue'
|
||||
import BigIcon from './BigIcon.vue'
|
||||
import BookDateWidget from './BookDateWidget.vue'
|
||||
import DateWidget from './DateWidget.vue'
|
||||
|
||||
const props = defineProps({
|
||||
wantread: Boolean,
|
||||
startReadDate: String,
|
||||
endReadDate: String,
|
||||
read: Boolean,
|
||||
})
|
||||
|
||||
const mobiledatesel = useTemplateRef('mobiledates')
|
||||
|
||||
const emit = defineEmits([
|
||||
'onWantReadIconClick',
|
||||
'onStartReadIconClick',
|
||||
'onReadIconClick',
|
||||
'onStartReadDateChange',
|
||||
'onEndReadDateChange',
|
||||
])
|
||||
|
||||
function isStartReadExpanded() {
|
||||
let isStartReadDateSet = props.startReadDate ? true : false
|
||||
let isReadUnset = !props.read ? true : false
|
||||
return isStartReadDateSet && isReadUnset
|
||||
}
|
||||
|
||||
async function onReadIconClick() {
|
||||
emit('onReadIconClick')
|
||||
await nextTick()
|
||||
mobiledatesel.value.scrollIntoView()
|
||||
}
|
||||
|
||||
async function onStartReadIconClick() {
|
||||
emit('onStartReadIconClick')
|
||||
await nextTick()
|
||||
mobiledatesel.value.scrollIntoView()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="iconscontainer" :class="props.read ? 'remove-border-bottom' : ''">
|
||||
<div
|
||||
class="bigiconcontainer"
|
||||
:class="props.wantread ? 'has-text-dark has-background-text border-radius-wantread-fill' : ''"
|
||||
>
|
||||
<BigIcon
|
||||
icon="BIconEye"
|
||||
:legend="$t('bookform.wantread')"
|
||||
:isSet="props.wantread"
|
||||
@click="$emit('onWantReadIconClick')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="bigiconcontainer is-hidden-desktop"
|
||||
:class="isStartReadExpanded() ? 'has-text-dark has-background-text' : ''"
|
||||
>
|
||||
<BigIcon
|
||||
icon="BIconBook"
|
||||
:legend="$t('bookform.startread')"
|
||||
:is-set="isStartReadExpanded()"
|
||||
@click="onStartReadIconClick"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="bigiconcontainer is-hidden-desktop"
|
||||
:class="props.read ? 'has-text-dark has-background-text border-radius-right-fill' : ''"
|
||||
>
|
||||
<BigIcon
|
||||
icon="BIconCheckCircle"
|
||||
:legend="$t('bookform.read')"
|
||||
:isSet="props.read"
|
||||
@click="onReadIconClick"
|
||||
/>
|
||||
</div>
|
||||
<BookDateWidget
|
||||
class="is-hidden-mobile"
|
||||
icon="BIconBook"
|
||||
:legend="$t('bookform.startread')"
|
||||
:start-read-date="props.startReadDate"
|
||||
:is-expanded="isStartReadExpanded()"
|
||||
@onStartDateChange="(d) => $emit('onStartReadDateChange', d)"
|
||||
@onIconClick="onStartReadIconClick"
|
||||
/>
|
||||
<BookDateWidget
|
||||
class="is-hidden-mobile"
|
||||
icon="BIconCheckCircle"
|
||||
:legend="$t('bookform.read')"
|
||||
:start-read-date="props.startReadDate"
|
||||
use-end-date
|
||||
last-widget
|
||||
:endReadDate="props.endReadDate"
|
||||
:isExpanded="props.read"
|
||||
@onStartDateChange="(d) => $emit('onStartReadDateChange', d)"
|
||||
@onEndDateChange="(d) => $emit('onEndReadDateChange', d)"
|
||||
@onIconClick="onReadIconClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref="mobiledates" class="mobile-dates pt-3 is-hidden-desktop">
|
||||
<div class="mobiledate">
|
||||
<DateWidget
|
||||
v-if="isStartReadExpanded() || props.read"
|
||||
dateinputid="startread"
|
||||
dateinputlabel="bookdatewidget.started"
|
||||
:initdate="props.startReadDate"
|
||||
is-horizontal
|
||||
@onDateChange="(d) => $emit('onStartReadDateChange', d)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mobiledate pt-2">
|
||||
<DateWidget
|
||||
v-if="props.read"
|
||||
dateinputid="endread"
|
||||
dateinputlabel="bookdatewidget.finished"
|
||||
:initdate="props.endReadDate"
|
||||
is-horizontal
|
||||
@onDateChange="(d) => $emit('onEndReadDateChange', d)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.iconscontainer {
|
||||
border: solid;
|
||||
border-radius: 50px;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.mobile-dates {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobiledate {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.border-radius-wantread-fill {
|
||||
border-radius: 45px 45px 0px 0px;
|
||||
}
|
||||
|
||||
.remove-border-bottom {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.bigiconcontainer {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.border-radius-wantread-fill {
|
||||
border-radius: 38px 0px 0px 38px;
|
||||
}
|
||||
.bigiconcontainer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.iconscontainer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.border-radius-right-fill {
|
||||
border-radius: 0px 38px 38px 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
51
front/src/DateWidget.vue
Normal file
51
front/src/DateWidget.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
dateinputid: String,
|
||||
dateinputlabel: String,
|
||||
initdate: String,
|
||||
isHorizontal: Boolean,
|
||||
})
|
||||
defineEmits(['onDateChange'])
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="datelabel" :class="props.isHorizontal ? 'pr-2' : 'pb-1'" :for="props.dateinputid">
|
||||
{{ $t(props.dateinputlabel) }}
|
||||
</label>
|
||||
<input
|
||||
class="datepicker has-background-dark has-text-light"
|
||||
:id="props.dateinputid"
|
||||
type="date"
|
||||
@change="(e) => $emit('onDateChange', e.target.value)"
|
||||
:value="props.initdate"
|
||||
:max="today"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.datelabel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 26px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.datepicker {
|
||||
font-size: 26px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.datelabel {
|
||||
font-size: 18px;
|
||||
}
|
||||
.datepicker {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
front/src/InstanceBrowser.vue
Normal file
61
front/src/InstanceBrowser.vue
Normal 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>
|
||||
135
front/src/ReviewWidget.vue
Normal file
135
front/src/ReviewWidget.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<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
|
||||
clearable
|
||||
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>
|
||||
@@ -1,21 +1,38 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, onMounted, ref } from 'vue'
|
||||
import { useTemplateRef, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { BrowserMultiFormatReader } from '@zxing/library'
|
||||
import { i18n } from '@/main'
|
||||
|
||||
const emit = defineEmits('readBarcode')
|
||||
|
||||
const { t } = i18n.global
|
||||
|
||||
const scanResult = ref(null)
|
||||
const scanErr = ref(null)
|
||||
const codeReader = new BrowserMultiFormatReader()
|
||||
const scannerElement = useTemplateRef('scanner')
|
||||
|
||||
onMounted(() => {
|
||||
codeReader.decodeFromVideoDevice(undefined, scannerElement.value, (result, err) => {
|
||||
if (result) {
|
||||
emit('readBarcode', result.text)
|
||||
if (!codeReader.isMediaDevicesSuported || !codeReader.canEnumerateDevices) {
|
||||
scanErr.value = 'This browser does not support this feature.'
|
||||
return
|
||||
}
|
||||
codeReader.listVideoInputDevices().then((mediaDevicesInfoArray) => {
|
||||
if (mediaDevicesInfoArray.length > 0) {
|
||||
codeReader.decodeFromVideoDevice(undefined, scannerElement.value, (result, err) => {
|
||||
if (result) {
|
||||
emit('readBarcode', result.text)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
scanErr.value = t('barcode.nocamera')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
codeReader.reset()
|
||||
})
|
||||
function onResult(result) {
|
||||
scanResult.value = result
|
||||
}
|
||||
@@ -23,6 +40,7 @@ function onResult(result) {
|
||||
|
||||
<template>
|
||||
<h1 class="subtitle">{{ $t('barcode.title') }}</h1>
|
||||
<div v-if="scanErr">{{ scanErr }}</div>
|
||||
<div v-if="scanResult">{{ scanResult }}</div>
|
||||
<video poster="data:image/gif,AAAA" ref="scanner"></video>
|
||||
</template>
|
||||
|
||||
@@ -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,35 +107,35 @@ export async function postImportBook(id, language) {
|
||||
}
|
||||
|
||||
export async function putReadBook(bookId) {
|
||||
return genericPayloadCall('/ws/book/' + bookId + '/read', { read: true }, 'PUT')
|
||||
return putEndReadDate(bookId, new Date().toISOString().slice(0, 10))
|
||||
}
|
||||
|
||||
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 putStartRead(bookId) {
|
||||
return putStartReadDate(bookId, new Date().toISOString().slice(0, 10))
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"navbar": {
|
||||
"mybooks": "My Books",
|
||||
"addbook": "Add Book",
|
||||
"explore": "Explore",
|
||||
"logout": "Log out",
|
||||
"signup": "Sign up",
|
||||
"search": "Search",
|
||||
@@ -13,7 +14,8 @@
|
||||
},
|
||||
"barcode": {
|
||||
"title": "Scan barcode",
|
||||
"barcode": "Scan barcode"
|
||||
"barcode": "Scan barcode",
|
||||
"nocamera": "No camera found."
|
||||
},
|
||||
"addbook": {
|
||||
"title": "Title",
|
||||
@@ -58,6 +60,7 @@
|
||||
},
|
||||
"bookform": {
|
||||
"error": "Error when loading book: {error}",
|
||||
"reviewbtn": "My review",
|
||||
"read": "Read",
|
||||
"startread": "Started",
|
||||
"wantread": "Interested"
|
||||
@@ -79,5 +82,9 @@
|
||||
"releasedate": "Release date:",
|
||||
"publisher": "Publisher:",
|
||||
"importing": "Importing..."
|
||||
},
|
||||
"review": {
|
||||
"title": "My review",
|
||||
"textplaceholder": "Write my review..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"navbar": {
|
||||
"mybooks": "Mes Livres",
|
||||
"explore": "Explorer",
|
||||
"addbook": "Ajouter Un Livre",
|
||||
"logout": "Se déconnecter",
|
||||
"signup": "S'inscrire",
|
||||
@@ -13,7 +14,8 @@
|
||||
},
|
||||
"barcode": {
|
||||
"title": "Scanner le code-barres",
|
||||
"barcode": "Scanner le code-barres"
|
||||
"barcode": "Scanner le code-barres",
|
||||
"nocamera": "Impossible de détecter la caméra."
|
||||
},
|
||||
"addbook": {
|
||||
"title": "Titre",
|
||||
@@ -58,6 +60,7 @@
|
||||
},
|
||||
"bookform": {
|
||||
"error": "Erreur pendant le chargement du livre: {error}",
|
||||
"reviewbtn": "Ma critique",
|
||||
"read": "Lu",
|
||||
"startread": "Commencé",
|
||||
"wantread": "À lire"
|
||||
@@ -79,5 +82,9 @@
|
||||
"releasedate": "Date de publication : ",
|
||||
"publisher": "Maison d'édition : ",
|
||||
"importing": "Import en cours..."
|
||||
},
|
||||
"review": {
|
||||
"title": "Ma critique",
|
||||
"textplaceholder": "Écrire ma critique..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import fr from './locales/fr.json'
|
||||
import en from './locales/en.json'
|
||||
|
||||
// configure i18n
|
||||
const i18n = createI18n({
|
||||
export const i18n = createI18n({
|
||||
locale: navigator.language,
|
||||
fallbackLocale: 'en',
|
||||
messages: { fr, en },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.centered {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
12
go.mod
12
go.mod
@@ -1,8 +1,9 @@
|
||||
module git.artlef.fr/bibliomane
|
||||
|
||||
go 1.25.1
|
||||
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
85
go.sum
@@ -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=
|
||||
|
||||
@@ -10,3 +10,5 @@ image-folder-path = "/tmp"
|
||||
|
||||
# The port to listen on for the server.
|
||||
port = "8080"
|
||||
|
||||
book-description-from-babelio = true
|
||||
|
||||
58
internal/apitest/fetchallbooks_test.go
Normal file
58
internal/apitest/fetchallbooks_test.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
203
internal/apitest/put_userbook_test.go
Normal file
203
internal/apitest/put_userbook_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
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 := "7"
|
||||
testPutUserBooks(t, payload, bookId, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func TestPutStartReadUserBooks_UnsetWantReadOk(t *testing.T) {
|
||||
payload :=
|
||||
`{
|
||||
"startDate": "2025-11-19"
|
||||
}`
|
||||
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) {
|
||||
payload :=
|
||||
`{
|
||||
"startDate": "null"
|
||||
}`
|
||||
bookId := "7"
|
||||
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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -42,13 +42,31 @@ func TestSearchBook_OneBookRead(t *testing.T) {
|
||||
assert.Equal(t, int64(1), result.Count)
|
||||
assert.Equal(t,
|
||||
[]dto.BookSearchGetBook{{
|
||||
Title: "Les dieux ont soif",
|
||||
Author: "Anatole France",
|
||||
ID: 4,
|
||||
Rating: 7,
|
||||
Read: true,
|
||||
WantRead: false,
|
||||
CoverPath: "/static/bookcover/lesdieuxontsoif.jpg",
|
||||
Title: "Les dieux ont soif",
|
||||
Author: "Anatole France",
|
||||
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)
|
||||
}
|
||||
|
||||
128
internal/babelio/babelio.go
Normal file
128
internal/babelio/babelio.go
Normal 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"))
|
||||
}
|
||||
30
internal/babelio/babelio_test.go
Normal file
30
internal/babelio/babelio_test.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -22,17 +22,19 @@ type CLI struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Port string `toml:"port" short:"p" default:"8080" help:"Port to listen on for the server." comment:"Port to listen on for the server."`
|
||||
DatabaseFilePath string `toml:"database-file-path" short:"d" default:"bibliomane.db" type:"path" help:"Path to sqlite database file." comment:"Path to sqlite database file."`
|
||||
DemoDataPath string `toml:"demo-data-path" help:"Path to the sql file to load for demo data." comment:"Path to the sql file to load for demo data."`
|
||||
JWTKey string `toml:"jwt-key" help:"Key used to encrypt JWT." comment:"Key used to encrypt the generated JWT. Encoded in base64. If empty a random one will be generated on every restart."`
|
||||
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."`
|
||||
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."`
|
||||
AddUser UserListAsStrings `toml:"add-user" short:"a" help:"Add users on startup following htpasswd bcrypt format." comment:"Add users on startup following htpasswd bcrypt format, example: [\"demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS\",\"user:$2y$10$3WYUp.VDpzJRywtrxO1s/uWfUIKpTE4yh5B1d2RCef3hvczYbEWTC\"]"`
|
||||
Port string `toml:"port" short:"p" default:"8080" help:"Port to listen on for the server." comment:"Port to listen on for the server."`
|
||||
DatabaseFilePath string `toml:"database-file-path" short:"d" default:"bibliomane.db" type:"path" help:"Path to sqlite database file." comment:"Path to sqlite database file."`
|
||||
DemoDataPath string `toml:"demo-data-path" help:"Path to the sql file to load for demo data." comment:"Path to the sql file to load for demo data."`
|
||||
JWTKey string `toml:"jwt-key" help:"Key used to encrypt JWT." comment:"Key used to encrypt the generated JWT. Encoded in base64. If empty a random one will be generated on every restart."`
|
||||
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."`
|
||||
AddUser UserListAsStrings `toml:"add-user" short:"a" help:"Add users on startup following htpasswd bcrypt format." comment:"Add users on startup following htpasswd bcrypt format, example: [\"demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS\",\"user:$2y$10$3WYUp.VDpzJRywtrxO1s/uWfUIKpTE4yh5B1d2RCef3hvczYbEWTC\"]"`
|
||||
}
|
||||
|
||||
type UserListAsStrings []string
|
||||
@@ -48,17 +50,19 @@ func (u UserListAsStrings) Validate() error {
|
||||
|
||||
func defaultConfig() CLI {
|
||||
c := Config{
|
||||
Port: "8080",
|
||||
DatabaseFilePath: "bibliomane.db",
|
||||
DemoDataPath: "",
|
||||
JWTKey: "",
|
||||
ImageFolderPath: "img",
|
||||
Limit: 100,
|
||||
InventaireUrl: "https://inventaire.io",
|
||||
DisableRegistration: false,
|
||||
DemoMode: false,
|
||||
DemoUsername: "demo",
|
||||
AddUser: []string{},
|
||||
Port: "8080",
|
||||
DatabaseFilePath: "bibliomane.db",
|
||||
DemoDataPath: "",
|
||||
JWTKey: "",
|
||||
ImageFolderPath: "img",
|
||||
Limit: 100,
|
||||
InventaireUrl: "https://inventaire.io",
|
||||
BookDescriptionFromBabelio: false,
|
||||
BabelioUrl: "https://www.babelio.com",
|
||||
DisableRegistration: false,
|
||||
DemoMode: false,
|
||||
DemoUsername: "demo",
|
||||
AddUser: []string{},
|
||||
}
|
||||
return CLI{NoConfigFile: false, ConfigFilePath: "bibliomane.toml", DisableStoreJWTKeyInConfig: false, ConfigFile: c}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
@@ -52,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"`
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ type UserBook struct {
|
||||
Rating int
|
||||
Read bool
|
||||
WantRead bool
|
||||
Review string
|
||||
StartReadDate *time.Time
|
||||
EndReadDate *time.Time
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
@@ -81,6 +97,10 @@ func computeValidationMessage(ac *appcontext.AppContext, fe *validator.FieldErro
|
||||
return fmt.Sprintf(i18nresource.GetTranslatedMessage(ac, "ValidationTooShort"), (*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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -11,7 +14,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()
|
||||
@@ -63,7 +66,7 @@ func fetchReadUserBookQuery(db *gorm.DB, userId uint) *gorm.DB {
|
||||
}
|
||||
func fetchReadingUserBookQuery(db *gorm.DB, userId uint) *gorm.DB {
|
||||
query := fetchUserBookGet(db, userId)
|
||||
query = query.Where("user_books.start_read_date IS NOT NULL")
|
||||
query = query.Where("user_books.start_read_date IS NOT NULL AND (user_books.read IS NULL OR user_books.read IS FALSE)")
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -40,18 +43,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,10 +65,40 @@ func saveInventaireBookToDb(ac appcontext.AppContext, inventaireEdition inventai
|
||||
}
|
||||
book.Cover = cover
|
||||
}
|
||||
err = ac.Db.Save(&book).Error
|
||||
|
||||
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)
|
||||
|
||||
40
internal/routes/booksget.go
Normal file
40
internal/routes/booksget.go
Normal 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})
|
||||
}
|
||||
@@ -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,61 @@ 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
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 +113,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 +125,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 +137,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)
|
||||
|
||||
@@ -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})
|
||||
})
|
||||
@@ -58,17 +60,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})
|
||||
@@ -104,7 +97,7 @@ func Setup(config *config.Config) *gin.Engine {
|
||||
r.StaticFS("/"+folder, http.FS(subFs))
|
||||
}
|
||||
|
||||
r.StaticFileFS("/favicon.ico", "favicon.ico", http.FS(front.Frontend))
|
||||
r.StaticFileFS("/favicon.svg", "favicon.svg", http.FS(front.Frontend))
|
||||
r.GET("/", serveIndexHtml)
|
||||
|
||||
r.NoRoute(serveIndexHtml)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user