diff --git a/config/routes.yaml b/config/routes.yaml index 35beba8a344fa874c3d65640369de95a8e0c03b4..404a9f9c1a51fb45f3c61c2f9c4ada85c23ba3d5 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -1,5 +1,2 @@ -#index: -# path: / -# controller: App\Controller\DefaultController::index gregwar_captcha_routing: - resource: "@GregwarCaptchaBundle/Resources/config/routing/routing.yml" + resource: "@GregwarCaptchaBundle/Resources/config/routing/routing.yml" \ No newline at end of file diff --git a/src/Controller/CapsuleController.php b/src/Controller/CapsuleController.php index c7b7968521d3599f5c1ef3de1be8e1b01cde74c2..6879e0caf1abdb4fbcef569678621652db359bf4 100644 --- a/src/Controller/CapsuleController.php +++ b/src/Controller/CapsuleController.php @@ -3,11 +3,14 @@ namespace App\Controller; use App\Entity\Capsule; +use App\Entity\User; +use App\Exception\CapsuleNotFoundException; use App\Form\CreateCapsuleFormType; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Exception\UserNotFoundException; use Symfony\Component\Uid\Uuid; class CapsuleController extends AbstractController @@ -16,7 +19,7 @@ class CapsuleController extends AbstractController * @Route("/my_capsules", name="capsule_list") * @Route("/", name="home") */ - public function index(Request $request): Response + public function index(): Response { return $this->render('capsule/index.html.twig', [ 'controller_name' => 'CapsuleController', @@ -26,11 +29,16 @@ class CapsuleController extends AbstractController /** * @Route("/create", name="create_capsule") */ - public function new(Request $request): Response + public function create(Request $request): Response { $capsule = new Capsule(); $form = $this->createForm(CreateCapsuleFormType::class, $capsule); $form->handleRequest($request); + $current_user = $this->getUser(); + + if (! $current_user instanceof User) { + return $this->redirectToRoute('app_logout'); + } if ($form->isSubmitted() && $form->isValid()) { $new_date_time = new \DateTime(); @@ -40,7 +48,7 @@ class CapsuleController extends AbstractController $edition_link = $preview_link . '/?p=edit'; $capsule->setName($capsule_name); - $capsule->setCreationAuthor($this->getUser()); + $capsule->setCreationAuthor($current_user); $capsule->setCreationDate($new_date_time); $capsule->setUpdatedDate($new_date_time); $capsule->setPreviewLink($preview_link); @@ -51,7 +59,7 @@ class CapsuleController extends AbstractController $entityManager->flush(); return $this->forward('App\Controller\ProjectController::create', [ - 'capsule_name' => $capsule_name, + 'capsule' => $capsule, 'video_url' => $video_url ]); } @@ -60,4 +68,48 @@ class CapsuleController extends AbstractController 'capsuleCreationForm' => $form->createView() ]); } + +// /** +// * @Route("/capsule/{edition_link}", name="edit_capsule") +// */ +// public function edit(string $edition_link): Response +// { +// $current_user = $this->getUser(); +// +// if (! $current_user instanceof User) { +// return $this->redirectToRoute('app_login'); +// } +// +// $user_id = $current_user->getId(); +// $capsule_repository = $this->getDoctrine() +// ->getRepository(Capsule::class); +// $capsule = $capsule_repository->findOneBy(['edition_link' => $edition_link]); +// +// if (! $capsule instanceof Capsule) { +// throw new CapsuleNotFoundException("The capsule was not found"); +// } +// $author_capsule_creation = $capsule->getCreationAuthor(); +// +// if (! $author_capsule_creation instanceof User) { +// throw new UserNotFoundException("The capsule author was not found"); +// } +// +// $author_capsule_creation_id = $author_capsule_creation->getId(); +// +// if ($user_id === $author_capsule_creation_id) { +// return $this->render("project/edit.html.twig", [ +//// 'url' => $url +// ]); +// } +// +// return $this->render('capsule/no_edition_access.html.twig'); +// } + +// /** +// * @Route("/capsule/{preview_link}", name="display_capsule") +// */ +// public function show(Capsule $capsule): Response +// { +// return $this->redirectToRoute('get_legacy_resource'); +// } } diff --git a/src/Controller/FallbackController.php b/src/Controller/FallbackController.php index 9270dac621915b46e38cc3d3eec6b0a1907e57ad..4a96ee8d8e71b54931388c6b522870b9a0d3f088 100644 --- a/src/Controller/FallbackController.php +++ b/src/Controller/FallbackController.php @@ -2,18 +2,13 @@ namespace App\Controller; -use App\Entity\Capsule; use App\Helper\LegacyHelper; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\BinaryFileResponse; -use Symfony\Component\HttpFoundation\File\File; -use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; -use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class FallbackController extends AbstractController { @@ -21,27 +16,18 @@ class FallbackController extends AbstractController * @Route("/", name="get_legacy_resource", priority=-2) * @Route("/{controller}", name="get_legacy_resource", requirements={"controller" = ".+"}, priority=-1) */ - public function getLegacyResourceAction(Request $request, $controller = null): Response + public function getLegacyResourceAction(Request $request, ?string $controller = null): Response { - // check if capsule edition (post or get) if ($request->query->has('p') || $request->request->has('p')) { $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); - $user = $this->getUser(); - $pass = ''; - if ($request->query->has('p')) { - $pass = $request->query->get('p'); - } elseif ($request->request->has('p')) { - $pass = $request->request->get('p'); - } - $userId = $user->getId(); - // don't work properly with special char - //$link = rtrim($controller, "php/project.php"); - $link = str_replace("php/project.php", "", $controller); - $link = rtrim($link, "/"); + // TODO : the following code MUST be re-enabled with tuleap-51 +// $user = $this->getUser(); +// $link = str_replace("php/project.php", "", $controller); +// $link = rtrim($link, "/"); +// $user_id = $user->getId(); -// TODO : the following code MUST be re-enabled with tuleap-51 -// // check if capsule exist in database +// // check if capsule exists in database // $cap = $this->getDoctrine() // ->getManager() // ->getRepository('AppBundle:Capsule') @@ -55,7 +41,7 @@ class FallbackController extends AbstractController // $cap = $this->getDoctrine() // ->getManager() // ->getRepository('AppBundle:Capsule') -// ->getCapsuleByLinkAndUser($link, $userId); +// ->getCapsuleByLinkAndUser($link, $user_id); // if (!$cap instanceof Capsule) { // // rediriger sur une page lui demandant de contacter // // l'administrateur de la capsule pour lui donner des droits @@ -71,9 +57,17 @@ class FallbackController extends AbstractController // $em->flush(); } - if ($controller == null) { - //if no controller, this is index1.php - return $this->redirectToRoute('get_legacy_resource', array('controller' => 'index1.php')); +// if ($controller == null) { +// //if no controller, this is index1.php +// return $this->redirectToRoute('get_legacy_resource', array('controller' => 'index1.php')); +// } + + if (null === $controller) { + return LegacyHelper::transferToLegacy( + $request, + strval($this->getParameter('app.legacy_external_prefix')), + strval($this->getParameter('app.legacy_url')) + ); } // use iframe to enhance speed but not for creation @@ -102,22 +96,26 @@ class FallbackController extends AbstractController if (!$extension) { $separator = '/'; } - $url = "{$this->getParameter('app.legacy_url_external')}/" . + + $app_legacy_url_external = strval($this->getParameter('app.legacy_url_external')); + + $url = "{$app_legacy_url_external}/" . "{$originalController}{$separator}?{$originalQueryString}"; $url = preg_replace('(^https?:\/\/[^/]+(:\d+)?)', '', $url); - $pattern = '/\/\//i'; - $url = preg_replace($pattern, '/', $url); - // $logger->info("Capsule iframe configuration for none index1.php ", ['url' => $url]); - return $this->render("legacy/legacy.html.twig", array( - 'url' => $url, - )); + // TODO: fix regex +// $pattern = '/\/\//i'; +// $url = preg_replace($pattern, '/', $url); + + return $this->render("project/edit.html.twig", [ + 'url' => $url + ]); } return LegacyHelper::transferToLegacy( $request, - $this->getParameter('app.legacy_external_prefix'), - $this->getParameter('app.legacy_url') + strval($this->getParameter('app.legacy_external_prefix')), + strval($this->getParameter('app.legacy_url')) ); } } diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 8fe319fa382be59dc1597456247f163fa77ab828..73d7df7dabab98cdee4b7bec56ee3070e841d2d0 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -2,18 +2,16 @@ namespace App\Controller; -use _PHPStan_c862bb974\Nette\Neon\Exception; +use App\Entity\Capsule; use App\Exception\ZipArchiveNotOpeningException; -use App\Helper\LegacyHelper; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Config\Util\Exception\XmlParsingException; -use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\Filesystem\Exception\FileNotFoundException; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Contracts\Translation\TranslatorInterface; use ZipArchive; -use function PHPUnit\Framework\throwException; - class ProjectController extends AbstractController { /** @@ -22,11 +20,13 @@ class ProjectController extends AbstractController */ public function create( TranslatorInterface $translator, - string $capsule_name, + Capsule $capsule, string $video_url - ): RedirectResponse { + ): Response { chdir('../legacy/'); + $capsule_name = $capsule->getName(); + if (file_exists($capsule_name)) { $this->addFlash( 'project_already_exists', @@ -50,11 +50,7 @@ class ProjectController extends AbstractController $this->extractZipArchiveInNewCapsuleDirectory($zip, $capsule_name); - $video_url_XML_tag_is_filled = $this->addProjectVideoUrlInXMLProjectFile($capsule_name, $video_url); - - if (! $video_url_XML_tag_is_filled) { - throw new XmlParsingException('Video URL could not be written in XML project file'); - } + $this->addProjectVideoUrlInXMLProjectFile($capsule_name, $video_url); $this->addFlash( 'capsule_created_success', @@ -67,6 +63,13 @@ class ProjectController extends AbstractController ); return $this->redirectToRoute('capsule_list'); + +// $capsule_edition_link = $capsule->getEditionLink(); + +// return $this->forward('App\Controller\CapsuleController::edit', [ +//// 'video_url' => $video_url, +// 'edition_link' => $capsule_edition_link +// ]); } private function extractZipArchiveInNewCapsuleDirectory(ZipArchive $zip, string $capsule_name): void @@ -75,20 +78,26 @@ class ProjectController extends AbstractController $zip->close(); } - /** - * @return false|int - */ - private function addProjectVideoUrlInXMLProjectFile(string $capsule_name, string $video_url) + private function addProjectVideoUrlInXMLProjectFile(string $capsule_name, string $video_url): void { $project_xml_file = $capsule_name . "/file/project.xml"; + $xml_file_content = file_get_contents($project_xml_file); + + if (false === $xml_file_content) { + throw new FileNotFoundException("The XML project file could not be found"); + } - return file_put_contents( + $video_url_XML_tag_is_filled = file_put_contents( $project_xml_file, str_replace( "__video__", $video_url, - file_get_contents($project_xml_file) + $xml_file_content ) ); + + if (false === $video_url_XML_tag_is_filled) { + throw new XmlParsingException('Video URL could not be written in XML project file'); + } } } diff --git a/src/Entity/Capsule.php b/src/Entity/Capsule.php index 5f212668ae7fcd81c6b075ec2fe7ef6c3df50cc4..1b2a822694deca0c313ca52a27955603c73ec723 100644 --- a/src/Entity/Capsule.php +++ b/src/Entity/Capsule.php @@ -7,6 +7,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Security\Core\User\UserInterface; /** * @ORM\Table(name="capsule", uniqueConstraints={@ORM\UniqueConstraint(name="index_capsule_nom", columns={"nom"})}) @@ -73,21 +74,6 @@ class Capsule */ private string $edition_link; - /** - * - * @ORM\ManyToMany(targetEntity="App\Entity\User", inversedBy="capsules") - * @ORM\JoinTable(name="editeur_capsule", - * joinColumns={@ORM\JoinColumn(name="capsule_id", referencedColumnName="id")}, - * inverseJoinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")} - * ) - */ - private Collection $editors; - - public function __construct() - { - $this->editors = new ArrayCollection(); - } - public function getId(): int { return $this->id; @@ -173,22 +159,4 @@ class Capsule { $this->updated_date = $update_date; } - - public function addEditor(User $editor): void - { - $editor->addCapsule($this); - $this->editors[] = $editor; - } - - public function removeEditor(User $editor): Capsule - { - $editor->removeCapsule($this); - $this->editors->removeElement($editor); - return $this; - } - - public function getEditors(): ArrayCollection - { - return $this->editors; - } } diff --git a/src/Exception/CapsuleNotFoundException.php b/src/Exception/CapsuleNotFoundException.php new file mode 100644 index 0000000000000000000000000000000000000000..5f33efe481c08690ba06ef5f9b797e6135a23335 --- /dev/null +++ b/src/Exception/CapsuleNotFoundException.php @@ -0,0 +1,8 @@ +<?php + +namespace App\Exception; + +class CapsuleNotFoundException extends \Exception +{ + +} diff --git a/src/Exception/CurlInitFailedException.php b/src/Exception/CurlInitFailedException.php index 13a0e5eb4b94dd0aba564fbe2ee9e9c0cbcbd21e..73ccb7df39d03124e21ffba9816695fa26caf3e5 100644 --- a/src/Exception/CurlInitFailedException.php +++ b/src/Exception/CurlInitFailedException.php @@ -4,5 +4,4 @@ namespace App\Exception; class CurlInitFailedException extends \Exception { - } diff --git a/src/Exception/ZipArchiveNotOpeningException.php b/src/Exception/ZipArchiveNotOpeningException.php index 34fc14865c617cfac770657f7a091050829ba76a..691b87c3a386a0d6ef3a77f50dc8e11d31abbe8d 100644 --- a/src/Exception/ZipArchiveNotOpeningException.php +++ b/src/Exception/ZipArchiveNotOpeningException.php @@ -2,8 +2,6 @@ namespace App\Exception; -use Throwable; - class ZipArchiveNotOpeningException extends \Exception { } diff --git a/src/Helper/LegacyHelper.php b/src/Helper/LegacyHelper.php index 545d553fd8f3e65f4d77e8ac65d90d2bde03d267..461be870c605d529d6dfc3aa0da5dede9717d29e 100644 --- a/src/Helper/LegacyHelper.php +++ b/src/Helper/LegacyHelper.php @@ -2,7 +2,9 @@ namespace App\Helper; +use _PHPStan_c862bb974\Nette\Neon\Exception; use App\Curl\CurlHandle; +use phpDocumentor\Reflection\Types\Boolean; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\File\Exception\FileException; use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; @@ -82,6 +84,10 @@ class LegacyHelper $request->getPathInfo() ); + if (null === $originalController) { + throw new Exception('Original controller not found'); + } + $originalQueryString = $request->getQueryString(); //@TODO : delete on linux server @@ -126,6 +132,10 @@ class LegacyHelper if ($request->getMethod() == 'POST') { $postParameters = $request->request->all()['create_capsule_form']; + if (! is_array($postParameters)) { + throw new Exception(); + } + // upload file to transfer if (isset($_FILES['fileToUpload'])) { $data = [ @@ -148,7 +158,7 @@ class LegacyHelper $result = $curl_handler->execute(); - if (! $result) { + if (! is_string($result)) { throw new NotFoundHttpException( (int) $curl_handler->getInfo(CURLINFO_HTTP_CODE) . $curl_handler->getErrorMessage() diff --git a/src/Repository/CapsuleRepository.php b/src/Repository/CapsuleRepository.php index 4ef53d0ea012589541bd402fe568bf2cca5b6235..3b09b96672a810b72fbebeed1326f7dece47819d 100644 --- a/src/Repository/CapsuleRepository.php +++ b/src/Repository/CapsuleRepository.php @@ -2,121 +2,12 @@ namespace App\Repository; -use App\Entity\Capsule; use Doctrine\ORM\EntityRepository; -use Doctrine\ORM\QueryBuilder; -use Doctrine\ORM\Tools\Pagination\Paginator; +/** + * @template CapsuleEntity of object + * @extends EntityRepository<CapsuleEntity> + */ class CapsuleRepository extends EntityRepository { - public function getAllCapsulesByUserId($user_id): array - { - $query = $this->createQueryBuilder('c') - ->join('c.editeurs', 'e') - ->addSelect('e') - ->where('e.id = :userId') - ->setParameter("userId", $user_id); - return $query->getResult(); - } - - public function getCapsuleByVideoUrl($video_url) - { - $query = $this->createQueryBuilder('c') - ->where("c.link like :link") - ->setParameter("link", $video_url) - ->getQuery() - ; - return $query->getOneOrNullResult(); - } - - public function getCapsuleByVideoUrlAndUser($video_url, $user_id) - { - $query = $this->createQueryBuilder('c') - ->join('c.editeurs', 'e') - ->addSelect('e') - ->where("c.link like :link") - ->andWhere('e.id = :userId') - ->setParameter("link", $video_url) - ->setParameter("userId", $user_id) - ->getQuery() - ; - return $query->getOneOrNullResult(); - } - -// public function getCapsuleByIdWithAllEditors($id) -// { -// $query = $this->createQueryBuilder('c') -// ->join('c.editeurs', 'e') -// ->addSelect('e') -// ->where("c.id = :id") -// ->setParameter("id", $id) -// ->getQuery() -// ; -// return $query->getResult(); -// } - - public function getCapsuleByIdAndUser($id, $user_id) - { - $query = $this->createQueryBuilder('c') - ->join('c.editeurs', 'e') - ->addSelect('e') - ->where("c.id = :id") - ->andWhere('e.id = :userId') - ->setParameter("id", $id) - ->setParameter("userId", $user_id) - ->getQuery() - ; - return $query->getOneOrNullResult(); - } - -// /** -// * @param $id integer The capsule id -// * @param $usersId integer The user id -// * @return Capsule The capsule if found or null -// * @throws \Doctrine\ORM\NonUniqueResultException several capsules have the same id for the user -// */ -// public function getCapsuleByIdAndUsers($id, $usersId) -// { -// $query = $this->createQueryBuilder('c') -// ->join('c.editeurs', 'e') -// ->addSelect('e') -// ->where("c.id = :id") -// ->andWhere('e.id in (:usersId)') -// ->setParameter("id", $id) -// ->setParameter("usersId", $usersId) -// ->getQuery() -// ; -// return $query->getOneOrNullResult(); -// } - -// public function getCapsulesByUserAndGroupPerPage($page, $nbPerPage, $userId, $groupId) -// { -// $query = $this->createQueryBuilder('c') -// ->join('c.editeurs', 'e') -// ->addSelect('e') -// ->where('e.id = :userId') -// ->setParameter("userId", $userId); -// -// if ($groupId > 0) { -// $query->join('c.groups', 'g') -// ->addSelect('g') -// ->andWhere('g.id = :groupId') -// ->setParameter("groupId", (int)$groupId); -// } -// //->addOrderBy('c.dtMaj', 'DESC') -// //->addOrderBy('c.dtCrea', 'DESC') -// -// $query = $query->getQuery(); -// -// $query -// // On définit l'annonce à partir de laquelle commencer la liste -// ->setFirstResult(($page-1) * $nbPerPage) -// // Ainsi que le nombre d'annonce à afficher sur une page -// ->setMaxResults($nbPerPage) -// ; -// -// // Enfin, on retourne l'objet Paginator correspondant à la requête construite -// // (n'oubliez pas le use correspondant en début de fichier) -// return new Paginator($query, true); -// } } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 8e0450fcb64c7f4f096a2d90077adea83c7ba07f..2104c06548a253332dc65d690000101ba0cb07d3 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -14,6 +14,8 @@ use Symfony\Component\Security\Core\User\PasswordUpgraderInterface; * @method User|null findOneBy(array $criteria, array $orderBy = null) * @method User[] findAll() * @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + * @template UserEntity of object + * @extends ServiceEntityRepository<UserEntity> */ class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface { diff --git a/templates/capsule/no_edition_access.html.twig b/templates/capsule/no_edition_access.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..d5ea96114c8df25dcf6d222d5a24509a4bde1ad0 --- /dev/null +++ b/templates/capsule/no_edition_access.html.twig @@ -0,0 +1,32 @@ +{% extends 'layout.html.twig' %} + +{% block title %} + {{ 'capsule.no_edition_access'|trans }} +{% endblock %} + +{% block body %} + + <div> + <h2> + {{ 'capsule.edition_not_allowed'|trans }} + </h2> + + <div> + <p> + {{ 'capsule.edition_not_allowed'|trans }} + </p> + <p> + {{ 'capsule.contact_capsule_author_for_access'|trans }} + </p> + </div> + </div> + + <div class="d-flex justify-content-center align-items-center"> + <form> + <button id="btn-primary" formaction="/capsule_list"> + {{ 'general.go_back_to_home_page'|trans }} + </button> + </form> + </div> + +{% endblock %} diff --git a/templates/project/edit.html.twig b/templates/project/edit.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..791b99bc7e15756ef647a54b0d8bcdc9261a470f --- /dev/null +++ b/templates/project/edit.html.twig @@ -0,0 +1,22 @@ +{% extends 'layout.html.twig' %} + +{% block title %} + {{ 'project.edit_project'|trans }} +{% endblock %} + +{% block body %} + +{# <iframe src="{{ url }}"#} + <iframe src="https://www.youtube.com/watch?v=544DTGHIBM0&ab_channel=Vogue" + + style="width:100%;height:100%;top:0;left:0;position:absolute" + {# width="1200" + height="600" #} + + frameborder="0" + webkitallowfullscreen + mozallowfullscreen + allowfullscreen> + </iframe> + +{% endblock %} diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 161afbf3146811d8a31876a009f259bc3c5017bc..aa69247980bf58e878d6ce1716868b100158fe2b 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -3,6 +3,7 @@ general: password: Password sign_in: Sign in log_out: Log out + go_back_to_home_page: Home page login: account_disabled_feedback: Your user account is disabled. Please click on the link your receive by email to validate your registration. @@ -49,6 +50,10 @@ capsule: name: Name of the capsule video_url: Youtube or Vimeo video URL created_success: Capsule capsule_name was created successfully + no_edition_access: No edition access + edition_not_allowed: You are not allowed to edit this capsule + contact_capsule_author_for_access: Please contact the author to gain access the edition mode project: - already_exists: Project capsule_name already exists so the capsule could not be created \ No newline at end of file + already_exists: Project capsule_name already exists so the capsule could not be created + edit_project: Project edition