diff --git a/ankiconnector.cpp b/ankiconnector.cpp index 88ddaff0..d8db4fea 100644 --- a/ankiconnector.cpp +++ b/ankiconnector.cpp @@ -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", "
" ); + + 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(); diff --git a/ankiconnector.h b/ankiconnector.h index 5bffc02e..460eb323 100644 --- a/ankiconnector.h +++ b/ankiconnector.h @@ -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: diff --git a/article-style.css b/article-style.css index 21adb068..b7404efb 100644 --- a/article-style.css +++ b/article-style.css @@ -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; diff --git a/article_maker.cc b/article_maker.cc index a8baee00..3aebc0f5 100644 --- a/article_maker.cc +++ b/article_maker.cc @@ -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 += "
"; + // 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( + + + + )EOF" }; + head += link.arg( Html::escape( dictId ).c_str(), tr( "Make a new Anki note" ) ).toStdString(); + } + head += "
getLangFrom() ).toLatin1().data(); head += "\" lang=\""; diff --git a/article_maker.hh b/article_maker.hh index 4d30adde..5a875ef1 100644 --- a/article_maker.hh +++ b/article_maker.hh @@ -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 diff --git a/config.cc b/config.cc index bdd510a6..5a1ce677 100644 --- a/config.cc +++ b/config.cc @@ -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(); diff --git a/config.hh b/config.hh index 69db5d0f..2dd81d17 100644 --- a/config.hh +++ b/config.hh @@ -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; diff --git a/icons/add-anki-icon.svg b/icons/add-anki-icon.svg new file mode 100644 index 00000000..4309930d --- /dev/null +++ b/icons/add-anki-icon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources.qrc b/resources.qrc index 24046f73..1a1a5c47 100644 --- a/resources.qrc +++ b/resources.qrc @@ -1,5 +1,6 @@ + icons/add-anki-icon.svg version.txt icons/arrow.png icons/prefix.png diff --git a/src/ui/articleview.cpp b/src/ui/articleview.cpp index 5a45bf68..8a4ad182 100644 --- a/src/ui/articleview.cpp +++ b/src/ui/articleview.cpp @@ -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 ) diff --git a/src/ui/articleview.h b/src/ui/articleview.h index edcc1079..fdfc96f2 100644 --- a/src/ui/articleview.h +++ b/src/ui/articleview.h @@ -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