47 Commits

Author SHA1 Message Date
e29743d5fa Add an user management page for admins 2026-04-30 23:51:11 +02:00
d8d7bc9570 Inventaire API: updated tests 2026-04-28 19:58:29 +02:00
ff8604eac1 Add admin user, and an option to add a user admin on startup 2026-04-28 19:50:35 +02:00
d5281e7d57 Collection form: small tweaks for mobile view 2026-04-28 14:56:21 +02:00
ed99ee772a Release 0.8.0 2026-04-25 21:20:43 +02:00
2f5fc3d0a3 Collections browser: add a button to remove collections 2026-04-25 17:39:01 +02:00
b48c42c40c Collection form: adapt close button to mobile 2026-04-25 15:13:22 +02:00
08a273b500 Collection form: add a button to remove added books 2026-04-24 19:48:38 +02:00
1ae76ed525 Collection form: improve transition on item change 2026-04-22 15:14:17 +02:00
7d867af654 Rework collection form display on mobile 2026-04-22 15:08:50 +02:00
11a23d174e Collection form items: fixed dragover detection when going up 2026-04-14 15:43:28 +02:00
e746e67e89 Collection form items: change dragover method to work on mobile 2026-04-14 15:16:37 +02:00
b47b09eb85 Collection browser: improve display on mobile 2026-04-13 15:11:25 +02:00
8d9431874f Release 0.7.1 2026-04-12 16:58:23 +02:00
3c621c01ce Collection: fixed adding book 2026-04-12 16:57:41 +02:00
d1d865b6ff Release 0.7.0 2026-04-12 15:47:26 +02:00
55a4a98b4d Collection book list: allow to directly input a position 2026-04-12 15:46:23 +02:00
255f24904c Collection: fixed changing position in element issue 2026-04-12 01:56:03 +02:00
178c688203 Collections: allow to drag and drop to change book position 2026-04-12 01:44:25 +02:00
d2fe3bf34f Add position for book in collection 2026-04-11 17:26:21 +02:00
36a21c8891 Improve input title for add book to collection widget 2026-04-09 13:50:13 +02:00
aca2a2c339 Refactor collection header query to remove warning 2026-04-08 15:35:00 +02:00
dbf0face76 Split query.go in two files 2026-04-08 15:26:40 +02:00
26931c734b Improve collections view
- Add opacity on book covers
- Enlarge book covers
2026-04-08 14:36:40 +02:00
6e3899b25e Collections: open collection form on creation 2026-04-08 14:18:34 +02:00
f2899b968c Use usual book widget for collection form view 2026-04-07 16:16:11 +02:00
a537c12a3b Fixed issue where querying empty collection returns an empty book record 2026-04-06 21:08:32 +02:00
2552ba8e94 Collection: new widget to add book to collection 2026-04-04 23:15:44 +02:00
c7abbfe4d4 demo data: fixed wrong column type error 2026-04-04 23:12:38 +02:00
625d2a2af1 Add a view to see all books in a collection 2026-04-03 22:57:45 +02:00
a5c4c0bbec Collections: add margin on pagination 2026-04-03 15:58:07 +02:00
488e3763e3 Fix translations when having an error on loading collections 2026-04-03 15:54:14 +02:00
b48ab1e4de Create new collections from my collections view 2026-04-03 15:51:18 +02:00
b1bad80426 Collections: sort by latest collections first 2026-04-03 15:47:54 +02:00
a280647575 First implementation of fetching collections of book managed by user 2026-04-02 16:23:22 +02:00
acdc3972bd fixup! Book: rename SmallDescription to ShortDescription in database 2026-04-01 14:37:29 +02:00
c4753ea388 Book: rename SmallDescription to ShortDescription in database 2026-04-01 14:36:13 +02:00
407f44d1e6 Book form edit: modify margin on summary and review box 2026-04-01 14:31:07 +02:00
126dea4689 Book form edit: display short description 2026-04-01 14:23:45 +02:00
8d97d00e93 Book form: can now edit an existing book 2026-04-01 00:34:09 +02:00
bcde39d51d Rename vue component for book form 2026-03-31 22:23:42 +02:00
32d39cabcd Add existing book fields in "create book" form 2026-03-31 17:35:32 +02:00
c1b6b61678 Revert import book summary feature 2026-03-31 14:57:45 +02:00
b8eacb9c10 Release 0.6.0 2026-03-27 22:17:02 +01:00
e05c9f2b45 Use the same widget for books everywhere 2026-03-27 22:08:24 +01:00
726c640657 Change tab order 2026-03-27 21:30:42 +01:00
7b5da2df61 Improve book list buttons in mobile view 2026-03-27 14:16:59 +01:00
84 changed files with 3208 additions and 683 deletions

View File

@@ -28,6 +28,8 @@ 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]`.
Use `--add-admin-user` or `-A` instead to add an user as an admin.
The password can be generated using `htpasswd -nBC10 [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

@@ -1,6 +1,7 @@
--users --users
INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba'); INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo2','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba'); INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo2','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
INSERT INTO users(created_at, name, admin, password) VALUES ('NOW', 'admin', 'true', '$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
-- cover -- cover
INSERT INTO static_files(name, path) VALUES ('odingosochateaux.jpg', 'odingosochateaux.jpg'); INSERT INTO static_files(name, path) VALUES ('odingosochateaux.jpg', 'odingosochateaux.jpg');
@@ -108,7 +109,7 @@ INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('
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 = 'L''insoutenable légèreté de l''être'), true,8); 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 = 'L''insoutenable légèreté de l''être'), true,8);
INSERT INTO books(created_at, title, isbn, author_id, summary, added_by_id, cover_id) VALUES ('NOW', 'Le complot contre l''Amérique', '9782070337903', (SELECT id FROM authors WHERE name = 'Philip Roth'), 'Lorsque le célèbre aviateur Charles Lindbergh battit le président Roosevelt aux élections présidentielles de 1940, la peur s''empara des Juifs américains. Non seulement Lindbergh avait, dans son discours radiophonique à la nation, reproché aux Juifs de pousser l''Amérique à entreprendre une guerre inutile avec l''Allemagne nazie, mais, en devenant trente-troisième président des États-Unis, il s''empressa de signer un pacte de non-agression avec Hitler. Alors la terreur pénétra dans les foyers juifs, notamment dans celui de la famille Roth. Ce contexte sert de décor historique au Complot contre l''Amérique, un roman où Philip Roth, qui avait sept ans à l''époque, raconte ce que vécut et ressentit sa famille - et des millions de familles semblables dans tout le pays - lors des lourdes années où s''exerça la présidence de Lindbergh, quand les citoyens américains qui étaient aussi des Juifs avaient de bonnes raisons de craindre le pire. Ce faisant, il nous offre un nouveau chef-d''oeuvre.', (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'lecomplotcontrelamerique.jpg')); INSERT INTO books(created_at, title, isbn, author_id, summary, added_by_id, cover_id) VALUES ('NOW', 'Le complot contre l''Amérique', '9782070337903', (SELECT id FROM authors WHERE name = 'Philip Roth'), 'Lorsque le célèbre aviateur Charles Lindbergh battit le président Roosevelt aux élections présidentielles de 1940, la peur s''empara des Juifs américains. Non seulement Lindbergh avait, dans son discours radiophonique à la nation, reproché aux Juifs de pousser l''Amérique à entreprendre une guerre inutile avec l''Allemagne nazie, mais, en devenant trente-troisième président des États-Unis, il s''empressa de signer un pacte de non-agression avec Hitler. Alors la terreur pénétra dans les foyers juifs, notamment dans celui de la famille Roth. Ce contexte sert de décor historique au Complot contre l''Amérique, un roman où Philip Roth, qui avait sept ans à l''époque, raconte ce que vécut et ressentit sa famille - et des millions de familles semblables dans tout le pays - lors des lourdes années où s''exerça la présidence de Lindbergh, quand les citoyens américains qui étaient aussi des Juifs avaient de bonnes raisons de craindre le pire. Ce faisant, il nous offre un nouveau chef-d''oeuvre.', (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'lecomplotcontrelamerique.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 = 'Le complot contre l''Amérique'),true,6); 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 = 'Le complot contre l''Amérique'),true,6);
INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Nord','Louis-Ferdinand Céline', (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'Nord.jpg')); INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Nord',(SELECT id FROM authors WHERE name = 'Louis-Ferdinand Céline'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'Nord.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 = 'Nord'),true, 10); 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 = 'Nord'),true, 10);
INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Sa majesté des mouches',(SELECT id FROM authors WHERE name = 'William Golding'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'sa+majesté+des+mouches.jpg')); INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Sa majesté des mouches',(SELECT id FROM authors WHERE name = 'William Golding'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'sa+majesté+des+mouches.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 = 'Sa majesté des mouches'),true, 5); 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 = 'Sa majesté des mouches'),true, 5);
@@ -125,3 +126,49 @@ INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('
INSERT INTO user_books(created_at, user_id, book_id, start_read_date, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'Recherches philosophiques'), '2025-11-22 00:00:00+00:00',0); INSERT INTO user_books(created_at, user_id, book_id, start_read_date, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'Recherches philosophiques'), '2025-11-22 00:00:00+00:00',0);
INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Le château',(SELECT id FROM authors WHERE name = 'Franz Kafka'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'le_chateau.jpg')); INSERT INTO books(created_at, title, author_id, added_by_id, cover_id) VALUES ('NOW', 'Le château',(SELECT id FROM authors WHERE name = 'Franz Kafka'), (SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM static_files WHERE name = 'le_chateau.jpg'));
INSERT INTO user_books(created_at, user_id, book_id, start_read_date, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'Le château'), '2025-10-30 00:00:00+00:00',0); INSERT INTO user_books(created_at, user_id, book_id, start_read_date, rating) VALUES ('NOW',(SELECT id FROM users WHERE name = 'demo'),(SELECT id FROM books WHERE title = 'Le château'), '2025-10-30 00:00:00+00:00',0);
-- collections
INSERT INTO collections(name, user_id) VALUES ('Littérature française',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('Nouvelles',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('Non fiction',(SELECT id FROM users WHERE name = 'demo2'));
INSERT INTO collections(name, user_id) VALUES ('Empty',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('Lu récemment',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('Brouillon',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('Traduit de l''anglais',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('À supprimer',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Nord'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Gargantua'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Duo'), 3);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Un barrage contre le Pacifique'), 4);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Rigodon'), 5);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Les dieux ont soif'), 6);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Dojoji et autres nouvelles'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Le meurtre d''O-tsuya'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Le coup de pistolet'), 3);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Duo'), 4);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'Recherches philosophiques'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'De sang-froid'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'The Life of Jesus'), 3);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'L''Homme sans qualités, tome 1'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Iliade'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Duo'), 3);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'De sang-froid'), 4);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Le Pavillon d''or'), 5);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Recherches philosophiques'), 6);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Dojoji et autres nouvelles'), 7);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Le château'), 8);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Nord'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Gargantua'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Duo'), 3);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Un barrage contre le Pacifique'), 4);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Rigodon'), 5);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Les dieux ont soif'), 6);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Traduit de l''anglais'), (SELECT id FROM books WHERE title = 'Sa majesté des mouches'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Traduit de l''anglais'), (SELECT id FROM books WHERE title = 'Le complot contre l''Amérique'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Traduit de l''anglais'), (SELECT id FROM books WHERE title = 'De sang-froid'), 3);

View File

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

View File

@@ -1,72 +0,0 @@
<script setup>
import { ref, reactive, computed } from 'vue'
import { postBook, extractFormErrorFromField } from './api.js'
import { useRouter } from 'vue-router'
import CoverUpload from './CoverUpload.vue'
const router = useRouter()
const book = ref({
title: '',
author: '',
coverId: null,
})
const errors = ref(null)
const titleError = computed(() => {
return extractFormErrorFromField('Title', errors.value)
})
const authorError = computed(() => {
return extractFormErrorFromField('Author', errors.value)
})
function onSubmit(e) {
postBook(book).then((res) => {
if (res.ok) {
router.push('/')
return
} else {
res.json().then((json) => (errors.value = json))
}
})
}
</script>
<template>
<form @submit.prevent="onSubmit">
<div class="field">
<label class="label">{{ $t('addbook.title') }}</label>
<div class="control">
<input
:class="'input ' + (titleError ? 'is-danger' : '')"
type="text"
maxlength="300"
required
v-model="book.title"
:placeholder="$t('addbook.title')"
/>
</div>
<p v-if="titleError" class="help is-danger">{{ titleError }}</p>
</div>
<div class="field">
<label class="label">{{ $t('addbook.author') }}</label>
<div class="control">
<input
:class="'input ' + (authorError ? 'is-danger' : '')"
type="text"
maxlength="100"
v-model="book.author"
:placeholder="$t('addbook.author')"
/>
</div>
<p v-if="authorError" class="help is-danger">{{ authorError }}</p>
</div>
<CoverUpload name="cover" @on-image-upload="(id) => (book.coverId = id)" />
<div class="field">
<div class="control">
<button class="button is-link">{{ $t('addbook.submit') }}</button>
</div>
</div>
</form>
</template>
<style scoped></style>

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref, computed } from 'vue'
import { getSearchBooks, postCollectionAddBook, extractFormErrorFromField } from './api.js'
const props = defineProps({
collectionId: Number,
})
const emit = defineEmits(['created'])
const book = ref({
id: 0,
title: '',
})
const addingBook = ref(false)
const data = ref(null)
const error = ref(null)
const titleError = computed(() => {
return extractFormErrorFromField('Title', error.value)
})
const vFocus = {
mounted: (el) => el.focus(),
}
const limit = 5
function fetchBooks() {
if (!book || book.value.title.length < 3) {
return
}
const lang = navigator.language.substring(0, 2)
getSearchBooks(data, error, book.value.title, lang, 0, limit, 0)
}
function addBook(bookId) {
postCollectionAddBook(props.collectionId, bookId).then((res) => {
if (res.ok) {
addingBook.value = false
book.value.id = 0
book.value.title = ''
data.value = null
error.value = null
emit('created')
} else {
res.json().then((json) => {
error.value = json
})
}
})
}
</script>
<template>
<div class="field has-addons">
<div v-if="addingBook" class="control">
<input
:class="'input is-large ' + (titleError ? 'is-danger' : '')"
v-focus
@keyup="fetchBooks()"
type="text"
maxlength="300"
v-model="book.title"
:placeholder="$t('inputbookwidget.searchinput')"
/>
<p v-if="titleError" class="help is-danger">{{ titleError }}</p>
<ul v-if="data" class="popupresults has-background-dark">
<li v-for="book in data.books" @click="addBook(book.id)" class="bookresult p-2">
{{ book.title }}
</li>
</ul>
</div>
<div v-if="!addingBook" class="control">
<button @click="addingBook = true" class="button is-large mb-2">
<span class="icon" :title="$t('collections.add')">
<b-icon-plus />
</span>
</button>
</div>
</div>
</template>
<style scoped>
.popupresults {
z-index: 999;
}
.bookresult {
cursor: pointer;
}
.bookresult:hover {
background-color: var(--bulma-text-40);
}
</style>

View File

@@ -0,0 +1,65 @@
<script setup>
import { ref, computed } from 'vue'
import { postCollection, extractFormErrorFromField } from './api.js'
const emit = defineEmits(['created'])
const collection = ref({
name: '',
})
const addingCollection = ref(false)
const errors = ref(null)
const error = computed(() => {
return extractFormErrorFromField('Name', errors.value)
})
const vFocus = {
mounted: (el) => el.focus(),
}
function onButtonClick() {
if (addingCollection.value) {
createCollection()
} else {
addingCollection.value = true
}
}
function createCollection() {
postCollection(collection.value).then((res) => {
if (res.ok) {
addingCollection.value = false
collection.value.name = ''
res.json().then((json) => emit('created', json.id))
} else {
res.json().then((json) => (errors.value = json))
}
})
}
</script>
<template>
<div class="field has-addons">
<div v-if="addingCollection" class="control">
<input
:class="'input is-medium ' + (error ? 'is-danger' : '')"
v-focus
@keyup.enter="createCollection()"
type="text"
maxlength="300"
v-model="collection.name"
:placeholder="$t('collections.name')"
/>
<p v-if="error" class="help is-danger">{{ error }}</p>
</div>
<div class="control">
<button @click="onButtonClick" class="button is-medium mb-2">
<span class="icon" :title="$t('collections.add')">
<b-icon-check v-if="addingCollection" />
<b-icon-plus v-else />
</span>
</button>
</div>
</div>
</template>
<style scoped></style>

View File

@@ -83,15 +83,31 @@ 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>
<RouterLink
v-if="authStore.user"
to="/collections"
class="navbar-item"
activeClass="is-active"
>
{{ $t('navbar.mycollections') }}
</RouterLink>
<RouterLink v-if="authStore.user" to="/browse" class="navbar-item" activeClass="is-active">
{{ $t('navbar.explore') }}
</RouterLink>
<RouterLink v-if="authStore.user" to="/add" class="navbar-item" activeClass="is-active"> <RouterLink v-if="authStore.user" to="/add" class="navbar-item" activeClass="is-active">
{{ $t('navbar.addbook') }} {{ $t('navbar.addbook') }}
</RouterLink> </RouterLink>
<RouterLink
v-if="authStore.user && authStore.user.admin"
to="/admin/users"
class="navbar-item"
activeClass="is-active"
>
{{ $t('navbar.usersmgt') }}
</RouterLink>
<div <div
v-if="authStore.user && appInfo && !appInfo.demoMode" v-if="authStore.user && appInfo && !appInfo.demoMode"
class="navbar-item is-hidden-desktop" class="navbar-item is-hidden-desktop"

View File

@@ -1,67 +0,0 @@
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { getImagePathOrDefault } from './api.js'
import { VRating } from 'vuetify/components/VRating'
const props = defineProps({
id: Number,
title: String,
author: String,
coverPath: String,
rating: Number,
read: Boolean,
})
const imagePathOrDefault = computed(() => getImagePathOrDefault(props.coverPath))
const router = useRouter()
function openBook() {
router.push(`/book/${props.id}`)
}
</script>
<template>
<div class="box container has-background-dark">
<div class="media" @click="openBook">
<div class="media-left">
<figure class="image mb-3">
<img v-bind:src="imagePathOrDefault" v-bind:alt="title" />
</figure>
</div>
<div class="media-content">
<div class="content">
<div class="is-size-5">{{ title }}</div>
<div class="is-size-5 is-italic">{{ author }}</div>
<VRating
v-if="rating > 0"
half-increments
readonly
:length="5"
size="medium"
:model-value="rating / 2"
active-color="bulma-body-color"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
img {
max-height: 100px;
max-width: 100px;
height: auto;
width: auto;
}
.box {
transition: ease-in-out 0.04s;
margin-bottom: 15px;
}
.box:hover {
transform: scale(1.01);
transition: ease-in-out 0.02s;
}
</style>

206
front/src/BookFormEdit.vue Normal file
View File

@@ -0,0 +1,206 @@
<script setup>
import { ref, reactive, computed } from 'vue'
import { postBook, putBook, extractFormErrorFromField, getBookCall } from './api.js'
import { useRouter } from 'vue-router'
import CoverUpload from './CoverUpload.vue'
const router = useRouter()
const props = defineProps({
id: String,
})
const fetchError = ref(null)
const book = ref({
title: '',
author: '',
isbn: '',
inventaireid: '',
openlibraryid: '',
shortdescription: '',
summary: '',
coverId: null,
})
if (props.id) {
getBookCall(props.id)
.then((res) => {
if (res.status === 401) {
const authStore = useAuthStore()
authStore.logout()
}
return res.json()
})
.then(fillBookWithJson)
.catch((err) => (fetchError.value = err))
}
function fillBookWithJson(json) {
book.value.title = json.title
book.value.author = json.author
book.value.isbn = json.isbn
book.value.inventaireid = json.inventaireid
book.value.openlibraryid = json.openlibraryid
book.value.shortdescription = json.shortdescription
book.value.summary = json.summary
book.value.coverId = json.coverId
}
const errors = ref(null)
const titleError = computed(() => {
return extractFormErrorFromField('Title', errors.value)
})
const authorError = computed(() => {
return extractFormErrorFromField('Author', errors.value)
})
const isbnError = computed(() => {
return extractFormErrorFromField('ISBN', errors.value)
})
const inventaireError = computed(() => {
return extractFormErrorFromField('InventaireID', errors.value)
})
const openLibraryError = computed(() => {
return extractFormErrorFromField('OpenLibraryId', errors.value)
})
const shortDescError = computed(() => {
return extractFormErrorFromField('ShortDescription', errors.value)
})
const summaryError = computed(() => {
return extractFormErrorFromField('ShortDescription', errors.value)
})
function postOrPutBook(book) {
if (props.id) {
return
} else {
return postBook(book)
}
}
function onSubmit(e) {
if (props.id) {
putBook(props.id, book).then((res) => {
if (res.ok) {
router.push('/book/' + props.id)
} else {
res.json().then((json) => (errors.value = json))
}
})
} else {
postBook(book).then((res) => {
if (res.ok) {
res.json().then((json) => router.push('/book/' + json.id))
return
} else {
res.json().then((json) => (errors.value = json))
}
})
}
}
</script>
<template>
<div v-if="error">{{ $t('bookform.error', { error: fetchError.message }) }}</div>
<form v-else @submit.prevent="onSubmit">
<div class="field">
<label class="label">{{ $t('addbook.title') }}</label>
<div class="control">
<input
:class="'input is-medium ' + (titleError ? 'is-danger' : '')"
type="text"
maxlength="300"
required
v-model="book.title"
:placeholder="$t('addbook.title')"
/>
</div>
<p v-if="titleError" class="help is-danger">{{ titleError }}</p>
</div>
<div class="field">
<label class="label">{{ $t('addbook.author') }}</label>
<div class="control">
<input
:class="'input is-medium ' + (authorError ? 'is-danger' : '')"
type="text"
maxlength="100"
v-model="book.author"
:placeholder="$t('addbook.author')"
/>
</div>
<p v-if="authorError" class="help is-danger">{{ authorError }}</p>
</div>
<div class="field">
<label class="label">{{ $t('addbook.shortdesc') }}</label>
<div class="control">
<input
:class="'input ' + (shortDescError ? 'is-danger' : '')"
type="text"
maxlength="300"
v-model="book.shortdescription"
:placeholder="$t('addbook.shortdesc')"
/>
</div>
<p v-if="shortDescError" class="help is-danger">{{ shortDescError }}</p>
</div>
<CoverUpload name="cover" @on-image-upload="(id) => (book.coverId = id)" />
<div class="field">
<label class="label">{{ $t('addbook.summary') }}</label>
<div class="control">
<textarea
:class="'textarea ' + (summaryError ? 'is-danger' : '')"
type="text"
v-model="book.summary"
:placeholder="$t('addbook.summary')"
/>
</div>
<p v-if="summaryError" class="help is-danger">{{ summaryError }}</p>
</div>
<div class="field">
<label class="label">ISBN</label>
<div class="control">
<input
:class="'input ' + (isbnError ? 'is-danger' : '')"
type="text"
maxlength="18"
v-model="book.isbn"
placeholder="ISBN"
/>
</div>
<p v-if="isbnError" class="help is-danger">{{ isbnError }}</p>
</div>
<div class="field">
<label class="label">Inventaire</label>
<div class="control">
<input
:class="'input ' + (inventaireError ? 'is-danger' : '')"
type="text"
maxlength="50"
v-model="book.inventaireid"
placeholder="Inventaire"
/>
</div>
<p v-if="inventaireError" class="help is-danger">{{ inventaireError }}</p>
</div>
<div class="field">
<label class="label">OpenLibrary</label>
<div class="control">
<input
:class="'input ' + (openLibraryError ? 'is-danger' : '')"
type="text"
maxlength="50"
v-model="book.openlibraryid"
placeholder="OpenLibrary"
/>
</div>
<p v-if="openLibraryError" class="help is-danger">{{ openLibraryError }}</p>
</div>
<div class="field">
<div class="control">
<button class="button is-link">{{ $t('addbook.submit') }}</button>
</div>
</div>
</form>
</template>
<style scoped></style>

View File

@@ -11,7 +11,7 @@ import {
putEndReadDateUnset, putEndReadDateUnset,
putUnreadBook, putUnreadBook,
} from './api.js' } from './api.js'
import { useRouter, onBeforeRouteUpdate } from 'vue-router' import { useRouter, onBeforeRouteUpdate, RouterLink } from 'vue-router'
import { VRating } from 'vuetify/components/VRating' import { VRating } from 'vuetify/components/VRating'
import BookFormIcons from './BookFormIcons.vue' import BookFormIcons from './BookFormIcons.vue'
import ReviewWidget from './ReviewWidget.vue' import ReviewWidget from './ReviewWidget.vue'
@@ -105,15 +105,24 @@ 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>
<div class="centered mt-2">
<RouterLink :to="'/book/' + props.id + '/edit'">
<span>
<b-icon-pencil-fill />
</span>
Modifier le livre
</RouterLink>
</div>
</div> </div>
<div class="column"> <div class="column">
<h3 class="title">{{ data.title }}</h3> <h3 class="title">{{ data.title }}</h3>
<h3 class="subtitle clickable" @click="goToAuthor">{{ data.author }}</h3> <h3 class="subtitle clickable" @click="goToAuthor">{{ data.author }}</h3>
<p>{{ data.summary }}</p> <p class="mb-2">{{ data.summary }}</p>
<div class="my-5" v-if="data.isbn">ISBN: {{ data.isbn }}</div> <div class="my-4" v-if="data.isbn">ISBN: {{ data.isbn }}</div>
<div class="my-5" v-if="data.inventaireid">Inventaire ID: {{ data.inventaireid }}</div> <div class="my-4" v-if="data.inventaireid">Inventaire ID: {{ data.inventaireid }}</div>
<div class="my-5" v-if="data.openlibraryid">OLID: {{ data.openlibraryid }}</div> <div class="my-4" v-if="data.openlibraryid">OLID: {{ data.openlibraryid }}</div>
<ReviewWidget <ReviewWidget
class="mt-5"
:reviewtext="data.review" :reviewtext="data.review"
:rating="data.rating" :rating="data.rating"
@on-review-update="onReviewUpdate" @on-review-update="onReviewUpdate"

View File

@@ -130,6 +130,7 @@ async function importInventaireEdition(inventaireid) {
<p>{{ error }}</p> <p>{{ error }}</p>
</div> </div>
<div class="columns no-padding box container has-background-dark"> <div class="columns no-padding box container has-background-dark">
<slot name="left"></slot>
<div class="media column no-margin clickable" @click="openBook"> <div class="media column no-margin clickable" @click="openBook">
<div class="media-left"> <div class="media-left">
<figure class="image mb-3"> <figure class="image mb-3">
@@ -143,6 +144,7 @@ async function importInventaireEdition(inventaireid) {
</div> </div>
</div> </div>
<div v-if="id && id != 0" class="column is-narrow"> <div v-if="id && id != 0" class="column is-narrow">
<div class="buttons">
<button @click="onUserBookWantRead" 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="isWantRead" /> <b-icon-eye-fill v-if="isWantRead" />
@@ -163,6 +165,8 @@ async function importInventaireEdition(inventaireid) {
</button> </button>
</div> </div>
</div> </div>
<slot name="right"></slot>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -183,6 +187,10 @@ img {
transition: ease-in-out 0.02s; transition: ease-in-out 0.02s;
} }
.buttons {
display: block;
}
.verticalbutton { .verticalbutton {
display: block; display: block;
} }
@@ -194,4 +202,19 @@ img {
.no-margin { .no-margin {
margin: 0px; margin: 0px;
} }
@media (max-width: 1024px) {
.buttons {
display: flex;
justify-content: center;
align-items: center;
}
img {
max-height: 100px;
max-width: 100px;
height: auto;
width: auto;
}
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import BookCard from './BookCard.vue'
import { getMyBooks } from './api.js' import { getMyBooks } from './api.js'
import BookListElement from './BookListElement.vue'
import Pagination from './Pagination.vue' import Pagination from './Pagination.vue'
const FilterStates = Object.freeze({ const FilterStates = Object.freeze({
@@ -10,7 +10,7 @@ const FilterStates = Object.freeze({
READING: 'reading', READING: 'reading',
}) })
const limit = 6 const limit = 5
const pageNumber = ref(1) const pageNumber = ref(1)
const offset = computed(() => (pageNumber.value - 1) * limit) const offset = computed(() => (pageNumber.value - 1) * limit)
@@ -76,7 +76,7 @@ function pageChange(newPageNumber) {
<div v-else-if="data"> <div v-else-if="data">
<div class=""> <div class="">
<div class="" v-for="book in data.books" :key="book.id"> <div class="" v-for="book in data.books" :key="book.id">
<BookCard v-bind="book" /> <BookListElement v-bind="book" />
</div> </div>
</div> </div>
<Pagination <Pagination

View File

@@ -0,0 +1,150 @@
<script setup>
import { computed, ref, useTemplateRef } from 'vue'
import { getCollection, postCollectionChangePosition, deleteCollectionItem } from './api.js'
import CollectionFormElement from './CollectionFormElement.vue'
import AddBookToCollection from './AddBookToCollection.vue'
import Pagination from './Pagination.vue'
const props = defineProps({
id: String,
})
const limit = 5
const pageNumber = ref(1)
const offset = computed(() => (pageNumber.value - 1) * limit)
const data = ref(null)
const error = ref(null)
const itemRefs = useTemplateRef('items')
const itemIdBeingGrabbed = ref(null)
const itemIdBeingOvered = ref(null)
let totalElementsNumber = computed(() =>
typeof data != 'undefined' && data.value != null ? data.value['count'] : 0,
)
let pageTotal = computed(() => Math.ceil(totalElementsNumber.value / limit))
getCollection(data, error, props.id, limit, offset.value)
function pageChange(newPageNumber) {
pageNumber.value = newPageNumber
data.value = null
error.value = null
getCollection(data, error, props.id, limit, offset.value)
}
function fetchCollection() {
pageChange(1)
}
function checkGrabbedPosition(itemId, y) {
const itemBeingMoved = itemRefs.value.find((it) => it.id == itemId)
const itemMovedY = itemBeingMoved.$el.offsetTop + y + itemBeingMoved.$el.offsetHeight / 2
itemRefs.value.forEach((it) => {
if (it.$props.id == itemIdBeingGrabbed.value) {
return
}
let lowerDetectionY = it.$el.offsetTop + it.$el.offsetHeight / 2
let upperDetectionY = it.$el.offsetTop + (3 * it.$el.offsetHeight) / 2
if (it.$props.position < itemBeingMoved.$props.position) {
lowerDetectionY = it.$el.offsetTop
upperDetectionY = it.$el.offsetTop + it.$el.offsetHeight / 2
}
if (lowerDetectionY < itemMovedY && itemMovedY < upperDetectionY) {
itemIdBeingOvered.value = it.$props.id
}
})
}
function onStopGrab() {
if (itemIdBeingOvered.value != null) {
const position = data.value.items.find((it) => it.id == itemIdBeingOvered.value).position
changePosition(itemIdBeingGrabbed.value, position)
}
itemIdBeingGrabbed.value = null
itemIdBeingOvered.value = null
}
function isDragoverFromAbove(position) {
if (itemIdBeingGrabbed.value == null) {
return false
}
const grabbedItemPosition = data.value.items.find(
(it) => it.id == itemIdBeingGrabbed.value,
).position
return position > grabbedItemPosition
}
function changePosition(id, position) {
postCollectionChangePosition(props.id, id, position).then((res) => {
if (res.ok) {
getCollection(data, error, props.id, limit, offset.value)
} else {
res.json().then((json) => {
error.value = json
})
}
})
}
function deleteItem(id) {
deleteCollectionItem(id).then((res) => {
if (res.ok) {
getCollection(data, error, props.id, limit, offset.value)
} else {
res.json().then((json) => {
error.value = json
})
}
})
}
</script>
<template>
<div v-if="error">{{ $t('collection.error', { error: error }) }}</div>
<div v-if="data">
<h2 class="title">{{ data.name }}</h2>
<AddBookToCollection :collection-id="props.id" @created="fetchCollection" />
<TransitionGroup name="list" tag="div">
<CollectionFormElement
@positionchange="(pos) => changePosition(item.id, pos)"
@startgrab="itemIdBeingGrabbed = item.id"
@grabbing="(y) => checkGrabbedPosition(item.id, y)"
@stopgrab="onStopGrab"
@delete="deleteItem(item.id)"
v-for="item in data.items"
:key="item.id"
v-bind="item"
ref="items"
:is-dragover="item.id == itemIdBeingOvered"
:is-dragover-from-above="isDragoverFromAbove(item.position)"
/>
</TransitionGroup>
<Pagination
class="mt-5"
:pageNumber="pageNumber"
:pageTotal="pageTotal"
maxItemDisplayed="11"
@pageChange="pageChange"
/>
</div>
</template>
<style scoped>
.list-move, /* apply transition to moving elements */
.list-enter-active,
.list-leave-active {
transition: all 0.2s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -0,0 +1,196 @@
<script setup>
import { ref } from 'vue'
import BookListElement from './BookListElement.vue'
const props = defineProps({
id: Number,
position: Number,
book: Array,
isDragover: Boolean,
isDragoverFromAbove: Boolean,
})
const emit = defineEmits(['positionchange', 'startgrab', 'stopgrab', 'grabbing', 'delete'])
const vFocus = {
mounted: (el) => el.focus(),
}
const isInputtingPosition = ref(false)
const inputtedPosition = ref('')
const initialGrabPosition = ref(null)
const draggedPosition = ref(null)
function onPositionInput() {
if (inputtedPosition.value != '' && !isNaN(inputtedPosition.value)) {
const parsedPosition = parseInt(inputtedPosition.value)
if (parsedPosition != props.position) {
emit('positionchange', parsedPosition)
}
}
clearPositionInput()
}
function clearPositionInput() {
isInputtingPosition.value = false
inputtedPosition.value = ''
}
function clearGrabVariables() {
initialGrabPosition.value = null
draggedPosition.value = null
}
function onPointerUp() {
clearGrabVariables()
emit('stopgrab')
}
function onPointerDown(e) {
initialGrabPosition.value = e.pageY
e.preventDefault()
emit('startgrab')
}
function onPointerMove(e) {
if (initialGrabPosition.value == null) {
return
}
draggedPosition.value = e.pageY - initialGrabPosition.value
emit('grabbing', draggedPosition.value)
}
</script>
<template>
<div>
<div v-if="isDragover && !isDragoverFromAbove" class="dragover" />
<div
:style="
draggedPosition
? 'transform: translateY(' + draggedPosition + 'px);position:relative;z-index:3'
: ''
"
ref="collectionitembox"
class="collectionitembox"
@pointermove.prevent="onPointerMove"
@pointerup="onPointerUp"
@pointerleave="clearGrabVariables"
>
<BookListElement v-bind="props.book">
<template v-slot:left>
<div class="is-hidden-desktop mobile-delete">
<div @click="$emit('delete')" class="centered closebtn clickable">
<b-icon-x />
</div>
</div>
<div class="inputpositionwidget centered">
<div
v-if="!isInputtingPosition"
@click="isInputtingPosition = true"
class="positionindicator centered is-narrow clickable"
>
{{ props.position }}
</div>
<div v-else>
<input
type="text"
v-model="inputtedPosition"
v-focus
@blur="clearPositionInput"
@keyup.enter="onPositionInput"
size="1"
class="positioninput"
:placeholder="props.position"
/>
</div>
</div>
<div class="separator" />
</template>
<template v-slot:right>
<div class="separator" />
<div class="positionwidget centered is-narrow" @pointerdown="onPointerDown">
<b-icon-list />
</div>
<div @click="$emit('delete')" class="is-hidden-touch centered closebtn clickable">
<b-icon-x />
</div>
</template>
</BookListElement>
</div>
<div v-if="isDragover && isDragoverFromAbove" class="dragover" />
</div>
</template>
<style scoped>
.collectionitembox {
transition: ease-in-out 0.04s;
display: flex;
z-index: 2;
}
.collectionitembox:hover {
transform: scale(1.01);
transition: ease-in-out 0.02s;
}
.separator {
width: 5px;
background: var(--bulma-scheme-main);
}
.positionindicator {
font-size: 36px;
margin-left: 40px;
margin-right: 40px;
}
.positioninput {
font-size: 36px;
margin-left: 20px;
margin-right: 20px;
text-align: center;
background: var(--bulma-scheme-main);
border-radius: 10%;
color: var(--bulma-body-color);
}
.positionwidget {
color: var(--bulma-scheme-main);
font-size: 48px;
margin-left: 30px;
border-top-right-radius: var(--bulma-box-radius);
border-bottom-right-radius: var(--bulma-box-radius);
cursor: grab;
touch-action: none;
}
.positionwidget:active {
cursor: grabbing;
}
.dragover {
border: 3px solid var(--bulma-primary);
border-radius: 10px;
}
.closebtn {
height: 40px;
width: 40px;
font-size: 36px;
}
.mobile-delete {
display: flex;
justify-content: flex-end;
height: 10px;
}
@media (max-width: 1024px) {
.positionwidget {
margin-bottom: 10px;
margin-left: 0px;
}
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup>
import { getImagePathOrDefault } from './api.js'
import { useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
const emit = defineEmits(['delete'])
const closeButtonDesktop = useTemplateRef('closeDesktop')
const closeButtonMobile = useTemplateRef('closeMobile')
const props = defineProps({
id: Number,
name: String,
books: Array,
count: Number,
})
const router = useRouter()
function onClick(e) {
if (
(closeButtonDesktop.value && closeButtonDesktop.value.contains(e.target)) ||
(closeButtonMobile.value && closeButtonMobile.value.contains(e.target))
) {
emit('delete')
} else {
router.push(`/collection/${props.id}`)
}
}
function setBookOpacityClass(index) {
const length = props.books.length
if (length < 5) {
return ''
} else if (length < 8) {
if (index < 4) {
return 'opacity-' + index
} else {
return ''
}
} else {
return 'opacity-' + index
}
}
</script>
<template>
<div class="collectioncontainer has-background-dark p-2" @click="onClick">
<div class="collectionheader">
<h2 class="namecontainer subtitle">
{{ props.name }}
</h2>
<div class="is-hidden-desktop align-right">
<div ref="closeMobile" @click="$emit('delete')" class="centered closebtn clickable">
<b-icon-x />
</div>
</div>
</div>
<div class="collectionpreviewbooks" v-if="props.books && props.books.length > 0">
<div
:class="index > 3 ? 'is-hidden-touch' : ''"
class="bookpreview mx-1"
v-for="(book, index) in props.books"
:key="book.id"
>
<img
:class="setBookOpacityClass(index)"
v-bind:src="getImagePathOrDefault(book.coverPath)"
v-bind:alt="book.title"
/>
</div>
</div>
<div ref="closeDesktop" class="is-hidden-touch centered closebtn clickable">
<b-icon-x />
</div>
</div>
</template>
<style scoped>
img {
max-height: 150px;
max-width: 150px;
height: auto;
width: auto;
}
.collectioncontainer {
display: flex;
transition: ease-in-out 0.04s;
border-radius: 20px;
}
.collectioncontainer:hover {
transform: scale(1.01);
transition: ease-in-out 0.02s;
}
.namecontainer {
flex: 1;
margin-bottom: 0px;
}
.collectionheader {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.collectionpreviewbooks {
flex: 6;
display: flex;
align-items: center;
}
.opacity-1 {
opacity: 100%;
}
.opacity-2 {
opacity: 100%;
}
.opacity-3 {
opacity: 100%;
}
.opacity-4 {
opacity: 70%;
}
.opacity-5 {
opacity: 40%;
}
.opacity-6 {
opacity: 20%;
}
.opacity-7 {
opacity: 10%;
}
.closebtn {
height: 40px;
width: 40px;
font-size: 36px;
}
.align-right {
display: flex;
justify-content: flex-end;
}
@media (max-width: 1024px) {
img {
max-height: 75px;
max-width: 75px;
}
.collectionpreviewbooks {
flex: 1;
margin-top: 20px;
justify-content: center;
}
.collectioncontainer {
flex-direction: column;
}
.opacity-1 {
opacity: 70%;
}
.opacity-2 {
opacity: 40%;
}
.opacity-3 {
opacity: 10%;
}
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup>
import { ref, computed } from 'vue'
import { getCollections, deleteCollection } from './api.js'
import { useRouter } from 'vue-router'
import CollectionListElement from './CollectionListElement.vue'
import Pagination from './Pagination.vue'
import AddCollection from './AddCollection.vue'
const router = useRouter()
const limit = 5
const pageNumber = ref(1)
const offset = computed(() => (pageNumber.value - 1) * limit)
const data = ref(null)
const error = ref(null)
let totalCollectionsNumber = computed(() =>
typeof data != 'undefined' && data.value != null ? data.value['count'] : 0,
)
let pageTotal = computed(() => Math.ceil(totalCollectionsNumber.value / limit))
fetchData()
function fetchData() {
getCollections(data, error, limit, offset.value)
}
function pageChange(newPageNumber) {
pageNumber.value = newPageNumber
data.value = null
fetchData()
}
function goToCollection(id) {
router.push(`/collection/${id}`)
}
function removeList(id) {
deleteCollection(id).then((res) => {
if (res.ok) {
fetchData()
} else {
res.json().then((json) => {
error.value = json
})
}
})
}
</script>
<template>
<div>
<div v-if="error">{{ $t('collections.error', { error: error.message }) }}</div>
<div v-else-if="data">
<AddCollection @created="goToCollection" />
<div class="collectionslist">
<div class="my-2" v-for="collection in data.collections" :key="collection.id">
<CollectionListElement @delete="removeList(collection.id)" v-bind="collection" />
</div>
</div>
<Pagination
class="mt-5"
:pageNumber="pageNumber"
:pageTotal="pageTotal"
maxItemDisplayed="11"
@pageChange="pageChange"
/>
</div>
<div v-else>{{ $t('bookbrowser.loading') }}</div>
</div>
</template>
<style scoped></style>

View File

@@ -23,7 +23,7 @@ const passwordError = computed(() => {
return extractFormErrorFromField('Password', errors.value) return extractFormErrorFromField('Password', errors.value)
}) })
async function onSubmit(e) { async function onSubmit() {
const res = await postLogin(user) const res = await postLogin(user)
if (res.ok) { if (res.ok) {
let json = await res.json() let json = await res.json()
@@ -36,7 +36,7 @@ async function onSubmit(e) {
} }
async function login(username, json) { async function login(username, json) {
useAuthStore().login({ username: username, token: json['token'] }) useAuthStore().login({ username: username, admin: json['admin'], token: json['token'] })
} }
</script> </script>

View File

@@ -36,7 +36,15 @@ function fetchData(searchTerm, authorId) {
error.value = null error.value = null
if (searchTerm != null) { if (searchTerm != null) {
const lang = navigator.language.substring(0, 2) const lang = navigator.language.substring(0, 2)
getSearchBooks(data, error, searchTerm, lang, forceSearchInventaire.value, limit, offset.value) getSearchBooks(
data,
error,
searchTerm,
lang,
forceSearchInventaire.value ? 2 : 1,
limit,
offset.value,
)
} else if (authorId != null) { } else if (authorId != null) {
getAuthorBooks(data, error, authorId, limit, offset.value) getAuthorBooks(data, error, authorId, limit, offset.value)
} }

View File

@@ -0,0 +1,76 @@
<script setup>
import { ref, computed } from 'vue'
import { getUsers } from './api.js'
const limit = 50
const pageNumber = ref(1)
const offset = computed(() => (pageNumber.value - 1) * limit)
const data = ref(null)
const error = ref(null)
let totalUsersNumber = computed(() =>
typeof data != 'undefined' && data.value != null ? data.value['count'] : 0,
)
let pageTotal = computed(() => Math.ceil(totalUsersNumber.value / limit))
fetchData()
function fetchData() {
getUsers(data, error, limit, offset.value)
}
function pageChange(newPageNumber) {
pageNumber.value = newPageNumber
data.value = null
fetchData()
}
</script>
<template>
<div v-if="error">{{ $t('usersmanagement.error', { error: error.message }) }}</div>
<div v-else-if="data">
<table class="table">
<thead>
<th>#</th>
<th>{{ $t('usersmanagement.name') }}</th>
<th>{{ $t('usersmanagement.admin') }}</th>
<th>
<abbr :title="$t('usersmanagement.addedbookshelp')">{{
$t('usersmanagement.addedbooks')
}}</abbr>
</th>
<th>
<abbr :title="$t('usersmanagement.bookshelp')">{{ $t('usersmanagement.books') }}</abbr>
</th>
</thead>
<tbody>
<tr v-for="user in data.users" :key="user.id">
<th class="numbercell">{{ user.id }}</th>
<td>{{ user.name }}</td>
<td class="boolcell"><input type="checkbox" disabled :checked="user.admin" /></td>
<td class="numbercell">{{ user.addedbookscount }}</td>
<td class="numbercell">{{ user.userbookscount }}</td>
</tr>
</tbody>
</table>
<Pagination
:pageNumber="pageNumber"
:pageTotal="pageTotal"
maxItemDisplayed="11"
@pageChange="pageChange"
/>
</div>
<div v-else>{{ $t('usersmanagement.loading') }}</div>
</template>
<style scoped>
.boolcell {
text-align: center;
}
.numbercell {
text-align: right;
}
</style>

View File

@@ -18,16 +18,23 @@ export function getImagePathOrGivenDefault(path, defaultpath) {
} }
} }
function useFetch(data, error, url) { function userFetch(url) {
const { user } = useAuthStore() const { user } = useAuthStore()
if (user != null) { if (user != null) {
fetch(url, { return fetch(url, {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: 'Bearer ' + user.token, Authorization: 'Bearer ' + user.token,
}, },
}) })
} else {
return Promise.resolve()
}
}
function useFetch(data, error, url) {
userFetch(url)
.then((res) => { .then((res) => {
if (res.status === 401) { if (res.status === 401) {
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -37,7 +44,6 @@ function useFetch(data, error, url) {
}) })
.then((json) => (data.value = json)) .then((json) => (data.value = json))
.catch((err) => (error.value = err)) .catch((err) => (error.value = err))
}
} }
export async function getAppInfo(appInfo, appInfoErr) { export async function getAppInfo(appInfo, appInfoErr) {
@@ -49,6 +55,16 @@ export async function getAppInfo(appInfo, appInfoErr) {
.catch((err) => (appInfoErr.value = err)) .catch((err) => (appInfoErr.value = err))
} }
export function getCollections(data, error, limit, offset) {
const queryParams = new URLSearchParams({ limit: limit, offset: offset })
return useFetch(data, error, '/ws/collections' + '?' + queryParams.toString())
}
export function getCollection(data, error, id, limit, offset) {
const queryParams = new URLSearchParams({ limit: limit, offset: offset })
return useFetch(data, error, '/ws/collection/' + id + '?' + queryParams.toString())
}
export function getMyBooks(data, error, arg, limit, offset) { export function getMyBooks(data, error, arg, limit, offset) {
const queryParams = new URLSearchParams({ limit: limit, offset: offset }) const queryParams = new URLSearchParams({ limit: limit, offset: offset })
return useFetch(data, error, '/ws/mybooks/' + arg + '?' + queryParams.toString()) return useFetch(data, error, '/ws/mybooks/' + arg + '?' + queryParams.toString())
@@ -98,6 +114,15 @@ export function getBook(data, error, id) {
return useFetch(data, error, '/ws/book/' + id) return useFetch(data, error, '/ws/book/' + id)
} }
export function getBookCall(id) {
return userFetch('/ws/book/' + id)
}
export function getUsers(data, error, limit, offset) {
const queryParams = new URLSearchParams({ limit: limit, offset: offset })
return useFetch(data, error, '/ws/admin/users' + '?' + queryParams.toString())
}
export function postBook(book) { export function postBook(book) {
return genericPayloadCall('/ws/book', book.value, 'POST') return genericPayloadCall('/ws/book', book.value, 'POST')
} }
@@ -106,6 +131,38 @@ export async function postImportBook(id, language) {
return genericPayloadCall('/ws/importbook', { inventaireid: id, lang: language }, 'POST') return genericPayloadCall('/ws/importbook', { inventaireid: id, lang: language }, 'POST')
} }
export function postCollection(collection) {
return genericPayloadCall('/ws/collection', collection, 'POST')
}
export function postCollectionAddBook(collectionId, bookId) {
return genericPayloadCall(
'/ws/collection/' + collectionId + '/addbook',
{ bookId: bookId },
'POST',
)
}
export function postCollectionChangePosition(collectionId, itemId, position) {
return genericPayloadCall(
'/ws/collection/' + collectionId + '/changeposition',
{ itemId: itemId, position: position },
'POST',
)
}
export function deleteCollectionItem(itemId) {
return deleteApiCall('/ws/collection/item/' + itemId)
}
export function deleteCollection(id) {
return deleteApiCall('/ws/collection/' + id)
}
export function putBook(id, book) {
return genericPayloadCall('/ws/book/edit/' + id, book.value, 'PUT')
}
export async function putReadBook(bookId) { export async function putReadBook(bookId) {
return putEndReadDate(bookId, new Date().toISOString().slice(0, 10)) return putEndReadDate(bookId, new Date().toISOString().slice(0, 10))
} }
@@ -190,14 +247,26 @@ export function genericPayloadCall(apiRoute, object, method) {
} }
} }
export function deleteApiCall(apiRoute) {
const { user } = useAuthStore()
if (user != null) {
return fetch(apiRoute, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + user.token,
},
})
} else {
return Promise.resolve()
}
}
export function extractFormErrorFromField(fieldName, errors) { export function extractFormErrorFromField(fieldName, errors) {
if (errors === null) { if (errors == null || typeof errors == 'undefined' || !Array.isArray(errors)) {
return '' return ''
} }
if (errors.value == null) {
return ''
}
console.log(errors.value)
const titleErr = errors.find((e) => e['field'] === fieldName) const titleErr = errors.find((e) => e['field'] === fieldName)
if (typeof titleErr !== 'undefined') { if (typeof titleErr !== 'undefined') {
return titleErr.error return titleErr.error

View File

@@ -5,11 +5,13 @@
}, },
"navbar": { "navbar": {
"mybooks": "My Books", "mybooks": "My Books",
"mycollections": "My Collections",
"addbook": "Add Book", "addbook": "Add Book",
"explore": "Explore", "explore": "Explore",
"logout": "Log out", "logout": "Log out",
"signup": "Sign up", "signup": "Sign up",
"search": "Search", "search": "Search",
"usersmgt": "Users Management",
"login": "Log In" "login": "Log In"
}, },
"barcode": { "barcode": {
@@ -20,9 +22,14 @@
"addbook": { "addbook": {
"title": "Title", "title": "Title",
"author": "Author", "author": "Author",
"shortdesc": "Short description",
"summary": "Summary",
"submit": "Submit", "submit": "Submit",
"coverupload": "Upload cover" "coverupload": "Upload cover"
}, },
"inputbookwidget": {
"searchinput": "Book title to add..."
},
"signup": { "signup": {
"title": "Sign up", "title": "Sign up",
"username": "Username", "username": "Username",
@@ -86,5 +93,23 @@
"review": { "review": {
"title": "My review", "title": "My review",
"textplaceholder": "Write my review..." "textplaceholder": "Write my review..."
},
"collections": {
"error": "Error when loading collections: {error}",
"add": "Add a collection",
"name": "Name"
},
"collection": {
"error": "Error when loading collection: {error}"
},
"usersmanagement": {
"error": "Error when loading users: {error}",
"name": "Name",
"admin": "Is Admin ?",
"addedbooks": "Added Books",
"addedbookshelp": "Number of books the user created or imported.",
"books": "Books Number",
"bookshelp": "Total number of books of the user.",
"loading": "Loading..."
} }
} }

View File

@@ -5,11 +5,13 @@
}, },
"navbar": { "navbar": {
"mybooks": "Mes Livres", "mybooks": "Mes Livres",
"mycollections": "Mes Listes",
"explore": "Explorer", "explore": "Explorer",
"addbook": "Ajouter Un Livre", "addbook": "Ajouter Un Livre",
"logout": "Se déconnecter", "logout": "Se déconnecter",
"signup": "S'inscrire", "signup": "S'inscrire",
"search": "Rechercher", "search": "Rechercher",
"usersmgt": "Gestion Des Utilisateurs",
"login": "Se connecter" "login": "Se connecter"
}, },
"barcode": { "barcode": {
@@ -20,9 +22,14 @@
"addbook": { "addbook": {
"title": "Titre", "title": "Titre",
"author": "Auteur", "author": "Auteur",
"shortdesc": "Description rapide",
"summary": "Résumé",
"submit": "Confirmer", "submit": "Confirmer",
"coverupload": "Téléverser la couverture" "coverupload": "Téléverser la couverture"
}, },
"inputbookwidget": {
"searchinput": "Titre du livre à ajouter..."
},
"signup": { "signup": {
"title": "Inscription", "title": "Inscription",
"username": "Nom d'utilisateur", "username": "Nom d'utilisateur",
@@ -86,5 +93,23 @@
"review": { "review": {
"title": "Ma critique", "title": "Ma critique",
"textplaceholder": "Écrire ma critique..." "textplaceholder": "Écrire ma critique..."
},
"collections": {
"error": "Erreur pendant le chargement des listes: {error}",
"add": "Ajouter une liste",
"name": "Nom"
},
"collection": {
"error": "Erreur pendant le chargement de la liste : {error}"
},
"usersmanagement": {
"error": "Erreur pendant le chargement des utilisateurs: {error}",
"name": "Nom",
"admin": "Administrateur ?",
"addedbooks": "Livres Ajoutés",
"addedbookshelp": "Nombre de livres créés ou importés par l'utilisateur.",
"books": "Nombre De Livres",
"bookshelp": "Le nombre total de livres de cet utilisateur.",
"loading": "Chargement..."
} }
} }

View File

@@ -1,9 +1,11 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import BooksBrowser from './BooksBrowser.vue' import BooksBrowser from './BooksBrowser.vue'
import AddBook from './AddBook.vue' import CollectionsBrowser from './CollectionsBrowser.vue'
import CollectionForm from './CollectionForm.vue'
import BookFormEdit from './BookFormEdit.vue'
import AuthorForm from './AuthorForm.vue' import AuthorForm from './AuthorForm.vue'
import BookForm from './BookForm.vue' import BookFormView from './BookFormView.vue'
import SignUp from './SignUp.vue' import SignUp from './SignUp.vue'
import LogIn from './LogIn.vue' import LogIn from './LogIn.vue'
import Home from './Home.vue' import Home from './Home.vue'
@@ -11,6 +13,7 @@ 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 InstanceBrowser from './InstanceBrowser.vue'
import UsersManagement from './UsersManagement.vue'
import { useAuthStore } from './auth.store' import { useAuthStore } from './auth.store'
const routes = [ const routes = [
@@ -18,13 +21,17 @@ const routes = [
{ path: '/scan', component: ScanBook }, { path: '/scan', component: ScanBook },
{ path: '/browse', component: InstanceBrowser }, { path: '/browse', component: InstanceBrowser },
{ path: '/books', component: BooksBrowser }, { path: '/books', component: BooksBrowser },
{ path: '/book/:id', component: BookForm, props: true }, { path: '/book/:id', component: BookFormView, props: true },
{ path: '/book/:id/edit', component: BookFormEdit, props: true },
{ path: '/collections', component: CollectionsBrowser },
{ path: '/collection/:id', component: CollectionForm, props: true },
{ path: '/author/:id', component: AuthorForm, props: true }, { path: '/author/:id', component: AuthorForm, props: true },
{ path: '/search/:searchterm', component: SearchBook, props: true }, { path: '/search/:searchterm', component: SearchBook, props: true },
{ path: '/import/inventaire/:inventaireid', component: ImportInventaire, props: true }, { path: '/import/inventaire/:inventaireid', component: ImportInventaire, props: true },
{ path: '/add', component: AddBook }, { path: '/add', component: BookFormEdit },
{ path: '/signup', component: SignUp }, { path: '/signup', component: SignUp },
{ path: '/login', component: LogIn }, { path: '/login', component: LogIn },
{ path: '/admin/users', component: UsersManagement },
] ]
export const router = createRouter({ export const router = createRouter({

133
internal/adapter/adapter.go Normal file
View File

@@ -0,0 +1,133 @@
package adapter
import (
"errors"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/query"
"gorm.io/gorm"
)
func CollectionItemsQueryToDto(itemsQueryResult []query.CollectionItemQueryResult) []dto.CollectionItemGet {
var dtoItems []dto.CollectionItemGet
for _, queryResult := range itemsQueryResult {
bookItem := dto.BookItemGet{
ID: queryResult.ID,
Title: queryResult.Title,
Author: queryResult.Author,
Description: queryResult.Description,
InventaireID: queryResult.InventaireID,
IsInventaireEdition: queryResult.IsInventaireEdition,
Rating: queryResult.Rating,
Read: queryResult.Read,
StartReadDate: queryResult.StartReadDate,
WantRead: queryResult.WantRead,
CoverPath: queryResult.CoverPath,
}
dtoItems = append(dtoItems,
dto.CollectionItemGet{
ID: queryResult.ItemID,
Position: queryResult.Position,
Book: bookItem,
})
}
return dtoItems
}
func CollectionQueryToCollectionItemDto(collectionsQueryResult []query.CollectionsQueryResult) []dto.CollectionListItemGet {
var collections []dto.CollectionListItemGet
for _, collectionDb := range collectionsQueryResult {
i := findIdInCollection(collections, collectionDb.ID)
if i == -1 {
collections = append(collections, dto.CollectionListItemGet{
ID: collectionDb.ID,
Name: collectionDb.Name,
})
//current collection is the last element
i = len(collections) - 1
}
book := collectionDbToCollectionBookItem(&collectionDb)
if book != nil {
collections[i].Books = append(collections[i].Books, *book)
}
}
return collections
}
func collectionDbToCollectionBookItem(collectionDb *query.CollectionsQueryResult) *dto.CollectionListBookItemGet {
if collectionDb.BookId > 0 {
bookItem := dto.CollectionListBookItemGet{
ID: collectionDb.BookId,
Title: collectionDb.BookTitle,
CoverPath: collectionDb.CoverPath,
}
return &bookItem
} else {
return nil
}
}
// returns the position in collections, -1 if not found
func findIdInCollection(collections []dto.CollectionListItemGet, collectionId uint) int {
for i, collection := range collections {
if collection.ID == collectionId {
return i
}
}
return -1
}
func FillBookDbFromFields(ac appcontext.AppContext, fields *dto.BookFields, book *model.Book) error {
if fields.Title != nil {
book.Title = *fields.Title
}
if fields.ISBN != nil {
book.ISBN = *fields.ISBN
}
if fields.InventaireID != nil {
book.InventaireID = *fields.InventaireID
}
if fields.OpenLibraryId != nil {
book.OpenLibraryId = *fields.OpenLibraryId
}
if fields.ShortDescription != nil {
book.ShortDescription = *fields.ShortDescription
}
if fields.Summary != nil {
book.Summary = *fields.Summary
}
if fields.CoverID != nil {
book.CoverID = *fields.CoverID
}
if fields.Author != nil {
author, err := fetchOrCreateAuthor(ac, *fields.Author)
if err != nil {
return err
}
book.AuthorID = author.ID
}
return nil
}
func fetchOrCreateAuthor(ac appcontext.AppContext, name string) (*model.Author, error) {
var author model.Author
res := ac.Db.Where("name = ?", name).First(&author)
err := res.Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
author = model.Author{Name: name}
err = ac.Db.Save(&author).Error
if err != nil {
return &author, err
}
return &author, nil
} else {
return &author, err
}
} else {
return &author, nil
}
}

View File

@@ -0,0 +1,45 @@
package apitest
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestDeleteCollectionBookHandler_DelOk(t *testing.T) {
status := testDeleteCollectionBookHandler("23")
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, "6", "10", "0")
assert.Equal(t, int64(5), collection.Count)
assert.Equal(t, uint(1), collection.Items[0].Position)
assert.Equal(t, uint(2), collection.Items[1].Position)
assert.Equal(t, uint(3), collection.Items[2].Position)
assert.Equal(t, uint(4), collection.Items[3].Position)
assert.Equal(t, uint(5), collection.Items[4].Position)
}
func TestDeleteCollectionBookHandler_NotFound(t *testing.T) {
status := testDeleteCollectionBookHandler("425")
assert.Equal(t, http.StatusNotFound, status)
}
func TestDeleteCollectionBookHandler_Unauthorized(t *testing.T) {
status := testDeleteCollectionBookHandler("11")
assert.Equal(t, http.StatusUnauthorized, status)
}
func testDeleteCollectionBookHandler(itemId string) int {
router := testutils.TestSetup()
w := httptest.NewRecorder()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("DELETE", "/ws/collection/item/"+itemId, nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
return w.Code
}

View File

@@ -0,0 +1,51 @@
package apitest
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestDeleteCollectionHandler_EmptyCollection(t *testing.T) {
collectionId := "8"
status := testDeleteCollectionHandler(collectionId)
assert.Equal(t, http.StatusOK, status)
getStatus, _ := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, http.StatusNotFound, getStatus)
}
func TestDeleteCollectionHandler_NotEmptyCollection(t *testing.T) {
collectionId := "7"
status := testDeleteCollectionHandler(collectionId)
assert.Equal(t, http.StatusOK, status)
getStatus, _ := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, http.StatusNotFound, getStatus)
}
func TestDeleteCollectionHandler_NonExistingCollection(t *testing.T) {
collectionId := "425"
status := testDeleteCollectionHandler(collectionId)
assert.Equal(t, http.StatusNotFound, status)
}
func TestDeleteCollectionHandler_ForbiddenCollection(t *testing.T) {
collectionId := "3"
status := testDeleteCollectionHandler(collectionId)
assert.Equal(t, http.StatusUnauthorized, status)
}
func testDeleteCollectionHandler(id string) int {
router := testutils.TestSetup()
w := httptest.NewRecorder()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("DELETE", "/ws/collection/"+id, nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
return w.Code
}

View File

@@ -1,11 +1,7 @@
package apitest package apitest
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest"
"net/url"
"testing" "testing"
"git.artlef.fr/bibliomane/internal/dto" "git.artlef.fr/bibliomane/internal/dto"
@@ -14,45 +10,12 @@ import (
) )
func TestFetchAllBooks(t *testing.T) { func TestFetchAllBooks(t *testing.T) {
result := testFetchBooks(t, "15", "0") status, result := testFetchBooks(t, "15", "0")
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, int64(31), result.Count) assert.Equal(t, int64(31), result.Count)
assert.Equal(t, 15, len(result.Books)) assert.Equal(t, 15, len(result.Books))
} }
func testFetchBooks(t *testing.T, limit string, offset string) dto.BookSearchGet { func testFetchBooks(t *testing.T, limit string, offset string) (int, dto.BookItemsGet) {
router := testutils.TestSetup() return testutils.TestFetchModel[dto.BookItemsGet](t, "/ws/books", limit, offset)
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

@@ -0,0 +1,21 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestFetchAllCollections_OK(t *testing.T) {
status, res := testFetchCollections(t, "10", "0")
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, int64(5), res.Count)
assert.Equal(t, 5, len(res.Collections))
}
func testFetchCollections(t *testing.T, limit string, offset string) (int, dto.CollectionItemsGet) {
return testutils.TestFetchModel[dto.CollectionItemsGet](t, "/ws/collections", limit, offset)
}

View File

@@ -28,4 +28,5 @@ func TestGetAppInfo_Ok(t *testing.T) {
assert.Equal(t, false, appInfo.RegistrationDisabled) assert.Equal(t, false, appInfo.RegistrationDisabled)
assert.Equal(t, false, appInfo.DemoMode) assert.Equal(t, false, appInfo.DemoMode)
assert.Equal(t, false, appInfo.Admin)
} }

View File

@@ -1,10 +1,7 @@
package apitest package apitest
import ( import (
"encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest"
"testing" "testing"
"git.artlef.fr/bibliomane/internal/dto" "git.artlef.fr/bibliomane/internal/dto"
@@ -15,7 +12,7 @@ import (
func TestGetBook_Ok(t *testing.T) { func TestGetBook_Ok(t *testing.T) {
book := testGetBook(t, "3", http.StatusOK) book := testGetBook(t, "3", http.StatusOK)
assert.Equal(t, assert.Equal(t,
dto.BookGet{ dto.FullBookGet{
Title: "D'un château l'autre", Title: "D'un château l'autre",
Author: "Louis-Ferdinand Céline", Author: "Louis-Ferdinand Céline",
AuthorID: 2, AuthorID: 2,
@@ -29,7 +26,7 @@ func TestGetBook_Ok(t *testing.T) {
func TestGetBook_NoUserBook(t *testing.T) { func TestGetBook_NoUserBook(t *testing.T) {
book := testGetBook(t, "18", http.StatusOK) book := testGetBook(t, "18", http.StatusOK)
assert.Equal(t, assert.Equal(t,
dto.BookGet{ dto.FullBookGet{
Title: "De sang-froid", Title: "De sang-froid",
Author: "Truman Capote", Author: "Truman Capote",
AuthorID: 14, AuthorID: 14,
@@ -41,7 +38,7 @@ func TestGetBook_NoUserBook(t *testing.T) {
func TestGetBook_Description(t *testing.T) { func TestGetBook_Description(t *testing.T) {
book := testGetBook(t, "22", http.StatusOK) book := testGetBook(t, "22", http.StatusOK)
assert.Equal(t, assert.Equal(t,
dto.BookGet{ dto.FullBookGet{
Title: "Le complot contre l'Amérique", Title: "Le complot contre l'Amérique",
Author: "Philip Roth", Author: "Philip Roth",
AuthorID: 17, AuthorID: 17,
@@ -61,20 +58,8 @@ func TestGetBook_IdNotInt(t *testing.T) {
testGetBook(t, "wrong", http.StatusBadRequest) testGetBook(t, "wrong", http.StatusBadRequest)
} }
func testGetBook(t *testing.T, id string, status int) dto.BookGet { func testGetBook(t *testing.T, id string, expectedStatus int) dto.FullBookGet {
router := testutils.TestSetup() status, book := testutils.TestFetchOneModel[dto.FullBookGet](t, "/ws/book", id)
assert.Equal(t, expectedStatus, status)
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("GET", "/ws/book/"+id, nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var book dto.BookGet
err := json.Unmarshal(w.Body.Bytes(), &book)
if err != nil {
t.Error(err)
}
assert.Equal(t, status, w.Code)
return book return book
} }

View File

@@ -0,0 +1,49 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestGetCollection_Ok(t *testing.T) {
status, collection := testGetCollection(t, "1", "10", "0")
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, "Littérature française", collection.Name)
assert.Equal(t, 6, len(collection.Items))
}
func TestGetCollection_Limit(t *testing.T) {
status, collection := testGetCollection(t, "2", "3", "0")
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, "Nouvelles", collection.Name)
assert.Equal(t, 3, len(collection.Items))
assert.Equal(t, int64(4), collection.Count)
}
func TestGetCollection_Unauthorized(t *testing.T) {
status, _ := testGetCollection(t, "3", "10", "0")
assert.Equal(t, http.StatusUnauthorized, status)
}
func TestGetCollection_Empty(t *testing.T) {
status, collection := testGetCollection(t, "4", "10", "0")
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, "Empty", collection.Name)
assert.Equal(t, 0, len(collection.Items))
assert.Equal(t, int64(0), collection.Count)
}
func TestGetCollection_Position(t *testing.T) {
status, collection := testGetCollection(t, "2", "3", "0")
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, "Nouvelles", collection.Name)
assert.Equal(t, uint(3), collection.Items[2].Position)
}
func testGetCollection(t *testing.T, id string, limit string, offset string) (int, dto.CollectionGet) {
return testutils.TestFetchModel[dto.CollectionGet](t, "/ws/collection/"+id, limit, offset)
}

View File

@@ -60,14 +60,14 @@ func TestGetReadBooksHandler_CheckOneBook(t *testing.T) {
token := testutils.ConnectDemo2User(router) token := testutils.ConnectDemo2User(router)
result := testGetReadBooksHandler(t, router, token, 200, "100", "") result := testGetReadBooksHandler(t, router, token, 200, "100", "")
var book dto.BookUserGetBook var book dto.BookItemGet
for _, b := range result.Books { for _, b := range result.Books {
if b.Title == "De sang-froid" { if b.Title == "De sang-froid" {
book = b book = b
} }
} }
assert.Equal(t, assert.Equal(t,
dto.BookUserGetBook{ dto.BookItemGet{
ID: 18, ID: 18,
Title: "De sang-froid", Title: "De sang-froid",
Author: "Truman Capote", Author: "Truman Capote",
@@ -77,7 +77,7 @@ func TestGetReadBooksHandler_CheckOneBook(t *testing.T) {
}, book) }, book)
} }
func testGetReadBooksHandler(t *testing.T, router *gin.Engine, userToken string, expectedCode int, limit string, offset string) dto.BookUserGet { func testGetReadBooksHandler(t *testing.T, router *gin.Engine, userToken string, expectedCode int, limit string, offset string) dto.BookItemsGet {
u, err := url.Parse("/ws/mybooks/read") u, err := url.Parse("/ws/mybooks/read")
if err != nil { if err != nil {
t.Error(err) t.Error(err)

View File

@@ -28,7 +28,7 @@ func TestGetReadingBooksHandler_Demo2(t *testing.T) {
assert.Equal(t, int64(0), result.Count) assert.Equal(t, int64(0), result.Count)
} }
func testGetReadingBooksHandler(t *testing.T, router *gin.Engine, userToken string, expectedCode int, limit string, offset string) dto.BookUserGet { func testGetReadingBooksHandler(t *testing.T, router *gin.Engine, userToken string, expectedCode int, limit string, offset string) dto.BookItemsGet {
u, err := url.Parse("/ws/mybooks/reading") u, err := url.Parse("/ws/mybooks/reading")
if err != nil { if err != nil {
t.Error(err) t.Error(err)

View File

@@ -13,14 +13,14 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func testGetbooksHandler(t *testing.T, router *gin.Engine, userToken string, expectedCode int, url string) dto.BookUserGet { func testGetbooksHandler(t *testing.T, router *gin.Engine, userToken string, expectedCode int, url string) dto.BookItemsGet {
req, _ := http.NewRequest("GET", url, nil) req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", userToken)) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", userToken))
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
var parsedResponse dto.BookUserGet var parsedResponse dto.BookItemsGet
err := json.Unmarshal(w.Body.Bytes(), &parsedResponse) err := json.Unmarshal(w.Body.Bytes(), &parsedResponse)
if err != nil { if err != nil {
t.Error(err) t.Error(err)

View File

@@ -0,0 +1,41 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestGetUsersHandler_OK(t *testing.T) {
status, result := testFetchUsers(t, "15", "0")
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, int64(3), result.Count)
assert.Equal(t, 3, len(result.Users))
}
func TestGetUsersHandler_Limit(t *testing.T) {
status, result := testFetchUsers(t, "2", "0")
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, int64(3), result.Count)
assert.Equal(t, 2, len(result.Users))
}
func TestGetUsersHandler_Forbidden(t *testing.T) {
status, _ := testutils.TestFetchModel[dto.UsersGet](t, "/ws/admin/users", "10", "0")
assert.Equal(t, http.StatusForbidden, status)
}
func TestGetUsersHandler_BookCountOK(t *testing.T) {
status, result := testFetchUsers(t, "1", "1")
assert.Equal(t, http.StatusOK, status)
assert.Equal(t, "demo2", result.Users[0].Name)
assert.Equal(t, int64(1), result.Users[0].AddedBooksCount)
assert.Equal(t, int64(3), result.Users[0].UserBooksCount)
}
func testFetchUsers(t *testing.T, limit string, offset string) (int, dto.UsersGet) {
return testutils.TestFetchAdminModel[dto.UsersGet](t, "/ws/admin/users", limit, offset)
}

View File

@@ -27,6 +27,6 @@ func TestGetWantReadBooksHandler_Demo2(t *testing.T) {
assert.Equal(t, int64(0), result.Count) assert.Equal(t, int64(0), result.Count)
} }
func testGetWantReadBooksHandler(t *testing.T, router *gin.Engine, userToken string, expectedCode int) dto.BookUserGet { func testGetWantReadBooksHandler(t *testing.T, router *gin.Engine, userToken string, expectedCode int) dto.BookItemsGet {
return testGetbooksHandler(t, router, userToken, expectedCode, "/ws/mybooks/wantread") return testGetbooksHandler(t, router, userToken, expectedCode, "/ws/mybooks/wantread")
} }

View File

@@ -1,10 +1,8 @@
package apitest package apitest
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httptest" "strconv"
"strings"
"testing" "testing"
"git.artlef.fr/bibliomane/internal/testutils" "git.artlef.fr/bibliomane/internal/testutils"
@@ -45,6 +43,28 @@ func TestPostBookHandler_noTitle(t *testing.T) {
testPostBookHandler(t, bookJson, 400) testPostBookHandler(t, bookJson, 400)
} }
func TestPostBookHandler_AllFields(t *testing.T) {
bookJson :=
`{
"author": "Kafka",
"title": "Amerika",
"isbn": "978-2-07-036803-7",
"inventaireid": "isbn:9782070368037",
"openlibraryid": "OL8838048M",
"shortdescription": "Roman de Franz Kafka",
"summary": "L'Amérique (Amerika en version originale allemande) ou Le Disparu (Der Verschollene, titre voulu par l'auteur et rendu au livre dans ses plus récentes éditions) est le premier roman de Franz Kafka (1883-1924)."
}`
id := testPostBookHandler(t, bookJson, 200)
createdBook := testGetBook(t, strconv.FormatUint(uint64(id), 10), http.StatusOK)
assert.Equal(t, "Amerika", createdBook.Title)
assert.Equal(t, "Kafka", createdBook.Author)
assert.Equal(t, "978-2-07-036803-7", createdBook.ISBN)
assert.Equal(t, "isbn:9782070368037", createdBook.InventaireId)
assert.Equal(t, "OL8838048M", createdBook.OpenLibraryId)
assert.Equal(t, "L'Amérique (Amerika en version originale allemande) ou Le Disparu (Der Verschollene, titre voulu par l'auteur et rendu au livre dans ses plus récentes éditions) est le premier roman de Franz Kafka (1883-1924).", createdBook.Summary)
}
func TestPostBookHandler_TitleTooLong(t *testing.T) { func TestPostBookHandler_TitleTooLong(t *testing.T) {
bookJson := bookJson :=
`{ `{
@@ -63,16 +83,8 @@ func TestPostBookHandler_AuthorTooLong(t *testing.T) {
testPostBookHandler(t, bookJson, 400) testPostBookHandler(t, bookJson, 400)
} }
func testPostBookHandler(t *testing.T, bookJson string, expectedCode int) { func testPostBookHandler(t *testing.T, bookJson string, expectedCode int) uint {
router := testutils.TestSetup() status, id := testutils.TestPostCall(t, "/ws/book", bookJson)
w := httptest.NewRecorder() assert.Equal(t, expectedCode, status)
return id
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("POST", "/ws/book",
strings.NewReader(string(bookJson)))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
assert.Equal(t, expectedCode, w.Code)
} }

View File

@@ -0,0 +1,76 @@
package apitest
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPostCollectionBookHandler_AddOk(t *testing.T) {
payload :=
`{
"bookId": 9
}`
collectionId := "1"
status := testPostCollectionBookHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, int64(7), collection.Count)
}
func TestPostCollectionBookHandler_BookOK(t *testing.T) {
payload :=
`{
"bookId": 7
}`
collectionId := "2"
status := testPostCollectionBookHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, uint(7), collection.Items[0].Book.ID)
}
func TestPostCollectionBookHandler_CollectionNotFound(t *testing.T) {
payload :=
`{
"bookId": 9
}`
status := testPostCollectionBookHandler("12", payload)
assert.Equal(t, http.StatusNotFound, status)
}
func TestPostCollectionBookHandler_BookNotFound(t *testing.T) {
payload :=
`{
"bookId": 14654
}`
status := testPostCollectionBookHandler("1", payload)
assert.Equal(t, http.StatusNotFound, status)
}
func TestPostCollectionBookHandler_Unauthorized(t *testing.T) {
payload :=
`{
"bookId": 9
}`
status := testPostCollectionBookHandler("3", payload)
assert.Equal(t, http.StatusUnauthorized, status)
}
func testPostCollectionBookHandler(collectionId string, payload string) int {
router := testutils.TestSetup()
w := httptest.NewRecorder()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("POST", "/ws/collection/"+collectionId+"/addbook",
strings.NewReader(payload))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
return w.Code
}

View File

@@ -0,0 +1,114 @@
package apitest
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPostCollectionChangePositionHandler_PositionOk(t *testing.T) {
payload :=
`{
"itemId": 14,
"position": 2
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, uint(1), collection.Items[0].Position)
assert.Equal(t, uint(2), collection.Items[1].Position)
assert.Equal(t, uint(3), collection.Items[2].Position)
assert.Equal(t, uint(4), collection.Items[3].Position)
}
func TestPostCollectionChangePositionHandler_ChangeOtherElement(t *testing.T) {
payload :=
`{
"itemId": 17,
"position": 3
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, "Duo", collection.Items[3].Book.Title)
}
func TestPostCollectionChangePositionHandler_LastPosition(t *testing.T) {
payload :=
`{
"itemId": 19,
"position": 546
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, "Recherches philosophiques", collection.Items[7].Book.Title)
assert.Equal(t, "Le château", collection.Items[6].Book.Title)
assert.Equal(t, uint(8), collection.Items[7].Position)
}
func TestPostCollectionChangePositionHandler_FirstPosition(t *testing.T) {
payload :=
`{
"itemId": 16,
"position": 1
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, "Duo", collection.Items[0].Book.Title)
}
func TestPostCollectionChangePositionHandler_WrongPosition(t *testing.T) {
payload :=
`{
"itemId": 9,
"position": 0
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusBadRequest, status)
}
func TestPostCollectionChangePositionHandler_MissingPosition(t *testing.T) {
payload :=
`{
"itemId": 9
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusBadRequest, status)
}
func TestPostCollectionChangePositionWrongCollection(t *testing.T) {
payload :=
`{
"itemId": 1,
"position": 9
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusInternalServerError, status)
}
func testPostCollectionChangePositionHandler(collectionId string, payload string) int {
router := testutils.TestSetup()
w := httptest.NewRecorder()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("POST", "/ws/collection/"+collectionId+"/changeposition",
strings.NewReader(payload))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
return w.Code
}

View File

@@ -0,0 +1,31 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPostCollectionHandler_Ok(t *testing.T) {
collectionJson :=
`{
"name": "My collection"
}`
status, _ := testPostCollectionHandler(t, collectionJson)
assert.Equal(t, http.StatusOK, status)
}
func TestPostCollectionHandler_NameTooLong(t *testing.T) {
collectionJson :=
`{
"name": "rsteerdemenschderraumschiffgebrauchlichtalsseinursprungvonkraftgestartseinlangefahrthinzwischensternartigraumaufdersuchenrsteerdemenschderraumschiffgebrauchlichtalsseinursprungvonkraftgestartseinlangefahrthinzwischensternartigraumaufdersuchenrsteerdemenschderraumschiffgebrauchlichtalsseinursprungvonkraftgestartseinlangefahrthinzwischensternartigraumaufdersuchen"
}`
status, _ := testPostCollectionHandler(t, collectionJson)
assert.Equal(t, http.StatusBadRequest, status)
}
func testPostCollectionHandler(t *testing.T, collectionJson string) (int, uint) {
return testutils.TestPostCall(t, "/ws/collection", collectionJson)
}

View File

@@ -22,12 +22,8 @@ func TestPostImportBookHandler_Ok(t *testing.T) {
book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200) book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200)
assert.Equal(t, "les Hauts de Hurle-Vent", book.Title) assert.Equal(t, "les Hauts de Hurle-Vent", book.Title)
assert.Equal(t, "Emily Brontë", book.Author) assert.Equal(t, "Emily Brontë", book.Author)
assert.Equal(t, "isbn:9782253004752", book.InventaireId) assert.Equal(t, "inv:31cee958a93ba50697a3fec2a360f437", 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) {
@@ -35,11 +31,8 @@ func TestPostImportBookHandler_OkAuthorKey(t *testing.T) {
book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200) book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200)
assert.Equal(t, "Dr Bloodmoney", book.Title) assert.Equal(t, "Dr Bloodmoney", book.Title)
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, "inv:ba7d376567114bd69a6c8f0a135a89d0", 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

@@ -0,0 +1,57 @@
package apitest
import (
"net/http"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPutBookHandler_TitleChange(t *testing.T) {
bookId := "17"
bookJson :=
`{
"title": "Le coup de pistolaid"
}`
testPutBook(t, bookJson, bookId, 200)
modifiedBook := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "Le coup de pistolaid", modifiedBook.Title)
}
func TestPutBookHandler_Author(t *testing.T) {
bookId := "17"
bookJson :=
`{
"author": "Alexander Pouchkine"
}`
testPutBook(t, bookJson, bookId, 200)
modifiedBook := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "Alexander Pouchkine", modifiedBook.Author)
}
func TestPutBookHandler_MultipleFields(t *testing.T) {
bookId := "17"
bookJson :=
`{
"title": "Le pistolet",
"author": "Pouchkine",
"isbn": "978-2-07-036803-7",
"inventaireid": "isbn:9782070368037",
"openlibraryid": "OL8838048M",
"shortdescription": "Roman de Pouchkine",
"summary": "En garnison dans une petite ville, un officier de l'armée impériale russe rencontre Silvio, ancien soldat et tireur exceptionnel. Celui-ci fait forte impression sur lui, jusqu'au jour où il refuse, à la suite d'un affront, de se battre en duel."
}`
testPutBook(t, bookJson, bookId, 200)
modifiedBook := testGetBook(t, bookId, http.StatusOK)
assert.Equal(t, "Le pistolet", modifiedBook.Title)
assert.Equal(t, "Pouchkine", modifiedBook.Author)
assert.Equal(t, "978-2-07-036803-7", modifiedBook.ISBN)
assert.Equal(t, "OL8838048M", modifiedBook.OpenLibraryId)
assert.Equal(t, "Roman de Pouchkine", modifiedBook.ShortDescription)
assert.Equal(t, "En garnison dans une petite ville, un officier de l'armée impériale russe rencontre Silvio, ancien soldat et tireur exceptionnel. Celui-ci fait forte impression sur lui, jusqu'au jour où il refuse, à la suite d'un affront, de se battre en duel.", modifiedBook.Summary)
}
func testPutBook(t *testing.T, payload string, bookId string, expectedCode int) {
testutils.TestBookPutCallWithDemoPayload(t, payload, bookId, expectedCode, "/ws/book/edit/"+bookId)
}

View File

@@ -3,6 +3,7 @@ package apitest
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -15,17 +16,17 @@ import (
) )
func TestSearchBook_MultipleBooks(t *testing.T) { func TestSearchBook_MultipleBooks(t *testing.T) {
result := testSearchBook(t, "san", "", "") result := testSearchBook(t, "san", "", "", dto.NoInventaireSearch)
assert.Equal(t, int64(2), result.Count) assert.Equal(t, int64(2), result.Count)
assert.Equal(t, 2, len(result.Books)) assert.Equal(t, 2, len(result.Books))
} }
func TestSearchBook_OneBookNotUserBook(t *testing.T) { func TestSearchBook_OneBookNotUserBook(t *testing.T) {
result := testSearchBook(t, "iliade", "", "") result := testSearchBook(t, "iliade", "", "", dto.NoInventaireSearch)
assert.Equal(t, int64(1), result.Count) assert.Equal(t, int64(1), result.Count)
assert.Equal(t, assert.Equal(t,
[]dto.BookSearchGetBook{{ []dto.BookItemGet{{
Title: "Iliade", Title: "Iliade",
Author: "Homère", Author: "Homère",
ID: 29, ID: 29,
@@ -38,10 +39,10 @@ func TestSearchBook_OneBookNotUserBook(t *testing.T) {
} }
func TestSearchBook_OneBookRead(t *testing.T) { func TestSearchBook_OneBookRead(t *testing.T) {
result := testSearchBook(t, "dieux", "", "") result := testSearchBook(t, "dieux", "", "", dto.NoInventaireSearch)
assert.Equal(t, int64(1), result.Count) assert.Equal(t, int64(1), result.Count)
assert.Equal(t, assert.Equal(t,
[]dto.BookSearchGetBook{{ []dto.BookItemGet{{
Title: "Les dieux ont soif", Title: "Les dieux ont soif",
Author: "Anatole France", Author: "Anatole France",
ID: 4, ID: 4,
@@ -55,10 +56,10 @@ func TestSearchBook_OneBookRead(t *testing.T) {
} }
func TestSearchBook_OneBookStartRead(t *testing.T) { func TestSearchBook_OneBookStartRead(t *testing.T) {
result := testSearchBook(t, "Recherches", "", "") result := testSearchBook(t, "Recherches", "", "", dto.NoInventaireSearch)
assert.Equal(t, int64(1), result.Count) assert.Equal(t, int64(1), result.Count)
assert.Equal(t, assert.Equal(t,
[]dto.BookSearchGetBook{{ []dto.BookItemGet{{
Title: "Recherches philosophiques", Title: "Recherches philosophiques",
Author: "Ludwig Wittgenstein", Author: "Ludwig Wittgenstein",
ID: 30, ID: 30,
@@ -72,10 +73,10 @@ func TestSearchBook_OneBookStartRead(t *testing.T) {
} }
func TestSearchBook_ISBN(t *testing.T) { func TestSearchBook_ISBN(t *testing.T) {
result := testSearchBook(t, "9782070337903", "", "") result := testSearchBook(t, "9782070337903", "", "", dto.NoInventaireSearch)
assert.Equal(t, int64(1), result.Count) assert.Equal(t, int64(1), result.Count)
assert.Equal(t, assert.Equal(t,
[]dto.BookSearchGetBook{{ []dto.BookItemGet{{
Title: "Le complot contre l'Amérique", Title: "Le complot contre l'Amérique",
Author: "Philip Roth", Author: "Philip Roth",
ID: 22, ID: 22,
@@ -88,15 +89,15 @@ func TestSearchBook_ISBN(t *testing.T) {
} }
func TestSearchBook_ISBNInventaire(t *testing.T) { func TestSearchBook_ISBNInventaire(t *testing.T) {
result := testSearchBook(t, "9782253158400", "", "") result := testSearchBook(t, "9782253158400", "", "", dto.InventaireIfNothingFound)
assert.Equal(t, int64(1), result.Count) assert.Equal(t, int64(1), result.Count)
assert.Equal(t, assert.Equal(t,
[]dto.BookSearchGetBook{{ []dto.BookItemGet{{
ID: 0, ID: 0,
Title: "Les premières enquêtes de Maigret", Title: "Les premières enquêtes de Maigret",
Author: "Georges Simenon", Author: "Georges Simenon",
Description: "roman de Georges Simenon", Description: "roman de Georges Simenon",
InventaireID: "isbn:9782253158400", InventaireID: "inv:cd8af0fc32d2f721d2853d02682dfd44",
IsInventaireEdition: true, IsInventaireEdition: true,
Rating: 0, Rating: 0,
Read: false, Read: false,
@@ -107,17 +108,17 @@ func TestSearchBook_ISBNInventaire(t *testing.T) {
} }
func TestSearchBook_Limit(t *testing.T) { func TestSearchBook_Limit(t *testing.T) {
result := testSearchBook(t, "a", "10", "") result := testSearchBook(t, "a", "10", "", dto.NoInventaireSearch)
assert.Equal(t, 10, len(result.Books)) assert.Equal(t, 10, len(result.Books))
} }
func TestSearchBook_Offset(t *testing.T) { func TestSearchBook_Offset(t *testing.T) {
result := testSearchBook(t, "sa", "", "2") result := testSearchBook(t, "sa", "", "2", dto.NoInventaireSearch)
assert.Equal(t, int64(5), result.Count) assert.Equal(t, int64(5), result.Count)
assert.Equal(t, 3, len(result.Books)) assert.Equal(t, 3, len(result.Books))
} }
func testSearchBook(t *testing.T, searchterm string, limit string, offset string) dto.BookSearchGet { func testSearchBook(t *testing.T, searchterm string, limit string, offset string, inventaireSearchType dto.InventaireSearchType) dto.BookItemsGet {
router := testutils.TestSetup() router := testutils.TestSetup()
u, err := url.Parse("/ws/search/" + searchterm) u, err := url.Parse("/ws/search/" + searchterm)
@@ -137,6 +138,7 @@ func testSearchBook(t *testing.T, searchterm string, limit string, offset string
q := u.Query() q := u.Query()
q.Set("lang", "fr") q.Set("lang", "fr")
q.Set("inventaire", strconv.Itoa(int(inventaireSearchType)))
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
token := testutils.ConnectDemoUser(router) token := testutils.ConnectDemoUser(router)
@@ -145,7 +147,7 @@ func testSearchBook(t *testing.T, searchterm string, limit string, offset string
w := httptest.NewRecorder() w := httptest.NewRecorder()
router.ServeHTTP(w, req) router.ServeHTTP(w, req)
var result dto.BookSearchGet var result dto.BookItemsGet
s := w.Body.String() s := w.Body.String()
err = json.Unmarshal([]byte(s), &result) err = json.Unmarshal([]byte(s), &result)
if err != nil { if err != nil {

View File

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

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

@@ -29,12 +29,11 @@ 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."`
AddUser UserListAsStrings `toml:"add-user" short:"a" help:"Add users on startup following htpasswd bcrypt format." comment:"Add users on startup following htpasswd bcrypt format, example: [\"demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS\",\"user:$2y$10$3WYUp.VDpzJRywtrxO1s/uWfUIKpTE4yh5B1d2RCef3hvczYbEWTC\"]"` AddUser UserListAsStrings `toml:"add-user" short:"a" help:"Add users on startup following htpasswd bcrypt format." comment:"Add users on startup following htpasswd bcrypt format, example: [\"demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS\",\"user:$2y$10$3WYUp.VDpzJRywtrxO1s/uWfUIKpTE4yh5B1d2RCef3hvczYbEWTC\"]"`
AddAdminUser UserListAsStrings `toml:"add-admin-user" short:"A" help:"Same as add-user but the added user has admin privilege." comment:"Same as add-user but the added user has admin privilege."`
} }
type UserListAsStrings []string type UserListAsStrings []string
@@ -57,8 +56,6 @@ 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

@@ -4,7 +4,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strings" "strings"
"git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/appcontext"
@@ -21,13 +20,19 @@ func CreateUser(ac appcontext.AppContext, username string, password string) erro
if err != nil { if err != nil {
return err return err
} }
return CreateUserWithHashedPassword(ac, username, string(hashedPassword))
user := model.User{
Name: username,
Password: string(hashedPassword),
Admin: false,
}
return CreateUserWithHashedPassword(ac, &user)
} }
// only call this method with hashed password // only call this method with hashed password
func CreateUserWithHashedPassword(ac appcontext.AppContext, username string, hashedPassword string) error { func CreateUserWithHashedPassword(ac appcontext.AppContext, userToCreate *model.User) error {
var existingUser model.User var existingUser model.User
err := ac.Db.Where("name = ?", username).First(&existingUser).Error err := ac.Db.Where("name = ?", userToCreate.Name).First(&existingUser).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
@@ -37,41 +42,72 @@ func CreateUserWithHashedPassword(ac appcontext.AppContext, username string, has
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "UserAlreadyExists")), Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "UserAlreadyExists")),
} }
} }
user := model.User{ return ac.Db.Model(&model.User{}).Save(&userToCreate).Error
Name: username,
Password: hashedPassword,
}
return ac.Db.Model(&model.User{}).Save(&user).Error
} }
func CreateDefaultUsers(ac appcontext.AppContext) error { func CreateDefaultUsers(ac appcontext.AppContext) error {
usersPasswordMap, err := createNormalUsersMap(ac)
if err != nil {
return err
}
err = createDefaultUsersFromMap(ac, usersPasswordMap, false)
if err != nil {
return err
}
adminUsersPasswordMap, err := createDefaultUsersMap(ac, ac.Config.AddAdminUser)
if err != nil {
return err
}
return createDefaultUsersFromMap(ac, adminUsersPasswordMap, true)
}
func createNormalUsersMap(ac appcontext.AppContext) (map[string]string, error) {
usersPasswordMap, err := createDefaultUsersMap(ac, ac.Config.AddUser)
if err != nil {
return usersPasswordMap, err
}
_, ok := usersPasswordMap[ac.Config.DemoUsername]
if !ok {
usersPasswordMap[ac.Config.DemoUsername] = ""
}
return usersPasswordMap, nil
}
func createDefaultUsersMap(ac appcontext.AppContext, adduser []string) (map[string]string, error) {
usersPasswordMap := make(map[string]string) usersPasswordMap := make(map[string]string)
var usernames []string for _, s := range adduser {
for _, s := range ac.Config.AddUser {
splittedString := strings.Split(s, ":") splittedString := strings.Split(s, ":")
if len(splittedString) < 2 { if len(splittedString) < 2 {
return fmt.Errorf(i18nresource.GetTranslatedMessage(&ac, "ErrorWhenCreatingUserFromStr"), s) return usersPasswordMap,
fmt.Errorf(i18nresource.GetTranslatedMessage(&ac, "ErrorWhenCreatingUserFromStr"), s)
} }
usernames = append(usernames, splittedString[0])
usersPasswordMap[splittedString[0]] = splittedString[1] usersPasswordMap[splittedString[0]] = splittedString[1]
} }
if !slices.Contains(usernames, ac.Config.DemoUsername) { return usersPasswordMap, nil
usernames = append(usernames, ac.Config.DemoUsername) }
usersPasswordMap[ac.Config.DemoUsername] = ""
} func createDefaultUsersFromMap(
ac appcontext.AppContext,
usersPasswordMap map[string]string,
isAdmin bool) error {
var existingUsers []model.User var existingUsers []model.User
err := ac.Db.Where("name IN ?", usernames).Find(&existingUsers).Error err := ac.Db.Where("name IN ?", mapToArrayKey(usersPasswordMap)).Find(&existingUsers).Error
if err != nil { if err != nil {
return err return err
} }
for _, username := range usernames { for username, password := range usersPasswordMap {
if isInExistingUsers(username, existingUsers) { if isInExistingUsers(username, existingUsers) {
continue continue
} }
err = CreateUserWithHashedPassword(ac, username, usersPasswordMap[username])
user := model.User{
Name: username,
Password: password,
Admin: isAdmin,
}
err = CreateUserWithHashedPassword(ac, &user)
if err != nil { if err != nil {
return err return err
} }
@@ -87,3 +123,11 @@ func isInExistingUsers(username string, existingUsers []model.User) bool {
} }
return false return false
} }
func mapToArrayKey(m map[string]string) []string {
var a []string
for k := range m {
a = append(a, k)
}
return a
}

View File

@@ -22,6 +22,8 @@ func Initdb(databasePath string, demoDataPath string) *gorm.DB {
db.AutoMigrate(&model.User{}) db.AutoMigrate(&model.User{})
db.AutoMigrate(&model.UserBook{}) db.AutoMigrate(&model.UserBook{})
db.AutoMigrate(&model.StaticFile{}) db.AutoMigrate(&model.StaticFile{})
db.AutoMigrate(&model.Collection{})
db.AutoMigrate(&model.CollectionItem{})
var book model.Book var book model.Book
queryResult := db.Limit(1).Find(&book) queryResult := db.Limit(1).Find(&book)
if queryResult.RowsAffected == 0 && demoDataPath != "" { if queryResult.RowsAffected == 0 && demoDataPath != "" {

View File

@@ -5,15 +5,28 @@ type AuthorGet struct {
Description string `json:"description"` Description string `json:"description"`
} }
type InventaireSearchType int
const (
NoInventaireSearch InventaireSearchType = iota
InventaireIfNothingFound
ForceInventaireSearch
)
type BookSearchGetParam struct { type BookSearchGetParam struct {
Lang string `form:"lang" binding:"max=5"` Lang string `form:"lang" binding:"max=5"`
Inventaire bool `form:"inventaire"` Inventaire InventaireSearchType `form:"inventaire"`
} }
type BookPostCreate struct { type BookFields struct {
Title string `json:"title" binding:"required,max=300"` Title *string `json:"title" binding:"omitempty,max=300"`
Author string `json:"author" binding:"max=100"` Author *string `json:"author" binding:"omitempty,max=100"`
CoverID uint `json:"coverId"` ISBN *string `json:"isbn" binding:"omitempty,max=18"`
InventaireID *string `json:"inventaireid" binding:"omitempty,max=50"`
OpenLibraryId *string `json:"openlibraryid" binding:"omitempty,max=50"`
ShortDescription *string `json:"shortdescription" binding:"omitempty,max=300"`
Summary *string `json:"summary"`
CoverID *uint `json:"coverId"`
} }
type BookPostImport struct { type BookPostImport struct {
@@ -30,6 +43,19 @@ type UserBookPutUpdate struct {
Review *string `json:"review"` Review *string `json:"review"`
} }
type CollectionFields struct {
Name string `json:"name" binding:"required,max=300"`
}
type CollectionBook struct {
BookID uint `json:"bookId" binding:"required"`
}
type CollectionItemPosition struct {
Position uint `json:"position" binding:"required,gte=1"`
ItemID uint `json:"itemId" binding:"required"`
}
type FileInfoPost struct { type FileInfoPost struct {
FileID uint `json:"fileId"` FileID uint `json:"fileId"`
FilePath string `json:"filepath"` FilePath string `json:"filepath"`

View File

@@ -4,15 +4,17 @@ type AppInfo struct {
RegistrationDisabled bool `json:"registrationDisabled"` RegistrationDisabled bool `json:"registrationDisabled"`
DemoMode bool `json:"demoMode"` DemoMode bool `json:"demoMode"`
DemoUsername string `json:"demoUsername"` DemoUsername string `json:"demoUsername"`
Admin bool `json:"admin"`
} }
type BookGet struct { type FullBookGet struct {
Title string `json:"title" binding:"required,max=300"` Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"` Author string `json:"author" binding:"max=100"`
AuthorID uint `json:"authorId"` AuthorID uint `json:"authorId"`
ISBN string `json:"isbn"` ISBN string `json:"isbn"`
InventaireId string `json:"inventaireid"` InventaireId string `json:"inventaireid"`
OpenLibraryId string `json:"openlibraryid"` OpenLibraryId string `json:"openlibraryid"`
ShortDescription string `json:"shortdescription"`
Summary string `json:"summary"` Summary string `json:"summary"`
Review string `json:"review"` Review string `json:"review"`
Rating int `json:"rating"` Rating int `json:"rating"`
@@ -23,28 +25,13 @@ type BookGet struct {
CoverPath string `json:"coverPath"` CoverPath string `json:"coverPath"`
} }
type BookUserGet struct { type BookItemsGet struct {
Count int64 `json:"count"`
Books []BookUserGetBook `json:"books"`
}
type BookUserGetBook struct {
ID uint `json:"id"`
Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"`
Rating int `json:"rating" binding:"min=0,max=10"`
Read bool `json:"read" binding:"boolean"`
WantRead bool `json:"wantread" binding:"boolean"`
CoverPath string `json:"coverPath"`
}
type BookSearchGet struct {
Count int64 `json:"count"` Count int64 `json:"count"`
Inventaire bool `json:"inventaire"` Inventaire bool `json:"inventaire"`
Books []BookSearchGetBook `json:"books"` Books []BookItemGet `json:"books"`
} }
type BookSearchGetBook struct { type BookItemGet struct {
ID uint `json:"id"` ID uint `json:"id"`
Title string `json:"title" binding:"required,max=300"` Title string `json:"title" binding:"required,max=300"`
Author string `json:"author" binding:"max=100"` Author string `json:"author" binding:"max=100"`
@@ -57,3 +44,45 @@ type BookSearchGetBook struct {
WantRead bool `json:"wantread"` WantRead bool `json:"wantread"`
CoverPath string `json:"coverPath"` CoverPath string `json:"coverPath"`
} }
type CollectionGet struct {
Name string `json:"name"`
Count int64 `json:"count"`
Items []CollectionItemGet `json:"items"`
}
type CollectionItemGet struct {
ID uint `json:"id"`
Position uint `json:"position"`
Book BookItemGet `json:"book"`
}
type CollectionItemsGet struct {
Count int64 `json:"count"`
Collections []CollectionListItemGet `json:"collections"`
}
type CollectionListItemGet struct {
ID uint `json:"id"`
Name string `json:"name"`
Books []CollectionListBookItemGet `json:"books"`
}
type CollectionListBookItemGet struct {
ID uint `json:"id"`
Title string `json:"title"`
CoverPath string `json:"coverPath"`
}
type UsersGet struct {
Count int64 `json:"count"`
Users []UserGet `json:"users"`
}
type UserGet struct {
ID uint `json:"id"`
Name string `json:"name"`
Admin bool `json:"admin"`
AddedBooksCount int64 `json:"addedbookscount"`
UserBooksCount int64 `json:"userbookscount"`
}

View File

@@ -9,6 +9,5 @@ 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." Unauthorized = "You are not allowed to access this document."
BabelioParseError = "Error when parsing babelio." ItemDoesNotBelongToCollection = "Item does not belong to the collection."
BabelioFetchDescError = "Error when fetching description on babelio."

View File

@@ -9,6 +9,5 @@ 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." Unauthorized = "Vous n'êtes pas autorisé à accéder à cette page."
BabelioParseError = "Erreur en parsant babelio." ItemDoesNotBelongToCollection = "Cet élément n'appartient pas à la liste."
BabelioFetchDescError = "Erreur lors de la récupération de la description sur babelio."

View File

@@ -1,10 +1,12 @@
package jwtauth package jwtauth
import ( import (
"strconv"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
func GenerateJwtToken(username string) (string, error) { func GenerateJwtToken(username string, admin bool) (string, error) {
var s string var s string
key, err := GetJwtKey() key, err := GetJwtKey()
if err != nil { if err != nil {
@@ -14,6 +16,7 @@ func GenerateJwtToken(username string) (string, error) {
jwt.MapClaims{ jwt.MapClaims{
"iss": "bibliomane", "iss": "bibliomane",
"sub": username, "sub": username,
"admin": strconv.FormatBool(admin),
}) })
return t.SignedString(key) return t.SignedString(key)
} }

View File

@@ -2,6 +2,7 @@ package middleware
import ( import (
"net/http" "net/http"
"strconv"
"strings" "strings"
"git.artlef.fr/bibliomane/internal/jwtauth" "git.artlef.fr/bibliomane/internal/jwtauth"
@@ -27,29 +28,33 @@ func Auth() gin.HandlerFunc {
return return
} }
username, err := parseUserFromJwt(c) jwtokenStr := jwtFromBearerToken(c.GetHeader("Authorization"))
jwtoken, err := jwt.Parse(jwtokenStr,
func(token *jwt.Token) (any, error) {
return jwtauth.GetJwtKey()
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, abortError(c)
gin.H{"error": "You must be logged in to access this resource."}) return
}
//check admin rights
if strings.HasPrefix(c.FullPath(), "/ws/admin/") && !hasAdminRights(jwtoken) {
c.AbortWithStatusJSON(http.StatusForbidden,
gin.H{"error": "You do not have the right to access this resource."})
return
}
username, err := jwtoken.Claims.GetSubject()
if err != nil {
abortError(c)
} else { } else {
c.Set("user", username) c.Set("user", username)
} }
} }
} }
func parseUserFromJwt(c *gin.Context) (string, error) {
jwtokenStr := jwtFromBearerToken(c.GetHeader("Authorization"))
jwtoken, parseErr := jwt.Parse(jwtokenStr,
func(token *jwt.Token) (any, error) {
return jwtauth.GetJwtKey()
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if parseErr != nil {
return "", parseErr
}
return jwtoken.Claims.GetSubject()
}
func jwtFromBearerToken(bearerToken string) string { func jwtFromBearerToken(bearerToken string) string {
splitToken := strings.Split(bearerToken, " ") splitToken := strings.Split(bearerToken, " ")
if len(splitToken) == 2 { if len(splitToken) == 2 {
@@ -58,3 +63,28 @@ func jwtFromBearerToken(bearerToken string) string {
return "" return ""
} }
} }
func hasAdminRights(token *jwt.Token) bool {
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return false
}
raw, ok := claims["admin"]
if !ok {
return false
}
adminStr, ok := raw.(string)
if !ok {
return false
}
isAdmin, err := strconv.ParseBool(adminStr)
if err != nil {
return false
}
return isAdmin
}
func abortError(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized,
gin.H{"error": "You must be logged in to access this resource."})
}

View File

@@ -8,7 +8,7 @@ type Book struct {
ISBN string `json:"isbn"` ISBN string `json:"isbn"`
InventaireID string `json:"inventaireid"` InventaireID string `json:"inventaireid"`
OpenLibraryId string `json:"openlibraryid"` OpenLibraryId string `json:"openlibraryid"`
SmallDescription string ShortDescription string
Summary string `json:"summary"` Summary string `json:"summary"`
Author Author Author Author
AuthorID uint AuthorID uint

View File

@@ -0,0 +1,10 @@
package model
import "gorm.io/gorm"
type Collection struct {
gorm.Model
Name string
User User
UserID uint
}

View File

@@ -0,0 +1,11 @@
package model
import "gorm.io/gorm"
type CollectionItem struct {
gorm.Model
CollectionID uint
Position uint
Book Book
BookID uint
}

View File

@@ -6,5 +6,5 @@ type User struct {
gorm.Model gorm.Model
Name string `gorm:"index;uniqueIndex"` Name string `gorm:"index;uniqueIndex"`
Password string Password string
UserBooks []UserBook Admin bool
} }

View File

@@ -57,6 +57,9 @@ func ReturnErrorsAsJsonResponse(ac *appcontext.AppContext, err error) {
ac.C.JSON(httpError.StatusCode, gin.H{"error": httpError.Err.Error()}) ac.C.JSON(httpError.StatusCode, gin.H{"error": httpError.Err.Error()})
return return
} }
if errors.Is(err, gorm.ErrRecordNotFound) {
ac.C.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
}
ac.C.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) ac.C.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
} }

View File

@@ -10,10 +10,10 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
func FetchBookGet(db *gorm.DB, userId uint, bookId uint64) (dto.BookGet, error) { func FetchBookGet(db *gorm.DB, userId uint, bookId uint64) (dto.FullBookGet, error) {
var book dto.BookGet var book dto.FullBookGet
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.short_description, books.summary, " +
"user_books.review, 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, " +
@@ -27,8 +27,8 @@ func FetchBookGet(db *gorm.DB, userId uint, bookId uint64) (dto.BookGet, error)
return book, res.Error return book, res.Error
} }
func FetchReadUserBook(db *gorm.DB, userId uint, limit int, offset int) ([]dto.BookUserGetBook, error) { func FetchReadUserBook(db *gorm.DB, userId uint, limit int, offset int) ([]dto.BookItemGet, error) {
var books []dto.BookUserGetBook var books []dto.BookItemGet
query := fetchReadUserBookQuery(db, userId) query := fetchReadUserBookQuery(db, userId)
query = query.Limit(limit) query = query.Limit(limit)
query = query.Offset(offset) query = query.Offset(offset)
@@ -43,8 +43,8 @@ func FetchReadUserBookCount(db *gorm.DB, userId uint) (int64, error) {
return count, res.Error return count, res.Error
} }
func FetchReadingUserBook(db *gorm.DB, userId uint, limit int, offset int) ([]dto.BookUserGetBook, error) { func FetchReadingUserBook(db *gorm.DB, userId uint, limit int, offset int) ([]dto.BookItemGet, error) {
var books []dto.BookUserGetBook var books []dto.BookItemGet
query := fetchReadingUserBookQuery(db, userId) query := fetchReadingUserBookQuery(db, userId)
query = query.Limit(limit) query = query.Limit(limit)
query = query.Offset(offset) query = query.Offset(offset)
@@ -70,8 +70,8 @@ func fetchReadingUserBookQuery(db *gorm.DB, userId uint) *gorm.DB {
return query return query
} }
func FetchWantReadUserBook(db *gorm.DB, userId uint, limit int, offset int) ([]dto.BookUserGetBook, error) { func FetchWantReadUserBook(db *gorm.DB, userId uint, limit int, offset int) ([]dto.BookItemGet, error) {
var books []dto.BookUserGetBook var books []dto.BookItemGet
query := fetchWantReadUserBookQuery(db, userId) query := fetchWantReadUserBookQuery(db, userId)
query = query.Limit(limit) query = query.Limit(limit)
@@ -93,9 +93,10 @@ func fetchWantReadUserBookQuery(db *gorm.DB, userId uint) *gorm.DB {
return query return query
} }
// fetch only books where userbook exists
func fetchUserBookGet(db *gorm.DB, userId uint) *gorm.DB { func fetchUserBookGet(db *gorm.DB, userId uint) *gorm.DB {
query := db.Model(&model.UserBook{}) query := db.Model(&model.UserBook{})
query = query.Select("books.id, books.title, authors.name as author, user_books.rating, user_books.read, user_books.want_read, " + selectStaticFilesPath()) query = query.Select(selectBookItem())
query = query.Joins("left join books on (books.id = user_books.book_id)") query = query.Joins("left join books on (books.id = user_books.book_id)")
query = joinAuthors(query) query = joinAuthors(query)
query = joinStaticFiles(query) query = joinStaticFiles(query)
@@ -103,8 +104,8 @@ 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) { func FetchAllBooks(db *gorm.DB, userId uint, limit int, offset int) ([]dto.BookItemGet, error) {
var books []dto.BookSearchGetBook var books []dto.BookItemGet
query := fetchBookQueryBuilder(db, userId) query := fetchBookQueryBuilder(db, userId)
query = query.Limit(limit) query = query.Limit(limit)
query = query.Offset(offset) query = query.Offset(offset)
@@ -120,8 +121,8 @@ func FetchAllBooksCount(db *gorm.DB, userId uint) (int64, error) {
return count, res.Error return count, res.Error
} }
func FetchBookSearchByAuthorGet(db *gorm.DB, userId uint, authorId uint64, limit int, offset int) ([]dto.BookSearchGetBook, error) { func FetchBookSearchByAuthorGet(db *gorm.DB, userId uint, authorId uint64, limit int, offset int) ([]dto.BookItemGet, error) {
var books []dto.BookSearchGetBook var books []dto.BookItemGet
query := fetchBookSearchByAuthorQuery(db, userId, authorId) query := fetchBookSearchByAuthorQuery(db, userId, authorId)
query = query.Limit(limit) query = query.Limit(limit)
query = query.Offset(offset) query = query.Offset(offset)
@@ -141,8 +142,8 @@ func fetchBookSearchByAuthorQuery(db *gorm.DB, userId uint, authorId uint64) *go
return query.Where("authors.id = ?", authorId) return query.Where("authors.id = ?", authorId)
} }
func FetchBookSearchGet(db *gorm.DB, userId uint, searchterm string, limit int, offset int) ([]dto.BookSearchGetBook, error) { func FetchBookSearchGet(db *gorm.DB, userId uint, searchterm string, limit int, offset int) ([]dto.BookItemGet, error) {
var books []dto.BookSearchGetBook var books []dto.BookItemGet
query := fetchBookSearchQuery(db, userId, searchterm) query := fetchBookSearchQuery(db, userId, searchterm)
query = query.Limit(limit) query = query.Limit(limit)
query = query.Offset(offset) query = query.Offset(offset)
@@ -169,15 +170,20 @@ func fetchBookSearchQuery(db *gorm.DB, userId uint, searchterm string) *gorm.DB
return query return query
} }
// fetch all books even whithout user books
func fetchBookQueryBuilder(db *gorm.DB, userId uint) *gorm.DB { func fetchBookQueryBuilder(db *gorm.DB, userId uint) *gorm.DB {
query := db.Model(&model.Book{}) 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 = query.Select(selectBookItem())
query = joinAuthors(query) query = joinAuthors(query)
query = query.Joins("left join user_books on (user_books.book_id = books.id and user_books.user_id = ?)", userId) query = query.Joins("left join user_books on (user_books.book_id = books.id and user_books.user_id = ?)", userId)
query = joinStaticFiles(query) query = joinStaticFiles(query)
return query return query
} }
func selectBookItem() string {
return "books.id, books.title, authors.name as author, books.short_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()
}
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

@@ -0,0 +1,136 @@
package query
import (
"git.artlef.fr/bibliomane/internal/model"
"gorm.io/gorm"
)
type CollectionHeader struct {
Name string
UserID uint
}
// collection header without the books
func FetchCollectionHeader(db *gorm.DB, collectionId uint) (CollectionHeader, error) {
var collection CollectionHeader
query := db.Model(&model.Collection{})
query = query.Select("collections.name, collections.user_id")
query = query.Where("collections.id = ?", collectionId)
res := query.Find(&collection)
return collection, res.Error
}
func FetchCollectionHeaderFromItem(db *gorm.DB, itemId uint) (CollectionHeader, error) {
var collection CollectionHeader
query := db.Model(&model.Collection{})
query = query.Select("collections.name, collections.user_id")
query = query.Joins("left join collection_items on (collection_items.collection_id = collections.id)")
query = query.Where("collection_items.id = ?", itemId)
res := query.Find(&collection)
return collection, res.Error
}
type CollectionsQueryResult struct {
ID uint
UserID uint
Name string
BookId uint
BookTitle string
CoverPath string
}
type collectionId struct {
ID uint
}
func FetchAllCollections(db *gorm.DB, userId uint, limit int, offset int) ([]CollectionsQueryResult, error) {
var collections []CollectionsQueryResult
var collectionIds []collectionId
res := fetchCollections(db, userId).Limit(limit).Offset(offset).Order("collections.id DESC").Find(&collectionIds)
if res.Error != nil {
return collections, res.Error
}
for _, collectionId := range collectionIds {
//only takes first 8 books
queryResults, err := fetchCollectionItemBook(db, collectionId.ID, 8, 0)
if err != nil {
return collections, res.Error
}
collections = append(collections, queryResults...)
}
return collections, res.Error
}
func fetchCollectionItemBook(db *gorm.DB, collectionId uint, limit int, offset int) ([]CollectionsQueryResult, error) {
var collections []CollectionsQueryResult
query := fetchCollectionItemBooksQuery(db, collectionId)
query = query.Limit(limit)
query = query.Offset(offset)
res := query.Find(&collections)
return collections, res.Error
}
func fetchCollectionItemBooksQuery(db *gorm.DB, collectionId uint) *gorm.DB {
query := db.Model(&model.Collection{})
query = query.Select("collections.id, collections.user_id, collections.name, books.id as book_id, books.title as book_title, " + selectStaticFilesPath())
query = query.Joins("left join collection_items on (collection_items.collection_id = collections.id)")
query = query.Joins("left join books on (books.id = collection_items.book_id)")
query = joinStaticFiles(query)
query = query.Where("collections.id = ?", collectionId)
return query
}
func FetchAllCollectionsCount(db *gorm.DB, userId uint) (int64, error) {
var count int64
res := fetchCollections(db, userId).Count(&count)
return count, res.Error
}
func fetchCollections(db *gorm.DB, userId uint) *gorm.DB {
return db.Model(&model.Collection{}).Where("collections.user_id = ?", userId)
}
type CollectionItemQueryResult struct {
ItemID uint
Position uint
ID uint
Title string
Author string
Description string
InventaireID string
IsInventaireEdition bool
Rating int
Read bool
StartReadDate string
WantRead bool
CoverPath string
}
func FetchCollectionItems(db *gorm.DB, userId uint, collectionId uint, limit int, offset int) ([]CollectionItemQueryResult, error) {
var collectionitems []CollectionItemQueryResult
query := fetchCollectionBooksQuery(db, userId, collectionId)
query = query.Limit(limit)
query = query.Offset(offset)
query = query.Order("collection_items.position")
res := query.Find(&collectionitems)
return collectionitems, res.Error
}
func FetchCollectionBooksCount(db *gorm.DB, userId uint, collectionId uint) (int64, error) {
var count int64
res := fetchCollectionBooksQuery(db, userId, collectionId).Count(&count)
return count, res.Error
}
func fetchCollectionBooksQuery(db *gorm.DB, userId uint, collectionId uint) *gorm.DB {
query := db.Model(&model.CollectionItem{})
query = query.Select("collection_items.position, collection_items.ID as item_id, " + selectBookItem())
query = query.Joins("left join collections on (collection_items.collection_id = collections.id)")
query = query.Joins("left join books on (books.id = collection_items.book_id)")
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)
query = query.Order("collection_items.position")
query = query.Where("collections.id = ?", collectionId)
return query
}

View File

@@ -0,0 +1,33 @@
package query
import (
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/model"
"gorm.io/gorm"
)
func FetchAllUsers(db *gorm.DB, limit int, offset int) ([]dto.UserGet, error) {
var books []dto.UserGet
query := fetchAllUserQuery(db)
query = query.Limit(limit)
query = query.Offset(offset)
query = query.Order("users.id DESC")
res := query.Find(&books)
return books, res.Error
}
func FetchAllUsersCount(db *gorm.DB) (int64, error) {
var count int64
query := fetchAllUserQuery(db)
res := query.Count(&count)
return count, res.Error
}
func fetchAllUserQuery(db *gorm.DB) *gorm.DB {
query := db.Model(&model.User{})
query = query.Select("users.id, users.name, users.admin, count(distinct books.id) as added_books_count, count(distinct user_books.id) as user_books_count")
query = query.Joins("left join books on (books.added_by_id = users.id)")
query = query.Joins("left join user_books on user_books.user_id = users.id")
query = query.Group("users.id, users.name")
return query
}

View File

@@ -5,12 +5,24 @@ import (
"git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto" "git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/myvalidator"
) )
func GetAppInfo(ac appcontext.AppContext) { func GetAppInfo(ac appcontext.AppContext) {
admin := false
_, userIsInContext := ac.C.Get("user")
if userIsInContext {
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
admin = user.Admin
}
ac.C.JSON(http.StatusOK, dto.AppInfo{ ac.C.JSON(http.StatusOK, dto.AppInfo{
RegistrationDisabled: ac.Config.DisableRegistration, RegistrationDisabled: ac.Config.DisableRegistration,
DemoMode: ac.Config.DemoMode, DemoMode: ac.Config.DemoMode,
DemoUsername: ac.Config.DemoUsername, DemoUsername: ac.Config.DemoUsername,
Admin: admin,
}) })
} }

View File

@@ -50,5 +50,5 @@ func GetAuthorBooksHandler(ac appcontext.AppContext) {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
ac.C.JSON(http.StatusOK, dto.BookSearchGet{Books: books, Count: count}) ac.C.JSON(http.StatusOK, dto.BookItemsGet{Books: books, Count: count})
} }

View File

@@ -2,72 +2,63 @@ package routes
import ( import (
"errors" "errors"
"net/http"
"git.artlef.fr/bibliomane/internal/adapter"
"git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto" "git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model" "git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator" "git.artlef.fr/bibliomane/internal/myvalidator"
"gorm.io/gorm" "github.com/gin-gonic/gin"
) )
func PostBookHandler(ac appcontext.AppContext) { func PostBookHandler(ac appcontext.AppContext) {
var book dto.BookPostCreate var book dto.BookFields
err := ac.C.ShouldBindJSON(&book) err := ac.C.ShouldBindJSON(&book)
if err != nil { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
err = myvalidator.ValidateId(ac.Db, book.CoverID, &model.StaticFile{}) //when creating a book, title is required
if book.Title == nil {
err = myvalidator.HttpError{
StatusCode: http.StatusBadRequest,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "ValidationRequired")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
if book.CoverID != nil {
err = myvalidator.ValidateId(ac.Db, *book.CoverID, &model.StaticFile{})
if err != nil { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
}
user, fetchUserErr := ac.GetAuthenticatedUser() user, fetchUserErr := ac.GetAuthenticatedUser()
if fetchUserErr != nil { if fetchUserErr != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
err = saveBookToDb(ac, book, &user) id, err := saveBookToDb(ac, book, &user)
if err != nil { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
ac.C.String(200, "Success") ac.C.JSON(http.StatusOK, gin.H{"id": id})
} }
func saveBookToDb(ac appcontext.AppContext, b dto.BookPostCreate, user *model.User) error { func saveBookToDb(ac appcontext.AppContext, b dto.BookFields, user *model.User) (uint, error) {
author, err := fetchOrCreateAuthor(ac, b.Author)
if err != nil {
return err
}
book := model.Book{ book := model.Book{
Title: b.Title,
AuthorID: author.ID,
AddedBy: *user, AddedBy: *user,
} }
if b.CoverID > 0 { err := adapter.FillBookDbFromFields(ac, &b, &book)
book.CoverID = b.CoverID if err != nil {
return 0, err
} }
return ac.Db.Save(&book).Error
}
func fetchOrCreateAuthor(ac appcontext.AppContext, name string) (*model.Author, error) { err = ac.Db.Save(&book).Error
var author model.Author return book.ID, err
res := ac.Db.Where("name = ?", name).First(&author)
err := res.Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
author = model.Author{Name: name}
err = ac.Db.Save(&author).Error
if err != nil {
return &author, err
}
return &author, nil
} else {
return &author, err
}
} else {
return &author, nil
}
} }

View File

@@ -2,11 +2,9 @@ package routes
import ( import (
"errors" "errors"
"log"
"strings" "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"
@@ -45,7 +43,7 @@ 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) {
book := model.Book{ book := model.Book{
Title: inventaireEdition.Title, Title: inventaireEdition.Title,
SmallDescription: inventaireEdition.Description, ShortDescription: inventaireEdition.Description,
InventaireID: inventaireEdition.Id, InventaireID: inventaireEdition.Id,
AddedBy: *user, AddedBy: *user,
} }
@@ -66,25 +64,6 @@ func saveInventaireBookToDb(ac appcontext.AppContext, inventaireEdition inventai
book.Cover = cover book.Cover = cover
} }
if ac.Config.BookDescriptionFromBabelio {
isbn := findIsbn(&inventaireEdition)
if isbn != "" {
desc, err := babelio.GetDescriptionFromISBN(ac.Config.BabelioUrl, isbn)
if err != nil {
te, isTrError := errors.AsType[myvalidator.TranslatedError](err)
var errToPrint string
if isTrError {
errToPrint = te.ToTranslatedMessage(&ac)
} else {
errToPrint = err.Error()
}
log.Println(errToPrint)
} else {
book.Summary = desc
}
}
}
err := ac.Db.Save(&book).Error err := ac.Db.Save(&book).Error
return &book, err return &book, err
} }

View File

@@ -0,0 +1,43 @@
package routes
import (
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/adapter"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin"
)
func PutBookHandler(ac appcontext.AppContext) {
bookId64, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
bookId := uint(bookId64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
var book model.Book
err = ac.Db.First(&book, bookId).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var bookPut dto.BookFields
err = ac.C.ShouldBindJSON(&bookPut)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
err = adapter.FillBookDbFromFields(ac, &bookPut, &book)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.Db.Save(&book)
ac.C.String(http.StatusOK, "Success")
}

View File

@@ -37,8 +37,8 @@ func GetSearchBooksHandler(ac appcontext.AppContext) {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
var returnedBooks dto.BookSearchGet var returnedBooks dto.BookItemsGet
if !params.Inventaire { if params.Inventaire != dto.ForceInventaireSearch {
books, err := query.FetchBookSearchGet(ac.Db, user.ID, searchterm, limit, offset) books, err := query.FetchBookSearchGet(ac.Db, user.ID, searchterm, limit, offset)
if err != nil { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
@@ -49,9 +49,9 @@ func GetSearchBooksHandler(ac appcontext.AppContext) {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
returnedBooks = dto.BookSearchGet{Count: count, Inventaire: false, Books: books} returnedBooks = dto.BookItemsGet{Count: count, Inventaire: false, Books: books}
} }
if params.Inventaire || len(returnedBooks.Books) == 0 { if (params.Inventaire == dto.InventaireIfNothingFound && len(returnedBooks.Books) == 0) || (params.Inventaire == dto.ForceInventaireSearch) {
returnedBooksPtr, err := searchInInventaireAPI(ac.Config.InventaireUrl, searchterm, limit, offset, params) returnedBooksPtr, err := searchInInventaireAPI(ac.Config.InventaireUrl, searchterm, limit, offset, params)
if err != nil { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
@@ -62,7 +62,7 @@ func GetSearchBooksHandler(ac appcontext.AppContext) {
ac.C.JSON(http.StatusOK, returnedBooks) ac.C.JSON(http.StatusOK, returnedBooks)
} }
func searchInInventaireAPI(inventaireUrl string, searchterm string, limit int, offset int, params dto.BookSearchGetParam) (*dto.BookSearchGet, error) { func searchInInventaireAPI(inventaireUrl string, searchterm string, limit int, offset int, params dto.BookSearchGetParam) (*dto.BookItemsGet, error) {
isIsbn, err := regexp.Match(`\d{10,13}`, []byte(searchterm)) isIsbn, err := regexp.Match(`\d{10,13}`, []byte(searchterm))
if err != nil { if err != nil {
@@ -74,11 +74,11 @@ func searchInInventaireAPI(inventaireUrl string, searchterm string, limit int, o
if err != nil { if err != nil {
return nil, err return nil, err
} }
var bookSearchGet dto.BookSearchGet var bookSearchGet dto.BookItemsGet
if queryResult != nil { if queryResult != nil {
bookSearchGet = inventaireEditionToBookSearchGet(*queryResult) bookSearchGet = inventaireEditionToBookSearchGet(*queryResult)
} else { } else {
bookSearchGet = dto.BookSearchGet{Count: 0, Inventaire: true} bookSearchGet = dto.BookItemsGet{Count: 0, Inventaire: true}
} }
return &bookSearchGet, err return &bookSearchGet, err
} else { } else {
@@ -91,9 +91,9 @@ func searchInInventaireAPI(inventaireUrl string, searchterm string, limit int, o
} }
} }
func inventaireEditionToBookSearchGet(result inventaire.InventaireEditionDetailedSingleResult) dto.BookSearchGet { func inventaireEditionToBookSearchGet(result inventaire.InventaireEditionDetailedSingleResult) dto.BookItemsGet {
var books []dto.BookSearchGetBook var books []dto.BookItemGet
bookSearchGetBook := dto.BookSearchGetBook{ bookSearchGetBook := dto.BookItemGet{
ID: 0, ID: 0,
Title: result.Title, Title: result.Title,
Author: result.Author.Name, Author: result.Author.Name,
@@ -106,17 +106,17 @@ func inventaireEditionToBookSearchGet(result inventaire.InventaireEditionDetaile
CoverPath: result.Image, CoverPath: result.Image,
} }
books = append(books, bookSearchGetBook) books = append(books, bookSearchGetBook)
return dto.BookSearchGet{Count: 1, Inventaire: true, Books: books} return dto.BookItemsGet{Count: 1, Inventaire: true, Books: books}
} }
func inventaireBooksToBookSearchGet(inventaireUrl string, results inventaire.InventaireSearchResult) dto.BookSearchGet { func inventaireBooksToBookSearchGet(inventaireUrl string, results inventaire.InventaireSearchResult) dto.BookItemsGet {
var books []dto.BookSearchGetBook var books []dto.BookItemGet
for _, b := range results.Results { for _, b := range results.Results {
coverPath := "" coverPath := ""
if b.Image != "" && strings.HasPrefix(b.Image, "/") { if b.Image != "" && strings.HasPrefix(b.Image, "/") {
coverPath = inventaireUrl + b.Image coverPath = inventaireUrl + b.Image
} }
bookSearchGetBook := dto.BookSearchGetBook{ bookSearchGetBook := dto.BookItemGet{
ID: 0, ID: 0,
Title: b.Label, Title: b.Label,
Author: "", Author: "",
@@ -129,5 +129,5 @@ func inventaireBooksToBookSearchGet(inventaireUrl string, results inventaire.Inv
} }
books = append(books, bookSearchGetBook) books = append(books, bookSearchGetBook)
} }
return dto.BookSearchGet{Count: results.Total, Inventaire: true, Books: books} return dto.BookItemsGet{Count: results.Total, Inventaire: true, Books: books}
} }

View File

@@ -36,5 +36,5 @@ func GetBooksHandler(ac appcontext.AppContext) {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
ac.C.JSON(http.StatusOK, dto.BookSearchGet{Count: count, Inventaire: false, Books: books}) ac.C.JSON(http.StatusOK, dto.BookItemsGet{Count: count, Inventaire: false, Books: books})
} }

View File

@@ -0,0 +1,72 @@
package routes
import (
"errors"
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func PostCollectionBookHandler(ac appcontext.AppContext) {
collectionId, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var collection model.Collection
err = ac.Db.First(&collection, collectionId).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
if collection.UserID != user.ID {
err := myvalidator.HttpError{
StatusCode: http.StatusUnauthorized,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "Unauthorized")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var collectionBook dto.CollectionBook
err = ac.C.ShouldBindJSON(&collectionBook)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var book model.Book
err = ac.Db.First(&book, collectionBook.BookID).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
//reorder other items
q := ac.Db.Model(&model.CollectionItem{})
q = q.Where("collection_id = ?", collection.ID)
err = q.UpdateColumn("position", gorm.Expr("position + 1")).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
item := model.CollectionItem{Position: 1, BookID: book.ID, CollectionID: collection.ID}
ac.Db.Save(&item)
ac.C.String(http.StatusOK, "Success")
}

View File

@@ -0,0 +1,106 @@
package routes
import (
"errors"
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"git.artlef.fr/bibliomane/internal/query"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func PostCollectionChangePositionHandler(ac appcontext.AppContext) {
collectionId, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var collection model.Collection
err = ac.Db.First(&collection, collectionId).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
if collection.UserID != user.ID {
err := myvalidator.HttpError{
StatusCode: http.StatusUnauthorized,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "Unauthorized")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var collectionBookPosition dto.CollectionItemPosition
err = ac.C.ShouldBindJSON(&collectionBookPosition)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var item model.CollectionItem
err = ac.Db.First(&item, collectionBookPosition.ItemID).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
if collection.ID != item.CollectionID {
err := myvalidator.HttpError{
StatusCode: http.StatusInternalServerError,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "ItemDoesNotBelongToCollection")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
count, err := query.FetchCollectionBooksCount(ac.Db, user.ID, item.CollectionID)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
newPosition := collectionBookPosition.Position
if int64(newPosition) > count {
newPosition = uint(count)
}
if item.Position == newPosition {
//nothing to do
ac.C.String(http.StatusOK, "Success")
return
}
lowerPosition := item.Position + 1
higherPosition := item.Position - 1
operationToDo := ""
if item.Position < newPosition {
higherPosition = newPosition
operationToDo = "position - 1"
} else {
lowerPosition = newPosition
operationToDo = "position + 1"
}
q := ac.Db.Model(&model.CollectionItem{})
q = q.Where("collection_id = ? AND position BETWEEN ? AND ?", collection.ID, lowerPosition, higherPosition)
err = q.UpdateColumn("position", gorm.Expr(operationToDo)).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
item.Position = newPosition
ac.Db.Save(&item)
ac.C.String(http.StatusOK, "Success")
}

View File

@@ -0,0 +1,66 @@
package routes
import (
"errors"
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"git.artlef.fr/bibliomane/internal/query"
"github.com/gin-gonic/gin"
)
func DeleteCollectionHandler(ac appcontext.AppContext) {
collectionId, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
err = myvalidator.ValidateId(ac.Db, uint(collectionId), &model.Collection{})
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
collection, err := query.FetchCollectionHeader(ac.Db, uint(collectionId))
if collection.UserID != user.ID {
err := myvalidator.HttpError{
StatusCode: http.StatusUnauthorized,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "Unauthorized")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
collectionDb := model.Collection{}
collectionDb.ID = uint(collectionId)
err = ac.Db.Where("collection_id = ?", collectionId).Delete(&model.CollectionItem{}).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
err = ac.Db.Delete(&collectionDb).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
err = ac.Db.Delete(&collectionDb).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.C.JSON(http.StatusOK, "Success")
}

View File

@@ -0,0 +1,75 @@
package routes
import (
"errors"
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/adapter"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"git.artlef.fr/bibliomane/internal/query"
"github.com/gin-gonic/gin"
)
func GetCollectionHandler(ac appcontext.AppContext) {
collectionId, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
err = myvalidator.ValidateId(ac.Db, uint(collectionId), &model.Collection{})
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
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
}
collectionHeader, err := query.FetchCollectionHeader(ac.Db, uint(collectionId))
if collectionHeader.UserID != user.ID {
err := myvalidator.HttpError{
StatusCode: http.StatusUnauthorized,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "Unauthorized")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
collection := dto.CollectionGet{Name: collectionHeader.Name}
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
itemsQueryResult, err := query.FetchCollectionItems(ac.Db, user.ID, uint(collectionId), limit, offset)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
items := adapter.CollectionItemsQueryToDto(itemsQueryResult)
collection.Items = items
count, err := query.FetchCollectionBooksCount(ac.Db, user.ID, uint(collectionId))
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
collection.Count = count
ac.C.JSON(http.StatusOK, collection)
}

View File

@@ -0,0 +1,62 @@
package routes
import (
"errors"
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"git.artlef.fr/bibliomane/internal/query"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func DeleteCollectionBookHandler(ac appcontext.AppContext) {
collectionItemId, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var collectionItem model.CollectionItem
err = ac.Db.First(&collectionItem, collectionItemId).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
collection, err := query.FetchCollectionHeaderFromItem(ac.Db, collectionItem.ID)
if collection.UserID != user.ID {
err := myvalidator.HttpError{
StatusCode: http.StatusUnauthorized,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "Unauthorized")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
err = ac.Db.Delete(&collectionItem).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
//update position on remaining items
q := ac.Db.Model(&model.CollectionItem{})
q = q.Where("collection_id = ? AND position > ?", collectionItem.CollectionID, collectionItem.Position)
err = q.UpdateColumn("position", gorm.Expr("position - 1")).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.C.JSON(http.StatusOK, "Success")
}

View File

@@ -0,0 +1,41 @@
package routes
import (
"net/http"
"git.artlef.fr/bibliomane/internal/adapter"
"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 GetCollectionsHandler(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
}
collectionsDb, err := query.FetchAllCollections(ac.Db, user.ID, limit, offset)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
collections := adapter.CollectionQueryToCollectionItemDto(collectionsDb)
count, err := query.FetchAllCollectionsCount(ac.Db, user.ID)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.C.JSON(http.StatusOK, dto.CollectionItemsGet{Count: count, Collections: collections})
}

View File

@@ -0,0 +1,41 @@
package routes
import (
"net/http"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin"
)
func PostCollectionHandler(ac appcontext.AppContext) {
var collection dto.CollectionFields
err := ac.C.ShouldBindJSON(&collection)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
user, fetchUserErr := ac.GetAuthenticatedUser()
if fetchUserErr != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
id, err := saveCollectionToDb(ac, &collection, &user)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.C.JSON(http.StatusOK, gin.H{"id": id})
}
func saveCollectionToDb(ac appcontext.AppContext, c *dto.CollectionFields, user *model.User) (uint, error) {
collection := model.Collection{
Name: c.Name,
User: *user,
}
err := ac.Db.Save(&collection).Error
return collection.ID, err
}

View File

@@ -35,5 +35,5 @@ func GetMyBooksReadHandler(ac appcontext.AppContext) {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
ac.C.JSON(http.StatusOK, dto.BookUserGet{Count: count, Books: userbooks}) ac.C.JSON(http.StatusOK, dto.BookItemsGet{Count: count, Books: userbooks})
} }

View File

@@ -35,5 +35,5 @@ func GetMyBooksReadingHandler(ac appcontext.AppContext) {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
ac.C.JSON(http.StatusOK, dto.BookUserGet{Count: count, Books: userbooks}) ac.C.JSON(http.StatusOK, dto.BookItemsGet{Count: count, Books: userbooks})
} }

View File

@@ -35,5 +35,5 @@ func GetMyBooksWantReadHandler(ac appcontext.AppContext) {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
ac.C.JSON(http.StatusOK, dto.BookUserGet{Count: count, Books: userbooks}) ac.C.JSON(http.StatusOK, dto.BookItemsGet{Count: count, Books: userbooks})
} }

View File

@@ -12,12 +12,12 @@ import (
"git.artlef.fr/bibliomane/internal/myvalidator" "git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
) )
func PostLoginHandler(ac appcontext.AppContext) { func PostLoginHandler(ac appcontext.AppContext) {
var username string var username string
admin := false
if !ac.Config.DemoMode { if !ac.Config.DemoMode {
var user dto.UserLogin var user dto.UserLogin
@@ -26,30 +26,27 @@ func PostLoginHandler(ac appcontext.AppContext) {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
var userDb model.User
ac.Db.Where("name = ?", user.Username).First(&userDb)
if !ac.Config.DemoMode && !isUserAndPasswordOk(ac.Db, user.Username, user.Password) { if !ac.Config.DemoMode &&
bcrypt.CompareHashAndPassword([]byte(userDb.Password), []byte(user.Password)) != nil {
ac.C.JSON(http.StatusUnauthorized, ac.C.JSON(http.StatusUnauthorized,
gin.H{"error": i18nresource.GetTranslatedMessage(&ac, "InvalidCredentials")}) gin.H{"error": i18nresource.GetTranslatedMessage(&ac, "InvalidCredentials")})
return return
} }
username = user.Username username = user.Username
admin = userDb.Admin
} else { } else {
username = ac.Config.DemoUsername username = ac.Config.DemoUsername
} }
var jwtToken string var jwtToken string
jwtToken, err := jwtauth.GenerateJwtToken(username) jwtToken, err := jwtauth.GenerateJwtToken(username, admin)
if err != nil { if err != nil {
ac.C.JSON(http.StatusUnauthorized, ac.C.JSON(http.StatusUnauthorized,
gin.H{"error": fmt.Errorf("Error when generating JWT token: %w", err)}) gin.H{"error": fmt.Errorf("Error when generating JWT token: %w", err)})
return return
} }
ac.C.JSON(http.StatusOK, gin.H{"message": i18nresource.GetTranslatedMessage(&ac, "AuthenticationSuccess"), "token": jwtToken}) ac.C.JSON(http.StatusOK, gin.H{"message": i18nresource.GetTranslatedMessage(&ac, "AuthenticationSuccess"), "admin": admin, "token": jwtToken})
}
func isUserAndPasswordOk(db *gorm.DB, username string, password string) bool {
var user model.User
db.Where("name = ?", username).First(&user)
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
return err == nil
} }

View File

@@ -0,0 +1,34 @@
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 GetUsersHandler(ac appcontext.AppContext) {
limit, err := ac.GetQueryLimit()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
offset, err := ac.GetQueryOffset()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
users, err := query.FetchAllUsers(ac.Db, limit, offset)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
count, err := query.FetchAllUsersCount(ac.Db)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.C.JSON(http.StatusOK, dto.UsersGet{Count: count, Users: users})
}

View File

@@ -63,6 +63,9 @@ func Setup(config *config.Config) *gin.Engine {
ws.PUT("/book/:id", func(c *gin.Context) { ws.PUT("/book/:id", func(c *gin.Context) {
routes.PutUserBookHandler(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/edit/:id", func(c *gin.Context) {
routes.PutBookHandler(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})
}) })
@@ -75,6 +78,21 @@ func Setup(config *config.Config) *gin.Engine {
ws.GET("/author/:id/books", func(c *gin.Context) { ws.GET("/author/:id/books", func(c *gin.Context) {
routes.GetAuthorBooksHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) routes.GetAuthorBooksHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
}) })
ws.GET("/collections", func(c *gin.Context) {
routes.GetCollectionsHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.GET("/collection/:id", func(c *gin.Context) {
routes.GetCollectionHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.POST("/collection/:id/addbook", func(c *gin.Context) {
routes.PostCollectionBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.POST("/collection/:id/changeposition", func(c *gin.Context) {
routes.PostCollectionChangePositionHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.POST("/collection", func(c *gin.Context) {
routes.PostCollectionHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.POST("/auth/signup", func(c *gin.Context) { ws.POST("/auth/signup", func(c *gin.Context) {
routes.PostSignupHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) routes.PostSignupHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
}) })
@@ -84,6 +102,15 @@ func Setup(config *config.Config) *gin.Engine {
ws.POST("/upload/cover", func(c *gin.Context) { ws.POST("/upload/cover", func(c *gin.Context) {
routes.PostUploadBookCoverHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) routes.PostUploadBookCoverHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
}) })
ws.DELETE("/collection/:id", func(c *gin.Context) {
routes.DeleteCollectionHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.DELETE("/collection/item/:id", func(c *gin.Context) {
routes.DeleteCollectionBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.GET("/admin/users", func(c *gin.Context) {
routes.GetUsersHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
r.Static("/static/bookcover", config.ImageFolderPath) r.Static("/static/bookcover", config.ImageFolderPath)

View File

@@ -6,6 +6,8 @@ import (
"log" "log"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
@@ -42,6 +44,15 @@ func ConnectDemo2User(router *gin.Engine) string {
return connectUser(router, loginJson) return connectUser(router, loginJson)
} }
func ConnectAdminUser(router *gin.Engine) string {
loginJson :=
`{
"username": "admin",
"password":"demopw"
}`
return connectUser(router, loginJson)
}
func connectUser(router *gin.Engine, loginJson string) string { func connectUser(router *gin.Engine, loginJson string) string {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/ws/auth/login", strings.NewReader(loginJson)) req, _ := http.NewRequest("POST", "/ws/auth/login", strings.NewReader(loginJson))
@@ -65,3 +76,92 @@ func TestBookPutCallWithDemoPayload(t *testing.T, payload string, bookId string,
t.Errorf("%s", w.Body.String()) t.Errorf("%s", w.Body.String())
} }
} }
func TestFetchOneModel[T any](t *testing.T, urlpath string, id string) (int, T) {
router := TestSetup()
token := ConnectDemoUser(router)
req, _ := http.NewRequest("GET", urlpath+"/"+id, nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
var result T
err := json.Unmarshal(w.Body.Bytes(), &result)
if err != nil {
t.Error(err)
}
return w.Code, result
}
func TestFetchModel[T any](t *testing.T, urlpath string, limit string, offset string) (int, T) {
router := TestSetup()
token := ConnectDemoUser(router)
return testFetchModelWithUser[T](t, urlpath, limit, offset, token)
}
func TestFetchAdminModel[T any](t *testing.T, urlpath string, limit string, offset string) (int, T) {
router := TestSetup()
token := ConnectAdminUser(router)
return testFetchModelWithUser[T](t, urlpath, limit, offset, token)
}
func testFetchModelWithUser[T any](t *testing.T, urlpath string, limit string, offset string, token string) (int, T) {
router := TestSetup()
u, err := url.Parse(urlpath)
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()
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 T
s := w.Body.String()
err = json.Unmarshal([]byte(s), &result)
if err != nil {
t.Error(err)
}
return w.Code, result
}
func TestPostCall(t *testing.T, urlpath string, payload string) (int, uint) {
router := TestSetup()
w := httptest.NewRecorder()
token := ConnectDemoUser(router)
req, _ := http.NewRequest("POST", urlpath,
strings.NewReader(payload))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
return w.Code, 0
}
var parsed struct {
ID uint
}
err := json.Unmarshal(w.Body.Bytes(), &parsed)
if err != nil {
t.Error(err)
}
return w.Code, parsed.ID
}

View File

@@ -6,7 +6,7 @@ import (
) )
func main() { func main() {
applicationVersion := "0.5.0" applicationVersion := "0.8.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)