41 Commits
0.2.0 ... 0.5.0

Author SHA1 Message Date
57a41e0e3e Release 0.5.0 2026-03-26 22:39:00 +01:00
bc077f176e Start reading cancel read: move logic to backend 2026-03-26 20:17:21 +01:00
9c18206483 Click on "start reading" now removes "want to read" 2026-03-26 20:07:26 +01:00
d8fc7396ff Books list: make the buttons work like in the form 2026-03-26 17:07:22 +01:00
4d687e3dcb Make "start read" icon full when the book is being read in list view 2026-03-26 15:11:56 +01:00
1da482c2ad Browse books: show latest books first 2026-03-26 14:26:31 +01:00
83088c689e Apply prettier format 2026-03-25 15:58:44 +01:00
950340beed fixed issue where buttons in book list did not appear 2026-03-25 15:58:20 +01:00
315d7db56a Add a new tab to browse all books on the instance 2026-03-25 15:45:50 +01:00
9db7957ad3 Backend query module: merge two files 2026-03-25 14:53:02 +01:00
5e6715d586 Improve error messages when fetching description on babelio 2026-03-24 17:23:06 +01:00
843c5b5dbc Add a new config to scrap description from babelio 2026-03-24 17:02:43 +01:00
c4390742b3 error check: refactor code to remove warning 2026-03-18 15:18:27 +01:00
0efc3629b0 API: improve validation message greater/lower than
Add translation and return a better error for lower than
2026-03-18 15:11:29 +01:00
a77d57603f Allow to cancel a rating 2026-03-18 13:51:38 +01:00
a023c97618 Release 0.4.0 2026-03-15 15:59:35 +01:00
67c475f14c Inventaire import: manage case where author does not exist 2026-03-15 15:57:34 +01:00
be5be81cbd Book form: fixed wrong label on mobile view 2026-03-15 15:54:21 +01:00
bc75334590 Run prettier on 2d0bce143a 2026-03-15 15:53:48 +01:00
7fdadf4b0b Inventaire API: remove duplicated entries when searching for edition 2026-03-15 15:51:27 +01:00
97198efb1c README: improve documentation on how to generate password hash 2026-03-15 15:24:13 +01:00
2d0bce143a Book form: add a field to write a review 2026-03-15 15:23:14 +01:00
524e517066 WIP: Add a review button on book form 2026-03-13 01:35:06 +01:00
d07f18d380 Refactor: make only one API route to update userbooks 2026-03-12 16:58:59 +01:00
f32bb49972 Backend: add a new field to userbook and allow to fetch it 2026-03-12 14:55:36 +01:00
8290f77889 Book form: reduce font size for dates on mobile 2026-03-09 13:25:41 +01:00
ce8145a42e App logo: remove background and center the logo 2026-03-08 20:03:51 +01:00
3064235a80 Upgrade go version in dockerfile 2026-03-08 19:12:09 +01:00
17068aa28c Release 0.3.0 2026-03-08 19:06:20 +01:00
aee6fbaf73 Book form: can now click "start read" even when the book is marked as read
It will do the same behavior as clicking on the "read" button again to
cancel it.
2026-03-08 19:00:40 +01:00
0d591c0fa9 Bookform: fix scrollTo on mobile view
regression of previous commit
2026-03-08 18:39:45 +01:00
898846c654 separate book form icons in a new component 2026-03-08 18:31:27 +01:00
65127c2273 Upgrade go version 2026-03-08 15:52:42 +01:00
55e80181df Add a logo 2026-03-08 15:50:13 +01:00
f01dfa01cb remove spaces in readme 2026-03-07 20:52:47 +01:00
d398de1b47 Book form mobile view: scroll to dates when opened 2026-03-07 20:45:20 +01:00
8a707610bf Book form: make buttons work on mobile view 2026-03-07 19:40:23 +01:00
2a1d8e13c8 Run prettier 2026-03-07 17:28:52 +01:00
93757126e1 Book form: change the icon background when book is read 2026-03-06 18:43:32 +01:00
e8e2df3c43 fixed "currently reading" book filter to remove read books 2026-03-06 17:17:53 +01:00
28e86e5032 Barcode scanner improvements
- Added a message when no cameras are found.
- Release the camera when the popup is closed.
2026-03-06 15:21:10 +01:00
57 changed files with 1583 additions and 700 deletions

View File

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

View File

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

View File

@@ -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 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 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 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 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 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 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')); 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'));

View File

@@ -2,7 +2,7 @@
<html lang=""> <html lang="">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/css/bulma.min.css" /> <link rel="stylesheet" href="/css/bulma.min.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bibliomane</title> <title>Bibliomane</title>

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

15
front/public/favicon.svg Normal file
View 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

View 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

View File

@@ -13,7 +13,7 @@ const router = useRouter()
const isMenuActive = ref(false) const isMenuActive = ref(false)
const isSearchBarShown = ref(false) const isSearchBarShown = ref(false)
const appVersion = import.meta.env.VITE_APP_VERSION; const appVersion = import.meta.env.VITE_APP_VERSION
function logout() { function logout() {
authStore.logout() authStore.logout()
@@ -47,7 +47,9 @@ onMounted(() => {
<template> <template>
<nav class="navbar"> <nav class="navbar">
<div class="navbar-brand"> <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"> <div class="navbar-item is-hidden-desktop">
<a <a
@click="isSearchBarShown = !isSearchBarShown" @click="isSearchBarShown = !isSearchBarShown"
@@ -81,6 +83,9 @@ onMounted(() => {
<div id="navmenu" class="navbar-menu" :class="isMenuActive ? 'is-active' : ''"> <div id="navmenu" class="navbar-menu" :class="isMenuActive ? 'is-active' : ''">
<div class="navbar-start"> <div class="navbar-start">
<NavBarSearch size-class="" class="is-hidden-touch" /> <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"> <RouterLink v-if="authStore.user" to="/books" class="navbar-item" activeClass="is-active">
{{ $t('navbar.mybooks') }} {{ $t('navbar.mybooks') }}
</RouterLink> </RouterLink>

View File

@@ -5,16 +5,13 @@ const props = defineProps({
icon: String, icon: String,
legend: String, legend: String,
isSet: Boolean, isSet: Boolean,
isReadonly: Boolean,
}) })
const hovered = ref(false) const hovered = ref(false)
const isOnMobile = ref(computeIsOnMobile()) const isOnMobile = ref(computeIsOnMobile())
const computedIcon = computed( const computedIcon = computed(
() => () => props.icon + ((hovered.value && !isOnMobile.value) || props.isSet ? 'Fill' : ''),
props.icon +
(!props.isReadonly && ((hovered.value && !isOnMobile.value) || props.isSet) ? 'Fill' : ''),
) )
function computeIsOnMobile() { function computeIsOnMobile() {
@@ -34,8 +31,7 @@ onUnmounted(() => {
<template> <template>
<div <div
class="bigiconandlegend" class="bigiconandlegend showcanclick"
:class="props.isReadonly ? '' : 'showcanclick'"
@mouseover="hovered = true" @mouseover="hovered = true"
@mouseout="hovered = false" @mouseout="hovered = false"
> >
@@ -91,7 +87,7 @@ onUnmounted(() => {
flex: 1; flex: 1;
font-size: 16px; font-size: 16px;
width: 100%; width: 100%;
padding-bottom: 0px; padding-bottom: 5px;
} }
.bigiconandlegend { .bigiconandlegend {

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import BigIcon from './BigIcon.vue' import BigIcon from './BigIcon.vue'
import DateWidget from './DateWidget.vue'
const props = defineProps({ const props = defineProps({
icon: String, icon: String,
@@ -8,7 +9,6 @@ const props = defineProps({
startReadDate: String, startReadDate: String,
endReadDate: String, endReadDate: String,
isExpanded: Boolean, isExpanded: Boolean,
isReadonly: Boolean,
useEndDate: Boolean, useEndDate: Boolean,
lastWidget: Boolean, lastWidget: Boolean,
}) })
@@ -26,9 +26,6 @@ function computeParentClasses() {
} else { } else {
classNames += ' border-radius-right' classNames += ' border-radius-right'
} }
if (props.isReadonly) {
classNames += ' widget-readonly'
}
return classNames return classNames
} }
</script> </script>
@@ -37,35 +34,24 @@ function computeParentClasses() {
<div :class="computeParentClasses()"> <div :class="computeParentClasses()">
<BigIcon <BigIcon
:icon="props.icon" :icon="props.icon"
:is-readonly="props.isReadonly"
:legend="props.legend" :legend="props.legend"
:isSet="props.isExpanded" :isSet="props.isExpanded"
@click="props.isReadonly ? null : $emit('onIconClick')" @click="$emit('onIconClick')"
/> />
<div v-if="props.isExpanded" class="inputdate"> <div v-if="props.isExpanded" class="inputdate">
<div class="ontopofinput"> <div class="ontopofinput">
<label class="datelabel" for="startread"> <DateWidget
{{ $t('bookdatewidget.started') }} dateinputid="startread"
</label> dateinputlabel="bookdatewidget.started"
<input :initdate="props.startReadDate"
class="datepicker has-background-dark has-text-light" @onDateChange="(d) => $emit('onStartDateChange', d)"
id="startread"
type="date"
@change="(e) => $emit('onStartDateChange', e.target.value)"
:value="props.startReadDate"
:max="today"
/> />
<div v-if="props.useEndDate"> <div v-if="props.useEndDate">
<label class="datelabel" for="endread"> <DateWidget
{{ $t('bookdatewidget.finished') }} dateinputid="endread"
</label> dateinputlabel="bookdatewidget.finished"
<input :initdate="props.endReadDate"
class="datepicker has-background-dark has-text-light" @onDateChange="(d) => $emit('onEndDateChange', d)"
id="endread"
type="date"
@change="(e) => $emit('onEndDateChange', e.target.value)"
:value="props.endReadDate"
:max="today"
/> />
</div> </div>
</div> </div>
@@ -93,24 +79,6 @@ function computeParentClasses() {
display: block; 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) { @media (max-width: 1024px) {
.bookdatewidget { .bookdatewidget {
flex: 1; flex: 1;

View File

@@ -3,19 +3,18 @@ import { ref, computed } from 'vue'
import { import {
getBook, getBook,
getImagePathOrDefault, getImagePathOrDefault,
putReadBook, putUpdateBook,
putWantReadBook,
putRateBook,
putStartReadDate, putStartReadDate,
putStartReadDateUnset, putStartReadDateUnset,
putReadBook,
putEndReadDate, putEndReadDate,
putEndReadDateUnset, putEndReadDateUnset,
putUnreadBook, putUnreadBook,
} from './api.js' } from './api.js'
import { useRouter, onBeforeRouteUpdate } from 'vue-router' import { useRouter, onBeforeRouteUpdate } from 'vue-router'
import { VRating } from 'vuetify/components/VRating' import { VRating } from 'vuetify/components/VRating'
import BigIcon from './BigIcon.vue' import BookFormIcons from './BookFormIcons.vue'
import BookDateWidget from './BookDateWidget.vue' import ReviewWidget from './ReviewWidget.vue'
const router = useRouter() const router = useRouter()
const props = defineProps({ const props = defineProps({
@@ -38,15 +37,20 @@ function onRatingUpdate(rating) {
data.value.read = true data.value.read = true
data.value.wantread = false 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 data.value.read = !data.value.read
if (data.value.read) { if (data.value.read) {
data.value.wantread = false data.value.wantread = false
data.value.endReadDate = today data.value.endReadDate = today
putEndReadDate(props.id, today) putReadBook(props.id)
} else { } else {
putUnreadBook(props.id) putUnreadBook(props.id)
} }
@@ -54,17 +58,21 @@ function onReadIconClick() {
function onWantReadIconClick() { function onWantReadIconClick() {
data.value.wantread = !data.value.wantread 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) { if (!data.value.startReadDate) {
data.value.startReadDate = today data.value.startReadDate = today
data.value.wantread = false
putStartReadDate(props.id, data.value.startReadDate) putStartReadDate(props.id, data.value.startReadDate)
} else { } else if (!data.value.read) {
data.value.startReadDate = null data.value.startReadDate = null
putStartReadDateUnset(props.id) putStartReadDateUnset(props.id)
} }
if (data.value.read) {
data.value.read = false
}
} }
function onStartReadDateChange(d) { 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() { function goToAuthor() {
router.push('/author/' + data.value.authorId) router.push('/author/' + data.value.authorId)
} }
@@ -103,17 +105,6 @@ function goToAuthor() {
<figure class="image"> <figure class="image">
<img v-bind:src="imagePathOrDefault" v-bind:alt="data.title" /> <img v-bind:src="imagePathOrDefault" v-bind:alt="data.title" />
</figure> </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>
<div class="column"> <div class="column">
<h3 class="title">{{ data.title }}</h3> <h3 class="title">{{ data.title }}</h3>
@@ -122,40 +113,23 @@ function goToAuthor() {
<div class="my-5" v-if="data.isbn">ISBN: {{ data.isbn }}</div> <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.inventaireid">Inventaire ID: {{ data.inventaireid }}</div>
<div class="my-5" v-if="data.openlibraryid">OLID: {{ data.openlibraryid }}</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>
<div class="column"> <div class="column">
<div class="iconscontainer" :class="data.read ? 'remove-border-bottom' : ''"> <BookFormIcons
<div class="bigiconcontainer"> v-bind="data"
<BigIcon @on-want-read-icon-click="onWantReadIconClick"
icon="BIconEye" @on-start-read-icon-click="onStartReadIconClick"
:legend="$t('bookform.wantread')" @on-read-icon-click="onReadIconClick"
:isSet="data.wantread" @on-start-read-date-change="onStartReadDateChange"
@click="onWantReadIconClick" @on-end-read-date-change="onEndReadDateChange"
/> />
</div> </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>
</div>
</div> </div>
</template> </template>
@@ -167,22 +141,6 @@ img {
width: auto; 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) { @media (min-width: 1024px) {
.left-panel { .left-panel {
margin-left: 3rem; margin-left: 3rem;
@@ -193,10 +151,7 @@ img {
img { img {
max-height: 250px; max-height: 250px;
max-width: 250px; max-width: 250px;
} padding: 20px;
.bigiconcontainer {
flex: 1;
} }
.image { .image {
@@ -204,10 +159,5 @@ img {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.iconscontainer {
display: flex;
width: 100%;
}
} }
</style> </style>

174
front/src/BookFormIcons.vue Normal file
View 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>

View File

@@ -1,6 +1,14 @@
<script setup> <script setup>
import { ref, computed } from 'vue' 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' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
@@ -14,16 +22,83 @@ const props = defineProps({
description: String, description: String,
rating: Number, rating: Number,
read: Boolean, read: Boolean,
startreaddate: String,
wantread: Boolean, wantread: Boolean,
coverPath: String, coverPath: String,
}) })
const imagePathOrDefault = computed(() => getImagePathOrDefault(props.coverPath)) const imagePathOrDefault = computed(() => getImagePathOrDefault(props.coverPath))
const error = ref(null) 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() { async function onUserBookRead() {
if (!isRead.value) {
userBookRead()
} else {
userBookUnread()
}
}
async function userBookRead() {
const res = await putReadBook(props.id) const res = await putReadBook(props.id)
if (res.ok) { 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 { } else {
res.json().then((json) => (error.value = json)) 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 class="has-text-text-65 is-size-6" v-if="props.description">{{ description }}</div>
</div> </div>
</div> </div>
<div v-if="!inventaireid" class="column is-narrow"> <div v-if="id && id != 0" class="column is-narrow">
<button @click="" class="button is-large verticalbutton"> <button @click="onUserBookWantRead" class="button is-large verticalbutton">
<span class="icon" :title="$t('booklistelement.wantread')"> <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 /> <b-icon-eye v-else />
</span> </span>
</button> </button>
<button @click="" class="button is-large verticalbutton"> <button @click="onUserBookStartRead" class="button is-large verticalbutton">
<span class="icon" :title="$t('booklistelement.startread')"> <span class="icon" :title="$t('booklistelement.startread')">
<b-icon-book /> <b-icon-book-fill v-if="isStartRead" />
<b-icon-book v-else />
</span> </span>
</button> </button>
<button @click="onUserBookRead" class="button is-large verticalbutton"> <button @click="onUserBookRead" class="button is-large verticalbutton">
<span class="icon" :title="$t('booklistelement.read')"> <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 /> <b-icon-check-circle v-else />
</span> </span>
</button> </button>

View File

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

51
front/src/DateWidget.vue Normal file
View 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>

View File

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

135
front/src/ReviewWidget.vue Normal file
View 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>

View File

@@ -1,21 +1,38 @@
<script setup> <script setup>
import { useTemplateRef, onMounted, ref } from 'vue' import { useTemplateRef, onMounted, onUnmounted, ref } from 'vue'
import { BrowserMultiFormatReader } from '@zxing/library' import { BrowserMultiFormatReader } from '@zxing/library'
import { i18n } from '@/main'
const emit = defineEmits('readBarcode') const emit = defineEmits('readBarcode')
const { t } = i18n.global
const scanResult = ref(null) const scanResult = ref(null)
const scanErr = ref(null)
const codeReader = new BrowserMultiFormatReader() const codeReader = new BrowserMultiFormatReader()
const scannerElement = useTemplateRef('scanner') const scannerElement = useTemplateRef('scanner')
onMounted(() => { onMounted(() => {
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) => { codeReader.decodeFromVideoDevice(undefined, scannerElement.value, (result, err) => {
if (result) { if (result) {
emit('readBarcode', result.text) emit('readBarcode', result.text)
} }
}) })
} else {
scanErr.value = t('barcode.nocamera')
}
})
}) })
onUnmounted(() => {
codeReader.reset()
})
function onResult(result) { function onResult(result) {
scanResult.value = result scanResult.value = result
} }
@@ -23,6 +40,7 @@ function onResult(result) {
<template> <template>
<h1 class="subtitle">{{ $t('barcode.title') }}</h1> <h1 class="subtitle">{{ $t('barcode.title') }}</h1>
<div v-if="scanErr">{{ scanErr }}</div>
<div v-if="scanResult">{{ scanResult }}</div> <div v-if="scanResult">{{ scanResult }}</div>
<video poster="data:image/gif,AAAA" ref="scanner"></video> <video poster="data:image/gif,AAAA" ref="scanner"></video>
</template> </template>

View File

@@ -54,6 +54,14 @@ export function getMyBooks(data, error, arg, limit, offset) {
return useFetch(data, error, '/ws/mybooks/' + arg + '?' + queryParams.toString()) 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) { export function getSearchBooks(data, error, searchterm, lang, searchInventaire, limit, offset) {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
lang: lang, lang: lang,
@@ -99,35 +107,35 @@ export async function postImportBook(id, language) {
} }
export async function putReadBook(bookId) { 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) { 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) { 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) { 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) { 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) { 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) { export async function putUpdateBook(bookId, payload) {
return genericPayloadCall('/ws/book/' + bookId + '/wantread', payload, 'PUT') return genericPayloadCall('/ws/book/' + bookId, payload, 'PUT')
}
export async function putRateBook(bookId, payload) {
return genericPayloadCall('/ws/book/' + bookId + '/rate', payload, 'PUT')
} }
export function postLogin(user) { export function postLogin(user) {

View File

@@ -6,6 +6,7 @@
"navbar": { "navbar": {
"mybooks": "My Books", "mybooks": "My Books",
"addbook": "Add Book", "addbook": "Add Book",
"explore": "Explore",
"logout": "Log out", "logout": "Log out",
"signup": "Sign up", "signup": "Sign up",
"search": "Search", "search": "Search",
@@ -13,7 +14,8 @@
}, },
"barcode": { "barcode": {
"title": "Scan barcode", "title": "Scan barcode",
"barcode": "Scan barcode" "barcode": "Scan barcode",
"nocamera": "No camera found."
}, },
"addbook": { "addbook": {
"title": "Title", "title": "Title",
@@ -58,6 +60,7 @@
}, },
"bookform": { "bookform": {
"error": "Error when loading book: {error}", "error": "Error when loading book: {error}",
"reviewbtn": "My review",
"read": "Read", "read": "Read",
"startread": "Started", "startread": "Started",
"wantread": "Interested" "wantread": "Interested"
@@ -79,5 +82,9 @@
"releasedate": "Release date:", "releasedate": "Release date:",
"publisher": "Publisher:", "publisher": "Publisher:",
"importing": "Importing..." "importing": "Importing..."
},
"review": {
"title": "My review",
"textplaceholder": "Write my review..."
} }
} }

View File

@@ -5,6 +5,7 @@
}, },
"navbar": { "navbar": {
"mybooks": "Mes Livres", "mybooks": "Mes Livres",
"explore": "Explorer",
"addbook": "Ajouter Un Livre", "addbook": "Ajouter Un Livre",
"logout": "Se déconnecter", "logout": "Se déconnecter",
"signup": "S'inscrire", "signup": "S'inscrire",
@@ -13,7 +14,8 @@
}, },
"barcode": { "barcode": {
"title": "Scanner le code-barres", "title": "Scanner le code-barres",
"barcode": "Scanner le code-barres" "barcode": "Scanner le code-barres",
"nocamera": "Impossible de détecter la caméra."
}, },
"addbook": { "addbook": {
"title": "Titre", "title": "Titre",
@@ -58,6 +60,7 @@
}, },
"bookform": { "bookform": {
"error": "Erreur pendant le chargement du livre: {error}", "error": "Erreur pendant le chargement du livre: {error}",
"reviewbtn": "Ma critique",
"read": "Lu", "read": "Lu",
"startread": "Commencé", "startread": "Commencé",
"wantread": "À lire" "wantread": "À lire"
@@ -79,5 +82,9 @@
"releasedate": "Date de publication : ", "releasedate": "Date de publication : ",
"publisher": "Maison d'édition : ", "publisher": "Maison d'édition : ",
"importing": "Import en cours..." "importing": "Import en cours..."
},
"review": {
"title": "Ma critique",
"textplaceholder": "Écrire ma critique..."
} }
} }

View File

@@ -14,7 +14,7 @@ import fr from './locales/fr.json'
import en from './locales/en.json' import en from './locales/en.json'
// configure i18n // configure i18n
const i18n = createI18n({ export const i18n = createI18n({
locale: navigator.language, locale: navigator.language,
fallbackLocale: 'en', fallbackLocale: 'en',
messages: { fr, en }, messages: { fr, en },

View File

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

View File

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

12
go.mod
View File

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

85
go.sum
View File

@@ -1,5 +1,7 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 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= 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/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 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= 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/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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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= 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/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 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= 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 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= 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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ func TestGetBook_Ok(t *testing.T) {
Rating: 10, Rating: 10,
Read: true, Read: true,
CoverPath: "/static/bookcover/dunchateaulautre.jpg", 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) }, book)
} }

View File

@@ -24,6 +24,10 @@ func TestPostImportBookHandler_Ok(t *testing.T) {
assert.Equal(t, "Emily Brontë", book.Author) assert.Equal(t, "Emily Brontë", book.Author)
assert.Equal(t, "isbn:9782253004752", book.InventaireId) assert.Equal(t, "isbn:9782253004752", book.InventaireId)
assert.Equal(t, "/static/bookcover/44abbcbdc1092212c2bae66f5165019dac1e2a7b.webp", book.CoverPath) 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) { 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, "Philip K. Dick", book.Author)
assert.Equal(t, "isbn:9782290033630", book.InventaireId) assert.Equal(t, "isbn:9782290033630", book.InventaireId)
assert.Equal(t, "/static/bookcover/1d1493159d031224a42b37c4417fcbb8c76b00bd.webp", book.CoverPath) 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) { func TestPostImportBookHandler_NoOLID(t *testing.T) {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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)
}

View File

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

View File

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

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

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

View File

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

View File

@@ -1,6 +1,7 @@
package callapiutils package callapiutils
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -20,9 +21,33 @@ func AddQueryParam(u *url.URL, paramName string, paramValue string) {
u.RawQuery = q.Encode() 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 { 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()) 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 { if err != nil {
return err return err
} }
@@ -42,18 +67,8 @@ func FetchAndParseResult[T any](u *url.URL, queryResult *T) error {
if err != nil { if err != nil {
return err return err
} }
return err
}
func DoApiQuery(u *url.URL) (*http.Response, error) { return err
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)
} }
func ComputeUrl(baseUrl string, paths ...string) (*url.URL, error) { func ComputeUrl(baseUrl string, paths ...string) (*url.URL, error) {

View File

@@ -29,6 +29,8 @@ type Config struct {
ImageFolderPath string `toml:"image-folder-path" short:"i" default:"img" type:"path" help:"Folder where uploaded files will be stored." comment:"Folder where uploaded files will be stored."` 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."` 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."` 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."` 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."` 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."` DemoUsername string `toml:"demo-username" default:"demo" help:"Name of the single user used for the demo." comment:"Name of the single user used for the demo."`
@@ -55,6 +57,8 @@ func defaultConfig() CLI {
ImageFolderPath: "img", ImageFolderPath: "img",
Limit: 100, Limit: 100,
InventaireUrl: "https://inventaire.io", InventaireUrl: "https://inventaire.io",
BookDescriptionFromBabelio: false,
BabelioUrl: "https://www.babelio.com",
DisableRegistration: false, DisableRegistration: false,
DemoMode: false, DemoMode: false,
DemoUsername: "demo", DemoUsername: "demo",

View File

@@ -21,6 +21,15 @@ type BookPostImport struct {
Lang string `json:"lang" binding:"required,max=5"` 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 { type FileInfoPost struct {
FileID uint `json:"fileId"` FileID uint `json:"fileId"`
FilePath string `json:"filepath"` FilePath string `json:"filepath"`

View File

@@ -14,6 +14,7 @@ type BookGet struct {
InventaireId string `json:"inventaireid"` InventaireId string `json:"inventaireid"`
OpenLibraryId string `json:"openlibraryid"` OpenLibraryId string `json:"openlibraryid"`
Summary string `json:"summary"` Summary string `json:"summary"`
Review string `json:"review"`
Rating int `json:"rating"` Rating int `json:"rating"`
Read bool `json:"read"` Read bool `json:"read"`
WantRead bool `json:"wantread"` WantRead bool `json:"wantread"`
@@ -52,6 +53,7 @@ type BookSearchGetBook struct {
IsInventaireEdition bool `json:"isinventaireedition"` IsInventaireEdition bool `json:"isinventaireedition"`
Rating int `json:"rating"` Rating int `json:"rating"`
Read bool `json:"read"` Read bool `json:"read"`
StartReadDate string `json:"startreaddate"`
WantRead bool `json:"wantread"` WantRead bool `json:"wantread"`
CoverPath string `json:"coverPath"` CoverPath string `json:"coverPath"`
} }

View File

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

View File

@@ -3,7 +3,12 @@ AuthenticationSuccess = "Connexion réussie."
ValidationRequired = "Ce champ est requis." ValidationRequired = "Ce champ est requis."
ValidationTooShort = "Ce champ est trop court. Il devrait contenir au moins %s caractères." 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." 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'." 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." 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à." UserAlreadyExists = "Un utilisateur avec le même nom existe déjà."
ErrorWhenCreatingUserFromStr = "Erreur lors de la création de l'utilisateur %s" ErrorWhenCreatingUserFromStr = "Erreur lors de la création de l'utilisateur %s"
ISBNNotFoundBabelio = "L'ISBN %s n'est pas sur babelio."
BabelioParseError = "Erreur en parsant babelio."
BabelioFetchDescError = "Erreur lors de la récupération de la description sur babelio."

View File

@@ -132,6 +132,27 @@ func TestCallInventaireEdition(t *testing.T) {
result) 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) { func TestCallInventaireEditionFromISBN(t *testing.T) {
result, err := CallInventaireFromISBN(getBaseInventaireUrl(), "9782070379248", "fr") result, err := CallInventaireFromISBN(getBaseInventaireUrl(), "9782070379248", "fr")
if err != nil { if err != nil {

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
package query package query
import ( import (
"regexp"
"strings"
"git.artlef.fr/bibliomane/internal/dto" "git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/fileutils" "git.artlef.fr/bibliomane/internal/fileutils"
"git.artlef.fr/bibliomane/internal/model" "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 var book dto.BookGet
query := db.Model(&model.Book{}) 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, " + 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.start_read_date) as start_read_date, " +
"DATE(user_books.end_read_date) AS end_read_date, " + "DATE(user_books.end_read_date) AS end_read_date, " +
selectStaticFilesPath() selectStaticFilesPath()
@@ -63,7 +66,7 @@ func fetchReadUserBookQuery(db *gorm.DB, userId uint) *gorm.DB {
} }
func fetchReadingUserBookQuery(db *gorm.DB, userId uint) *gorm.DB { func fetchReadingUserBookQuery(db *gorm.DB, userId uint) *gorm.DB {
query := fetchUserBookGet(db, userId) 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 return query
} }
@@ -100,6 +103,81 @@ func fetchUserBookGet(db *gorm.DB, userId uint) *gorm.DB {
return query 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 { func selectStaticFilesPath() string {
return "(CASE COALESCE(static_files.path, '') WHEN '' THEN '' ELSE concat('" + fileutils.GetWsLinkPrefix() + "', static_files.path) END) as CoverPath" return "(CASE COALESCE(static_files.path, '') WHEN '' THEN '' ELSE concat('" + fileutils.GetWsLinkPrefix() + "', static_files.path) END) as CoverPath"
} }

View File

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

View File

@@ -2,8 +2,11 @@ package routes
import ( import (
"errors" "errors"
"log"
"strings"
"git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/babelio"
"git.artlef.fr/bibliomane/internal/dto" "git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/fileutils" "git.artlef.fr/bibliomane/internal/fileutils"
"git.artlef.fr/bibliomane/internal/inventaire" "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) { 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{ book := model.Book{
Title: inventaireEdition.Title, Title: inventaireEdition.Title,
SmallDescription: inventaireEdition.Description, SmallDescription: inventaireEdition.Description,
InventaireID: inventaireEdition.Id, InventaireID: inventaireEdition.Id,
Author: *author,
AddedBy: *user, AddedBy: *user,
} }
if inventaireEdition.Author != nil {
author, err := fetchOrCreateInventaireAuthor(ac, inventaireEdition.Author)
if err != nil {
return nil, err
}
book.Author = *author
}
if inventaireEdition.Image != "" { if inventaireEdition.Image != "" {
cover, err := fileutils.DownloadFile(ac, inventaireEdition.Image) cover, err := fileutils.DownloadFile(ac, inventaireEdition.Image)
if err != nil { if err != nil {
@@ -59,10 +65,40 @@ func saveInventaireBookToDb(ac appcontext.AppContext, inventaireEdition inventai
} }
book.Cover = cover 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 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) { func fetchOrCreateInventaireAuthor(ac appcontext.AppContext, inventaireAuthor *inventaire.InventaireAuthorResult) (*model.Author, error) {
var author model.Author var author model.Author
res := ac.Db.Where("inventaire_id = ?", inventaireAuthor.ID).First(&author) res := ac.Db.Where("inventaire_id = ?", inventaireAuthor.ID).First(&author)

View File

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

View File

@@ -7,21 +7,35 @@ import (
"time" "time"
"git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/model" "git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator" "git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"gorm.io/gorm" "gorm.io/gorm"
) )
func PutReadUserBookHandler(ac appcontext.AppContext) { func PutUserBookHandler(ac appcontext.AppContext) {
data, err := retrieveDataFromContext(ac) bookId64, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
bookId := uint(bookId64)
if err != nil { if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return return
} }
bookId := data.BookId err = myvalidator.ValidateId(ac.Db, bookId, &model.Book{})
user := data.User if err != nil {
var read userbookPutRead myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
err = ac.C.ShouldBindJSON(&read) 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 { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
@@ -32,14 +46,61 @@ func PutReadUserBookHandler(ac appcontext.AppContext) {
return return
} }
userbook.Read = read.Read if userBookPut.Read != nil {
err = updateReadStatus(&userbook, &userBookPut)
if read.EndDate != "" {
d, err := parseDate(read.EndDate)
if err != nil { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return 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 userbook.EndReadDate = d
} }
@@ -52,84 +113,11 @@ func PutReadUserBookHandler(ac appcontext.AppContext) {
if !userbook.Read { if !userbook.Read {
userbook.EndReadDate = nil userbook.EndReadDate = nil
} }
return nil
ac.Db.Save(&userbook)
ac.C.String(http.StatusOK, "Success")
} }
func PutWantReadUserBookHandler(ac appcontext.AppContext) { func updateRating(userbook *model.UserBook, userBookPut *dto.UserBookPutUpdate) {
data, err := retrieveDataFromContext(ac) userbook.Rating = *userBookPut.Rating
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
//if rated, set to "read" (a rating = 0 means unrated) //if rated, set to "read" (a rating = 0 means unrated)
if userbook.Rating > 0 { if userbook.Rating > 0 {
@@ -137,30 +125,6 @@ func PutRateUserBookHandler(ac appcontext.AppContext) {
//if set to read, remove want read //if set to read, remove want read
userbook.WantRead = false 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) { 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) { func fetchOrCreateUserBook(ac appcontext.AppContext, bookId uint, user *model.User) (model.UserBook, error) {
var userbook model.UserBook var userbook model.UserBook
res := ac.Db.Where("user_id = ? AND book_id = ?", user.ID, bookId).First(&userbook) res := ac.Db.Where("user_id = ? AND book_id = ?", user.ID, bookId).First(&userbook)

View File

@@ -39,7 +39,9 @@ func Setup(config *config.Config) *gin.Engine {
ws.GET("/appinfo", func(c *gin.Context) { ws.GET("/appinfo", func(c *gin.Context) {
routes.GetAppInfo(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) 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) { ws.GET("/mybooks/read", func(c *gin.Context) {
routes.GetMyBooksReadHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) 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) { ws.GET("/book/:id", func(c *gin.Context) {
routes.GetBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) routes.GetBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
}) })
ws.PUT("/book/:id/read", func(c *gin.Context) { ws.PUT("/book/:id", func(c *gin.Context) {
routes.PutReadUserBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) routes.PutUserBookHandler(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.POST("/book", func(c *gin.Context) { ws.POST("/book", func(c *gin.Context) {
routes.PostBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) 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.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.GET("/", serveIndexHtml)
r.NoRoute(serveIndexHtml) r.NoRoute(serveIndexHtml)

View File

@@ -12,7 +12,6 @@ import (
"git.artlef.fr/bibliomane/internal/config" "git.artlef.fr/bibliomane/internal/config"
"git.artlef.fr/bibliomane/internal/setup" "git.artlef.fr/bibliomane/internal/setup"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
) )
func TestSetup() *gin.Engine { 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)) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
assert.Equal(t, expectedCode, w.Code) if w.Code != expectedCode {
t.Errorf("%s", w.Body.String())
}
} }

View File

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