Skip to content

Commit

Permalink
feat: add icalendar subscription
Browse files Browse the repository at this point in the history
  • Loading branch information
Miguel Ribeiro committed Dec 6, 2024
1 parent def6841 commit 7838acf
Show file tree
Hide file tree
Showing 26 changed files with 291 additions and 104 deletions.
210 changes: 210 additions & 0 deletions api/subscriptions/get_ical_feed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?php
/*
This API Endpoint accepts both POST and GET requests.
It receives the following parameters:
- convert_currency: whether to convert to the main currency (boolean) default false.
- apiKey: the API key of the user.
It returns a downloadable VCAL file with the active subscriptions
*/

require_once '../../includes/connect_endpoint.php';

header('Content-Type: application/json, charset=UTF-8');

if ($_SERVER["REQUEST_METHOD"] === "POST" || $_SERVER["REQUEST_METHOD"] === "GET") {
// if the parameters are not set, return an error

if (!isset($_REQUEST['api_key'])) {
$response = [
"success" => false,
"title" => "Missing parameters"
];
echo json_encode($response);
exit;
}

function getPriceConverted($price, $currency, $database)
{
$query = "SELECT rate FROM currencies WHERE id = :currency";
$stmt = $database->prepare($query);
$stmt->bindParam(':currency', $currency, SQLITE3_INTEGER);
$result = $stmt->execute();

$exchangeRate = $result->fetchArray(SQLITE3_ASSOC);
if ($exchangeRate === false) {
return $price;
} else {
$fromRate = $exchangeRate['rate'];
return $price / $fromRate;
}
}

$apiKey = $_REQUEST['api_key'];

// Get user from API key
$sql = "SELECT * FROM user WHERE api_key = :apiKey";
$stmt = $db->prepare($sql);
$stmt->bindValue(':apiKey', $apiKey);
$result = $stmt->execute();
$user = $result->fetchArray(SQLITE3_ASSOC);

// If the user is not found, return an error
if (!$user) {
$response = [
"success" => false,
"title" => "Invalid API key"
];
echo json_encode($response);
exit;
}

$userId = $user['id'];
$userCurrencyId = $user['main_currency'];

// Get last exchange update date for user
$sql = "SELECT * FROM last_exchange_update WHERE user_id = :userId";
$stmt = $db->prepare($sql);
$stmt->bindValue(':userId', $userId);
$result = $stmt->execute();
$lastExchangeUpdate = $result->fetchArray(SQLITE3_ASSOC);

$canConvertCurrency = empty($lastExchangeUpdate['date']) ? false : true;

// Get currencies for user
$sql = "SELECT * FROM currencies WHERE user_id = :userId";
$stmt = $db->prepare($sql);
$stmt->bindValue(':userId', $userId);
$result = $stmt->execute();
$currencies = [];
while ($currency = $result->fetchArray(SQLITE3_ASSOC)) {
$currencies[$currency['id']] = $currency;
}

// Get categories for user
$sql = "SELECT * FROM categories WHERE user_id = :userId";
$stmt = $db->prepare($sql);
$stmt->bindValue(':userId', $userId);
$result = $stmt->execute();
$categories = [];
while ($category = $result->fetchArray(SQLITE3_ASSOC)) {
$categories[$category['id']] = $category['name'];
}

// Get members for user
$sql = "SELECT * FROM household WHERE user_id = :userId";
$stmt = $db->prepare($sql);
$stmt->bindValue(':userId', $userId);
$result = $stmt->execute();
$members = [];
while ($member = $result->fetchArray(SQLITE3_ASSOC)) {
$members[$member['id']] = $member['name'];
}

// Get payment methods for user
$sql = "SELECT * FROM payment_methods WHERE user_id = :userId";
$stmt = $db->prepare($sql);
$stmt->bindValue(':userId', $userId);
$result = $stmt->execute();
$paymentMethods = [];
while ($paymentMethod = $result->fetchArray(SQLITE3_ASSOC)) {
$paymentMethods[$paymentMethod['id']] = $paymentMethod['name'];
}

$sql = "SELECT * FROM subscriptions WHERE user_id = :userId ORDER BY next_payment ASC";

$stmt = $db->prepare($sql);
$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER);
$result = $stmt->execute();

if ($result) {
$subscriptions = array();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$subscriptions[] = $row;
}
}

$subscriptionsToReturn = array();

foreach ($subscriptions as $subscription) {
$subscriptionToReturn = $subscription;

if (isset($_REQUEST['convert_currency']) && $_REQUEST['convert_currency'] === 'true' && $canConvertCurrency && $subscription['currency_id'] != $userCurrencyId) {
$subscriptionToReturn['price'] = getPriceConverted($subscription['price'], $subscription['currency_id'], $db);
} else {
$subscriptionToReturn['price'] = $subscription['price'];
}

$subscriptionToReturn['category_name'] = $categories[$subscription['category_id']];
$subscriptionToReturn['payer_user_name'] = $members[$subscription['payer_user_id']];
$subscriptionToReturn['payment_method_name'] = $paymentMethods[$subscription['payment_method_id']];

$subscriptionsToReturn[] = $subscriptionToReturn;
}

$stmt->bindValue(':inactive', false, SQLITE3_INTEGER);
$result = $stmt->execute();

header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="subscriptions.ics"');

if ($result === false) {
die("BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:NAME:\nEND:VCALENDAR");
}

$icsContent = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Wallos//iCalendar//EN\nNAME:Wallos\nX-WR-CALNAME:Wallos\n";

while ($subscription = $result->fetchArray(SQLITE3_ASSOC)) {
$subscription['payer_user'] = $members[$subscription['payer_user_id']];
$subscription['category'] = $categories[$subscription['category_id']];
$subscription['payment_method'] = $paymentMethods[$subscription['payment_method_id']];
$subscription['currency'] = $currencies[$subscription['currency_id']]['symbol'];
$subscription['trigger'] = $subscription['notify_days_before'] ? $subscription['notify_days_before'] : 1;
$subscription['price'] = number_format($subscription['price'], 2);

$uid = uniqid();
$summary = "Wallos: " . $subscription['name'];
$description = "Price: {$subscription['currency']}{$subscription['price']}\\nCategory: {$subscription['category']}\\nPayment Method: {$subscription['payment_method']}\\nPayer: {$subscription['payer_user']}\\nNotes: {$subscription['notes']}";
$dtstart = (new DateTime($subscription['next_payment']))->format('Ymd\THis\Z');
$dtend = (new DateTime($subscription['next_payment']))->modify('+1 hour')->format('Ymd\THis\Z');
$location = isset($subscription['url']) ? $subscription['url'] : '';
$alarm_trigger = '-' . $subscription['trigger'] . 'D';

$icsContent .= <<<ICS
BEGIN:VEVENT
UID:$uid
SUMMARY:$summary
DESCRIPTION:$description
DTSTART:$dtstart
DTEND:$dtend
LOCATION:$location
STATUS:CONFIRMED
TRANSP:OPAQUE
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:$alarm_trigger
END:VALARM
END:VEVENT
ICS;
}

$icsContent .= "END:VCALENDAR\n";
echo $icsContent;
$db->close();
exit;



} else {
$response = [
"success" => false,
"title" => "Invalid request method"
];
echo json_encode($response);
exit;
}


?>
58 changes: 0 additions & 58 deletions api/subscriptions/get_subscriptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -306,64 +306,6 @@ function getPriceConverted($price, $currency, $database)
$subscriptionsToReturn[] = $subscriptionToReturn;
}

if (isset($_REQUEST['type'])) {
$type = $_REQUEST['type'];
$stmt->bindValue(':inactive', false, SQLITE3_INTEGER);
$result = $stmt->execute();

if ($type == "iCalendar") {
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="subscriptions.ics"');

if ($result === false) {
die("BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:NAME:\nEND:VCALENDAR");
}

$icsContent = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Wallos//$type//EN\nNAME:Wallos\nX-WR-CALNAME:Wallos\n";

while ($subscription = $result->fetchArray(SQLITE3_ASSOC)) {
$subscription['payer_user'] = $members[$subscription['payer_user_id']];
$subscription['category'] = $categories[$subscription['category_id']];
$subscription['payment_method'] = $paymentMethods[$subscription['payment_method_id']];
$subscription['currency'] = $currencies[$subscription['currency_id']]['symbol'];
$subscription['trigger'] = $subscription['notify_days_before'] ? $subscription['notify_days_before'] : 1;
$subscription['price'] = number_format($subscription['price'], 2);

$uid = uniqid();
$summary = "Wallos: " . $subscription['name'];
$description = "Price: {$subscription['currency']}{$subscription['price']}\\nCategory: {$subscription['category']}\\nPayment Method: {$subscription['payment_method']}\\nPayer: {$subscription['payer_user']}\\nNotes: {$subscription['notes']}";
$dtstart = (new DateTime($subscription['next_payment']))->format('Ymd\THis\Z');
$dtend = (new DateTime($subscription['next_payment']))->modify('+1 hour')->format('Ymd\THis\Z');
$location = isset($subscription['url']) ? $subscription['url'] : '';
$alarm_trigger = '-' . $subscription['trigger'] . 'D';

$icsContent .= <<<ICS
BEGIN:VEVENT
UID:$uid
SUMMARY:$summary
DESCRIPTION:$description
DTSTART:$dtstart
DTEND:$dtend
LOCATION:$location
STATUS:CONFIRMED
TRANSP:OPAQUE
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER:$alarm_trigger
END:VALARM
END:VEVENT
ICS;
}

$icsContent .= "END:VCALENDAR\n";
echo $icsContent;
$db->close();
exit;
}
}

$response = [
"success" => true,
"title" => "subscriptions",
Expand Down
62 changes: 17 additions & 45 deletions calendar.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,51 +104,23 @@ function getPriceConverted($price, $currency, $database, $userId)
}
?>
<div class="split-header">
<h2>Calendar</h2>
<button class="button tiny" onClick="showExportPopup()" style="margin-right: auto"> <?= translate('subscriptions', $i18n) ?> </button>
<div id="subscriptions_calendar" class="subscription-modal">
<div class="modal-header">
<h3><?= translate('subscriptions', $i18n) ?></h3>
<span class="fa-solid fa-xmark close-modal" onclick="closePopup()"></span>
</div>
<div class="form-group-inline">
<input id="iCalendarUrl" type="text" value="" readonly>
<button onclick="copyToClipboard()" class="button tiny"> <?= translate('copy_to_clipboard', $i18n) ?> </button>
</div>
</div>

<script>
function showExportPopup() {
const host = window.location.origin;
const apiPath = "/api/subscriptions/get_subscriptions.php";
const apiKey = "<?= $userData['api_key'] ?>";
const queryParams = `?api_key=${apiKey}&type=iCalendar`;
const fullUrl = `${host}${apiPath}${queryParams}`;
document.getElementById('iCalendarUrl').value = fullUrl;

if (apiKey === "") {
showErrorMessage( "<?= translate('invalid_api_key', $i18n) ?>" );
return;
}
document.getElementById('subscriptions_calendar').classList.add('is-open');
}
function closePopup() {
document.getElementById('subscriptions_calendar').classList.remove('is-open');
}

function copyToClipboard() {
const urlField = document.getElementById('iCalendarUrl');
urlField.select();
urlField.setSelectionRange(0, 99999); // For mobile devices
navigator.clipboard.writeText(urlField.value)
.then(() => {
showSuccessMessage(translate('copied_to_clipboard'));
})
.catch(() => {
showErrorMessage(translate('unknown_error'));
});
}
</script>
<h2>
Calendar
<button class="button export-ical" onClick="showExportPopup()" title="<?= translate('export_icalendar', $i18n) ?>">
<?php require_once 'images/siteicons/svg/export_ical.php'; ?>
</button>
</h2>
<div id="subscriptions_calendar" class="subscription-modal">
<div class="modal-header">
<h3><?= translate('export_icalendar', $i18n) ?></h3>
<span class="fa-solid fa-xmark close-modal" onclick="closePopup()"></span>
</div>
<div class="form-group-inline">
<input id="iCalendarUrl" type="text" value="" readonly>
<input type="hidden" id="apiKey" value="<?= $userData['api_key'] ?>">
<button onclick="copyToClipboard()" class="button tiny"> <?= translate('copy_to_clipboard', $i18n) ?> </button>
</div>
</div>

<div class="calendar-nav">
<?php
Expand Down
4 changes: 4 additions & 0 deletions images/siteicons/svg/export_ical.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="mdi-calendar-export" width="24" height="24" viewBox="0 0 24 24">
<path d="M12,22L16,18H13V12H11V18H8M19,4H18V2H16V4H8V2H6V4H5A2,2 0 0,0 3,6V20A2,2 0 0,0 5,22H8V20H5V9H19V20H16V22H19A2,2 0 0,0 21,20V6A2,2 0 0,0 19,4Z" fill="currentColor"/>
</svg>
1 change: 1 addition & 0 deletions includes/i18n/de.php
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@
"month-11" => "November",
"month-12" => "Dezember",
"total_cost" => "Gesamtkosten",
"export_icalendar" => "iCalendar exportieren",
// TOTP Page
"insert_totp_code" => "Bitte geben Sie den TOTP-Code ein",

Expand Down
1 change: 1 addition & 0 deletions includes/i18n/el.php
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@
"month-11" => "Νοέμβριος",
"month-12" => "Δεκέμβριος",
"total_cost" => "Συνολικό κόστος",
"export_icalendar" => "Εξαγωγή iCalendar",
// TOTP Page
"insert_totp_code" => "Εισάγετε τον κωδικό TOTP",

Expand Down
1 change: 1 addition & 0 deletions includes/i18n/en.php
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@
"month-11" => "November",
"month-12" => "December",
"total_cost" => "Total Cost",
"export_icalendar" => "Export iCalendar",
// TOTP Page
"insert_totp_code" => "Insert TOTP code",

Expand Down
1 change: 1 addition & 0 deletions includes/i18n/es.php
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@
"month-11" => "Noviembre",
"month-12" => "Diciembre",
"total_cost" => "Costo Total",
"export_icalendar" => "Exportar iCalendar",
// TOTP Page
"insert_totp_code" => "Introduce el código TOTP",

Expand Down
1 change: 1 addition & 0 deletions includes/i18n/fr.php
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@
"month-11" => "Novembre",
"month-12" => "Décembre",
"total_cost" => "Coût total",
"export_icalendar" => "Exporter en iCalendar",
// TOTP Page
"insert_totp_code" => "Veuillez insérer le code TOTP",

Expand Down
1 change: 1 addition & 0 deletions includes/i18n/it.php
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@
"month-11" => "Novembre",
"month-12" => "Dicembre",
"total_cost" => "Costo totale",
"export_icalendar" => "Esporta iCal",

// TOTP Page
"insert_totp_code" => "Inserisci il codice TOTP",
Expand Down
Loading

0 comments on commit 7838acf

Please sign in to comment.