diff options
Diffstat (limited to 'src/Wallabag/CoreBundle')
4 files changed, 314 insertions, 64 deletions
diff --git a/src/Wallabag/CoreBundle/Command/InstallCommand.php b/src/Wallabag/CoreBundle/Command/InstallCommand.php index c1b72604..a528c309 100644 --- a/src/Wallabag/CoreBundle/Command/InstallCommand.php +++ b/src/Wallabag/CoreBundle/Command/InstallCommand.php | |||
@@ -4,134 +4,194 @@ namespace Wallabag\CoreBundle\Command; | |||
4 | 4 | ||
5 | use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand; | 5 | use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand; |
6 | use Symfony\Component\Console\Input\InputInterface; | 6 | use Symfony\Component\Console\Input\InputInterface; |
7 | use Symfony\Component\Console\Input\InputOption; | ||
8 | use Symfony\Component\Console\Input\ArrayInput; | ||
7 | use Symfony\Component\Console\Output\OutputInterface; | 9 | use Symfony\Component\Console\Output\OutputInterface; |
10 | use Symfony\Component\Console\Output\NullOutput; | ||
8 | use Wallabag\CoreBundle\Entity\User; | 11 | use Wallabag\CoreBundle\Entity\User; |
9 | use Wallabag\CoreBundle\Entity\Config; | 12 | use Wallabag\CoreBundle\Entity\Config; |
10 | 13 | ||
11 | class InstallCommand extends ContainerAwareCommand | 14 | class InstallCommand extends ContainerAwareCommand |
12 | { | 15 | { |
16 | /** | ||
17 | * @var InputInterface | ||
18 | */ | ||
19 | protected $defaultInput; | ||
20 | |||
21 | /** | ||
22 | * @var OutputInterface | ||
23 | */ | ||
24 | protected $defaultOutput; | ||
25 | |||
13 | protected function configure() | 26 | protected function configure() |
14 | { | 27 | { |
15 | $this | 28 | $this |
16 | ->setName('wallabag:install') | 29 | ->setName('wallabag:install') |
17 | ->setDescription('Wallabag installer.') | 30 | ->setDescription('Wallabag installer.') |
31 | ->addOption( | ||
32 | 'reset', | ||
33 | null, | ||
34 | InputOption::VALUE_NONE, | ||
35 | 'Reset current database' | ||
36 | ) | ||
18 | ; | 37 | ; |
19 | } | 38 | } |
20 | 39 | ||
21 | protected function execute(InputInterface $input, OutputInterface $output) | 40 | protected function execute(InputInterface $input, OutputInterface $output) |
22 | { | 41 | { |
23 | $output->writeln('<info>Installing Wallabag.</info>'); | 42 | $this->defaultInput = $input; |
43 | $this->defaultOutput = $output; | ||
44 | |||
45 | $output->writeln('<info>Installing Wallabag...</info>'); | ||
24 | $output->writeln(''); | 46 | $output->writeln(''); |
25 | 47 | ||
26 | $this | 48 | $this |
27 | ->checkStep($output) | 49 | ->checkRequirements() |
28 | ->setupStep($input, $output) | 50 | ->setupDatabase() |
51 | ->setupAdmin() | ||
52 | ->setupAsset() | ||
29 | ; | 53 | ; |
30 | 54 | ||
31 | $output->writeln('<info>Wallabag has been successfully installed.</info>'); | 55 | $output->writeln('<info>Wallabag has been successfully installed.</info>'); |
32 | $output->writeln('<comment>Just execute `php app/console server:run` for using wallabag: http://localhost:8000</comment>'); | 56 | $output->writeln('<comment>Just execute `php app/console server:run` for using wallabag: http://localhost:8000</comment>'); |
33 | } | 57 | } |
34 | 58 | ||
35 | protected function checkStep(OutputInterface $output) | 59 | protected function checkRequirements() |
36 | { | 60 | { |
37 | $output->writeln('<info>Checking system requirements.</info>'); | 61 | $this->defaultOutput->writeln('<info><comment>Step 1 of 4.</comment> Checking system requirements.</info>'); |
38 | 62 | ||
39 | $fulfilled = true; | 63 | $fulfilled = true; |
40 | 64 | ||
41 | // @TODO: find a better way to check requirements | 65 | // @TODO: find a better way to check requirements |
42 | $output->writeln('<comment>Check PCRE</comment>'); | 66 | $label = '<comment>PCRE</comment>'; |
43 | if (extension_loaded('pcre')) { | 67 | if (extension_loaded('pcre')) { |
44 | $output->writeln(' <info>OK</info>'); | 68 | $status = '<info>OK!</info>'; |
69 | $help = ''; | ||
45 | } else { | 70 | } else { |
46 | $fulfilled = false; | 71 | $fulfilled = false; |
47 | $output->writeln(' <error>ERROR</error>'); | 72 | $status = '<error>ERROR!</error>'; |
48 | $output->writeln('<comment>You should enabled PCRE extension</comment>'); | 73 | $help = 'You should enabled PCRE extension'; |
49 | } | 74 | } |
75 | $rows[] = array($label, $status, $help); | ||
50 | 76 | ||
51 | $output->writeln('<comment>Check DOM</comment>'); | 77 | $label = '<comment>DOM</comment>'; |
52 | if (extension_loaded('DOM')) { | 78 | if (extension_loaded('DOM')) { |
53 | $output->writeln(' <info>OK</info>'); | 79 | $status = '<info>OK!</info>'; |
80 | $help = ''; | ||
54 | } else { | 81 | } else { |
55 | $fulfilled = false; | 82 | $fulfilled = false; |
56 | $output->writeln(' <error>ERROR</error>'); | 83 | $status = '<error>ERROR!</error>'; |
57 | $output->writeln('<comment>You should enabled DOM extension</comment>'); | 84 | $help = 'You should enabled DOM extension'; |
58 | } | 85 | } |
86 | $rows[] = array($label, $status, $help); | ||
87 | |||
88 | $this->getHelper('table') | ||
89 | ->setHeaders(array('Checked', 'Status', 'Recommendation')) | ||
90 | ->setRows($rows) | ||
91 | ->render($this->defaultOutput); | ||
59 | 92 | ||
60 | if (!$fulfilled) { | 93 | if (!$fulfilled) { |
61 | throw new RuntimeException('Some system requirements are not fulfilled. Please check output messages and fix them.'); | 94 | throw new \RuntimeException('Some system requirements are not fulfilled. Please check output messages and fix them.'); |
95 | } else { | ||
96 | $this->defaultOutput->writeln('<info>Success! Your system can run Wallabag properly.</info>'); | ||
62 | } | 97 | } |
63 | 98 | ||
64 | $output->writeln(''); | 99 | $this->defaultOutput->writeln(''); |
65 | 100 | ||
66 | return $this; | 101 | return $this; |
67 | } | 102 | } |
68 | 103 | ||
69 | protected function setupStep(InputInterface $input, OutputInterface $output) | 104 | protected function setupDatabase() |
70 | { | 105 | { |
71 | $output->writeln('<info>Setting up database.</info>'); | 106 | $this->defaultOutput->writeln('<info><comment>Step 2 of 4.</comment> Setting up database.</info>'); |
72 | 107 | ||
73 | $this->setupDatabase($input, $output); | 108 | // user want to reset everything? Don't care about what is already here |
109 | if (true === $this->defaultInput->getOption('reset')) { | ||
110 | $this->defaultOutput->writeln('Droping database, creating database and schema'); | ||
74 | 111 | ||
75 | // if ($this->getHelperSet()->get('dialog')->askConfirmation($output, '<question>Load fixtures (Y/N)?</question>', false)) { | 112 | $this |
76 | // $this->setupFixtures($input, $output); | 113 | ->runCommand('doctrine:database:drop', array('--force' => true)) |
77 | // } | 114 | ->runCommand('doctrine:database:create') |
115 | ->runCommand('doctrine:schema:create') | ||
116 | ; | ||
78 | 117 | ||
79 | $output->writeln(''); | 118 | return $this; |
80 | $output->writeln('<info>Administration setup.</info>'); | 119 | } |
81 | 120 | ||
82 | $this->setupAdmin($output); | 121 | if (!$this->isDatabasePresent()) { |
122 | $this->defaultOutput->writeln('Creating database and schema, clearing the cache'); | ||
83 | 123 | ||
84 | $output->writeln(''); | 124 | $this |
125 | ->runCommand('doctrine:database:create') | ||
126 | ->runCommand('doctrine:schema:create') | ||
127 | ->runCommand('cache:clear') | ||
128 | ; | ||
85 | 129 | ||
86 | return $this; | 130 | return $this; |
87 | } | 131 | } |
88 | 132 | ||
89 | protected function setupDatabase(InputInterface $input, OutputInterface $output) | 133 | $dialog = $this->getHelper('dialog'); |
90 | { | ||
91 | if ($this->getHelperSet()->get('dialog')->askConfirmation($output, '<question>Drop current database (Y/N)?</question>', true)) { | ||
92 | $connection = $this->getContainer()->get('doctrine')->getConnection(); | ||
93 | $params = $connection->getParams(); | ||
94 | 134 | ||
95 | $name = isset($params['path']) ? $params['path'] : (isset($params['dbname']) ? $params['dbname'] : false); | 135 | if ($dialog->askConfirmation($this->defaultOutput, '<question>It appears that your database already exists. Would you like to reset it? (y/N)</question> ', false)) { |
96 | unset($params['dbname']); | 136 | $this->defaultOutput->writeln('Droping database, creating database and schema'); |
97 | 137 | ||
98 | if (!isset($params['path'])) { | 138 | $this |
99 | $name = $connection->getDatabasePlatform()->quoteSingleIdentifier($name); | 139 | ->runCommand('doctrine:database:drop', array('--force' => true)) |
100 | } | 140 | ->runCommand('doctrine:database:create') |
141 | ->runCommand('doctrine:schema:create') | ||
142 | ; | ||
143 | } elseif ($this->isSchemaPresent()) { | ||
144 | if ($dialog->askConfirmation($this->defaultOutput, '<question>Seems like your database contains schema. Do you want to reset it? (y/N)</question> ', false)) { | ||
145 | $this->defaultOutput->writeln('Droping schema and creating schema'); | ||
101 | 146 | ||
102 | $connection->getSchemaManager()->dropDatabase($name); | 147 | $this |
148 | ->runCommand('doctrine:schema:drop', array('--force' => true)) | ||
149 | ->runCommand('doctrine:schema:create') | ||
150 | ; | ||
151 | } | ||
103 | } else { | 152 | } else { |
104 | throw new \Exception("Install setup stopped, database need to be dropped. Please backup your current one and re-launch the install command."); | 153 | $this->defaultOutput->writeln('Creating schema'); |
154 | |||
155 | $this | ||
156 | ->runCommand('doctrine:schema:create') | ||
157 | ; | ||
105 | } | 158 | } |
106 | 159 | ||
107 | $this | 160 | $this->defaultOutput->writeln('Clearing the cache'); |
108 | ->runCommand('doctrine:database:create', $input, $output) | 161 | $this->runCommand('cache:clear'); |
109 | ->runCommand('doctrine:schema:create', $input, $output) | 162 | |
110 | ->runCommand('cache:clear', $input, $output) | 163 | /* |
111 | ->runCommand('assets:install', $input, $output) | 164 | if ($this->getHelperSet()->get('dialog')->askConfirmation($this->defaultOutput, '<question>Load fixtures (Y/N)?</question>', false)) { |
112 | ->runCommand('assetic:dump', $input, $output) | 165 | $doctrineConfig = $this->getContainer()->get('doctrine.orm.entity_manager')->getConnection()->getConfiguration(); |
113 | ; | 166 | $logger = $doctrineConfig->getSQLLogger(); |
114 | } | 167 | // speed up fixture load |
168 | $doctrineConfig->setSQLLogger(null); | ||
169 | $this->runCommand('doctrine:fixtures:load'); | ||
170 | $doctrineConfig->setSQLLogger($logger); | ||
171 | } | ||
172 | */ | ||
115 | 173 | ||
116 | protected function setupFixtures(InputInterface $input, OutputInterface $output) | 174 | $this->defaultOutput->writeln(''); |
117 | { | 175 | |
118 | $doctrineConfig = $this->getContainer()->get('doctrine.orm.entity_manager')->getConnection()->getConfiguration(); | 176 | return $this; |
119 | $logger = $doctrineConfig->getSQLLogger(); | ||
120 | // speed up fixture load | ||
121 | $doctrineConfig->setSQLLogger(null); | ||
122 | $this->runCommand('doctrine:fixtures:load', $input, $output); | ||
123 | $doctrineConfig->setSQLLogger($logger); | ||
124 | } | 177 | } |
125 | 178 | ||
126 | protected function setupAdmin(OutputInterface $output) | 179 | protected function setupAdmin() |
127 | { | 180 | { |
181 | $this->defaultOutput->writeln('<info><comment>Step 3 of 4.</comment> Administration setup.</info>'); | ||
182 | |||
128 | $dialog = $this->getHelperSet()->get('dialog'); | 183 | $dialog = $this->getHelperSet()->get('dialog'); |
184 | |||
185 | if (false === $dialog->askConfirmation($this->defaultOutput, '<question>Would you like to create a new user ? (y/N)</question>', true)) { | ||
186 | return $this; | ||
187 | } | ||
188 | |||
129 | $em = $this->getContainer()->get('doctrine.orm.entity_manager'); | 189 | $em = $this->getContainer()->get('doctrine.orm.entity_manager'); |
130 | 190 | ||
131 | $user = new User(); | 191 | $user = new User(); |
132 | $user->setUsername($dialog->ask($output, '<question>Username</question> <comment>(default: wallabag)</comment> :', 'wallabag')); | 192 | $user->setUsername($dialog->ask($this->defaultOutput, '<question>Username</question> <comment>(default: wallabag)</comment> :', 'wallabag')); |
133 | $user->setPassword($dialog->ask($output, '<question>Password</question> <comment>(default: wallabag)</comment> :', 'wallabag')); | 193 | $user->setPassword($dialog->ask($this->defaultOutput, '<question>Password</question> <comment>(default: wallabag)</comment> :', 'wallabag')); |
134 | $user->setEmail($dialog->ask($output, '<question>Email:</question>', '')); | 194 | $user->setEmail($dialog->ask($this->defaultOutput, '<question>Email:</question>', '')); |
135 | 195 | ||
136 | $em->persist($user); | 196 | $em->persist($user); |
137 | 197 | ||
@@ -141,16 +201,100 @@ class InstallCommand extends ContainerAwareCommand | |||
141 | $config->setLanguage($this->getContainer()->getParameter('language')); | 201 | $config->setLanguage($this->getContainer()->getParameter('language')); |
142 | 202 | ||
143 | $em->persist($config); | 203 | $em->persist($config); |
204 | |||
205 | $em->flush(); | ||
206 | |||
207 | $this->defaultOutput->writeln(''); | ||
208 | |||
209 | return $this; | ||
144 | } | 210 | } |
145 | 211 | ||
146 | protected function runCommand($command, InputInterface $input, OutputInterface $output) | 212 | protected function setupAsset() |
147 | { | 213 | { |
214 | $this->defaultOutput->writeln('<info><comment>Step 4 of 4.</comment> Installing assets.</info>'); | ||
215 | |||
148 | $this | 216 | $this |
149 | ->getApplication() | 217 | ->runCommand('assets:install') |
150 | ->find($command) | 218 | ->runCommand('assetic:dump') |
151 | ->run($input, $output) | ||
152 | ; | 219 | ; |
153 | 220 | ||
221 | $this->defaultOutput->writeln(''); | ||
222 | |||
223 | return $this; | ||
224 | } | ||
225 | |||
226 | /** | ||
227 | * Run a command | ||
228 | * | ||
229 | * @param string $command | ||
230 | * @param array $parameters Parameters to this command (usually 'force' => true) | ||
231 | */ | ||
232 | protected function runCommand($command, $parameters = array()) | ||
233 | { | ||
234 | $parameters = array_merge( | ||
235 | array('command' => $command), | ||
236 | $parameters, | ||
237 | array( | ||
238 | '--no-debug' => true, | ||
239 | '--env' => $this->defaultInput->getOption('env') ?: 'dev', | ||
240 | ) | ||
241 | ); | ||
242 | |||
243 | if ($this->defaultInput->getOption('no-interaction')) { | ||
244 | $parameters = array_merge($parameters, array('--no-interaction' => true)); | ||
245 | } | ||
246 | |||
247 | $this->getApplication()->setAutoExit(false); | ||
248 | $exitCode = $this->getApplication()->run(new ArrayInput($parameters), new NullOutput()); | ||
249 | |||
250 | if (0 !== $exitCode) { | ||
251 | $this->getApplication()->setAutoExit(true); | ||
252 | |||
253 | $errorMessage = sprintf('The command "%s" terminated with an error code: %u.', $command, $exitCode); | ||
254 | $this->defaultOutput->writeln("<error>$errorMessage</error>"); | ||
255 | $exception = new \Exception($errorMessage, $exitCode); | ||
256 | |||
257 | throw $exception; | ||
258 | } | ||
259 | |||
260 | // PDO does not always close the connection after Doctrine commands. | ||
261 | // See https://github.com/symfony/symfony/issues/11750. | ||
262 | $this->getContainer()->get('doctrine')->getManager()->getConnection()->close(); | ||
263 | |||
154 | return $this; | 264 | return $this; |
155 | } | 265 | } |
266 | |||
267 | /** | ||
268 | * Check if the database already exists | ||
269 | * | ||
270 | * @return boolean | ||
271 | */ | ||
272 | private function isDatabasePresent() | ||
273 | { | ||
274 | $databaseName = $this->getContainer()->getParameter('database_name'); | ||
275 | |||
276 | try { | ||
277 | $schemaManager = $this->getContainer()->get('doctrine')->getManager()->getConnection()->getSchemaManager(); | ||
278 | } catch (\Exception $exception) { | ||
279 | if (false !== strpos($exception->getMessage(), sprintf("Unknown database '%s'", $databaseName))) { | ||
280 | return false; | ||
281 | } | ||
282 | |||
283 | throw $exception; | ||
284 | } | ||
285 | |||
286 | return in_array($databaseName, $schemaManager->listDatabases()); | ||
287 | } | ||
288 | |||
289 | /** | ||
290 | * Check if the schema is already created | ||
291 | * | ||
292 | * @return boolean | ||
293 | */ | ||
294 | private function isSchemaPresent() | ||
295 | { | ||
296 | $schemaManager = $this->getContainer()->get('doctrine')->getManager()->getConnection()->getSchemaManager(); | ||
297 | |||
298 | return $schemaManager->tablesExist(array('entry')); | ||
299 | } | ||
156 | } | 300 | } |
diff --git a/src/Wallabag/CoreBundle/DataFixtures/ORM/LoadConfigData.php b/src/Wallabag/CoreBundle/DataFixtures/ORM/LoadConfigData.php new file mode 100644 index 00000000..900e151d --- /dev/null +++ b/src/Wallabag/CoreBundle/DataFixtures/ORM/LoadConfigData.php | |||
@@ -0,0 +1,45 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Wallabag\CoreBundle\DataFixtures\ORM; | ||
4 | |||
5 | use Doctrine\Common\DataFixtures\AbstractFixture; | ||
6 | use Doctrine\Common\DataFixtures\OrderedFixtureInterface; | ||
7 | use Doctrine\Common\Persistence\ObjectManager; | ||
8 | use Wallabag\CoreBundle\Entity\Config; | ||
9 | |||
10 | class LoadConfigData extends AbstractFixture implements OrderedFixtureInterface | ||
11 | { | ||
12 | /** | ||
13 | * {@inheritDoc} | ||
14 | */ | ||
15 | public function load(ObjectManager $manager) | ||
16 | { | ||
17 | $adminConfig = new Config($this->getReference('admin-user')); | ||
18 | $adminConfig->setTheme('baggy'); | ||
19 | $adminConfig->setItemsPerPage(30); | ||
20 | $adminConfig->setLanguage('en_US'); | ||
21 | |||
22 | $manager->persist($adminConfig); | ||
23 | |||
24 | $this->addReference('admin-config', $adminConfig); | ||
25 | |||
26 | $bobConfig = new Config($this->getReference('bob-user')); | ||
27 | $bobConfig->setTheme('default'); | ||
28 | $bobConfig->setItemsPerPage(10); | ||
29 | $bobConfig->setLanguage('fr_FR'); | ||
30 | |||
31 | $manager->persist($bobConfig); | ||
32 | |||
33 | $this->addReference('bob-config', $bobConfig); | ||
34 | |||
35 | $manager->flush(); | ||
36 | } | ||
37 | |||
38 | /** | ||
39 | * {@inheritDoc} | ||
40 | */ | ||
41 | public function getOrder() | ||
42 | { | ||
43 | return 20; | ||
44 | } | ||
45 | } | ||
diff --git a/src/Wallabag/CoreBundle/DataFixtures/ORM/LoadEntryData.php b/src/Wallabag/CoreBundle/DataFixtures/ORM/LoadEntryData.php index 520b44b8..3be323ed 100644 --- a/src/Wallabag/CoreBundle/DataFixtures/ORM/LoadEntryData.php +++ b/src/Wallabag/CoreBundle/DataFixtures/ORM/LoadEntryData.php | |||
@@ -49,6 +49,6 @@ class LoadEntryData extends AbstractFixture implements OrderedFixtureInterface | |||
49 | */ | 49 | */ |
50 | public function getOrder() | 50 | public function getOrder() |
51 | { | 51 | { |
52 | return 20; | 52 | return 30; |
53 | } | 53 | } |
54 | } | 54 | } |
diff --git a/src/Wallabag/CoreBundle/Tests/Command/InstallCommandTest.php b/src/Wallabag/CoreBundle/Tests/Command/InstallCommandTest.php new file mode 100644 index 00000000..6bcc9707 --- /dev/null +++ b/src/Wallabag/CoreBundle/Tests/Command/InstallCommandTest.php | |||
@@ -0,0 +1,61 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Wallabag\CoreBundle\Tests\Command; | ||
4 | |||
5 | use Wallabag\CoreBundle\Tests\WallabagTestCase; | ||
6 | use Wallabag\CoreBundle\Command\InstallCommand; | ||
7 | use Symfony\Bundle\FrameworkBundle\Console\Application; | ||
8 | use Symfony\Component\Console\Tester\CommandTester; | ||
9 | use Symfony\Component\Console\Input\ArrayInput; | ||
10 | use Symfony\Component\Console\Output\NullOutput; | ||
11 | |||
12 | class InstallCommandTest extends WallabagTestCase | ||
13 | { | ||
14 | public function tearDown() | ||
15 | { | ||
16 | parent::tearDown(); | ||
17 | |||
18 | $application = new Application(static::$kernel); | ||
19 | $application->setAutoExit(false); | ||
20 | |||
21 | $code = $application->run(new ArrayInput(array( | ||
22 | 'command' => 'doctrine:fixtures:load', | ||
23 | '--no-interaction' => true, | ||
24 | '--env' => 'test', | ||
25 | )), new NullOutput()); | ||
26 | } | ||
27 | |||
28 | public function testRunInstallCommand() | ||
29 | { | ||
30 | $this->container = static::$kernel->getContainer(); | ||
31 | |||
32 | $application = new Application(static::$kernel); | ||
33 | $application->add(new InstallCommand()); | ||
34 | |||
35 | $command = $application->find('wallabag:install'); | ||
36 | |||
37 | // We mock the DialogHelper | ||
38 | $dialog = $this->getMockBuilder('Symfony\Component\Console\Helper\DialogHelper') | ||
39 | ->disableOriginalConstructor() | ||
40 | ->getMock(); | ||
41 | $dialog->expects($this->any()) | ||
42 | ->method('ask') | ||
43 | ->will($this->returnValue('test')); | ||
44 | $dialog->expects($this->any()) | ||
45 | ->method('askConfirmation') | ||
46 | ->will($this->returnValue(true)); | ||
47 | |||
48 | // We override the standard helper with our mock | ||
49 | $command->getHelperSet()->set($dialog, 'dialog'); | ||
50 | |||
51 | $tester = new CommandTester($command); | ||
52 | $tester->execute(array( | ||
53 | 'command' => $command->getName() | ||
54 | )); | ||
55 | |||
56 | $this->assertContains('Step 1 of 4. Checking system requirements.', $tester->getDisplay()); | ||
57 | $this->assertContains('Step 2 of 4. Setting up database.', $tester->getDisplay()); | ||
58 | $this->assertContains('Step 3 of 4. Administration setup.', $tester->getDisplay()); | ||
59 | $this->assertContains('Step 4 of 4. Installing assets.', $tester->getDisplay()); | ||
60 | } | ||
61 | } | ||