Add ankicard link and button

make selectedText const

add a new keyboard shortcut: ctrl+shift+n to make a card

if word is empty, warn and exit

rename

return after ankisearch

remove temp vars

change the anki action's text depending on selected text

reformat article maker

the anki button is shown under the heading

revert to the previous way of constructing gddictname

to reduce size of the diff
This commit is contained in:
GenjiFujimoto 2023-03-23 04:34:05 +08:00 committed by xiaoyifang
parent 25daf37f65
commit 16943ccab1
11 changed files with 168 additions and 53 deletions

View file

@ -17,9 +17,17 @@ AnkiConnector::AnkiConnector( QObject * parent, Config::Class const & _cfg ) : Q
connect( mgr, &QNetworkAccessManager::finished, this, &AnkiConnector::finishedSlot );
}
void AnkiConnector::sendToAnki( QString const & word, QString const & text, QString const & sentence )
void AnkiConnector::sendToAnki( QString const & word, QString text, QString const & sentence )
{
QString postTemplate = R"anki({
if ( word.isEmpty() ) {
emit this->errorText( tr( "anki: can't create a card without a word" ) );
return;
}
// Anki doesn't understand the newline character, so it should be escaped.
text = text.replace( "\n", "<br>" );
QString const postTemplate = R"anki({
"action": "addNote",
"version": 6,
"params": {
@ -48,7 +56,7 @@ void AnkiConnector::sendToAnki( QString const & word, QString const & text, QStr
Utils::json2String( fields ) );
// qDebug().noquote() << postData;
postToAnki( postData );
postToAnki( postData );
}
void AnkiConnector::ankiSearch( QString const & word )
@ -91,26 +99,25 @@ void AnkiConnector::postToAnki( QString const & postData )
void AnkiConnector::finishedSlot( QNetworkReply * reply )
{
if( reply->error() == QNetworkReply::NoError )
{
QByteArray bytes = reply->readAll();
QJsonDocument json = QJsonDocument::fromJson( bytes );
auto obj = json.object();
if( obj.size() != 2 || !obj.contains( "error" ) || !obj.contains( "result" ) ||
obj[ "result" ].toString().isEmpty() )
{
emit errorText( QObject::tr( "anki: post to anki failed" ) );
}
QString result = obj[ "result" ].toString();
if ( reply->error() == QNetworkReply::NoError ) {
QByteArray const bytes = reply->readAll();
QJsonDocument const json = QJsonDocument::fromJson( bytes );
auto const obj = json.object();
qDebug() << "anki result:" << result;
// Normally AnkiConnect always returns result and error,
// unless Anki is not running.
if ( obj.size() == 2 && obj.contains( "result" ) && obj.contains( "error" ) && obj[ "error" ].isNull() ) {
emit errorText( tr( "anki: post to anki success" ) );
}
else {
emit errorText( tr( "anki: post to anki failed" ) );
}
emit errorText( tr( "anki: post to anki success" ) );
qDebug().noquote() << "anki response:" << Utils::json2String( obj );
}
else
{
qDebug() << "anki connect error" << reply->errorString();
emit errorText( "anki:" + reply->errorString() );
else {
qDebug() << "anki connect error" << reply->errorString();
emit errorText( "anki:" + reply->errorString() );
}
reply->deleteLater();

View file

@ -13,7 +13,7 @@ class AnkiConnector : public QObject
public:
explicit AnkiConnector( QObject * parent, Config::Class const & cfg );
void sendToAnki( QString const & word, QString const & text, QString const & sentence );
void sendToAnki( QString const & word, QString text, QString const & sentence );
void ankiSearch( QString const & word);
private:

View file

@ -47,6 +47,20 @@ pre
user-select: none;
}
/* The anki plus button, which is shown if enabled. */
.ankibutton {
float: right;
display: grid;
place-items: center;
cursor: pointer;
border-radius: 4px;
padding: 3px;
transition: background-color 0.2s;
}
.ankibutton:active {
background-color: hsl(0deg 0% 70%);
}
.gddicttitle
{
user-select: none;

View file

@ -22,6 +22,8 @@ using gd::wstring;
using std::set;
using std::list;
inline bool ankiConnectEnabled() { return GlobalBroadcaster::instance()->getPreference()->ankiConnectServer.enabled; }
ArticleMaker::ArticleMaker( vector< sptr< Dictionary::Class > > const & dictionaries_,
vector< Instances::Group > const & groups_,
const Config::Preferences & cfg_ ):
@ -428,19 +430,21 @@ bool ArticleMaker::adjustFilePath( QString & fileName )
//////// ArticleRequest
ArticleRequest::ArticleRequest(
Config::InputPhrase const & phrase, QString const & group_,
QMap< QString, QString > const & contexts_,
vector< sptr< Dictionary::Class > > const & activeDicts_,
string const & header,
int sizeLimit, bool needExpandOptionalParts_, bool ignoreDiacritics_ ):
word( phrase.phrase ), group( group_ ), contexts( contexts_ ),
activeDicts( activeDicts_ ),
altsDone( false ), bodyDone( false ), foundAnyDefinitions( false ),
closePrevSpan( false )
, articleSizeLimit( sizeLimit )
, needExpandOptionalParts( needExpandOptionalParts_ )
, ignoreDiacritics( ignoreDiacritics_ )
ArticleRequest::ArticleRequest( Config::InputPhrase const & phrase,
QString const & group_,
QMap< QString, QString > const & contexts_,
vector< sptr< Dictionary::Class > > const & activeDicts_,
string const & header,
int sizeLimit,
bool needExpandOptionalParts_,
bool ignoreDiacritics_ ):
word( phrase.phrase ),
group( group_ ),
contexts( contexts_ ),
activeDicts( activeDicts_ ),
articleSizeLimit( sizeLimit ),
needExpandOptionalParts( needExpandOptionalParts_ ),
ignoreDiacritics( ignoreDiacritics_ )
{
if ( !phrase.punctuationSuffix.isEmpty() )
alts.insert( gd::toWString( phrase.phraseWithSuffix() ) );
@ -658,6 +662,17 @@ void ArticleRequest::bodyFinished()
head += "<div class=\"gddictnamebodyseparator\"></div>";
// If the user has enabled Anki integration in settings,
// Show a (+) button that lets the user add a new Anki card.
if ( ankiConnectEnabled() ) {
QString link{ R"EOF(
<a href="ankicard:%1" class="ankibutton" title="%2" >
<img src="qrc:///icons/add-anki-icon.svg">
</a>
)EOF" };
head += link.arg( Html::escape( dictId ).c_str(), tr( "Make a new Anki note" ) ).toStdString();
}
head += "<div class=\"gdarticlebody gdlangfrom-";
head += LangCoder::intToCode2( activeDict->getLangFrom() ).toLatin1().data();
head += "\" lang=\"";

View file

@ -87,11 +87,12 @@ class ArticleRequest: public Dictionary::DataRequest
std::set< gd::wstring > alts; // Accumulated main forms
std::list< sptr< Dictionary::WordSearchRequest > > altSearches;
bool altsDone, bodyDone;
std::list< sptr< Dictionary::DataRequest > > bodyRequests;
bool foundAnyDefinitions;
bool closePrevSpan; // Indicates whether the last opened article span is to
// be closed after the article ends.
bool altsDone{ false };
bool bodyDone{ false };
bool foundAnyDefinitions{ false };
bool closePrevSpan{ false }; // Indicates whether the last opened article span is to
// be closed after the article ends.
sptr< WordFinder > stemmedWordFinder; // Used when there're no results
/// A sequence of words and spacings between them, including the initial

View file

@ -976,7 +976,7 @@ Class load()
{
c.preferences.ankiConnectServer.enabled = ( ankiConnectServer.toElement().attribute( "enabled" ) == "1" );
c.preferences.ankiConnectServer.host = ankiConnectServer.namedItem( "host" ).toElement().text();
c.preferences.ankiConnectServer.port = ankiConnectServer.namedItem( "port" ).toElement().text().toULong();
c.preferences.ankiConnectServer.port = ankiConnectServer.namedItem( "port" ).toElement().text().toInt();
c.preferences.ankiConnectServer.deck = ankiConnectServer.namedItem( "deck" ).toElement().text();
c.preferences.ankiConnectServer.model = ankiConnectServer.namedItem( "model" ).toElement().text();

View file

@ -143,7 +143,8 @@ struct AnkiConnectServer
bool enabled;
QString host;
unsigned port;
int port; // Port will be passed to QUrl::setPort() which expects an int.
QString deck;
QString model;

24
icons/add-anki-icon.svg Normal file
View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="linearGradient4582" x1="-1.7198" x2="-1.7198" y1="3.5719" y2=".79375" gradientTransform="matrix(3.7795 0 0 3.7795 14.5 -6.308e-7)" gradientUnits="userSpaceOnUse">
<stop stop-color="#6fb558" offset="0"/>
<stop stop-color="#a5db9b" offset="1"/>
</linearGradient>
<linearGradient id="linearGradient4758-7" x1="7.5406" x2="5.1594" y1="3.3073" y2=".92604" gradientTransform="matrix(3.7795 0 0 3.7795 -16 -6e-7)" gradientUnits="userSpaceOnUse">
<stop stop-color="#34812c" offset="0"/>
<stop stop-color="#87b870" offset="1"/>
</linearGradient>
<radialGradient id="radialGradient4683-3" cx="2.1167" cy="2.1167" r=".66146" gradientTransform="matrix(4.5354 8.0301e-7 -8.0301e-7 4.5354 -1.6 -1.6)" gradientUnits="userSpaceOnUse">
<stop stop-opacity=".28986" offset="0"/>
<stop stop-opacity="0" offset="1"/>
</radialGradient>
</defs>
<g>
<circle cx="8" cy="8" r="6.5" fill="url(#linearGradient4582)"/>
<circle cx="8" cy="8" r="5.75" fill="none" stroke="#fff" stroke-opacity=".50196" stroke-width="1.5"/>
<circle cx="8" cy="8" r="6.5" fill="none" stroke="url(#linearGradient4758-7)"/>
<circle cx="8" cy="8" r="3" fill="url(#radialGradient4683-3)"/>
<path d="m5 7h2v-2h2v2h2v2h-2v2h-2v-2h-2v-2" fill="#fff"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -1,5 +1,6 @@
<RCC>
<qresource prefix="/">
<file>icons/add-anki-icon.svg</file>
<file>version.txt</file>
<file>icons/arrow.png</file>
<file>icons/prefix.png</file>

View file

@ -391,6 +391,13 @@ ArticleView::ArticleView( QWidget * parent, ArticleNetworkAccessManager & nm, Au
&AnkiConnector::errorText,
this,
[ this ]( QString const & errorText ) { emit statusBarMessage( errorText ); } );
// Set up an Anki action if Anki integration is enabled in settings.
if ( cfg.preferences.ankiConnectServer.enabled ) {
sendToAnkiAction.setShortcut( QKeySequence( "Ctrl+Shift+N" ) );
webview->addAction( &sendToAnkiAction );
connect( &sendToAnkiAction, &QAction::triggered, this, &ArticleView::handleAnkiAction );
}
}
// explicitly report the minimum size, to avoid
@ -532,8 +539,9 @@ void ArticleView::showDefinition( QString const & word, QStringList const & dict
webview->setCursor( Qt::WaitCursor );
}
void ArticleView::sendToAnki(QString const & word, QString const & text, QString const & sentence ){
ankiConnector->sendToAnki(word,text,sentence);
void ArticleView::sendToAnki( QString const & word, QString const & dict_definition, QString const & sentence )
{
ankiConnector->sendToAnki( word, dict_definition, sentence );
}
void ArticleView::showAnticipation()
@ -1124,6 +1132,15 @@ void ArticleView::linkClickedInHtml( QUrl const & url_ )
linkClicked( url_ );
}
}
void ArticleView::makeAnkiCardFromArticle( QString const & article_id )
{
auto const js_code = QString( R"EOF(document.getElementById("gdarticlefrom-%1").innerText)EOF" ).arg( article_id );
webview->page()->runJavaScript( js_code, [ this ]( const QVariant & article_text ) {
sendToAnki( webview->title(), article_text.toString(), translateLine->text() );
} );
}
void ArticleView::openLink( QUrl const & url, QUrl const & ref, QString const & scrollTo, Contexts const & contexts_ )
{
audioPlayer->stop();
@ -1142,6 +1159,19 @@ void ArticleView::openLink( QUrl const & url, QUrl const & ref, QString const &
load( url );
else if( url.scheme().compare( "ankisearch" ) == 0 ) {
ankiConnector->ankiSearch( url.path() );
return;
}
else if ( url.scheme().compare( "ankicard" ) == 0 ) {
// If article id is set in path and selection is empty, use text from the current article.
// Otherwise, grab currently selected text and use it as the definition.
if ( !url.path().isEmpty() && webview->selectedText().isEmpty() ) {
makeAnkiCardFromArticle( url.path() );
}
else {
sendToAnki( webview->title(), webview->selectedText(), translateLine->text() );
}
qDebug() << "requested to make Anki card.";
return;
}
else if( url.scheme().compare( "bword" ) == 0 || url.scheme().compare( "entry" ) == 0 ) {
if( Utils::Url::hasQueryItem( ref, "dictionaries" ) )
@ -1617,6 +1647,18 @@ void ArticleView::forward()
webview->forward();
}
void ArticleView::handleAnkiAction()
{
// React to the "send *word* to anki" action.
// If selected text is empty, use the whole article as the definition.
if ( webview->selectedText().isEmpty() ) {
makeAnkiCardFromArticle( getActiveArticleId() );
}
else {
sendToAnki( webview->title(), webview->selectedText(), translateLine->text() );
}
}
void ArticleView::reload() { webview->reload(); }
void ArticleView::hasSound( const std::function< void( bool ) > & callback )
@ -1706,8 +1748,7 @@ void ArticleView::contextMenuRequested( QPoint const & pos )
QAction * followLink = 0;
QAction * followLinkExternal = 0;
QAction * followLinkNewTab = 0;
QAction * lookupSelection = 0;
QAction * sendToAnkiAction = 0 ;
QAction * lookupSelection = 0;
QAction * lookupSelectionGr = 0;
QAction * lookupSelectionNewTab = 0;
QAction * lookupSelectionNewTabGr = 0;
@ -1773,7 +1814,7 @@ void ArticleView::contextMenuRequested( QPoint const & pos )
menu.addAction( saveSoundAction );
}
QString selectedText = webview->selectedText();
QString const selectedText = webview->selectedText();
QString text = Utils::trimNonChar( selectedText );
if ( text.size() && text.size() < 60 )
@ -1844,12 +1885,12 @@ void ArticleView::contextMenuRequested( QPoint const & pos )
menu.addAction( saveBookmark );
}
// add anki menu
if( !text.isEmpty() && cfg.preferences.ankiConnectServer.enabled )
{
QString txt = webview->title();
sendToAnkiAction = new QAction( tr( "&Send \"%1\" to anki with selected text." ).arg( txt ), &menu );
menu.addAction( sendToAnkiAction );
// Add anki menu (if enabled)
// If there is no selected text, it will extract text from the current article.
if ( cfg.preferences.ankiConnectServer.enabled ) {
menu.addAction( &sendToAnkiAction );
sendToAnkiAction.setText( webview->selectedText().isEmpty() ? tr( "&Send Current Article to Anki" ) :
tr( "&Send selected text to Anki" ) );
}
if( text.isEmpty() && !cfg.preferences.storeHistory)
@ -1946,8 +1987,9 @@ void ArticleView::contextMenuRequested( QPoint const & pos )
else if( result == saveBookmark ) {
emit saveBookmarkSignal( text.left( 60 ) );
}
else if( result == sendToAnkiAction ) {
sendToAnki( webview->title(), webview->selectedText(), translateLine->text() );
else if( result == &sendToAnkiAction ) {
// This action is handled by a slot.
return;
}
else
if ( result == lookupSelectionGr && groupComboBox )

View file

@ -55,6 +55,9 @@ class ArticleView: public QWidget
bool expandOptionalParts;
QString rangeVarName;
/// An action used to create Anki notes.
QAction sendToAnkiAction{ tr( "&Create Anki note" ), this };
/// Any resource we've decided to download off the dictionary gets stored here.
/// Full vector capacity is used for search requests, where we have to make
/// a multitude of requests.
@ -148,6 +151,10 @@ public:
/// which will be restored when some article loads eventually.
void showAnticipation();
/// Create a new Anki card from a currently displayed article with the provided id.
/// This function will call QWebEnginePage::runJavaScript() to fetch the corresponding HTML.
void makeAnkiCardFromArticle( QString const & article_id );
/// Opens the given link. Supposed to be used in response to
/// openLinkInNewTab() signal. The link scheme is therefore supposed to be
/// one of the internal ones.
@ -187,6 +194,9 @@ public:
/// Takes the focus to the view
void focus() { webview->setFocus( Qt::ShortcutFocusReason ); }
/// Sends *word* to Anki.
void handleAnkiAction();
public:
/// Reloads the view