Compare commits

...

22 Commits

Author SHA1 Message Date
Lucas Verney 73ae6d89f8 Update home screenshot 2018-03-13 19:10:51 +01:00
Lucas Verney 171bf33fff Add screenshots 2018-03-13 19:09:16 +01:00
Lucas Verney 680f00c188 Factor API calls 2018-03-13 19:02:10 +01:00
Lucas Verney 9bbac3b99e Handle HTTP auth for API calls 2018-03-12 19:49:53 +01:00
Lucas Verney 9813796a5d Fix issue #3 2018-03-11 23:46:28 +01:00
Lucas Verney 8a5450c40c Add support for journaldesfemmes Weboob module 2018-03-11 22:02:47 +01:00
Lucas Verney 6fad7bec13 Fix a bug when a recipe does not have any instructions 2018-03-11 21:57:13 +01:00
Lucas Verney 40fbc5b662 Fix a typo in README 2018-03-11 21:43:36 +01:00
Lucas Verney f529619c99 Add some db migrations 2018-03-11 17:36:49 +01:00
Lucas Verney d30e5f9053 Allow to edit a recipe 2018-03-11 16:49:05 +01:00
Lucas Verney da844e0a08 Cleaner separation in components 2018-03-11 12:45:52 +01:00
Lucas Verney 55751467f8 Fix a missing this in New component 2018-03-10 11:44:08 +01:00
Lucas Verney f60b59d789 Add a form to manually add a recipe 2018-03-10 11:43:14 +01:00
Lucas Verney e128171ddc Fix lint 2018-03-06 18:31:37 +01:00
Lucas Verney 09e0c4f883 Mention that compatibility is only assured with Python 3 2018-03-06 18:29:50 +01:00
Lucas Verney 1c4053de8e Add i18n, fix #1 2018-03-06 18:29:37 +01:00
Lucas Verney f042214c92 Better feedbacks in UI 2018-03-04 23:37:10 +01:00
Lucas Verney b5946ccc84 Few UI improvements + add refetch ability
* Order by decreasing ids on the home page.
* Confirmation modals when deleting / refetching items.
* Spinners when loading.
* Getting started message when there is no recipe.
* Add the ability to refetch a recipe from the website (when a Weboob
module has been improved for instance).
2018-03-03 15:56:18 +01:00
Lucas Verney c873021500 Better display of recipe instructions 2018-03-03 12:02:47 +01:00
Lucas Verney a1bf22c31e No more external calls to google CDN 2018-03-03 11:54:22 +01:00
Lucas Verney 3ace510aac UI improvements 2018-03-03 11:41:23 +01:00
Lucas Verney 9aa4756887 Fix incorrect parsing of JSON request 2018-03-02 20:45:37 +01:00
33 changed files with 1256 additions and 380 deletions

View File

@ -24,6 +24,11 @@ module.exports = {
},
// add your custom rules here
rules: {
// Use 4 spaces indent
'indent': ['error', 4],
'import/prefer-default-export': 'off',
'no-console': 'off',
'no-underscore-dangle': 'off',
// don't require .vue extension when importing
'import/extensions': ['error', 'always', {
js: 'never',

View File

@ -5,12 +5,18 @@ Cuizin is a tool wrapping around [Web Outside Of Browsers](http://weboob.org/)
to help you sort and organize recipes you find online. You can also manually
add new recipes.
![Homepage](screenshots/home.png)
More screenshots are available in the [`screenshots` folder](screenshots/).
## Installation
Cuizin requires Python 3 and may not be compatible with Python 2.
```
$ git clone … # Clone this repository
$ pip install -r requirements.txt # Install Python dependencies
$ pip3 install -r requirements.txt # Install Python dependencies
$ npm install # Install JS dependencies for the frontend
$ npm run build # Build the JS dependencies
```
@ -31,7 +37,7 @@ WSGI to serve the application directly (`application` variable is exported in
You can customize the behavior of the app by passing environment variables:
* `CUIZIN_HOST` to set the host on which the webserver should listen to
(defaults to `localhost` only). Use `HOST=0.0.0.0` to make it
(defaults to `localhost` only). Use `CUIZIN_HOST=0.0.0.0` to make it
world-accessible.
* `CUIZIN_PORT` to set the port on which the webserver should listen. Defaults
to `8080`.
@ -47,6 +53,14 @@ If you serve the app with a reverse proxy, you should serve the content of
Bottle webserver.
## Updating
If you are updating the app, the database schema may have changed. There is a
set of migrations under the `migrations` folder which is a set of standalone
scripts to run to update your database schema. Each script is named after the
commit it is handling.
## Contributing
All contributions are welcome, feel free to open a MR. Just in case, if you

View File

@ -40,7 +40,7 @@ class Recipe(Model):
# Author
author = CharField(null=True)
# Picture as a binary blob
picture = BlobField(null=True)
picture = TextField(null=True)
# Short description
short_description = TextField(null=True)
# Number of persons as text, as it can be either "N persons" or "N parts"
@ -56,36 +56,45 @@ class Recipe(Model):
class Meta:
database = database
@staticmethod
def from_weboob(obj):
recipe = Recipe()
def update_from_dict(self, d):
"""
Update field taking values from a dict of values.
"""
# Set fields
for field in ['title', 'url', 'author', 'picture_url',
'short_description', 'preparation_time', 'cooking_time',
'ingredients', 'instructions']:
value = getattr(obj, field)
'ingredients', 'instructions', 'nb_person']:
value = d.get(field, None)
if value:
setattr(recipe, field, value)
# Serialize number of person
recipe.nb_person = '-'.join(str(num) for num in obj.nb_person)
setattr(self, field, value)
# Download picture and save it as a blob
recipe.picture = requests.get(obj.picture_url).content
return recipe
if d.get('picture_url', None):
try:
picture = requests.get(d['picture_url']).content
picture_mime = (
'data:%s;base64' % magic.from_buffer(picture,
mime=True)
)
self.picture = '%s,%s' % (
picture_mime,
base64.b64encode(picture).decode('utf-8')
)
except requests.exceptions.InvalidSchema:
self.picture = d['picture_url']
def update_from_weboob(self, weboob_obj):
"""
Update fields taking values from the Weboob object.
"""
weboob_dict = dict(weboob_obj.iter_fields())
if weboob_dict.get('nb_person', None):
weboob_dict['nb_person'] = '-'.join(
str(num) for num in weboob_dict['nb_person']
)
self.update_from_dict(weboob_dict)
def to_dict(self):
"""
Dict conversion function, for serialization in the API.
"""
serialized = model_to_dict(self)
# Dump picture as a base64 string, compatible with HTML `src` attribute
# for images.
if serialized['picture']:
picture_mime = (
'data:%s;base64' % magic.from_buffer(serialized['picture'],
mime=True)
)
serialized['picture'] = '%s,%s' % (
picture_mime,
base64.b64encode(serialized['picture']).decode('utf-8')
)
return serialized
return model_to_dict(self)

View File

@ -1,31 +1,36 @@
<template>
<v-app>
<v-toolbar app>
<v-toolbar-title class="toolbar-title">
<router-link to="/">Cuizin</router-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<router-link to="/new">
<v-btn icon>
<v-icon>add</v-icon>
</v-btn>
</router-link>
</v-toolbar>
<v-content>
<router-view/>
</v-content>
</v-app>
<v-app>
<v-toolbar app>
<v-toolbar-title class="toolbar-title">
<router-link to="/">Cuizin</router-link>
</v-toolbar-title>
<v-spacer></v-spacer>
<router-link to="/new">
<v-btn icon>
<v-icon>add</v-icon>
</v-btn>
</router-link>
</v-toolbar>
<v-content>
<router-view></router-view>
</v-content>
</v-app>
</template>
<script>
export default {
name: 'App',
name: 'App',
};
</script>
<style>
.toolbar-title a {
color: rgba(0,0,0,.87);
text-decoration: none;
color: rgba(0,0,0,.87);
text-decoration: none;
}
.panel {
max-width: 600px;
}
</style>

88
cuizin/js_src/api.js Normal file
View File

@ -0,0 +1,88 @@
import * as constants from '@/constants';
function fetchAPI(endpoint, params = {}) {
return fetch(
`${constants.API_URL}${endpoint}`,
Object.assign({}, { credentials: 'same-origin' }, params),
);
}
function loadJSON(response) {
return response.json();
}
function _postProcessRecipes(response) {
return loadJSON(response)
.then((jsonResponse) => {
const parsed = jsonResponse;
if (parsed.recipes) {
// Customize instructions
parsed.recipes = parsed.recipes.map(item => Object.assign(
item,
{
instructions: item.instructions ? item.instructions.split(/[\r\n]\n/).map(
line => line.trim(),
) : [],
},
));
}
return parsed;
});
}
export function loadRecipes() {
return fetchAPI('api/v1/recipes')
.then(_postProcessRecipes);
}
export function loadRecipe(id) {
return fetchAPI(`api/v1/recipe/${id}`)
.then(_postProcessRecipes);
}
export function refetchRecipe(id) {
return fetchAPI(`api/v1/recipe/${id}/refetch`)
.then(_postProcessRecipes);
}
export function postRecipeByUrl(recipe) {
return fetchAPI('api/v1/recipes/by_url', {
method: 'POST',
body: JSON.stringify(recipe),
})
.then(_postProcessRecipes);
}
export function postRecipeManually(recipe) {
return fetchAPI('api/v1/recipes/manually', {
method: 'POST',
body: JSON.stringify(recipe),
})
.then(_postProcessRecipes);
}
export function editRecipe(id, recipe) {
return fetchAPI(`api/v1/recipe/${id}`, {
method: 'POST',
body: JSON.stringify(recipe),
})
.then(_postProcessRecipes);
}
export function deleteRecipe(id) {
return fetchAPI(`api/v1/recipe/${id}`, {
method: 'DELETE',
});
}

View File

@ -0,0 +1,224 @@
<template>
<v-flex xs12>
<ErrorDialog v-model="error" :description="$t('error.title')" />
<h2 v-if="recipe">{{ $t('new.edit_recipe') }}</h2>
<h2 v-else>{{ $t('new.add_manually') }}</h2>
<v-form v-model="isValidForm">
<v-text-field
:label="$t('new.title')"
v-model="title"
required
:rules="[v => !!v || $t('new.title_is_required')]"
></v-text-field>
<v-text-field
:label="$t('new.picture_url')"
v-model="picture_url"
:rules="urlRules"
></v-text-field>
<p>
<img :src="picture_url" />
</p>
<v-text-field
:label="$t('new.short_description')"
v-model="short_description"
textarea
></v-text-field>
<v-layout row wrap>
<v-flex xs12 md5>
<v-text-field
:label="$t('new.preparation_time')"
v-model="preparation_time"
type="number"
:suffix="$t('new.mins')"
></v-text-field>
</v-flex>
<v-flex xs12 md5 offset-md2>
<v-text-field
:label="$t('new.cooking_time')"
v-model="cooking_time"
type="number"
:suffix="$t('new.mins')"
></v-text-field>
</v-flex>
</v-layout>
<v-text-field
:label="$t('new.nb_persons')"
v-model="nb_person"
></v-text-field>
<v-layout row>
<v-flex xs12 class="text-xs-left">
<h3>{{ $t('new.ingredients') }}</h3>
<v-list v-if="ingredients.length" class="transparent">
<v-list-tile v-for="ingredient in ingredients" :key="ingredient">
<v-list-tile-action>
<v-btn flat icon color="red" v-on:click="() => removeIngredient(ingredient)">
<v-icon>delete</v-icon>
</v-btn>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title>{{ ingredient }}</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</v-list>
<p class="ml-5 my-3" v-else>{{ $t('new.none') }}</p>
<v-text-field
:label="$t('new.add_ingredient')"
v-model="new_ingredient"
@keyup.enter.native="addIngredient"
></v-text-field>
</v-flex>
</v-layout>
<v-text-field
:label="$t('new.instructions')"
v-model="instructions"
:rules="[v => !!v || $t('new.instructions_are_required')]"
textarea
required
></v-text-field>
<v-btn
@click="submitEdit"
:disabled="!isValidForm || isImporting"
v-if="recipe"
>
{{ $t('new.edit') }}
</v-btn>
<v-btn
@click="submitAdd"
:disabled="!isValidForm || isImporting"
v-else
>
{{ $t('new.add') }}
</v-btn>
</v-form>
</v-flex>
</template>
<script>
import * as api from '@/api';
import * as rules from '@/rules';
import ErrorDialog from '@/components/ErrorDialog';
export default {
components: {
ErrorDialog,
},
props: {
recipe: {
default: null,
type: Object,
},
value: Boolean,
},
computed: {
isImporting: {
get() {
return this.value;
},
set(val) {
this.$emit('input', val);
},
},
},
data() {
let defaultPreparationTime = null;
if (this.recipe &&
this.recipe.preparation_time !== null && this.recipe.preparation_time !== undefined
) {
defaultPreparationTime = this.recipe.preparation_time;
}
let defaultCookingTime = null;
if (this.recipe &&
this.recipe.cooking_time !== null && this.recipe.cooking_time !== undefined
) {
defaultCookingTime = this.recipe.cooking_time;
}
return {
error: null,
url: null,
isValidForm: false,
title: (this.recipe && this.recipe.title) || null,
picture_url: (this.recipe && this.recipe.picture) || null,
short_description: (this.recipe && this.recipe.short_description) || null,
nb_person: (this.recipe && this.recipe.nb_person) || null,
preparation_time: defaultPreparationTime,
cooking_time: defaultCookingTime,
new_ingredient: null,
ingredients: (this.recipe && this.recipe.ingredients) || [],
instructions: (this.recipe && this.recipe.instructions.join('\n\n').replace(/\n{2,}/, '\n\n')) || null,
urlRules: rules.url,
};
},
methods: {
addIngredient() {
this.ingredients.push(this.new_ingredient);
this.new_ingredient = null;
},
removeIngredient(ingredient) {
const index = this.ingredients.indexOf(ingredient);
if (index !== -1) {
this.ingredients.splice(index, 1);
}
},
submitAdd() {
this.isImporting = true;
api.postRecipeManually({
title: this.title,
picture_url: this.picture_url,
short_description: this.short_description,
preparation_time: this.preparation_time,
cooking_time: this.cooking_time,
nb_person: this.nb_person,
ingredients: this.ingredients,
instructions: this.instructions,
})
.then(response => this.$router.push({
name: 'Recipe',
params: {
recipeId: response.recipes[0].id,
},
}))
.catch((error) => {
this.isImporting = false;
this.error = error;
});
},
submitEdit() {
this.isImporting = true;
api.editRecipe(this.recipe.id, {
title: this.title,
picture_url: this.picture_url,
short_description: this.short_description,
preparation_time: this.preparation_time,
cooking_time: this.cooking_time,
nb_person: this.nb_person,
ingredients: this.ingredients,
instructions: this.instructions,
})
.then(response => this.$router.push({
name: 'Recipe',
params: {
recipeId: response.recipes[0].id,
},
}))
.catch((error) => {
this.isImporting = false;
this.error = error;
});
},
},
};
</script>
<style scoped>
.transparent {
background: transparent;
}
img {
max-height: 150px;
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<v-dialog v-if="error" v-model="error" max-width="500px">
<v-card>
<v-card-title class="headline">{{ $t('error.title') }}</v-card-title>
<v-card-text>
{{ description }} {{ value.message }}.
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="secondary" flat @click.stop="error=null">{{ $t('misc.cancel') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
props: {
description: String,
value: Error,
},
computed: {
error: {
get() {
return this.value;
},
set(val) {
this.$emit('input', val);
},
},
},
};
</script>

View File

@ -1,61 +0,0 @@
<template>
<v-container fluid grid-list-md>
<v-layout row wrap>
<v-flex
v-for="recipe in recipes"
:key="recipe.title"
xs3
>
<v-card :to="{name: 'Recipe', params: { recipeId: recipe.id }}">
<v-card-media :src="recipe.picture" height="200px"></v-card-media>
<v-card-title primary-title>
<div>
<h3 class="headline mb-0">{{ recipe.title }}</h3>
</div>
</v-card-title>
<p>{{ recipe.short_description }}</p>
<v-layout row text-xs-center>
<v-flex xs6>
<p><v-icon>timelapse</v-icon> {{ recipe.preparation_time }} mins</p>
</v-flex>
<v-flex xs6>
<p><v-icon>whatshot</v-icon> {{ recipe.cooking_time }} mins</p>
</v-flex>
</v-layout>
</v-card>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import * as constants from '@/constants';
export default {
data() {
return {
isLoading: false,
recipes: [],
};
},
created() {
this.fetchRecipes();
},
watch: {
// call again the method if the route changes
$route: 'fetchRecipes',
},
methods: {
fetchRecipes() {
this.isLoading = true;
fetch(`${constants.API_URL}api/v1/recipes`)
.then(response => response.json())
.then((response) => {
this.recipes = response.recipes;
this.isLoading = false;
});
},
},
};
</script>

View File

@ -0,0 +1,75 @@
<template>
<v-flex xs12>
<ErrorDialog v-model="error" :description="$t('error.unable_import_recipe')" />
<h2>{{ $t('new.import_from_url') }}</h2>
<v-form v-model="isValidImport">
<v-text-field
label="URL"
v-model="url"
required
:rules="requiredUrlRules"
></v-text-field>
<v-btn
@click="submitImport"
:disabled="!isValidImport || isImporting"
>
{{ $t('new.import') }}
</v-btn>
</v-form>
</v-flex>
</template>
<script>
import * as api from '@/api';
import * as rules from '@/rules';
import ErrorDialog from '@/components/ErrorDialog';
export default {
props: {
value: Boolean,
},
computed: {
isImporting: {
get() {
return this.value;
},
set(val) {
this.$emit('input', val);
},
},
},
components: {
ErrorDialog,
},
data() {
return {
error: null,
url: null,
isValidImport: false,
requiredUrlRules: Array.concat(
[],
[v => !!v || this.$t('new.url_is_required')],
rules.url,
),
};
},
methods: {
submitImport() {
this.isImporting = true;
api.postRecipeByUrl({ url: this.url })
.then(response => this.$router.push({
name: 'Recipe',
params: {
recipeId: response.recipes[0].id,
},
}))
.catch((error) => {
this.isImporting = false;
this.error = error;
});
},
},
};
</script>

View File

@ -0,0 +1,7 @@
<template>
<v-layout row>
<v-flex xs12 class="text-xs-center">
<v-progress-circular indeterminate :size="70" :width="7"></v-progress-circular>
</v-flex>
</v-layout>
</template>

View File

@ -1,145 +0,0 @@
<template>
<v-container text-xs-center>
<v-layout row wrap>
<v-flex xs12>
<h2>Import from URL</h2>
<v-form v-model="validImport">
<v-text-field
label="URL"
v-model="url"
required
:rules="urlRules"
></v-text-field>
<v-btn
@click="submitImport"
:disabled="!validImport || disabledImport"
>
Import
</v-btn>
</v-form>
</v-flex>
</v-layout>
<v-layout row wrap mt-5 v-if="featureAddManually">
<v-flex xs12>
<h2>Add manually</h2>
<v-form v-model="validAdd">
<v-text-field
label="Title"
v-model="title"
required
></v-text-field>
<v-text-field
label="Picture"
v-model="picture"
></v-text-field>
<v-text-field
label="Short description"
v-model="short_description"
textarea
></v-text-field>
<v-layout row>
<v-flex xs4 mr-3>
<v-text-field
label="Number of persons"
v-model="nb_person"
type="number"
></v-text-field>
</v-flex>
<v-flex xs4 mx-3>
<v-text-field
label="Preparation time"
v-model="preparation_time"
type="number"
suffix="mins"
></v-text-field>
</v-flex>
<v-flex xs4 ml-3>
<v-text-field
label="Cooking time"
v-model="cooking_time"
type="number"
suffix="mins"
></v-text-field>
</v-flex>
</v-layout>
<v-text-field
label="Ingredients"
v-model="ingredients"
textarea
></v-text-field>
<v-text-field
label="Instructions"
v-model="instructions"
textarea
required
></v-text-field>
<v-btn
@click="submitAdd"
:disabled="!validAdd"
>
Add
</v-btn>
</v-form>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import * as constants from '@/constants';
export default {
data() {
return {
url: null,
validImport: false,
disabledImport: false,
validAdd: false,
title: null,
picture: null,
short_description: null,
nb_person: null,
preparation_time: null,
cooking_time: null,
ingredients: null,
instructions: null,
urlRules: [
v => !!v || 'URL is required',
(v) => {
try {
new URL(v); // eslint-disable-line no-new
return true;
} catch (e) {
return 'URL must be valid';
}
},
],
featureAddManually: false,
};
},
methods: {
submitImport() {
this.disabledImport = true;
fetch(`${constants.API_URL}api/v1/recipes`, {
method: 'POST',
body: JSON.stringify({
url: this.url,
}),
})
.then(response => response.json())
.then(response => response.recipes[0])
.then(recipe => this.$router.push({
name: 'Recipe',
params: {
recipeId: recipe.id,
},
}));
},
submitAdd() {
// TODO
},
},
};
</script>

View File

@ -1,78 +0,0 @@
<template>
<v-container grid-list-md>
<v-layout row wrap>
<v-flex xs6 offset-xs3>
<h1 class="text-xs-center mt-3 mb-3">
{{ this.recipe.title }}
<v-btn @click="handleDelete">Delete</v-btn>
</h1>
<p class="text-xs-center">
<img :src="this.recipe.picture" />
</p>
<v-layout row text-xs-center>
<v-flex xs6>
<p><v-icon>timelapse</v-icon> {{ recipe.preparation_time }} mins</p>
</v-flex>
<v-flex xs6>
<p><v-icon>whatshot</v-icon> {{ recipe.cooking_time }} mins</p>
</v-flex>
</v-layout>
<ul>
<li v-for="ingredient in this.recipe.ingredients">
{{ ingredient }}
</li>
</ul>
<p>{{ this.recipe.nb_person }}</p>
<p>{{ this.recipe.short_description }}</p>
<p>{{ this.recipe.instructions }}</p>
<p v-if="this.recipe.url">
<a :href="this.recipe.url">Original link</a>
</p>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import * as constants from '@/constants';
export default {
data() {
return {
isLoading: false,
recipe: null,
};
},
created() {
this.fetchRecipe();
},
watch: {
// call again the method if the route changes
$route: 'fetchRecipe',
},
methods: {
fetchRecipe() {
this.isLoading = true;
fetch(`${constants.API_URL}api/v1/recipe/${this.$route.params.recipeId}`)
.then(response => response.json())
.then((response) => {
this.recipe = response.recipes[0];
this.isLoading = false;
});
},
handleDelete() {
fetch(`${constants.API_URL}api/v1/recipe/${this.$route.params.recipeId}`, {
method: 'DELETE',
})
.then(() => this.$router.replace('/'));
},
},
};
</script>
<style scoped>
img {
width: 75%;
}
</style>

View File

@ -1 +1 @@
export const API_URL = process.env.API_URL || '/'; // eslint-disable-line import/prefer-default-export,no-use-before-define
export const API_URL = process.env.API_URL || '/'; // eslint-disable-line no-use-before-define

51
cuizin/js_src/helpers.js Normal file
View File

@ -0,0 +1,51 @@
/**
* Get all locales supported by the client browser.
*/
export function getBrowserLocales() {
let langs = [];
if (navigator.languages) {
// Chrome does not currently set navigator.language correctly
// https://code.google.com/p/chromium/issues/detail?id=101138
// but it does set the first element of navigator.languages correctly
langs = navigator.languages;
} else if (navigator.userLanguage) {
// IE only
langs = [navigator.userLanguage];
} else {
// as of this writing the latest version of firefox + safari set this correctly
langs = [navigator.language];
}
// Some browsers does not return uppercase for second part
const locales = langs.map((lang) => {
const locale = lang.split('-');
return locale[1] ? `${locale[0]}-${locale[1].toUpperCase()}` : lang;
});
return locales;
}
/**
* Find the best matching locale from the browser preferred locales.
*
* @param messages A translation object with supported locales as keys.
* @param defaultLocale An optional default locale.
* @return The best locale to use.
*/
export function getBestMatchingLocale(messages, defaultLocale = 'en') {
const locales = getBrowserLocales();
let bestLocale = defaultLocale;
// Get best matching locale
locales.some((locale) => {
if (messages[locale]) {
bestLocale = locale;
// Stop at first matching locale
return true;
}
return false;
});
return bestLocale;
}

View File

@ -0,0 +1,57 @@
export default {
error: {
title: 'Error',
unable_delete_recipe: 'Unable to delete recipe: ',
unable_fetch_recipe: 'Unable to fetch recipe: ',
unable_import_recipe: 'Unable to import recipe: ',
unable_load_recipes: 'Unable to load recipes: ',
unable_refetch_recipe: 'Unable to refetch recipe: ',
},
home: {
onboarding: 'Start by adding a recipe with the "+" button on the top right corner!',
},
misc: {
cancel: 'Cancel',
Nmins: '1 min | {count} mins',
},
new: {
add: 'Add',
add_ingredient: 'Add ingredient',
add_manually: 'Add manually',
cooking_time: 'Cooking time',
edit: 'Edit',
edit_recipe: 'Edit recipe',
import: 'Import',
import_from_url: 'Import from URL',
importing: 'Importing…',
ingredients: 'Ingredients:',
instructions: 'Instructions',
instructions_are_required: 'Instructions are required',
mins: 'mins',
nb_persons: 'Serves',
none: 'None',
picture_url: 'Picture URL',
preparation_time: 'Preparation time',
short_description: 'Short description',
title: 'Title',
title_is_required: 'Title is required',
updating: 'Updating…',
url_is_required: 'URL is required',
url_must_be_valid: 'URL must be a valid one',
},
recipe: {
cooking: 'Cooking:',
delete: 'Delete',
delete_recipe: 'Delete recipe',
delete_recipe_description: 'This will delete this recipe. Are you sure?',
edit: 'Edit',
ingredients: 'Ingredients',
instructions: 'Instructions',
none: 'Aucun',
preparation: 'Preparation:',
refetch: 'Refetch',
refetch_recipe: 'Refetch recipe',
refetch_recipe_description: 'This will refetch the recipe from the website and replace all current data with newly fetched ones. Are you sure?',
website: 'Recipe web page',
},
};

View File

@ -0,0 +1,57 @@
export default {
error: {
title: 'Erreur',
unable_delete_recipe: 'Impossible de supprimer la recette : ',
unable_fetch_recipe: 'Impossible de récupérer la recette : ',
unable_import_recipe: 'Impossible d\'importer la recette : ',
unable_load_recipes: 'Impossible de charger les recettes : ',
unable_refetch_recipe: 'Impossible de réactualiser la recette : ',
},
home: {
onboarding: 'Commencez par ajouter une recette avec le bouton "+" dans le coin supérieur droit !',
},
misc: {
cancel: 'Annuler',
Nmins: '1 min | {count} mins',
},
new: {
add: 'Ajouter',
add_ingredient: 'Ajouter un ingrédient',
add_manually: 'Ajouter manuellement',
cooking_time: 'Temps de cuisson',
edit: 'Modifier',
edit_recipe: 'Modifier une recette',
import: 'Importer',
import_from_url: 'Importer depuis l\'URL',
importing: 'En cours d\'import…',
ingredients: 'Ingrédients :',
instructions: 'Instructions',
instructions_are_required: 'Des instructions sont requises',
mins: 'mins',
nb_persons: 'Nombre de personnes / parts',
none: 'Aucun',
picture_url: 'URL d\'une photo',
preparation_time: 'Temps de préparation',
short_description: 'Description brève',
title: 'Titre',
title_is_required: 'Un titre est requis',
updating: 'En cours de mise à jour…',
url_is_required: 'Une URL est requise',
url_must_be_valid: 'L\'URL est invalide',
},
recipe: {
cooking: 'Cuisson :',
delete: 'Supprimer',
delete_recipe: 'Suppression d\'une recette',
delete_recipe_description: 'Vous allez supprimer cette recette. En êtes-vous sûr ?',
edit: 'Modifier',
ingredients: 'Ingrédients',
instructions: 'Instructions',
none: 'Aucun',
preparation: 'Préparation :',
refetch: 'Réactualiser',
refetch_recipe: 'Réactualiser la recette',
refetch_recipe_description: 'Vous allez remplacer le contenu de cette recette par des données à jour récupérées depuis le site web de la recette. En êtes-vous sûr ?',
website: 'Page de la recette',
},
};

View File

@ -0,0 +1,7 @@
import en from './en';
import fr from './fr';
export default {
en,
fr,
};

View File

@ -2,23 +2,36 @@
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue';
import Vuetify from 'vuetify';
import VueI18n from 'vue-i18n';
import 'roboto-fontface/css/roboto/roboto-fontface.css';
import 'font-awesome/css/font-awesome.css';
import 'material-design-icons/iconfont/material-icons.css';
import 'vuetify/dist/vuetify.min.css';
import App from './App';
import messages from './i18n';
import router from './router';
import { getBestMatchingLocale } from './helpers';
// Isomorphic fetch
require('es6-promise').polyfill();
require('isomorphic-fetch');
Vue.use(Vuetify);
Vue.use(VueI18n);
Vue.config.productionTip = false;
const i18n = new VueI18n({
locale: getBestMatchingLocale(messages),
messages,
});
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
components: { App },
template: '<App/>',
el: '#app',
i18n,
router,
components: { App },
template: '<App/>',
});

View File

@ -1,27 +1,34 @@
import Vue from 'vue';
import Router from 'vue-router';
import Home from '@/components/Home';
import New from '@/components/New';
import Recipe from '@/components/Recipe';
import Home from '@/views/Home';
import Edit from '@/views/Edit';
import New from '@/views/New';
import Recipe from '@/views/Recipe';
Vue.use(Router);
export default new Router({
routes: [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/new',
name: 'New',
component: New,
},
{
path: '/recipe/:recipeId',
name: 'Recipe',
component: Recipe,
},
],
routes: [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/new',
name: 'New',
component: New,
},
{
path: '/recipe/:recipeId',
name: 'Recipe',
component: Recipe,
},
{
path: '/edit/:recipeId',
name: 'Edit',
component: Edit,
},
],
});

13
cuizin/js_src/rules.js Normal file
View File

@ -0,0 +1,13 @@
export const url = [
(v) => {
if (!v) {
return true;
}
try {
new URL(v); // eslint-disable-line no-new
return true;
} catch (e) {
return this.$t('new.url_must_be_valid');
}
},
];

View File

@ -0,0 +1,72 @@
<template>
<v-container text-xs-center v-if="isLoading">
<v-layout row>
<Loader></Loader>
</v-layout>
</v-container>
<v-container text-xs-center v-else-if="isImporting">
<v-layout row wrap>
<Loader></Loader>
<v-flex xs12>
<p>{{ $t('new.updating') }}</p>
</v-flex>
</v-layout>
</v-container>
<v-container text-xs-center v-else class="panel">
<v-layout row wrap>
<EditForm v-model="isImporting" :recipe="recipe"></EditForm>
</v-layout>
</v-container>
</template>
<script>
import * as api from '@/api';
import EditForm from '@/components/EditForm';
import Loader from '@/components/Loader';
export default {
components: {
EditForm,
Loader,
},
data() {
return {
isImporting: false,
isLoading: false,
error: null,
recipe: null,
};
},
created() {
this.loadRecipe();
},
watch: {
// call again the method if the route changes
$route: 'loadRecipe',
},
methods: {
handleRecipesResponse(response) {
if (response.recipes.length < 1) {
this.$router.replace({
name: 'Home',
});
}
this.recipe = response.recipes[0];
this.isLoading = false;
},
loadRecipe() {
this.isLoading = true;
api.loadRecipe(this.$route.params.recipeId)
.then(this.handleRecipesResponse)
.catch((error) => {
this.isLoading = false;
this.error = error;
});
},
},
};
</script>

View File

@ -0,0 +1,85 @@
<template>
<v-container fluid grid-list-md>
<Loader v-if="isLoading"></Loader>
<v-layout row wrap v-else>
<ErrorDialog v-model="error" :description="$t('error.unable_load_recipes')" />
<v-flex xs12 v-if="!error && !recipes.length" class="text-xs-center">
<p>{{ $t('home.onboarding') }}</p>
</v-flex>
<v-flex
v-for="recipe in recipes"
:key="recipe.title"
xs12 sm6 md3 lg2>
<v-card :to="{name: 'Recipe', params: { recipeId: recipe.id }}">
<v-card-media :src="recipe.picture" height="200px" class="grey"></v-card-media>
<v-card-title primary-title>
<div>
<h3 class="headline mb-0">{{ recipe.title }}</h3>
</div>
</v-card-title>
<v-layout row text-xs-center wrap>
<v-flex xs12 v-if="recipe.short_description">
<p>{{ recipe.short_description }}</p>
</v-flex>
<v-flex xs6>
<p><v-icon>timelapse</v-icon> {{ $tc('misc.Nmins', recipe.preparation_time, { count: timeOrUnknown(recipe.preparation_time) }) }}</p>
</v-flex>
<v-flex xs6>
<p><v-icon>whatshot</v-icon> {{ $tc('misc.Nmins', recipe.cooking_time, { count: timeOrUnknown(recipe.cooking_time) }) }}</p>
</v-flex>
</v-layout>
</v-card>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import * as api from '@/api';
import ErrorDialog from '@/components/ErrorDialog';
import Loader from '@/components/Loader';
export default {
components: {
ErrorDialog,
Loader,
},
data() {
return {
isLoading: false,
error: null,
recipes: [],
};
},
created() {
this.fetchRecipes();
},
watch: {
// call again the method if the route changes
$route: 'fetchRecipes',
},
methods: {
fetchRecipes() {
this.isLoading = true;
api.loadRecipes()
.then((response) => {
this.recipes = response.recipes;
this.isLoading = false;
})
.catch((error) => {
this.error = error;
this.isLoading = false;
});
},
timeOrUnknown(time) {
if (time !== null && time !== undefined) {
return time;
}
return '?';
},
},
};
</script>

View File

@ -0,0 +1,39 @@
<template>
<v-container text-xs-center v-if="isImporting">
<v-layout row wrap>
<Loader></Loader>
<v-flex xs12>
<p>{{ $t('new.importing') }}</p>
</v-flex>
</v-layout>
</v-container>
<v-container text-xs-center v-else class="panel">
<v-layout row wrap>
<ImportForm v-model="isImporting"></ImportForm>
</v-layout>
<v-layout row wrap mt-5>
<EditForm v-model="isImporting"></EditForm>
</v-layout>
</v-container>
</template>
<script>
import EditForm from '@/components/EditForm';
import ImportForm from '@/components/ImportForm';
import Loader from '@/components/Loader';
export default {
components: {
EditForm,
ImportForm,
Loader,
},
data() {
return {
isImporting: false,
};
},
};
</script>

View File

@ -0,0 +1,174 @@
<template>
<v-container grid-list-md class="panel">
<Loader v-if="isLoading"></Loader>
<v-layout row v-else>
<ErrorDialog v-model="errorDelete" :description="$t('error.unable_delete_recipe')" />
<ErrorDialog v-model="errorFetch" :description="$t('error.unable_fetch_recipe')" />
<ErrorDialog v-model="errorRefetch" :description="$t('error.unable_refetch_recipe')" />
<v-dialog v-model="refetchConfirm" max-width="500px">
<v-card>
<v-card-title class="headline">{{ $t('recipe.refetch_recipe') }}</v-card-title>
<v-card-text>
{{ $t('recipe.refetch_recipe_description') }}
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="secondary" flat @click.stop="refetchConfirm=false">{{ $t('misc.cancel') }}</v-btn>
<v-btn color="error" flat @click.stop="handleRefetch">{{ $t('recipe.refetch') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteConfirm" max-width="500px">
<v-card>
<v-card-title class="headline">{{ $t('recipe.delete_recipe') }}</v-card-title>
<v-card-text>{{ $t('recipe.delete_recipe_description') }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="secondary" flat @click.stop="deleteConfirm=false">{{ $t('misc.cancel') }}</v-btn>
<v-btn color="error" flat @click.stop="handleDelete">{{ $t('recipe.delete') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-flex xs12 v-if="recipe">
<h1 class="text-xs-center mt-3 mb-3">
{{ recipe.title }}
</h1>
<p>
</p>
<p class="text-xs-center">
<img :src="recipe.picture" />
</p>
<v-layout row class="text-xs-center">
<v-flex xs6>
<p>{{ recipe.nb_person }}</p>
</v-flex>
<v-flex xs6>
<p><v-icon>timelapse</v-icon> {{ $t('recipe.preparation') }} {{ $tc('misc.Nmins', recipe.preparation_time, { count: timeOrUnknown(recipe.preparation_time) }) }}</p>
<p><v-icon>whatshot</v-icon> {{ $t('recipe.cooking') }} {{ $tc('misc.Nmins', recipe.cooking_time, { count: timeOrUnknown(recipe.cooking_time) }) }}</p>
</v-flex>
</v-layout>
<p>{{ recipe.short_description }}</p>
<h2>{{ $t('recipe.ingredients') }}</h2>
<ul class="ml-5" v-if="recipe.ingredients && recipe.ingredients.length">
<li v-for="ingredient in recipe.ingredients">
{{ ingredient }}
</li>
</ul>
<p class="ml-5 my-3" v-else>{{ $t('new.none') }}</p>
<h2 class="mt-3">{{ $t('recipe.instructions') }}</h2>
<p v-for="item in recipe.instructions">
{{ item }}
</p>
<p class="text-xs-center">
<v-btn :href="recipe.url" :title="$t('recipe.website')" v-if="recipe.url">
<v-icon class="fa-icon">fa-external-link</v-icon>
</v-btn>
<v-btn :to="{name: 'Edit', params: { recipeId: recipe.id }}" :title="$t('recipe.edit')">
<v-icon>edit</v-icon>
</v-btn>
<v-btn @click.stop="deleteConfirm = true" :title="$t('recipe.delete')">
<v-icon>delete</v-icon>
</v-btn>
<v-btn @click.stop="refetchConfirm = true" :title="$t('recipe.refetch')">
<v-icon>autorenew</v-icon>
</v-btn>
</p>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import * as api from '@/api';
import ErrorDialog from '@/components/ErrorDialog';
import Loader from '@/components/Loader';
export default {
components: {
ErrorDialog,
Loader,
},
data() {
return {
isLoading: false,
recipe: null,
errorFetch: null,
errorDelete: null,
errorRefetch: null,
deleteConfirm: false,
refetchConfirm: false,
};
},
created() {
this.loadRecipe();
},
watch: {
// call again the method if the route changes
$route: 'loadRecipe',
},
methods: {
timeOrUnknown(time) {
if (time !== null && time !== undefined) {
return time;
}
return '?';
},
handleRecipesResponse(response) {
if (response.recipes.length < 1) {
this.$router.replace({
name: 'Home',
});
}
this.recipe = response.recipes[0];
this.isLoading = false;
},
loadRecipe() {
this.isLoading = true;
api.loadRecipe(this.$route.params.recipeId)
.then(this.handleRecipesResponse)
.catch((error) => {
this.isLoading = false;
this.errorFetch = error;
});
},
handleDelete() {
this.isLoading = true;
this.deleteConfirm = false;
api.deleteRecipe(this.$route.params.recipeId)
.then(() => this.$router.replace('/'))
.catch((error) => {
this.isLoading = false;
this.errorDelete = error;
});
},
handleRefetch() {
this.isLoading = true;
this.refetchConfirm = false;
api.refetchRecipe(this.$route.params.recipeId)
.then(this.handleRecipesResponse)
.catch((error) => {
this.isLoading = false;
this.errorRefetch = error;
});
},
},
};
</script>
<style scoped>
img {
width: 100%;
}
.fa-icon {
font-size: 20px;
}
</style>

View File

@ -12,14 +12,16 @@ from weboob.core.ouiboube import WebNip
from cuizin import db
# List of backends with recipe abilities in Weboob
BACKENDS = ['750g', 'allrecipes', 'cuisineaz', 'marmiton', 'supertoinette']
BACKENDS = ['750g', 'allrecipes', 'cuisineaz', 'journaldesfemmes', 'marmiton',
'supertoinette']
def add_recipe(url):
def fetch_recipe(url, recipe=None):
"""
Add a recipe, trying to scrape from a given URL.
:param url: URL of the recipe.
:param recipe: An optional recipe object to update.
:return: A ``cuizin.db.Recipe`` model.
"""
# Eventually load modules from a local clone
@ -36,22 +38,20 @@ def add_recipe(url):
for module in BACKENDS
]
# Create a new Recipe object if none is given
if not recipe:
recipe = db.Recipe()
# Try to fetch the recipe with a Weboob backend
recipe = None
for backend in backends:
browser = backend.browser
if url.startswith(browser.BASEURL):
browser.location(url)
recipe = db.Recipe.from_weboob(browser.page.get_recipe())
# Ensure URL is set
recipe.url = url
recipe.update_from_weboob(browser.page.get_recipe())
break
# If we could not scrape anything, simply create an empty recipe storing
# the URL.
if not recipe:
recipe = db.Recipe()
recipe.url = url
# Ensure URL is set
recipe.url = url
recipe.save()
return recipe

View File

@ -4,7 +4,6 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>cuizin</title>
<link href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons' rel="stylesheet">
</head>
<body>
<div id="app"></div>

View File

@ -2,16 +2,17 @@ import json
import os
import bottle
import peewee
from cuizin import db
from cuizin.scraping import add_recipe
from cuizin.scraping import fetch_recipe
MODULE_DIR = os.path.dirname(os.path.realpath(__file__))
app = bottle.Bottle()
@app.hook('after_request')
@app.hook('before_request')
def enable_cors():
"""
Add CORS headers at each request.
@ -49,35 +50,55 @@ def api_v1_recipes():
return {
'recipes': [
recipe.to_dict() for recipe in db.Recipe.select()
recipe.to_dict()
for recipe in db.Recipe.select().order_by(db.Recipe.id.desc())
]
}
@app.post('/api/v1/recipes')
def api_v1_recipes_post():
@app.post('/api/v1/recipes/by_url')
def api_v1_recipes_post_by_url():
"""
Create a new recipe from URL
"""
data = json.load(bottle.request.body)
data = json.loads(bottle.request.body.read().decode('utf-8'))
if 'url' not in data:
return {
'error': 'No URL provided'
}
recipes = []
try:
# Try to add
return {
'recipes': [fetch_recipe(data['url']).to_dict()]
}
except peewee.IntegrityError:
# Redirect to pre-existing recipe if already there
recipe = db.Recipe.select().where(
db.Recipe.url == data['url']
).first()
assert recipe
recipes = [recipe.to_dict()]
except AssertionError:
recipes = [add_recipe(data['url']).to_dict()]
bottle.redirect('/api/v1/recipe/%s' % recipe.id, 301)
return {
'recipes': recipes
}
@app.post('/api/v1/recipes/manually')
def api_v1_recipes_post_manual():
"""
Create a new recipe manually
"""
data = json.loads(bottle.request.body.read().decode('utf-8'))
try:
# Try to add
recipe = db.Recipe()
recipe.update_from_dict(data)
recipe.save()
return {
'recipes': [recipe.to_dict()]
}
except peewee.IntegrityError:
return {
'error': 'Duplicate recipe.'
}
@app.route('/api/v1/recipe/:id', ['GET', 'OPTIONS'])
@ -98,6 +119,56 @@ def api_v1_recipe(id):
}
@app.route('/api/v1/recipe/:id', ['POST', 'OPTIONS'])
def api_v1_recipe_edit(id):
"""
Edit a given recipe from db
"""
# CORS
if bottle.request.method == 'OPTIONS':
return ''
data = json.loads(bottle.request.body.read().decode('utf-8'))
recipe = db.Recipe.select().where(
db.Recipe.id == id
).first()
if not recipe:
return bottle.abort(400, 'No recipe with id %s.' % id)
recipe.update_from_dict(data)
recipe.save()
return {
'recipes': [
recipe.to_dict()
]
}
@app.route('/api/v1/recipe/:id/refetch', ['GET', 'OPTIONS'])
def api_v1_recipe_refetch(id):
"""
Refetch a given recipe.
"""
# CORS
if bottle.request.method == 'OPTIONS':
return ''
recipe = db.Recipe.select().where(
db.Recipe.id == id
).first()
if not recipe:
return bottle.abort(400, 'No recipe with id %s.' % id)
recipe = fetch_recipe(recipe.url, recipe=recipe)
return {
'recipes': [
recipe.to_dict()
]
}
@app.delete('/api/v1/recipe/:id', ['DELETE', 'OPTIONS'])
def api_v1_recipe_delete(id):
"""

View File

@ -0,0 +1,51 @@
import base64
import os
import magic
from peewee import TextField
from playhouse.migrate import (
SqliteDatabase, SqliteMigrator, migrate
)
SCRIPT_DIR = os.path.dirname(__file__)
def update_picture(picture):
if not picture:
return picture
picture_mime = (
'data:%s;base64' % magic.from_buffer(picture,
mime=True)
)
return '%s,%s' % (
picture_mime,
base64.b64encode(picture).decode('utf-8')
)
def run_migration():
recipes_db = SqliteDatabase(os.path.join(SCRIPT_DIR, '../recipes.db'))
migrator = SqliteMigrator(recipes_db)
new_picture_field = TextField(null=True)
updated_pictures = [
(recipe_id, update_picture(picture))
for (recipe_id, picture) in recipes_db.execute_sql(
'SELECT id, picture FROM recipe'
)
]
migrate(
migrator.drop_column('recipe', 'picture'),
migrator.add_column('recipe', 'picture', new_picture_field),
)
for (recipe_id, picture) in updated_pictures:
if picture:
recipes_db.execute_sql('UPDATE recipe SET picture=? WHERE id=?',
(picture, recipe_id))
if __name__ == "__main__":
run_migration()

View File

@ -12,8 +12,12 @@
},
"dependencies": {
"es6-promise": "^4.2.4",
"font-awesome": "^4.7.0",
"isomorphic-fetch": "^2.2.1",
"material-design-icons": "^3.0.1",
"roboto-fontface": "^0.9.0",
"vue": "^2.5.2",
"vue-i18n": "^7.4.2",
"vue-router": "^3.0.1",
"vuetify": "^1.0.0"
},

BIN
screenshots/add_recipe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
screenshots/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

BIN
screenshots/recipe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 KiB