Add Fever API support

This commit is contained in:
Frédéric Guillot 2014-10-29 21:28:23 -04:00
parent ab406e8eaa
commit 5801258ace
15 changed files with 411 additions and 82 deletions

View File

@ -69,6 +69,7 @@ Documentation
- [Translations](docs/translations.markdown) - [Translations](docs/translations.markdown)
- [Themes](docs/themes.markdown) - [Themes](docs/themes.markdown)
- [API documentation](http://miniflux.net/api.html) - [API documentation](http://miniflux.net/api.html)
- [Fever API](docs/fever.markdown)
- [FAQ](docs/faq.markdown) - [FAQ](docs/faq.markdown)
Todo and known bugs Todo and known bugs

View File

@ -32,7 +32,7 @@ defined('HTTP_TIMEOUT') or define('HTTP_TIMEOUT', 20);
defined('BASE_URL_DIRECTORY') or define('BASE_URL_DIRECTORY', dirname($_SERVER['PHP_SELF'])); defined('BASE_URL_DIRECTORY') or define('BASE_URL_DIRECTORY', dirname($_SERVER['PHP_SELF']));
defined('ROOT_DIRECTORY') or define('ROOT_DIRECTORY', __DIR__); defined('ROOT_DIRECTORY') or define('ROOT_DIRECTORY', __DIR__);
defined('DATA_DIRECTORY') or define('DATA_DIRECTORY', 'data'); defined('DATA_DIRECTORY') or define('DATA_DIRECTORY', __DIR__.'/data');
defined('ENABLE_MULTIPLE_DB') or define('ENABLE_MULTIPLE_DB', true); defined('ENABLE_MULTIPLE_DB') or define('ENABLE_MULTIPLE_DB', true);
defined('DB_FILENAME') or define('DB_FILENAME', 'db.sqlite'); defined('DB_FILENAME') or define('DB_FILENAME', 'db.sqlite');

View File

@ -74,7 +74,7 @@ Router\get_action('auto-update', function() {
Router\get_action('generate-tokens', function() { Router\get_action('generate-tokens', function() {
Model\Config\new_tokens(); Model\Config\new_tokens();
Response\redirect('?action=config#api'); Response\redirect('?action=config');
}); });
// Optimize the database manually // Optimize the database manually

27
docs/fever-api.markdown Normal file
View File

@ -0,0 +1,27 @@
Fever API
=========
Miniflux support the [Fever API](http://feedafever.com/api).
That means you can use mobile applications compatible with Fever.
This feature have been tested with the following apps:
- [Press for Android](http://twentyfivesquares.com/press/)
Configuration
-------------
Miniflux generates a random password for the Fever API.
All information are available from the **preferences page**.
- URL: http://your_miniflux_url/fever/
- Username: Your username
- Password: random (visible on the settings page)
Notes
-----
- Links, sparks, kindling, favicons and groups are not supported.
- All feeds will be under a category "All" because Miniflux doesn't support categories.
- Only JSON responses are handled.
- If you have multiple users with Miniflux, that will works only with the default user.

318
fever/index.php Normal file
View File

@ -0,0 +1,318 @@
<?php
require '../common.php';
use Model\Feed;
use PicoDb\Database;
// Route handler
function route($name, Closure $callback = null)
{
static $routes = array();
if ($callback !== null) {
$routes[$name] = $callback;
}
else if (isset($routes[$name])) {
$routes[$name]();
}
}
// Serialize the payload in Json (XML is not supported)
function response(array $response)
{
header('Content-Type: application/json');
echo json_encode($response);
exit;
}
// Fever authentication
function auth()
{
$credentials = Database::get('db')->table('config')
->columns('username', 'fever_token')
->findOne();
$api_key = md5($credentials['username'].':'.$credentials['fever_token']);
$response = array(
'api_version' => 3,
'auth' => (int) (@$_POST['api_key'] === $api_key),
'last_refreshed_on_time' => time(),
);
return $response;
}
// Call: ?api&groups
route('groups', function() {
$response = auth();
if ($response['auth']) {
$feed_ids = Database::get('db')
->table('feeds')
->findAllByColumn('id');
$response['groups'] = array(
array(
'id' => 1,
'title' => t('All'),
)
);
$response['feeds_groups'] = array(
array(
'group_id' => 1,
'feed_ids' => implode(',', $feed_ids),
)
);
}
response($response);
});
// Call: ?api&feeds
route('feeds', function() {
$response = auth();
if ($response['auth']) {
$response['feeds'] = array();
$feeds = Feed\get_all();
$feed_ids = array();
foreach ($feeds as $feed) {
$response['feeds'][] = array(
'id' => (int) $feed['id'],
'favicon_id' => 1,
'title' => $feed['title'],
'url' => $feed['feed_url'],
'site_url' => $feed['site_url'],
'is_spark' => 0,
'last_updated_on_time' => $feed['last_checked'] ?: time(),
);
$feed_ids[] = $feed['id'];
}
$response['feeds_groups'] = array(
array(
'group_id' => 1,
'feed_ids' => implode(',', $feed_ids),
)
);
}
response($response);
});
// Call: ?api&favicons
route('favicons', function() {
$response = auth();
if ($response['auth']) {
$response['favicons'] = array();
}
response($response);
});
// Call: ?api&items
route('items', function() {
$response = auth();
if ($response['auth']) {
$offset = 0;
$direction = 'ASC';
if (isset($_GET['since_id']) && is_numeric($_GET['since_id'])) {
$offset = $_GET['since_id'];
$direction = 'ASC';
}
else if (isset($_GET['max_id']) && is_numeric($_GET['max_id'])) {
$offset = $_GET['max_id'];
$direction = 'DESC';
}
$query = Database::get('db')
->table('items')
->columns(
'rowid',
'feed_id',
'title',
'author',
'content',
'url',
'updated',
'status',
'bookmark'
)
->orderby('rowid', $direction)
->offset($offset)
->limit(50);
if (! empty($_GET['with_ids'])) {
$query->in('rowid', explode(',', $_GET['with_ids']));
}
$items = $query->findAll();
$response['items'] = array();
foreach ($items as $item) {
$response['items'][] = array(
'id' => (int) $item['rowid'],
'feed_id' => (int) $item['feed_id'],
'title' => $item['title'],
'author' => $item['author'],
'html' => $item['content'],
'url' => $item['url'],
'is_saved' => (int) $item['bookmark'],
'is_read' => $item['status'] == 'read' ? 1 : 0,
'created_on_time' => $item['updated'],
);
}
$response['total_items'] = Database::get('db')
->table('items')
->neq('status', 'removed')
->count();
}
response($response);
});
// Call: ?api&links
route('links', function() {
$response = auth();
if ($response['auth']) {
$response['links'] = array();
}
response($response);
});
// Call: ?api&unread_item_ids
route('unread_item_ids', function() {
$response = auth();
if ($response['auth']) {
$item_ids = Database::get('db')
->table('items')
->eq('status', 'unread')
->findAllByColumn('rowid');
$response['unread_item_ids'] = implode(',', $item_ids);
}
response($response);
});
// Call: ?api&saved_item_ids
route('saved_item_ids', function() {
$response = auth();
if ($response['auth']) {
$item_ids = Database::get('db')
->table('items')
->eq('bookmark', 1)
->findAllByColumn('rowid');
$response['saved_item_ids'] = implode(',', $item_ids);
}
response($response);
});
// handle write items
route('write_items', function() {
$response = auth();
if ($response['auth']) {
$query = Database::get('db')
->table('items')
->eq('rowid', $_POST['id']);
if ($_POST['as'] === 'saved') {
$query->update(array('bookmark' => 1));
}
else if ($_POST['as'] === 'unsaved') {
$query->update(array('bookmark' => 0));
}
else if ($_POST['as'] === 'read') {
$query->update(array('status' => 'read'));
}
else if ($_POST['as'] === 'unread') {
$query->update(array('status' => 'unread'));
}
}
response($response);
});
// handle write feeds
route('write_feeds', function() {
$response = auth();
if ($response['auth']) {
Database::get('db')
->table('items')
->eq('feed_id', $_POST['id'])
->lte('updated', $_POST['before'])
->update(array('status' => $_POST['as'] === 'read' ? 'read' : 'unread'));
}
response($response);
});
// handle write groups
route('write_groups', function() {
$response = auth();
if ($response['auth']) {
Database::get('db')
->table('items')
->lte('updated', $_POST['before'])
->update(array('status' => $_POST['as'] === 'read' ? 'read' : 'unread'));
}
response($response);
});
foreach (array_keys($_GET) as $action) {
route($action);
}
if (! empty($_POST['mark']) && ! empty($_POST['as']) && ! empty($_POST['id'])) {
if ($_POST['mark'] === 'item') {
route('write_items');
}
else if ($_POST['mark'] === 'feed' && ! empty($_POST['before'])) {
route('write_feeds');
}
else if ($_POST['mark'] === 'group' && ! empty($_POST['before'])) {
route('write_groups');
}
}
response(auth());

View File

@ -96,14 +96,6 @@ return array(
'Never' => 'Nikdy', 'Never' => 'Nikdy',
'After %d day' => 'Po %d dni', 'After %d day' => 'Po %d dni',
'After %d days' => 'Po %d dnech', 'After %d days' => 'Po %d dnech',
'French' => 'Francouzština',
'English' => 'Angličtina',
'German' => 'Němčina',
'Italian' => 'Italština',
// 'Spanish' => '',
'Simplified Chinese' => 'Zjednodušená čínština',
'Czech' => 'Čeština',
// 'Portuguese' => '',
'unread' => 'nepřečtené', 'unread' => 'nepřečtené',
'bookmark' => 'přidat do záložek', 'bookmark' => 'přidat do záložek',
'remove bookmark' => 'odstranit záložku', 'remove bookmark' => 'odstranit záložku',
@ -230,4 +222,7 @@ return array(
// 'Remove this feed' => '', // 'Remove this feed' => '',
// 'Miniflux' => '', // 'Miniflux' => '',
// 'mini<span>flux</span>' => '', // 'mini<span>flux</span>' => '',
// 'Username:' => '',
// 'Password:' => '',
// 'All' => '',
); );

View File

@ -96,14 +96,6 @@ return array(
'Never' => 'Niemals', 'Never' => 'Niemals',
'After %d day' => 'Nach %d Tag', 'After %d day' => 'Nach %d Tag',
'After %d days' => 'Nach %d Tagen', 'After %d days' => 'Nach %d Tagen',
'French' => 'Französisch',
'English' => 'Englisch',
'German' => 'Deutsch',
'Italian' => 'Italienisch',
'Spanish' => 'Spanisch',
'Simplified Chinese' => 'Vereinfachtes Chinesisch',
'Czech' => 'Tschechisch',
'Portuguese' => 'Portugiesisch',
'unread' => 'ungelesen', 'unread' => 'ungelesen',
'bookmark' => 'lesezeichen', 'bookmark' => 'lesezeichen',
'remove bookmark' => 'lesezeichen entfernen', 'remove bookmark' => 'lesezeichen entfernen',
@ -230,4 +222,7 @@ return array(
// 'Remove this feed' => '', // 'Remove this feed' => '',
// 'Miniflux' => '', // 'Miniflux' => '',
// 'mini<span>flux</span>' => '', // 'mini<span>flux</span>' => '',
// 'Username:' => '',
// 'Password:' => '',
// 'All' => '',
); );

View File

@ -96,14 +96,6 @@ return array(
'Never' => 'Nunca', 'Never' => 'Nunca',
'After %d day' => 'Después de %d día', 'After %d day' => 'Después de %d día',
'After %d days' => 'Después de %d días', 'After %d days' => 'Después de %d días',
'French' => 'Francés',
'English' => 'Inglés',
'German' => 'Alemán',
'Italian' => 'Italiano',
'Spanish' => 'Español',
'Simplified Chinese' => 'Chino simplificado',
'Czech' => 'Checo',
'Portuguese' => 'Portugués',
'unread' => 'no leídos', 'unread' => 'no leídos',
'bookmark' => 'añadir a marcadores', 'bookmark' => 'añadir a marcadores',
'remove bookmark' => 'borrar marcador', 'remove bookmark' => 'borrar marcador',
@ -230,4 +222,7 @@ return array(
// 'Remove this feed' => '', // 'Remove this feed' => '',
// 'Miniflux' => '', // 'Miniflux' => '',
// 'mini<span>flux</span>' => '', // 'mini<span>flux</span>' => '',
// 'Username:' => '',
// 'Password:' => '',
// 'All' => '',
); );

View File

@ -96,14 +96,6 @@ return array(
'Never' => 'Jamais', 'Never' => 'Jamais',
'After %d day' => 'Après %d jour', 'After %d day' => 'Après %d jour',
'After %d days' => 'Après %d jours', 'After %d days' => 'Après %d jours',
'French' => 'Français',
'English' => 'Anglais',
'German' => 'Allemand',
'Italian' => 'Italien',
'Spanish' => 'Espagnol',
'Simplified Chinese' => 'Chinois simplifié',
'Czech' => 'Tchèque',
'Portuguese' => 'Portuguais',
'unread' => 'non lus', 'unread' => 'non lus',
'bookmark' => 'ajouter aux favoris', 'bookmark' => 'ajouter aux favoris',
'remove bookmark' => 'supprimer des favoris', 'remove bookmark' => 'supprimer des favoris',
@ -230,4 +222,7 @@ return array(
'Remove this feed' => 'Supprimer cet abonnement', 'Remove this feed' => 'Supprimer cet abonnement',
'Miniflux' => 'Miniflux', 'Miniflux' => 'Miniflux',
'mini<span>flux</span>' => 'mini<span>flux</span>', 'mini<span>flux</span>' => 'mini<span>flux</span>',
'Username:' => 'Utilisateur :',
'Password:' => 'Mot de passe :',
'All' => 'Tout',
); );

View File

@ -96,14 +96,6 @@ return array(
'Never' => 'Mai', 'Never' => 'Mai',
'After %d day' => 'Dopo %d giorno', 'After %d day' => 'Dopo %d giorno',
'After %d days' => 'Dopo %d giorni', 'After %d days' => 'Dopo %d giorni',
'French' => 'Francese',
'English' => 'Inglese',
'German' => 'Tedesco',
'Italian' => 'Italiano',
// 'Spanish' => '',
'Simplified Chinese' => 'Cinese semplificato',
'Czech' => 'Ceco',
// 'Portuguese' => '',
'unread' => 'non letti', 'unread' => 'non letti',
'bookmark' => 'bookmark', 'bookmark' => 'bookmark',
'remove bookmark' => 'cancella bookmark', 'remove bookmark' => 'cancella bookmark',
@ -230,4 +222,7 @@ return array(
// 'Remove this feed' => '', // 'Remove this feed' => '',
// 'Miniflux' => '', // 'Miniflux' => '',
// 'mini<span>flux</span>' => '', // 'mini<span>flux</span>' => '',
// 'Username:' => '',
// 'Password:' => '',
// 'All' => '',
); );

View File

@ -96,14 +96,6 @@ return array(
'Never' => 'Nunca', 'Never' => 'Nunca',
'After %d day' => 'Depois %d dias', 'After %d day' => 'Depois %d dias',
'After %d days' => 'Depois %d dias', 'After %d days' => 'Depois %d dias',
'French' => 'Frances',
'English' => 'Ingles',
'German' => 'Alemão',
'Italian' => 'Italiano',
// 'Spanish' => '',
'Simplified Chinese' => 'Chinês Simplificado',
// 'Czech' => '',
'Portuguese' => 'Português',
'unread' => 'não lido', 'unread' => 'não lido',
'bookmark' => 'lesezeichen', 'bookmark' => 'lesezeichen',
'remove bookmark' => 'lesezeichen löschen', 'remove bookmark' => 'lesezeichen löschen',
@ -230,4 +222,7 @@ return array(
// 'Remove this feed' => '', // 'Remove this feed' => '',
// 'Miniflux' => '', // 'Miniflux' => '',
// 'mini<span>flux</span>' => '', // 'mini<span>flux</span>' => '',
// 'Username:' => '',
// 'Password:' => '',
// 'All' => '',
); );

View File

@ -96,14 +96,6 @@ return array(
'Never' => '从不', 'Never' => '从不',
'After %d day' => '%d 天之后', 'After %d day' => '%d 天之后',
'After %d days' => '%d 天之后', 'After %d days' => '%d 天之后',
'French' => '法语',
'English' => '英语',
'German' => '德语',
'Italian' => '意大利人',
// 'Spanish' => '',
'Simplified Chinese' => '简体中文',
'Czech' => '捷克语',
// 'Portuguese' => '',
'unread' => '未读', 'unread' => '未读',
'bookmark' => '收藏', 'bookmark' => '收藏',
'remove bookmark' => '取消收藏', 'remove bookmark' => '取消收藏',
@ -230,4 +222,7 @@ return array(
// 'Remove this feed' => '', // 'Remove this feed' => '',
// 'Miniflux' => '', // 'Miniflux' => '',
// 'mini<span>flux</span>' => '', // 'mini<span>flux</span>' => '',
// 'Username:' => '',
// 'Password:' => '',
// 'All' => '',
); );

View File

@ -2,13 +2,14 @@
namespace Model\Config; namespace Model\Config;
use DirectoryIterator;
use SimpleValidator\Validator; use SimpleValidator\Validator;
use SimpleValidator\Validators; use SimpleValidator\Validators;
use PicoDb\Database; use PicoDb\Database;
use PicoFeed\Config as ReaderConfig; use PicoFeed\Config as ReaderConfig;
use PicoFeed\Logging; use PicoFeed\Logging;
const DB_VERSION = 28; const DB_VERSION = 29;
const HTTP_USER_AGENT = 'Miniflux (http://miniflux.net)'; const HTTP_USER_AGENT = 'Miniflux (http://miniflux.net)';
// Get PicoFeed config // Get PicoFeed config
@ -34,7 +35,6 @@ function get_reader_config()
function get_iframe_whitelist() function get_iframe_whitelist()
{ {
return array( return array(
'//www.youtube.com',
'http://www.youtube.com', 'http://www.youtube.com',
'https://www.youtube.com', 'https://www.youtube.com',
'http://player.vimeo.com', 'http://player.vimeo.com',
@ -62,27 +62,23 @@ function write_debug()
// Get available timezone // Get available timezone
function get_timezones() function get_timezones()
{ {
$timezones = \timezone_identifiers_list(); $timezones = timezone_identifiers_list();
return array_combine(array_values($timezones), $timezones); return array_combine(array_values($timezones), $timezones);
} }
// Get all supported languages // Get all supported languages
function get_languages() function get_languages()
{ {
$languages = array( return array(
'cs_CZ' => t('Czech'), 'cs_CZ' => 'Čeština',
'de_DE' => t('German'), 'de_DE' => 'Deutsch',
'en_US' => t('English'), 'en_US' => 'English',
'es_ES' => t('Spanish'), 'es_ES' => 'Español',
'fr_FR' => t('French'), 'fr_FR' => 'Français',
'it_IT' => t('Italian'), 'it_IT' => 'Italiano',
'pt_BR' => t('Portuguese'), 'pt_BR' => 'Português',
'zh_CN' => t('Simplified Chinese'), 'zh_CN' => '简体中国',
); );
asort($languages);
return $languages;
} }
// Get all skins // Get all skins
@ -94,7 +90,7 @@ function get_themes()
if (file_exists(THEME_DIRECTORY)) { if (file_exists(THEME_DIRECTORY)) {
$dir = new \DirectoryIterator(THEME_DIRECTORY); $dir = new DirectoryIterator(THEME_DIRECTORY);
foreach ($dir as $fileinfo) { foreach ($dir as $fileinfo) {
@ -180,6 +176,7 @@ function new_tokens()
'api_token' => generate_token(), 'api_token' => generate_token(),
'feed_token' => generate_token(), 'feed_token' => generate_token(),
'bookmarklet_token' => generate_token(), 'bookmarklet_token' => generate_token(),
'fever_token' => substr(generate_token(), 0, 8),
); );
return Database::get('db')->table('config')->update($values); return Database::get('db')->table('config')->update($values);
@ -242,6 +239,7 @@ function get_all()
'theme', 'theme',
'api_token', 'api_token',
'feed_token', 'feed_token',
'fever_token',
'bookmarklet_token', 'bookmarklet_token',
'auth_google_token', 'auth_google_token',
'auth_mozilla_token', 'auth_mozilla_token',

View File

@ -2,6 +2,14 @@
namespace Schema; namespace Schema;
use PDO;
use Model\Config;
function version_29($pdo)
{
$pdo->exec('ALTER TABLE config ADD COLUMN fever_token INTEGER DEFAULT "'.substr(Config\generate_token(), 0, 8).'"');
}
function version_28($pdo) function version_28($pdo)
{ {
$pdo->exec('ALTER TABLE feeds ADD COLUMN rtl INTEGER DEFAULT 0'); $pdo->exec('ALTER TABLE feeds ADD COLUMN rtl INTEGER DEFAULT 0');
@ -14,7 +22,7 @@ function version_27($pdo)
function version_26($pdo) function version_26($pdo)
{ {
$pdo->exec('ALTER TABLE config ADD COLUMN bookmarklet_token TEXT DEFAULT "'.\Model\Config\generate_token().'"'); $pdo->exec('ALTER TABLE config ADD COLUMN bookmarklet_token TEXT DEFAULT "'.Config\generate_token().'"');
} }
function version_25($pdo) function version_25($pdo)
@ -95,7 +103,7 @@ function version_15($pdo)
function version_14($pdo) function version_14($pdo)
{ {
$pdo->exec('ALTER TABLE config ADD COLUMN feed_token TEXT DEFAULT "'.\Model\Config\generate_token().'"'); $pdo->exec('ALTER TABLE config ADD COLUMN feed_token TEXT DEFAULT "'.Config\generate_token().'"');
} }
function version_13($pdo) function version_13($pdo)
@ -105,7 +113,7 @@ function version_13($pdo)
function version_12($pdo) function version_12($pdo)
{ {
$pdo->exec('ALTER TABLE config ADD COLUMN api_token TEXT DEFAULT "'.\Model\Config\generate_token().'"'); $pdo->exec('ALTER TABLE config ADD COLUMN api_token TEXT DEFAULT "'.Config\generate_token().'"');
} }
function version_11($pdo) function version_11($pdo)
@ -119,7 +127,7 @@ function version_11($pdo)
$rq->execute(); $rq->execute();
$items = $rq->fetchAll(\PDO::FETCH_ASSOC); $items = $rq->fetchAll(PDO::FETCH_ASSOC);
foreach ($items as $item) { foreach ($items as $item) {

View File

@ -72,15 +72,27 @@
</div> </div>
<section> <section>
<div class="alert alert-normal"> <div class="alert alert-normal">
<h3 id="api"><?= t('API') ?></h3> <h3 id="fever"><?= t('Fever API') ?></h3>
<ul>
<li><?= t('Link:') ?> <strong><?= Helper\get_current_base_url().'fever/' ?></strong></li>
<li><?= t('Username:') ?> <strong><?= Helper\escape($values['username']) ?></strong></li>
<li><?= t('Password:') ?> <strong><?= Helper\escape($values['fever_token']) ?></strong></li>
</ul>
</div>
<div class="alert alert-normal">
<h3 id="bookmarks"><?= t('Bookmarks') ?></h3>
<ul> <ul>
<li> <li>
<?= t('Bookmarklet:') ?> <?= t('Bookmarklet:') ?>
<a href="javascript:location.href='<?= Helper\get_current_base_url() ?>?action=subscribe&amp;token=<?= urlencode($values['bookmarklet_token']) ?>&amp;url='+encodeURIComponent(location.href)"><?= t('Subscribe with Miniflux') ?></a> (<?= t('Drag and drop this link to your bookmarks') ?>) <a href="javascript:location.href='<?= Helper\get_current_base_url() ?>?action=subscribe&amp;token=<?= urlencode($values['bookmarklet_token']) ?>&amp;url='+encodeURIComponent(location.href)"><?= t('Subscribe with Miniflux') ?></a> (<?= t('Drag and drop this link to your bookmarks') ?>)
<li> <li>
<?= t('Bookmarks RSS Feed:') ?> <a href="<?= Helper\get_current_base_url().'?action=bookmark-feed&amp;token='.urlencode($values['feed_token']) ?>" target="_blank"><?= t('Bookmark RSS Feed') ?></a>
<a href="<?= Helper\get_current_base_url().'?action=bookmark-feed&amp;token='.urlencode($values['feed_token']) ?>" target="_blank"><?= Helper\get_current_base_url().'?action=bookmark-feed&amp;token='.urlencode($values['feed_token']) ?></a>
</li> </li>
</ul>
</div>
<div class="alert alert-normal">
<h3 id="api"><?= t('API') ?></h3>
<ul>
<li><?= t('API endpoint:') ?> <strong><?= Helper\get_current_base_url().'jsonrpc.php' ?></strong></li> <li><?= t('API endpoint:') ?> <strong><?= Helper\get_current_base_url().'jsonrpc.php' ?></strong></li>
<li><?= t('API username:') ?> <strong><?= Helper\escape($values['username']) ?></strong></li> <li><?= t('API username:') ?> <strong><?= Helper\escape($values['username']) ?></strong></li>
<li><?= t('API token:') ?> <strong><?= Helper\escape($values['api_token']) ?></strong></li> <li><?= t('API token:') ?> <strong><?= Helper\escape($values['api_token']) ?></strong></li>