29 Commits

Author SHA1 Message Date
95911a210a fix: main.css
fix path for fontawesome library
2026-03-27 13:49:15 -04:00
9482ea3708 upd: scripts
update permissions to install and setup.php along with adding #! to first line of files
2026-03-27 13:48:54 -04:00
82d03aeb6a upd: entrypoint permissions 2026-03-27 13:46:32 -04:00
3f1c9841a2 upd: lock files 2026-03-27 12:58:02 -04:00
9fdda7e2ba add: entrypoint 2026-03-27 12:55:06 -04:00
2e4028d623 upd: docker-compose
Add logging notation
2026-03-26 19:58:21 -04:00
5e6d63ef26 upd: Dockerfile
remove logrotate functionality in favor of docker native logging
2026-03-26 19:57:59 -04:00
39ef5a4c49 upd: Dockerfile
convert to do a git clone instead of copying the files into the folder and with it dev code
2026-03-26 18:46:06 -04:00
db9e1cd469 upd: Database transfer (#25)
stylize transfer_summary.html.twig by adding stylesheet and javascript blocks
2026-03-26 18:35:21 -04:00
b1726bba34 upd: PWA (#15)
add service worker script block, manifest file, and fix error for share button email
2026-03-26 18:34:11 -04:00
4ed6c18825 upd: DefaultController
Clean up
2026-03-26 18:32:37 -04:00
5ac8736004 feat: PWA (#15)
add for PWA capability
2026-03-26 18:32:07 -04:00
36f3ab3bd1 upd: install.php
comment out building asset map
2026-03-26 18:31:06 -04:00
4a1777d160 upd: Dockerfile
add pdo_sqlite and move asset-map compiling to this instead of install.php
2026-03-26 18:30:36 -04:00
2a22a3e027 upd: DefaultController (#25)
add database transfer capability
2026-03-26 15:14:15 -04:00
c948b1e39d upd: csrf
add csrf protection for forms
2026-03-26 15:12:56 -04:00
840058873a upd: update permissions on files after copy 2026-03-26 15:12:12 -04:00
d12b94b4b1 upd: symfony/config 2026-03-26 08:26:06 -04:00
26b9e6fe97 upd: .gitignore
ignore .continue folder
2026-03-26 08:22:32 -04:00
3aab29cd03 add: migration 2026-03-26 08:22:01 -04:00
d06f24b1fa upd: utils
Add method to get file permissions
2026-02-16 14:16:37 -05:00
ed774a5a37 upd: profile twig
add saveProfile method and required support
2026-02-16 14:15:51 -05:00
6664a7c71e upd: user
add home church rss field
2026-02-16 14:15:10 -05:00
4be33834d4 upd: ajaxcontroller
add method to save profile and include home church rss feed url
2026-02-16 14:14:42 -05:00
b445295959 upd: database
add home church rss url for users
2026-02-16 14:13:56 -05:00
20ba17c684 git: ignore
ignore composer.lock file
2026-02-16 14:12:44 -05:00
323e668ac9 upd: dockerfile
install logrotate and cron to accomplish scripting to retrieve audio recording links for sermon
2026-02-16 14:12:09 -05:00
0d384a8fa3 fix: install
fix typo
2026-02-16 14:10:51 -05:00
b14a0c23f6 add: app:get-audio
Script command to crawl RSS feed and find uploaded recordings to update the notes
2026-01-16 13:54:45 -05:00
35 changed files with 3605 additions and 1565 deletions

4
.gitignore vendored
View File

@@ -22,4 +22,6 @@
/assets/vendor/
###< symfony/asset-mapper ###
/references/
/references/
composer.lock
.continue

View File

@@ -1,4 +1,4 @@
FROM php:8.4-apache
FROM php:8.5-apache
RUN apt update && \
apt upgrade -y && \
@@ -14,8 +14,12 @@ RUN apt update && \
sqlite3 \
curl \
git \
cron \
nano
RUN service start cron
RUN service enable cron
RUN docker-php-ext-configure gd --with-jpeg
RUN docker-php-ext-configure zip
@@ -28,7 +32,8 @@ RUN docker-php-ext-install \
xml \
intl \
pdo_mysql \
pdo_pgsql
pdo_pgsql \
pdo_sqlite
RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" && \
php composer-setup.php --install-dir=/usr/local/bin --filename=composer && \
@@ -37,22 +42,26 @@ RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" &&
RUN curl -sS https://get.symfony.com/cli/installer | bash && \
mv /root/.symfony5/bin/symfony /usr/local/bin/symfony
COPY . /var/www/html/
RUN cd /var/www/html
RUN git clone https://gitea.rkprather.com/ryan/sermon-notes.git ./
RUN mv 000-default.conf /etc/apache2/sites-available/
RUN rm /var/www/html/.env*
RUN rm -rf /var/www/html/var/*
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 COMPOSER_ALLOW_SUPERUSER=1 composer install --no-scripts --no-dev --optimize-autoloader
RUN /usr/local/bin/php bin/console asset-map:compile
RUN mkdir /data
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/*
RUN a2enmod rewrite setenvif headers
EXPOSE 80

View File

@@ -1,4 +1,4 @@
@import 'fontawesome-all.min.css';
@import '../css/fontawesome-all.min.css';
@import url("https://fonts.googleapis.com/css?family=Open+Sans:400,600,400italic,600italic|Roboto+Slab:400,700");
/*
Editorial by HTML5 UP

6
bin/entrypoint.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
# Start the cron service in the background
service cron start
# Execute the default Docker CMD (which starts Apache in the foreground)
exec "$@"

3660
composer.lock generated

File diff suppressed because it is too large Load Diff

11
config/packages/csrf.yaml Normal file
View 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

View File

@@ -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): ''

View File

@@ -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%'

View File

@@ -1,22 +1,34 @@
services:
sermon-notes:
image: gitea.rkprather.com/ryan/sermon-notes:latest
image: gitea.rkprather.com/ryan/sermon-notes:1.3
container_name: sermon-notes
hostname: sermon-notes
restart: unless-stopped
env_file: .env
ports:
- ${HTTP_PORT}:80
volumes:
- ${PWD}/.env:/var/www/html/.env
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
depends_on:
- db
db:
image: mariadb
image: mariadb:12.3
container_name: db
hostname: db
restart: unless-stopped
env_file: .env
volumes:
- ${PWD}/db_data:/var/lib/mysql

View File

@@ -1,22 +1,34 @@
services:
sermon-notes:
image: gitea.rkprather.com/ryan/sermon-notes:latest
image: gitea.rkprather.com/ryan/sermon-notes:1.3
container_name: sermon-notes
hostname: sermon-notes
restart: unless-stopped
env_file: .env
ports:
- ${HTTP_PORT}:80
volumes:
- ${PWD}/.env:/var/www/html/.env
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
depends_on:
- db
db:
image: mysql
image: mysql:9.6
container_name: db
hostname: db
restart: unless-stopped
env_file: .env
volumes:
- ${PWD}/db-data:/var/lib/mysql

View File

@@ -1,14 +1,24 @@
services:
sermon-notes:
container_name: sermon-notes
image: gitea.rkprather.com/ryan/sermon-notes:latest
image: gitea.rkprather.com/ryan/sermon-notes:1.3
hostname: sermon-notes
restart: unless-stopped
env_file: .env
ports:
- ${HTTP_PORT}:80
volumes:
- ${PWD}/.env:/var/www/html/.env
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
depends_on:
- db
@@ -17,7 +27,9 @@ services:
container_name: db
hostname: db
restart: unless-stopped
env_file: .env
volumes:
- ${PWD}/db-data:/var/lib/postgresql/data

View File

@@ -1,11 +1,20 @@
services:
sermon-notes:
image: gitea.rkprather.com/ryan/sermon-notes:latest
image: gitea.rkprather.com/ryan/sermon-notes:1.3
container_name: sermon-notes
hostname: sermon-notes
restart: unless-stopped
env_file: .env
ports:
- ${HTTP_PORT}:80
volumes:
- ${PWD}/.env:/var/www/html/.env
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

View File

@@ -1,12 +1,21 @@
services:
sermon-notes:
image: gitea.rkprather.com/ryan/sermon-notes:latest
image: gitea.rkprather.com/ryan/sermon-notes:1.3
container_name: sermon-notes
hostname: sermon-notes
restart: unless-stopped
env_file: .env
ports:
- ${HTTP_PORT}:80
volumes:
- ${PWD}/data:/data
- ${PWD}/.env:/var/www/html/.env
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

View File

@@ -1,10 +1,10 @@
#!/usr/local/bin/php
#!/usr/bin/env php
<?php
print "Updating packages and compiling assets".PHP_EOL;
`COMPOSE_ALLOW_SUPERUSER=1 composer update`;
`symfony console asset-map:compile`;
//`symfony console asset-map:compile`;
print "Creating database schema".PHP_EOL;
`symfony console doctrine:database:create`;
@@ -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}`;

View 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)');
}
}

View 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)');
}
}

9
public/.htaccess Normal file
View File

@@ -0,0 +1,9 @@
<IfModule mod_rewrite.c>
Options -MultiViews
RewriteEngine On
SetEnvIf X-Forwarded-Proto "https" HTTPS=on
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php [QSA,L]
</IfModule>

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

35
public/manifest.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "Sermon Notes",
"short_name": "Sermon Notes",
"description": "A personal note-taking app for sermons with reference material",
"start_url": "/",
"display": "standalone",
"orientation": "landscape",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{
"src": "/images/Notes-icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/images/Notes-icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"screenshots": [
{
"src": "/images/Notes-icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/images/Notes-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"form_factor": "wide"
}
]
}

56
public/sw.js Normal file
View File

@@ -0,0 +1,56 @@
// public/sw.js
const CACHE_NAME = 'app-cache-v1';
const ASSETS_TO_CACHE = [
'/',
'/manifest.json',
// Add paths to your main CSS and JS files here
// e.g., '/build/app.css', '/build/app.js'
];
// Install Event: Cache core static assets
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(ASSETS_TO_CACHE);
})
);
self.skipWaiting();
});
// Activate Event: Clean up old caches if you change the CACHE_NAME version
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cache => {
if (cache !== CACHE_NAME) {
return caches.delete(cache);
}
})
);
})
);
self.clients.claim();
});
// Fetch Event: Network First, Fallback to Cache
self.addEventListener('fetch', event => {
// Only cache GET requests
if (event.request.method !== 'GET') return;
event.respondWith(
fetch(event.request)
.then(networkResponse => {
// If the network request succeeds, clone it and put it in the cache
return caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
})
.catch(() => {
// If the network fails (offline), try to serve from cache
return caches.match(event.request);
})
);
});

View File

@@ -1,4 +1,4 @@
#!/usr/local/bin/php
#!/usr/bin/env php
<?php

View 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));
}
}

View File

@@ -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
{

View File

@@ -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,64 @@ 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' => (isset($classes[$step+1]) ? end(explode("\\", $classes[$step+1])) : 'summary'),
'progress' => $progress,
'next_step' => $nextStep,
]);
}
}
// Step 7: Logout and redirect
return $this->redirectToRoute('app_logout');
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,4 +1,13 @@
{
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "2.12",
"recipe": {
@@ -91,6 +100,18 @@
".env"
]
},
"symfony/form": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
},
"files": [
"config/packages/csrf.yaml"
]
},
"symfony/framework-bundle": {
"version": "7.0",
"recipe": {
@@ -182,6 +203,18 @@
"tests/bootstrap.php"
]
},
"symfony/property-info": {
"version": "8.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.0",
"recipe": {

View File

@@ -8,6 +8,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<link rel='manifest' href='{{ asset('manifest.json') }}'>
<title>{% block title %}Welcome!{% endblock %}</title>
{% block stylesheets %}{% endblock %}
@@ -15,5 +16,19 @@
<body class='is-preload' onload='{% if onLoad is defined %}{{ onLoad }}{% endif %}'>
{% block body %}{% endblock %}
{% block javascripts %}{% endblock %}
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(error => {
console.log('ServiceWorker registration failed: ', error);
});
});
}
</script>
</body>
</html>

View File

@@ -181,7 +181,7 @@ let saveFailureCount = {{ meta.saveFailureCount }};
<!-- The modal body -->
<form id="emailForm" class="modal-body">
<label for="shareEmail">Enter Friends Email:</label>
<input type="email" id="shareEmail" name="email" required />
<input type="email" id="shareEmail" name="email" autocomplete=false required />
<button type='button' id="submit" class="btn btn-primary" onclick='shareNote()'>Submit</button>
</form>

View File

@@ -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/>

View File

@@ -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>

View 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 %}

View File

@@ -0,0 +1,57 @@
{% extends 'base.html.twig' %}
{% block stylesheets %}
<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('styles/style.css') }}' rel='stylesheet' />
<link href='//cdn.datatables.net/2.0.8/css/dataTables.dataTables.min.css' rel='stylesheet' />
<style>
button.button i {
font-size: 1.5em;
}
.input-error {
border: solid 2px red !important;
}
</style>
{% endblock %}
{% block javascripts %}
<script src="{{ asset('js/jquery.min.js') }}"></script>
<script src='{{ asset('js/jquery-ui.js') }}'></script>
<script src="{{ asset('js/browser.min.js') }}"></script>
<script src="{{ asset('js/breakpoints.min.js') }}"></script>
<script src="{{ asset('js/util.js') }}"></script>
<script src="{{ asset('js/main.js') }}"></script>
<script src='//momentjs.com/downloads/moment-with-locales.js'></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/markdown-it/13.0.2/markdown-it.min.js" integrity="sha512-ohlWmsCxOu0bph1om5eDL0jm/83eH09fvqLDhiEdiqfDeJbEvz4FSbeY0gLJSVJwQAp0laRhTXbUQG+ZUuifUQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src='//cdn.datatables.net/2.0.8/js/dataTables.min.js'></script>
{% endblock %}
{% 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 %}