Compare commits
15 Commits
main
...
2a22a3e027
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a22a3e027 | |||
| c948b1e39d | |||
| 840058873a | |||
| d12b94b4b1 | |||
| 26b9e6fe97 | |||
| 3aab29cd03 | |||
| d06f24b1fa | |||
| ed774a5a37 | |||
| 6664a7c71e | |||
| 4be33834d4 | |||
| b445295959 | |||
| 20ba17c684 | |||
| 323e668ac9 | |||
| 0d384a8fa3 | |||
| b14a0c23f6 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -22,4 +22,6 @@
|
||||
/assets/vendor/
|
||||
###< symfony/asset-mapper ###
|
||||
|
||||
/references/
|
||||
/references/
|
||||
composer.lock
|
||||
.continue
|
||||
25
Dockerfile
25
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM php:8.4-apache
|
||||
FROM php:8.5-apache
|
||||
|
||||
RUN apt update && \
|
||||
apt upgrade -y && \
|
||||
@@ -14,8 +14,13 @@ RUN apt update && \
|
||||
sqlite3 \
|
||||
curl \
|
||||
git \
|
||||
cron \
|
||||
logrotate \
|
||||
nano
|
||||
|
||||
RUN service start cron
|
||||
RUN service enable cron
|
||||
|
||||
RUN docker-php-ext-configure gd --with-jpeg
|
||||
RUN docker-php-ext-configure zip
|
||||
|
||||
@@ -46,6 +51,19 @@ RUN rm -rf /var/www/html/vendor
|
||||
RUN rm -rf /var/www/html/tests
|
||||
RUN rm -rf /var/www/html/translations
|
||||
|
||||
RUN echo "20 1 * * 6 root cd /var/www/html && /usr/local/bin/php bin/console app:get-audio > /var/log/sermon-notes.log 2>&1" > /etc/cron.d/get-audio
|
||||
RUN chmod 644 /etc/cron.d/get-audio
|
||||
|
||||
RUN echo "/var/log/sermon-notes.log {
|
||||
monthly
|
||||
rotate 12
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 644 root root
|
||||
}" > /etc/logrotate.d/sermon-notes
|
||||
|
||||
RUN COMPOSER_ALLOW_SUPERUSER=1 composer install --no-scripts --no-dev --optimize-autoloader
|
||||
RUN mkdir /data
|
||||
|
||||
@@ -53,6 +71,9 @@ RUN mkdir /var/www/html/var/cache
|
||||
RUN mkdir /var/www/html/var/log
|
||||
|
||||
RUN chown -R 33:33 /var/www/html /data
|
||||
RUN chmod -R 755 /var/www/html /data
|
||||
RUN find /var/www/html -type d -exec chmod 755 '{}' \;
|
||||
RUN find /var/www/html -type f -exec chmod 644 '{}' \;
|
||||
RUN chmod 755 /data
|
||||
RUN chmod 644 /data/*
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
11
config/packages/csrf.yaml
Normal file
11
config/packages/csrf.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
# Enable stateless CSRF protection for forms and logins/logouts
|
||||
framework:
|
||||
form:
|
||||
csrf_protection:
|
||||
token_id: submit
|
||||
|
||||
csrf_protection:
|
||||
stateless_token_ids:
|
||||
- submit
|
||||
- authenticate
|
||||
- logout
|
||||
@@ -1,29 +1,49 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
connections:
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
transfer:
|
||||
url: '%env(default::XFER_DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
orm:
|
||||
auto_generate_proxy_classes: true
|
||||
enable_lazy_ghost_objects: true
|
||||
report_fields_where_declared: true
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
# report_fields_where_declared: true
|
||||
# validate_xml_mapping: true
|
||||
# auto_mapping: true
|
||||
default_entity_manager: default
|
||||
entity_managers:
|
||||
default:
|
||||
connection: default
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
transfer:
|
||||
connection: transfer
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
controller_resolver:
|
||||
auto_mapping: true
|
||||
auto_mapping: false
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
@@ -50,3 +70,6 @@ when@prod:
|
||||
adapter: cache.app
|
||||
doctrine.system_cache_pool:
|
||||
adapter: cache.system
|
||||
|
||||
parameters:
|
||||
env(default_url): ''
|
||||
@@ -22,3 +22,6 @@ services:
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
App\Service\DatabaseTransferService:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
@@ -17,7 +17,7 @@ print "Updating migrations and setting permissions for data folder".PHP_EOL;
|
||||
// import reference material
|
||||
|
||||
print "Importing Bible and Eccumenical Creeds".PHP_EOL;
|
||||
`symfony console app:ingest-bible /var/www/html/reference/esv-bible`;
|
||||
`symfony console app:ingest-bible /var/www/html/references/esv-bible`;
|
||||
`symfony console app:import-ref /var/www/html/references/creeds/Apostles 'Apostles Creed' creed apc`;
|
||||
`symfony console app:import-ref /var/www/html/references/creeds/Athanasian 'Athanasian Creed' creed ath`;
|
||||
`symfony console app:import-ref /var/www/html/references/creeds/Chalcedon 'Definition of Chalcedon' creed dc`;
|
||||
@@ -50,13 +50,13 @@ if ($westminsterStandards) {
|
||||
`symfony console app:import-wlc /var/www/html/references/wlc 'Westminster Larger' wlc WLC{\$ndx}`;
|
||||
}
|
||||
|
||||
$helviticConfessions = (
|
||||
$helveticConfessions = (
|
||||
strtolower(
|
||||
readline("Do you want to import the Helvetic Confessions (1st & 2nd) (y/n)? ")
|
||||
) == 'y'
|
||||
);
|
||||
|
||||
if ($helviticConfessions) {
|
||||
if ($helveticConfessions) {
|
||||
print "Importing Helvitic standards".PHP_EOL;
|
||||
`symfony console app:import-ref /var/www/html/references/fhc 'First Helvetic Confession' 1hc 1HC{\$ndx}`;
|
||||
`symfony console app:import-ref /var/www/html/references/shc 'Second Helvetic Confession' 2hc 2HC{\$ndx}`;
|
||||
|
||||
39
migrations/Version20260114224910.php
Normal file
39
migrations/Version20260114224910.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260114224910 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE user ADD COLUMN home_church_rss VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__user AS SELECT id, email, roles, password, name, meta_data FROM user');
|
||||
$this->addSql('DROP TABLE user');
|
||||
$this->addSql('CREATE TABLE user (id BLOB NOT NULL --(DC2Type:uuid)
|
||||
, email VARCHAR(180) NOT NULL, roles CLOB NOT NULL --(DC2Type:json)
|
||||
, password VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, meta_data CLOB DEFAULT NULL --(DC2Type:json)
|
||||
, PRIMARY KEY(id))');
|
||||
$this->addSql('INSERT INTO user (id, email, roles, password, name, meta_data) SELECT id, email, roles, password, name, meta_data FROM __temp__user');
|
||||
$this->addSql('DROP TABLE __temp__user');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL ON user (email)');
|
||||
}
|
||||
}
|
||||
49
migrations/Version20260217014215.php
Normal file
49
migrations/Version20260217014215.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260217014215 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__messenger_messages AS SELECT id, body, headers, queue_name, created_at, available_at, delivered_at FROM messenger_messages');
|
||||
$this->addSql('DROP TABLE messenger_messages');
|
||||
$this->addSql('CREATE TABLE messenger_messages (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, body CLOB NOT NULL, headers CLOB NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
|
||||
, available_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
|
||||
, delivered_at DATETIME DEFAULT NULL --(DC2Type:datetime_immutable)
|
||||
)');
|
||||
$this->addSql('INSERT INTO messenger_messages (id, body, headers, queue_name, created_at, available_at, delivered_at) SELECT id, body, headers, queue_name, created_at, available_at, delivered_at FROM __temp__messenger_messages');
|
||||
$this->addSql('DROP TABLE __temp__messenger_messages');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__messenger_messages AS SELECT id, body, headers, queue_name, created_at, available_at, delivered_at FROM messenger_messages');
|
||||
$this->addSql('DROP TABLE messenger_messages');
|
||||
$this->addSql('CREATE TABLE messenger_messages (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, body CLOB NOT NULL, headers CLOB NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
|
||||
, available_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
|
||||
, delivered_at DATETIME DEFAULT NULL --(DC2Type:datetime_immutable)
|
||||
)');
|
||||
$this->addSql('INSERT INTO messenger_messages (id, body, headers, queue_name, created_at, available_at, delivered_at) SELECT id, body, headers, queue_name, created_at, available_at, delivered_at FROM __temp__messenger_messages');
|
||||
$this->addSql('DROP TABLE __temp__messenger_messages');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
|
||||
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
|
||||
}
|
||||
}
|
||||
282
src/Command/GetAudioCommand.php
Normal file
282
src/Command/GetAudioCommand.php
Normal file
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Note;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:get-audio',
|
||||
description: 'Finds Notes with missing recordings and matches them to RSS feed by Date and Title.',
|
||||
)]
|
||||
class GetAudioCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private HttpClientInterface $httpClient
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'No DB changes.');
|
||||
// No specific --debug flag needed, we will output verbose logs by default for now
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$isDryRun = $input->getOption('dry-run');
|
||||
$noteRepository = $this->entityManager->getRepository(Note::class);
|
||||
|
||||
$io->title("Starting Audio Matcher");
|
||||
|
||||
// 1. Fetch Notes
|
||||
$qb = $noteRepository->createQueryBuilder('n')
|
||||
->leftJoin('n.user', 'u')
|
||||
->addSelect('u')
|
||||
->where('n.recording IS NULL OR n.recording = :empty')
|
||||
->andWhere('u.homeChurchRSS IS NOT NULL')
|
||||
->orderBy('n.date', 'DESC') // <--- Added Sort Here
|
||||
->setParameter('empty', '');
|
||||
//$query = $qb->getQuery();
|
||||
|
||||
//print ($query->getSql());
|
||||
|
||||
$notesMissingAudio = $qb->getQuery()->getResult();
|
||||
$count = count($notesMissingAudio);
|
||||
$io->text("Found $count notes in database missing audio.");
|
||||
|
||||
if ($count === 0) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 2. Group by User
|
||||
$notesByUser = [];
|
||||
foreach ($notesMissingAudio as $note) {
|
||||
$userId = (string) $note->getUser()->getId();
|
||||
$notesByUser[$userId]['user'] = $note->getUser();
|
||||
$notesByUser[$userId]['notes'][] = $note;
|
||||
}
|
||||
|
||||
// 3. Process Per User
|
||||
foreach ($notesByUser as $userId => $data) {
|
||||
$user = $data['user'];
|
||||
$userNotes = $data['notes'];
|
||||
$rssUrl = $user->getHomeChurchRSS();
|
||||
|
||||
$io->section("User: {$user->getEmail()} (Notes: " . count($userNotes) . ")");
|
||||
$io->text("Fetching RSS: $rssUrl");
|
||||
|
||||
try {
|
||||
// Pass $io to helper for debug output
|
||||
$rssItems = $this->fetchRssItems($rssUrl, $io);
|
||||
|
||||
if (empty($rssItems)) {
|
||||
$io->warning("RSS feed was empty or failed to parse.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$matchCount = 0;
|
||||
|
||||
foreach ($userNotes as $note) {
|
||||
if (!$note->getDate()) {
|
||||
$io->text(" > Note ID {$note->getId()} skipped (No Date)");
|
||||
continue;
|
||||
}
|
||||
|
||||
$noteDateString = $note->getDate()->format('Y-m-d');
|
||||
$noteTitle = $note->getTitle();
|
||||
$io->text("---------------------------------------------------");
|
||||
$io->text("Checking Note: [$noteDateString] '$noteTitle'");
|
||||
|
||||
$bestMatch = null;
|
||||
$highestConfidence = 0;
|
||||
|
||||
foreach ($rssItems as $item) {
|
||||
// DEBUG: Show Date Comparison
|
||||
if ($item['date_string'] !== $noteDateString) {
|
||||
// Uncomment the line below if you want to see EVERY failed date comparison (can be noisy)
|
||||
// $io->text(" - REJECTED: Date mismatch (RSS: {$item['date_string']})");
|
||||
continue;
|
||||
}
|
||||
|
||||
// DEBUG: Show Score Calculation
|
||||
$confidence = $this->calculateConfidence($note, $item);
|
||||
$io->text(sprintf(
|
||||
" - DATE MATCHED. Score: %d%%. RSS Title: '%s'",
|
||||
$confidence,
|
||||
$item['title']
|
||||
));
|
||||
|
||||
if ($confidence >= 80 && $confidence > $highestConfidence) {
|
||||
$highestConfidence = $confidence;
|
||||
$bestMatch = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if ($bestMatch) {
|
||||
$matchCount++;
|
||||
$io->success("Match Found! ($highestConfidence%)");
|
||||
if (!$isDryRun) {
|
||||
$note->setRecording($bestMatch['url']);
|
||||
}
|
||||
} else {
|
||||
$io->text(" > No match found for this note.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isDryRun) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
if ($matchCount > 0) {
|
||||
$io->success("Found $matchCount matches");
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$io->error("Error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively fetches RSS items if pagination links are present.
|
||||
*/
|
||||
private function fetchRssItems(string $startUrl, SymfonyStyle $io): array
|
||||
{
|
||||
$items = [];
|
||||
$nextUrl = $startUrl;
|
||||
$pageCount = 0;
|
||||
$maxPages = 20; // Safety brake to prevent infinite loops
|
||||
|
||||
do {
|
||||
$pageCount++;
|
||||
$io->text(" > Fetching Feed Page $pageCount: $nextUrl");
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $nextUrl);
|
||||
$content = $response->getContent();
|
||||
|
||||
// Suppress warnings for malformed XML
|
||||
$xml = @simplexml_load_string($content);
|
||||
|
||||
if ($xml === false) {
|
||||
$io->warning("XML Parsing Failed on page $pageCount");
|
||||
break;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$io->warning("HTTP Request Failed on page $pageCount: " . $e->getMessage());
|
||||
break;
|
||||
}
|
||||
|
||||
// 1. Parse Items on this page
|
||||
$pageItemsCount = 0;
|
||||
foreach ($xml->channel->item as $item) {
|
||||
$namespaces = $item->getNamespaces(true);
|
||||
$speaker = '';
|
||||
|
||||
// Speaker Logic
|
||||
if (isset($namespaces['itunes'])) {
|
||||
$itunes = $item->children($namespaces['itunes']);
|
||||
$speaker = (string) ($itunes->author ?? '');
|
||||
}
|
||||
if (empty($speaker) && isset($namespaces['dc'])) {
|
||||
$dc = $item->children($namespaces['dc']);
|
||||
$speaker = (string) ($dc->creator ?? '');
|
||||
}
|
||||
if (empty($speaker)) {
|
||||
$speaker = (string) ($item->author ?? '');
|
||||
}
|
||||
|
||||
// Date Parsing
|
||||
$dateString = null;
|
||||
if (isset($item->pubDate)) {
|
||||
try {
|
||||
$dt = new \DateTimeImmutable((string)$item->pubDate);
|
||||
$dateString = $dt->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
// ignore bad date
|
||||
}
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'title' => (string) $item->title,
|
||||
'speaker' => $speaker,
|
||||
'url' => (string) ($item->enclosure['url'] ?? ''),
|
||||
'date_string' => $dateString,
|
||||
];
|
||||
$pageItemsCount++;
|
||||
}
|
||||
|
||||
$io->text(" Found $pageItemsCount items on this page.");
|
||||
|
||||
// 2. Look for "Next Page" link (RFC 5005 / Atom)
|
||||
$nextUrl = null;
|
||||
|
||||
// Get namespaces on the <channel> element
|
||||
$namespaces = $xml->channel->getNamespaces(true);
|
||||
|
||||
if (isset($namespaces['atom'])) {
|
||||
$atom = $xml->channel->children($namespaces['atom']);
|
||||
foreach ($atom->link as $link) {
|
||||
// We are looking for <atom:link rel="next" href="..." />
|
||||
$attributes = $link->attributes();
|
||||
if (isset($attributes['rel']) && (string)$attributes['rel'] === 'next') {
|
||||
$nextUrl = (string)$attributes['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Check for raw <link rel="next"> if atom ns missing (rare but happens)
|
||||
if (!$nextUrl && property_exists($xml->channel, 'link')) {
|
||||
foreach ($xml->channel->link as $link) {
|
||||
$attributes = $link->attributes();
|
||||
if (isset($attributes['rel']) && (string)$attributes['rel'] === 'next') {
|
||||
$nextUrl = (string)$attributes['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} while ($nextUrl && $pageCount < $maxPages);
|
||||
|
||||
$io->success(sprintf("Finished fetching. Total items: %d (across %d pages)", count($items), $pageCount));
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function calculateConfidence(Note $note, array $rssItem): float
|
||||
{
|
||||
$noteTitle = $this->normalize($note->getTitle());
|
||||
$rssTitle = $this->normalize($rssItem['title']);
|
||||
|
||||
$noteSpeaker = $this->normalize($note->getSpeaker()->getName() ?? '');
|
||||
$rssSpeaker = $this->normalize($rssItem['speaker']);
|
||||
|
||||
similar_text($noteTitle, $rssTitle, $titlePercent);
|
||||
|
||||
if (!empty($noteSpeaker) && !empty($rssSpeaker)) {
|
||||
similar_text($noteSpeaker, $rssSpeaker, $speakerPercent);
|
||||
return ($titlePercent + $speakerPercent) / 2;
|
||||
}
|
||||
|
||||
return $titlePercent;
|
||||
}
|
||||
|
||||
private function normalize(string $input): string
|
||||
{
|
||||
return strtolower(trim($input));
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ use Exception;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -453,6 +454,47 @@ class AjaxController extends AbstractController
|
||||
return $res;
|
||||
}
|
||||
|
||||
#[Route('/save-profile', name: 'app_save_profile', methods: ['POST'])]
|
||||
public function saveProfile(Request $req, EntityManagerInterface $emi): Response
|
||||
{
|
||||
$data = json_decode($req->getContent());
|
||||
/** @var App\Entity\User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
if (!$user) {
|
||||
return new JsonResponse(['msg' => 'No User']);
|
||||
}
|
||||
|
||||
if ($data->passChange) {
|
||||
if(!$data->password) {
|
||||
return new JsonResponse(['msg' => 'Blank password']);
|
||||
}
|
||||
|
||||
// @todo check that password matches current password
|
||||
if ($data->password != $user->getPassword()) {
|
||||
return new JsonResponse(['msg' => 'Invalid password']);
|
||||
}
|
||||
|
||||
if ($data->newPassword != $data->confPassword) {
|
||||
return new JsonResponse(['msg' => 'Passwords don\'t match']);
|
||||
}
|
||||
}
|
||||
|
||||
$user->setName($data->name);
|
||||
$user->setEmail($data->email);
|
||||
$user->setHomeChurchRSS($data->homeChurch);
|
||||
|
||||
$emi->persist($user);
|
||||
|
||||
try {
|
||||
$emi->flush();
|
||||
} catch (Exception $e) {
|
||||
return new JsonResponse();
|
||||
}
|
||||
|
||||
return new JsonResponse(['msg' => 'Updated']);
|
||||
}
|
||||
|
||||
#[Route('/save-settings', name: 'app_save_settings', methods: ['POST'])]
|
||||
public function saveSettings(Request $req, EntityManagerInterface $emi): Response
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Entity\User;
|
||||
use App\Entity\Speaker;
|
||||
use App\Entity\Series;
|
||||
use App\Entity\SharedNote;
|
||||
use App\Service\DatabaseTransferService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -40,6 +41,7 @@ class DefaultController extends AbstractController
|
||||
'last4Notes' => $last4Notes,
|
||||
'reverseNoteSort' => $openNotes,
|
||||
'isAdmin' => $this->isGranted('ROLE_ADMIN'),
|
||||
'xferDB' => DatabaseTransferService::isTransferEnabled(),
|
||||
'meta' => $meta,
|
||||
'speakers' => $speakers,
|
||||
'series' => $series,
|
||||
@@ -89,7 +91,7 @@ class DefaultController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/reference-editor', name: 'app_reference_editor')]
|
||||
#[Route('/reference-editor', 'app_reference_editor')]
|
||||
public function referenceEditor(EntityManagerInterface $emi): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
@@ -97,10 +99,63 @@ class DefaultController extends AbstractController
|
||||
return $this->render('editors/reference-editor.html.twig');
|
||||
}
|
||||
|
||||
#[Route('/template-editor', name: 'app_template_editor')]
|
||||
#[Route('/template-editor', 'app_template_editor')]
|
||||
public function templateEditor(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||
return $this->render('editors/template-editor.html.twig');
|
||||
}
|
||||
|
||||
#[Route('/xfer-database', name: 'app_admin_transfer_db')]
|
||||
public function transfer(DatabaseTransferService $service, Request $request): Response
|
||||
{
|
||||
$step = $request->query->get('step', 'init');
|
||||
$session = $request->getSession();
|
||||
|
||||
if ($step === 'init') {
|
||||
$service->createSchema();
|
||||
$session->remove('transfer_logs');
|
||||
return $this->redirectToRoute('app_admin_transfer_db', ['step' => 0]);
|
||||
}
|
||||
|
||||
if ($step === 'summary') {
|
||||
// Finalize
|
||||
$service->finalizeEnvSwap();
|
||||
|
||||
return $this->render('default/transfer_summary.html.twig', [
|
||||
'logs' => $session->get('transfer_logs', [])
|
||||
]);
|
||||
}
|
||||
|
||||
$classes = $service->getEntityClasses();
|
||||
$totalClasses = count($classes);
|
||||
|
||||
if (isset($classes[$step])) {
|
||||
$class = explode("\\", $classes[$step]);
|
||||
$className = end($class);
|
||||
$func = "transfer{$className}Table";
|
||||
|
||||
if (method_exists($service, $func)) {
|
||||
$skippedCount = $service->{$func}();
|
||||
|
||||
if ($skippedCount > 0) {
|
||||
$logs = $session->get('transfer_logs', []);
|
||||
$logs[] = "Skipped $skippedCount orphaned records in the $className table.";
|
||||
$session->set('transfer_logs', $logs);
|
||||
}
|
||||
|
||||
$progress = round((($step+1) / $totalClasses) * 100);
|
||||
$nextStep = ($step + 1 < $totalClasses) ? ($step + 1) : 'summary';
|
||||
|
||||
return $this->render('default/transfer_progress.html.twig', [
|
||||
'current' => $className,
|
||||
'progress' => $progress,
|
||||
'next_step' => $nextStep,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Logout and redirect
|
||||
return $this->redirectToRoute('app_logout');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, JsonSer
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?array $metaData = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $homeChurchRSS = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->series = new ArrayCollection();
|
||||
@@ -323,4 +326,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, JsonSer
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHomeChurchRSS(): ?string
|
||||
{
|
||||
return $this->homeChurchRSS;
|
||||
}
|
||||
|
||||
public function setHomeChurchRSS(?string $homeChurchRSS): static
|
||||
{
|
||||
$this->homeChurchRSS = $homeChurchRSS;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
501
src/Service/DatabaseTransferService.php
Normal file
501
src/Service/DatabaseTransferService.php
Normal file
@@ -0,0 +1,501 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Bible;
|
||||
use App\Entity\Note;
|
||||
use App\Entity\Reference;
|
||||
use App\Entity\Series;
|
||||
use App\Entity\Speaker;
|
||||
use App\Entity\Template;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\Tools\SchemaTool;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
class DatabaseTransferService
|
||||
{
|
||||
private PDO $srcDB;
|
||||
private PDO $destDB;
|
||||
|
||||
public function __construct(
|
||||
private string $projectDir,
|
||||
private ManagerRegistry $doctrine
|
||||
) {
|
||||
// connect the source database
|
||||
switch (explode(":", $_ENV['DATABASE_URL'])[0]) {
|
||||
case 'pdo-sqlite';
|
||||
list($driverName, $dbFile) = explode(":/", $_ENV['DATABASE_URL']);
|
||||
$this->srcDB = new PDO('sqlite:'.$dbFile);
|
||||
break;
|
||||
case 'pdo-mysql';
|
||||
case 'pdo-pgsql';
|
||||
$dsn = $this->convertDBURLString($_ENV['DATABASE_URL']);
|
||||
$this->srcDB = new PDO($dsn['dsn'], $dsn['username'], $dsn['password']);
|
||||
}
|
||||
|
||||
// connect the destination database
|
||||
switch(explode(':', $_ENV['XFER_DATABASE_URL'])[0]) {
|
||||
case 'pdo-sqlite':
|
||||
list($driverName, $dbFile) = explode(":/", $_ENV['XFER_DATABASE']);
|
||||
$this->destDB = new PDO('sqlite:'.$dbFile);
|
||||
break;
|
||||
case 'pdo-mysql':
|
||||
case 'pdo-pgsql':
|
||||
$dsn = $this->convertDBURLString($_ENV['XFER_DATABASE_URL']);
|
||||
$this->destDB = new PDO($dsn['dsn'], $dsn['username'], $dsn['password']);
|
||||
}
|
||||
}
|
||||
|
||||
public function createSchema(): void
|
||||
{
|
||||
$destEm = $this->doctrine->getManager('transfer');
|
||||
$metadata = $destEm->getMetadataFactory()->getAllMetadata();
|
||||
$tool = new SchemaTool($destEm);
|
||||
|
||||
$tool->dropSchema($metadata);
|
||||
$tool->createSchema($metadata);
|
||||
}
|
||||
|
||||
public function getEntityClasses(): array
|
||||
{
|
||||
return [
|
||||
User::class,
|
||||
Bible::class,
|
||||
Reference::class,
|
||||
Template::class,
|
||||
Series::class,
|
||||
Speaker::class,
|
||||
Note::class
|
||||
];
|
||||
}
|
||||
|
||||
public function transferUserTable(): int
|
||||
{
|
||||
$sql = "SELECT * FROM user";
|
||||
$stmt = $this->srcDB->prepare($sql);
|
||||
$stmt->execute();
|
||||
$insQuery = "INSERT INTO user (id, email, roles, password, name, meta_data, home_church_rss) ".
|
||||
"VALUES ".
|
||||
"(:id, :email, :roles, :password, :name, :meta_data, :home_church)";
|
||||
$destStmt = $this->destDB->prepare($insQuery);
|
||||
$skippedCount = 0;
|
||||
|
||||
$this->destDB->beginTransaction();
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$params = [
|
||||
'id' => $row['id'],
|
||||
'email' => $row['email'],
|
||||
'roles' => $row['roles'],
|
||||
'password' => $row['password'],
|
||||
'name' => $row['name'],
|
||||
'meta_data' => $row['meta_data'],
|
||||
'home_church' => $row['home_church_rss'],
|
||||
];
|
||||
|
||||
$this->destDB->exec("SAVEPOINT row_insert");
|
||||
|
||||
try {
|
||||
$destStmt->execute($params);
|
||||
|
||||
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
|
||||
} catch (PDOException $e) {
|
||||
$skipCodes = [
|
||||
'23000',
|
||||
'23503',
|
||||
'23505',
|
||||
];
|
||||
if (in_array($e->getCode(), $skipCodes, true)) {
|
||||
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
|
||||
$skippedCount++; // 4. Increment the counter
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->destDB->rollBack();
|
||||
print_r($params);
|
||||
die("Critical Error: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->destDB->commit();
|
||||
|
||||
return $skippedCount;
|
||||
}
|
||||
|
||||
public function transferBibleTable(): int
|
||||
{
|
||||
$sql = "SELECT * FROM bible";
|
||||
$stmt = $this->srcDB->prepare($sql);
|
||||
$stmt->execute();
|
||||
$insQuery = "INSERT INTO bible (id, book, chapter, verse, content, book_index, label) ".
|
||||
"VALUES ".
|
||||
"(:id, :book, :chapter, :verse, :content, :book_index, :label)";
|
||||
$destStmt = $this->destDB->prepare($insQuery);
|
||||
$skippedCount = 0;
|
||||
|
||||
$this->destDB->beginTransaction();
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$uuid = Uuid::fromString($row['id']);
|
||||
|
||||
$params = [
|
||||
'id' => $uuid->toBinary(),
|
||||
'book' => $row['book'],
|
||||
'chapter' => $row['chapter'],
|
||||
'verse' => $row['verse'],
|
||||
'content' => $row['content'],
|
||||
'book_index' => $row['book_index'],
|
||||
'label' => $row['label'],
|
||||
];
|
||||
|
||||
$this->destDB->exec("SAVEPOINT row_insert");
|
||||
|
||||
try {
|
||||
$destStmt->execute($params);
|
||||
|
||||
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
|
||||
} catch (PDOException $e) {
|
||||
$skipCodes = [
|
||||
'23000',
|
||||
'23503',
|
||||
'23505',
|
||||
];
|
||||
if (in_array($e->getCode(), $skipCodes, true)) {
|
||||
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
|
||||
$skippedCount++; // 4. Increment the counter
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->destDB->rollBack();
|
||||
print_r($params);
|
||||
die("Critical Error: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
$this->destDB->commit();
|
||||
|
||||
return $skippedCount;
|
||||
}
|
||||
|
||||
public function transferReferenceTable(): int
|
||||
{
|
||||
$sql = "SELECT * FROM reference";
|
||||
$stmt = $this->srcDB->prepare($sql);
|
||||
$stmt->execute();
|
||||
$insQuery = "INSERT INTO reference (id, type, name, label, ndx, content) ".
|
||||
"VALUES ".
|
||||
"(:id, :type, :name, :label, :ndx, :content)";
|
||||
$destStmt = $this->destDB->prepare($insQuery);
|
||||
$skippedCount = 0;
|
||||
|
||||
$this->destDB->beginTransaction();
|
||||
while($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$params = [
|
||||
'id' => $row['id'],
|
||||
'type' => $row['type'],
|
||||
'name' => $row['name'],
|
||||
'label' => $row['label'],
|
||||
'ndx' => $row['ndx'],
|
||||
'content' => $row['content'],
|
||||
];
|
||||
|
||||
$this->destDB->exec("SAVEPOINT row_insert");
|
||||
|
||||
try {
|
||||
$destStmt->execute($params);
|
||||
|
||||
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
|
||||
} catch (PDOException $e) {
|
||||
$skipCodes = [
|
||||
'23000',
|
||||
'23503',
|
||||
'23505',
|
||||
];
|
||||
if (in_array($e->getCode(), $skipCodes, true)) {
|
||||
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
|
||||
$skippedCount++; // 4. Increment the counter
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->destDB->rollBack();
|
||||
print_r($params);
|
||||
die("Critical Error: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->destDB->commit();
|
||||
|
||||
return $skippedCount;
|
||||
}
|
||||
|
||||
public function transferTemplateTable(): int
|
||||
{
|
||||
$sql = "SELECT * FROM template";
|
||||
$stmt = $this->srcDB->prepare($sql);
|
||||
$stmt->execute();
|
||||
$insQuery = "INSERT INTO template (id, user_id, name, content) ".
|
||||
"VALUES ".
|
||||
"(:id, :user_id, :name, :content)";
|
||||
$destStmt = $this->destDB->prepare($insQuery);
|
||||
$skippedCount = 0;
|
||||
|
||||
$this->destDB->beginTransaction();
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$params = [
|
||||
'id' => $row['id'],
|
||||
'user_id' => $row['user_id'],
|
||||
'name' => $row['name'],
|
||||
'content' => $row['content'],
|
||||
];
|
||||
|
||||
$this->destDB->exec("SAVEPOINT row_insert");
|
||||
|
||||
try {
|
||||
$destStmt->execute($params);
|
||||
|
||||
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
|
||||
} catch (PDOException $e) {
|
||||
$skipCodes = [
|
||||
'23000',
|
||||
'23503',
|
||||
'23505',
|
||||
];
|
||||
if (in_array($e->getCode(), $skipCodes, true)) {
|
||||
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
|
||||
$skippedCount++; // 4. Increment the counter
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->destDB->rollBack();
|
||||
print_r($params);
|
||||
die("Critical Error: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->destDB->commit();
|
||||
|
||||
return $skippedCount;
|
||||
}
|
||||
|
||||
public function transferSpeakerTable(): int
|
||||
{
|
||||
$sql = "SELECT * FROM speaker";
|
||||
$stmt = $this->srcDB->prepare($sql);
|
||||
$stmt->execute();
|
||||
$insQuery = "INSERT INTO speaker (id, name, user_id) ".
|
||||
"VALUES ".
|
||||
"(:id, :name, :user_id)";
|
||||
$destStmt = $this->destDB->prepare($insQuery);
|
||||
$skippedCount = 0;
|
||||
|
||||
$this->destDB->beginTransaction();
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$params = [
|
||||
'id' => $row['id'],
|
||||
'name' => $row['name'],
|
||||
'user_id' => $row['user_id'],
|
||||
];
|
||||
|
||||
$this->destDB->exec("SAVEPOINT row_insert");
|
||||
|
||||
try {
|
||||
$destStmt->execute($params);
|
||||
|
||||
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
|
||||
} catch (PDOException $e) {
|
||||
$skipCodes = [
|
||||
'23000',
|
||||
'23503',
|
||||
'23505',
|
||||
];
|
||||
if (in_array($e->getCode(), $skipCodes, true)) {
|
||||
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
|
||||
$skippedCount++; // 4. Increment the counter
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->destDB->rollBack();
|
||||
print_r($params);
|
||||
die("Critical Error: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->destDB->commit();
|
||||
|
||||
return $skippedCount;
|
||||
}
|
||||
|
||||
public function transferSeriesTable(): int
|
||||
{
|
||||
$sql = "SELECT * FROM series";
|
||||
$stmt = $this->srcDB->prepare($sql);
|
||||
$stmt->execute();
|
||||
$insQuery = "INSERT INTO series (id, name, user_id, template_id) ".
|
||||
"VALUES ".
|
||||
"(:id, :name, :user_id, :template_id)";
|
||||
$destStmt = $this->destDB->prepare($insQuery);
|
||||
$skippedCount = 0;
|
||||
|
||||
$this->destDB->beginTransaction();
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$params = [
|
||||
'id' => $row['id'],
|
||||
'name' => $row['name'],
|
||||
'user_id' => $row['user_id'],
|
||||
'template_id' => $row['template_id'],
|
||||
];
|
||||
|
||||
$this->destDB->exec("SAVEPOINT row_insert");
|
||||
|
||||
try {
|
||||
$destStmt->execute($params);
|
||||
|
||||
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
|
||||
} catch (PDOException $e) {
|
||||
$skipCodes = [
|
||||
'23000',
|
||||
'23503',
|
||||
'23505',
|
||||
];
|
||||
if (in_array($e->getCode(), $skipCodes, true)) {
|
||||
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
|
||||
$skippedCount++; // 4. Increment the counter
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->destDB->rollBack();
|
||||
print_r($params);
|
||||
die("Critical Error: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->destDB->commit();
|
||||
|
||||
return $skippedCount;
|
||||
}
|
||||
|
||||
public function transferNoteTable(): int
|
||||
{
|
||||
$sql = "SELECT * FROM note";
|
||||
$stmt = $this->srcDB->prepare($sql);
|
||||
$stmt->execute();
|
||||
$insQuery = "INSERT INTO note (id, title, date, passage, refs, text, speaker_id, series_id, user_id, recording) ".
|
||||
"VALUES ".
|
||||
"(:id, :title, :date, :passage, :refs, :text, :speaker_id, :series_id, :user_id, :recording)";
|
||||
$destStmt = $this->destDB->prepare($insQuery);
|
||||
$skippedCount = 0;
|
||||
|
||||
$this->destDB->beginTransaction();
|
||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$params = [
|
||||
'id' => $row['id'],
|
||||
'speaker_id' => $row['speaker_id'],
|
||||
'series_id' => $row['series_id'],
|
||||
'user_id' => $row['user_id'],
|
||||
'title' => $row['title'],
|
||||
'date' => $row['date'],
|
||||
'passage' => $row['passage'],
|
||||
'refs' => $row['refs'],
|
||||
'text' => $row['text'],
|
||||
'recording' => $row['recording'],
|
||||
];
|
||||
|
||||
$this->destDB->exec("SAVEPOINT row_insert");
|
||||
|
||||
try {
|
||||
$destStmt->execute($params);
|
||||
|
||||
$this->destDB->exec("RELEASE SAVEPOINT row_insert");
|
||||
} catch (PDOException $e) {
|
||||
$skipCodes = [
|
||||
'23000',
|
||||
'23503',
|
||||
'23505',
|
||||
];
|
||||
if (in_array($e->getCode(), $skipCodes, true)) {
|
||||
$this->destDB->exec("ROLLBACK TO SAVEPOINT row_insert");
|
||||
$skippedCount++; // 4. Increment the counter
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->destDB->rollBack();
|
||||
print_r($params);
|
||||
die("Critical Error: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->destDB->commit();
|
||||
|
||||
return $skippedCount;
|
||||
}
|
||||
|
||||
public function finalizeEnvSwap(): void
|
||||
{
|
||||
$envPath = $this->projectDir . '/.env';
|
||||
|
||||
if (!file_exists($envPath)) {
|
||||
throw new \Exception(".env file not found at $envPath");
|
||||
}
|
||||
|
||||
$lines = file($envPath);
|
||||
$newLines = [];
|
||||
$xferUrlValue = null;
|
||||
|
||||
// 1. First pass: Find the value of XFER_DATABASE_URL
|
||||
foreach ($lines as $line) {
|
||||
if (preg_match('/^XFER_DATABASE_URL=(.*)/', trim($line), $matches)) {
|
||||
$xferUrlValue = $matches[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$xferUrlValue) {
|
||||
throw new \Exception("XFER_DATABASE_URL not found in .env file.");
|
||||
}
|
||||
|
||||
// 2. Second pass: Perform the swap
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
// Prepend #OLD_ to the existing DATABASE_URL
|
||||
if (preg_match('/^DATABASE_URL=/', $trimmed)) {
|
||||
$newLines[] = "# OLD_" . $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rename XFER_DATABASE_URL to DATABASE_URL
|
||||
if (preg_match('/^XFER_DATABASE_URL=/', $trimmed)) {
|
||||
$newLines[] = "DATABASE_URL=" . $xferUrlValue . PHP_EOL;
|
||||
continue;
|
||||
}
|
||||
|
||||
$newLines[] = $line;
|
||||
}
|
||||
|
||||
file_put_contents($envPath, implode('', $newLines));
|
||||
}
|
||||
|
||||
public static function isTransferEnabled(): bool
|
||||
{
|
||||
return (isset($_ENV['XFER_DATABASE_URL']) && !empty($_ENV['XFER_DATABASE_URL']));
|
||||
}
|
||||
|
||||
private function convertDBURLString(string $DBURL): array
|
||||
{
|
||||
$dsn = preg_split("/[:|\/|\@]+/", $DBURL);
|
||||
if (count($dsn) > 6) {
|
||||
die('You cannot have special characters in your password for the database entry during this process. Change the database user password to just use alpha-numeric characters, then run again. You can change it back to more secure after this transfer is complete');
|
||||
}
|
||||
|
||||
$res = [
|
||||
'dsn' => substr($dsn[0], 4).':'.
|
||||
'host='.$dsn[3].';'.
|
||||
'port='.$dsn[4].';'.
|
||||
'dbname='.$dsn[5],
|
||||
'username' => $dsn[1],
|
||||
'password' => $dsn[2]
|
||||
];
|
||||
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
@@ -32,4 +32,58 @@ class Utils
|
||||
// error message or try to resend the message
|
||||
}
|
||||
}
|
||||
|
||||
public static function filePerms($file): string
|
||||
{
|
||||
$perms = fileperms($file);
|
||||
|
||||
switch ($perms & 0xF000) {
|
||||
case 0xC000: // socket
|
||||
$info = 's';
|
||||
break;
|
||||
case 0xA000: // symbolic link
|
||||
$info = 'l';
|
||||
break;
|
||||
case 0x8000: // regular
|
||||
$info = 'r';
|
||||
break;
|
||||
case 0x6000: // block special
|
||||
$info = 'b';
|
||||
break;
|
||||
case 0x4000: // directory
|
||||
$info = 'd';
|
||||
break;
|
||||
case 0x2000: // character special
|
||||
$info = 'c';
|
||||
break;
|
||||
case 0x1000: // FIFO pipe
|
||||
$info = 'p';
|
||||
break;
|
||||
default: // unknown
|
||||
$info = 'u';
|
||||
}
|
||||
|
||||
// Owner
|
||||
$info .= (($perms & 0x0100) ? 'r' : '-');
|
||||
$info .= (($perms & 0x0080) ? 'w' : '-');
|
||||
$info .= (($perms & 0x0040) ?
|
||||
(($perms & 0x0800) ? 's' : 'x' ) :
|
||||
(($perms & 0x0800) ? 'S' : '-'));
|
||||
|
||||
// Group
|
||||
$info .= (($perms & 0x0020) ? 'r' : '-');
|
||||
$info .= (($perms & 0x0010) ? 'w' : '-');
|
||||
$info .= (($perms & 0x0008) ?
|
||||
(($perms & 0x0400) ? 's' : 'x' ) :
|
||||
(($perms & 0x0400) ? 'S' : '-'));
|
||||
|
||||
// World
|
||||
$info .= (($perms & 0x0004) ? 'r' : '-');
|
||||
$info .= (($perms & 0x0002) ? 'w' : '-');
|
||||
$info .= (($perms & 0x0001) ?
|
||||
(($perms & 0x0200) ? 't' : 'x' ) :
|
||||
(($perms & 0x0200) ? 'T' : '-'));
|
||||
|
||||
return $info;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<link href="{{ asset('css/main.css') }}" rel="stylesheet" />
|
||||
<link href="{{ asset('css/jquery-ui.theme.css') }}" rel='stylesheet' />
|
||||
<link href="{{ asset('css/jquery-ui.structure.css') }}" rel='stylesheet' />
|
||||
<link href="{{ asset('css/style.css') }}" rel='stylesheet' />
|
||||
<link href="{{ asset('styles/style.css') }}" rel='stylesheet' />
|
||||
<link href='//cdn.datatables.net/2.0.8/css/dataTables.dataTables.min.css' rel='stylesheet' />
|
||||
<style>
|
||||
.flex-container {
|
||||
@@ -80,6 +80,49 @@ $(function() {
|
||||
|
||||
});
|
||||
|
||||
function saveProfile() {
|
||||
const name = $('#name');
|
||||
const email = $('#email');
|
||||
const homeChurch = $('#home-church');
|
||||
const password = $('#password');
|
||||
const newPassword = $('#new-password');
|
||||
const confPassword = $('#conf-password');
|
||||
let passChange = false;
|
||||
|
||||
if (newPassword.val().length > 0) {
|
||||
if (password.val().length == 0) {
|
||||
alert('If you want to change your password you need to put in the current password as well');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.val() != confPassword.val()) {
|
||||
alert('New password and confirm passwords do not match');
|
||||
return;
|
||||
}
|
||||
passChange = true;
|
||||
}
|
||||
|
||||
fetch('/save-profile', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'name': name.val(),
|
||||
'email': email.val(),
|
||||
'homeChurch': homeChurch.val(),
|
||||
'passChange': passChange,
|
||||
'password': password.val(),
|
||||
'newPassword': newPassword.val(),
|
||||
'confPassword': confPassword.val()
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(results => {
|
||||
alert(results.msg);
|
||||
});
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
var saveInterval = $('#save-interval');
|
||||
var saveReferences = $('#save-references');
|
||||
@@ -128,6 +171,9 @@ function rollUp(cont) {
|
||||
<label for='email'>Email: </label>
|
||||
<input type='email' id='email' name='email' value='{{ app.user.email }}' /><br />
|
||||
|
||||
<label for='home-church'>Home Church RSS Feed: </label>
|
||||
<input type='text' id='home-church' name='home-church' value='{{ app.user.homeChurchRSS }}' /><br />
|
||||
|
||||
<label for='password'>Password: </label>
|
||||
<input type='password' id='password' name='password' /><br/>
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
<li><a href="#" onclick="saveNote()">Save Note</a></li>
|
||||
{% if isAdmin is defined and isAdmin %}
|
||||
<li><a href='/reference-editor'>Reference Editor</a></li>
|
||||
{% if xferDB is defined and xferDB %}
|
||||
<li><a href='/xfer-database'>Transfer Database</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<li><a href='#' onclick="openRef()">Open Reference</a></li>
|
||||
<li><a href='/template-editor'>Template Editor</a></li>
|
||||
|
||||
24
templates/default/transfer_progress.html.twig
Normal file
24
templates/default/transfer_progress.html.twig
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container mt-5 text-center">
|
||||
<h2>Transferring Database...</h2>
|
||||
<p>Processing: <strong>{{ current }}</strong></p>
|
||||
|
||||
<div class="progress mb-3" style="height: 30px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
role="progressbar" style="width: {{ progress }}%;">
|
||||
{{ progress }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>Please do not close this window.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Automatically move to the next step after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = "{{ path('app_admin_transfer_db', {step: next_step}) }}";
|
||||
}, 500);
|
||||
</script>
|
||||
{% endblock %}
|
||||
29
templates/default/transfer_summary.html.twig
Normal file
29
templates/default/transfer_summary.html.twig
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container mt-5 text-center">
|
||||
<h2 class="text-success">Database Transfer Complete!</h2>
|
||||
<p>Your data has been successfully moved to the new database, and the environment variables have been updated.</p>
|
||||
|
||||
{% if logs|length > 0 %}
|
||||
<div class="alert alert-warning text-start mx-auto" style="max-width: 600px;">
|
||||
<h5 class="alert-heading">Data Clean-up Notice</h5>
|
||||
<p>During the transfer, the following orphaned records were safely ignored to maintain database integrity:</p>
|
||||
<ul>
|
||||
{% for log in logs %}
|
||||
<li>{{ log }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-success mx-auto" style="max-width: 600px;">
|
||||
Perfect transfer! No orphaned records were found.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<p class="text-muted">You must log in again to establish a connection with the new database.</p>
|
||||
<a href="{{ path('app_logout') }}" class="btn btn-primary btn-lg">Acknowledge & Relogin</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user