diff --git a/src/browsermanager.cpp b/src/browsermanager.cpp index dfd17e1..8002105 100644 --- a/src/browsermanager.cpp +++ b/src/browsermanager.cpp @@ -1,96 +1,121 @@ /*************************************************************************** * Copyright 2014 Sebastian Kügler * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the * * Free Software Foundation, Inc., * * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA . * ***************************************************************************/ #include "browsermanager.h" #include #include +#include + using namespace AngelFish; BrowserManager::BrowserManager(QObject *parent) : QObject(parent), m_bookmarks(nullptr), - m_history(nullptr) + m_history(nullptr), + m_settings(new QSettings) { } BrowserManager::~BrowserManager() { history()->save(); bookmarks()->save(); + delete m_settings; } void BrowserManager::reload() { qDebug() << "BookmarksManager::reload()"; } UrlModel* BrowserManager::bookmarks() { // qDebug() << "BookmarksManager::bookmarks()"; if (!m_bookmarks) { m_bookmarks = new UrlModel(QStringLiteral("bookmarks.json"), this); m_bookmarks->load(); } return m_bookmarks; } UrlModel* BrowserManager::history() { // qDebug() << "BrowserManager::history()"; if (!m_history) { m_history = new UrlModel(QStringLiteral("history.json"), this); m_history->load(); } return m_history; } void BrowserManager::addBookmark(const QVariantMap& bookmarkdata) { qDebug() << "Add bookmark"; qDebug() << " data: " << bookmarkdata; bookmarks()->add(QJsonObject::fromVariantMap(bookmarkdata)); } void BrowserManager::removeBookmark(const QString& url) { bookmarks()->remove(url); } void BrowserManager::addToHistory(const QVariantMap& pagedata) { // qDebug() << "Add History"; // qDebug() << " data: " << pagedata; history()->add(QJsonObject::fromVariantMap(pagedata)); emit historyChanged(); } void BrowserManager::removeFromHistory(const QString& url) { history()->remove(url); emit historyChanged(); } QString BrowserManager::urlFromUserInput(const QString& input) { QUrl url = QUrl::fromUserInput(input); return url.toString(); } +void BrowserManager::setHomepage(const QString homepage) +{ + m_settings->setValue("browser/homepage", homepage); + emit homepageChanged(); +} + +QString BrowserManager::homepage() +{ + return m_settings->value("browser/homepage", "https://searx.me").toString(); +} + +void BrowserManager::setSearchBaseUrl(const QString searchBaseUrl) +{ + m_settings->setValue("browser/searchBaseUrl", searchBaseUrl); + emit searchBaseUrlChanged(); +} + +QString BrowserManager::searchBaseUrl() +{ + return m_settings->value("browser/searchBaseUrl", "https://searx.me/?q=").toString(); +} diff --git a/src/browsermanager.h b/src/browsermanager.h index 83cc7eb..50f28c2 100644 --- a/src/browsermanager.h +++ b/src/browsermanager.h @@ -1,78 +1,91 @@ /*************************************************************************** * * * Copyright 2014 Sebastian Kügler * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the * * Free Software Foundation, Inc., * * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA . * * * ***************************************************************************/ #ifndef BOOKMARKSMANAGER_H #define BOOKMARKSMANAGER_H #include #include #include "urlmodel.h" +class QSettings; + namespace AngelFish { /** * @class BookmarksManager * @short Access to Bookmarks and History. This is a singleton for * administration and access to the various models and browser-internal * data. */ class BrowserManager : public QObject { Q_OBJECT Q_PROPERTY(QAbstractListModel* bookmarks READ bookmarks NOTIFY bookmarksChanged) Q_PROPERTY(QAbstractListModel* history READ history NOTIFY historyChanged) + Q_PROPERTY(QString homepage READ homepage WRITE setHomepage NOTIFY homepageChanged) + Q_PROPERTY(QString searchBaseUrl READ searchBaseUrl WRITE setSearchBaseUrl NOTIFY searchBaseUrlChanged) + public: - BrowserManager(QObject *parent = 0); + BrowserManager(QObject *parent = nullptr); ~BrowserManager(); UrlModel* bookmarks(); UrlModel* history(); - Q_INVOKABLE static QString urlFromUserInput(const QString &input); + QString homepage(); + QString searchBaseUrl(); + Q_INVOKABLE static QString urlFromUserInput(const QString &input); -Q_SIGNALS: +signals: void updated(); void bookmarksChanged(); void historyChanged(); -public Q_SLOTS: + void homepageChanged(); + void searchBaseUrlChanged(); + +public slots: void reload(); void addBookmark(const QVariantMap &bookmarkdata); void removeBookmark(const QString &url); void addToHistory(const QVariantMap &pagedata); void removeFromHistory(const QString &url); + void setHomepage(const QString homepage); + void setSearchBaseUrl(const QString searchBaseUrl); + private: UrlModel* m_bookmarks; UrlModel* m_history; + QSettings* m_settings; }; } // namespace #endif //BOOKMARKSMANAGER_H - diff --git a/src/contents/ui/Settings.qml b/src/contents/ui/InputSheet.qml similarity index 59% copy from src/contents/ui/Settings.qml copy to src/contents/ui/InputSheet.qml index 97818e3..6df42c6 100644 --- a/src/contents/ui/Settings.qml +++ b/src/contents/ui/InputSheet.qml @@ -1,67 +1,64 @@ /*************************************************************************** * * - * Copyright 2014-2015 Sebastian Kügler * + * Copyright 2019 Jonah Brüchert * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the * * Free Software Foundation, Inc., * * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA . * * * ***************************************************************************/ -import QtQuick 2.3 -import QtQuick.Controls 2.4 as Controls -import QtQuick.Layouts 1.11 +import QtQuick.Controls 2.1 as Controls +import QtQuick.Layouts 1.7 +import QtQuick 2.7 -import org.kde.kirigami 2.2 as Kirigami +import org.kde.kirigami 2.5 as Kirigami +Kirigami.OverlaySheet { + id: inputSheet + property string placeholderText + property string description + property string title + property string text -Kirigami.ScrollablePage { - title: i18n("Settings") + signal accepted ColumnLayout { - id: settingsPage + Kirigami.Heading { + text: title + } - Controls.CheckDelegate { - text: i18n("Enable javascript") + Controls.Label { Layout.fillWidth: true - onCheckedChanged: { - var settings = currentWebView.settings; - settings.javascriptEnabled = checked; - // FIXME: save to config - } - Component.onCompleted: { - checked = currentWebView.settings.javascriptEnabled; - } + text: inputSheet.description + wrapMode: Text.WordWrap } - Controls.CheckDelegate { - text: i18n("Load images") + Controls.TextField { + id: sheetTextField Layout.fillWidth: true - onCheckedChanged: { - var settings = currentWebView.settings; - settings.autoLoadImages = checked; - // FIXME: save to config - } - Component.onCompleted: { - checked = currentWebView.settings.autoLoadImages; - } + placeholderText: inputSheet.placeholderText } - Item { - Layout.fillHeight: true + Controls.Button { + text: "Ok" + Layout.alignment: Qt.AlignRight + onClicked: { + inputSheet.text = sheetTextField.text + inputSheet.close() + accepted() + } } } - } - diff --git a/src/contents/ui/ListWebView.qml b/src/contents/ui/ListWebView.qml index 91d26dc..f2efcec 100644 --- a/src/contents/ui/ListWebView.qml +++ b/src/contents/ui/ListWebView.qml @@ -1,67 +1,74 @@ /*************************************************************************** * * * Copyright 2014-2015 Sebastian Kügler * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the * * Free Software Foundation, Inc., * * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA . * * * ***************************************************************************/ import QtQuick 2.3 import QtQuick.Controls 2.0 import QtQuick.Controls.Styles 1.0 import QtQml.Models 2.1 import QtWebEngine 1.6 ListView { id: tabs // Make sure we don't delete and re-create tabs "randomly" cacheBuffer: 10000 // Don't animate tab switching, this just feels slow highlightMoveDuration: 0 // No horizontal swiping between tabs, disturbs page interaction interactive: false property int pageHeight: height property int pageWidth: width property alias count: tabsModel.count orientation: Qt.Horizontal model: ListModel { id: tabsModel - ListElement { pageurl: "https://duckduckgo.com" } -// ListElement { pageurl: "http://tagesschau.de" } -// ListElement { pageurl: "http://bbc.co.uk" } + ListElement { pageurl: "https://plasma-mobile.org" } } delegate: WebView { url: pageurl; } function createEmptyTab() { var t = newTab(""); tabs.currentIndex = tabs.count - 1 return t; } function newTab(url) { tabsModel.append({pageurl: url}); } + + Component.onCompleted: { + if (initialUrl !== "") { + load(initialUrl) + } else { + console.log("Using homepage") + load(browserManager.homepage) + } + } } diff --git a/src/contents/ui/Navigation.qml b/src/contents/ui/Navigation.qml index c57b426..bf30ed1 100644 --- a/src/contents/ui/Navigation.qml +++ b/src/contents/ui/Navigation.qml @@ -1,159 +1,157 @@ /*************************************************************************** * * * Copyright 2014-2015 Sebastian Kügler * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the * * Free Software Foundation, Inc., * * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA . * * * ***************************************************************************/ import QtQuick 2.3 import QtQuick.Layouts 1.0 import QtWebEngine 1.4 import QtQuick.Controls 2.0 as Controls import org.kde.kirigami 2.0 as Kirigami import "regex-weburl.js" as RegexWebUrl Item { id: errorHandler - property string searchUrl: "https://duckduckgo.com/?q=" - property string errorCode: "" property bool navigationShown: errorCode != "" || webBrowser.url === "" || true property alias textFocus: urlInput.activeFocus property alias text: urlInput.text property int expandedHeight: Kirigami.Units.gridUnit * 2.5 property int buttonSize: Kirigami.Units.gridUnit * 2 Behavior on height { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad} } Rectangle { anchors.fill: parent; color: Kirigami.Theme.backgroundColor; } RowLayout { id: layout anchors.fill: parent anchors.leftMargin: Kirigami.Units.gridUnit / 2 anchors.rightMargin: Kirigami.Units.gridUnit / 2 visible: navigationShown spacing: Kirigami.Units.smallSpacing /* PlasmaComponents.ToolButton { id: backButton Layout.preferredWidth: buttonSize Layout.preferredHeight: buttonSize visible: currentWebView.canGoBack iconSource: "go-previous" onClicked: currentWebView.goBack() } PlasmaComponents.ToolButton { id: forwardButton Layout.preferredWidth: buttonSize Layout.preferredHeight: buttonSize visible: currentWebView.canGoForward iconSource: "go-next" onClicked: currentWebView.goForward() } PlasmaComponents.ToolButton { id: reloadButton Layout.preferredWidth: buttonSize Layout.preferredHeight: buttonSize iconSource: currentWebView.loading ? "process-stop" : "view-refresh" onClicked: currentWebView.loading ? currentWebView.stop() : currentWebView.reload() } */ Controls.TextField { id: urlInput Layout.fillWidth: true text: currentWebView.url selectByMouse: true focus: false onAccepted: { if (text.match(RegexWebUrl.re_weburl)) { load(browserManager.urlFromUserInput(text)) } else { - load(browserManager.urlFromUserInput(searchUrl + text)) + load(browserManager.urlFromUserInput(browserManager.searchBaseUrl + text)) } } } Item { Layout.preferredWidth: buttonSize Layout.preferredHeight: buttonSize visible: currentWebView.loading Controls.BusyIndicator { width: buttonSize height: width anchors.centerIn: parent running: currentWebView.loading } } OptionButton { id: optionsButton property string targetState: "overview" Layout.fillWidth: false Layout.preferredWidth: buttonSize Layout.preferredHeight: buttonSize iconSource: "open-menu-symbolic" onClicked: options.state = (options.state != "hidden" ? "hidden" : targetState) } } states: [ State { name: "shown" when: navigationShown PropertyChanges { target: errorHandler; x: -expandedHeight} }, State { name: "hidden" when: !navigationShown PropertyChanges { target: errorHandler; x: 0} } ] } diff --git a/src/contents/ui/Settings.qml b/src/contents/ui/Settings.qml index 97818e3..9d3c93e 100644 --- a/src/contents/ui/Settings.qml +++ b/src/contents/ui/Settings.qml @@ -1,67 +1,104 @@ /*************************************************************************** * * * Copyright 2014-2015 Sebastian Kügler * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the * * Free Software Foundation, Inc., * * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA . * * * ***************************************************************************/ import QtQuick 2.3 import QtQuick.Controls 2.4 as Controls import QtQuick.Layouts 1.11 import org.kde.kirigami 2.2 as Kirigami Kirigami.ScrollablePage { title: i18n("Settings") ColumnLayout { id: settingsPage Controls.CheckDelegate { text: i18n("Enable javascript") Layout.fillWidth: true onCheckedChanged: { var settings = currentWebView.settings; settings.javascriptEnabled = checked; // FIXME: save to config } Component.onCompleted: { checked = currentWebView.settings.javascriptEnabled; } } Controls.CheckDelegate { text: i18n("Load images") Layout.fillWidth: true onCheckedChanged: { var settings = currentWebView.settings; settings.autoLoadImages = checked; // FIXME: save to config } Component.onCompleted: { checked = currentWebView.settings.autoLoadImages; } } + InputDialog { + id: homePagePopup + title: i18n("Homepage") + description: i18n("website that should be loaded on startup") + placeholderText: browserManager.homepage + onAccepted: { + if (homePagePopup.text !== "") + browserManager.homepage = homePagePopup.text + } + } + + InputDialog { + id: searchEnginePopup + title: i18n("Search Engine") + description: i18n("Base url of your preferred search engine") + placeholderText: browserManager.searchBaseUrl + onAccepted: { + if (searchEnginePopup.text !== "") + browserManager.searchBaseUrl = searchEnginePopup.text; + } + } + + Controls.ItemDelegate { + text: i18n("Homepage") + Layout.fillWidth: true + onClicked: { + homePagePopup.open() + } + } + + Controls.ItemDelegate { + text: i18n("Search Engine") + Layout.fillWidth: true + onClicked: { + searchEnginePopup.open() + } + } + Item { Layout.fillHeight: true } } - } diff --git a/src/contents/ui/webbrowser.qml b/src/contents/ui/webbrowser.qml index 6c20fe6..2feaa28 100644 --- a/src/contents/ui/webbrowser.qml +++ b/src/contents/ui/webbrowser.qml @@ -1,201 +1,195 @@ /*************************************************************************** * * * Copyright 2014-2015 Sebastian Kügler * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the * * Free Software Foundation, Inc., * * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA . * * * ***************************************************************************/ import QtQuick 2.1 import QtWebEngine 1.6 import QtQuick.Window 2.1 import org.kde.kirigami 2.4 as Kirigami Kirigami.ApplicationWindow { id: webBrowser title: "Angelfish Webbrowser" /** Pointer to the currently active view. * * Browser-level functionality should use this to refer to the current * view, rather than looking up views in the mode, as far as possible. */ property Item currentWebView: tabs.currentIndex < tabs.count ? tabs.currentItem : null onCurrentWebViewChanged: { print("Current WebView is now : " + tabs.currentIndex); } property int borderWidth: Math.round(Kirigami.Units.gridUnit / 18); property color borderColor: Kirigami.Theme.highlightColor; /** * Load a url in the current tab */ function load(url) { print("Loading url: " + url); currentWebView.url = url; currentWebView.forceActiveFocus() } width: Kirigami.Units.gridUnit * 20 height: Kirigami.Units.gridUnit * 30 function addHistoryEntry() { //print("Adding history"); var request = new Object;// FIXME request.url = currentWebView.url; request.title = currentWebView.title; request.icon = currentWebView.icon; browserManager.addToHistory(request); } property bool layerShown : pageStack.layers.depth > 1 pageStack.globalToolBar.style: layerShown ? Kirigami.ApplicationHeaderStyle.Auto : Kirigami.ApplicationHeaderStyle.None pageStack.initialPage: Kirigami.Page { leftPadding: 0 rightPadding: 0 topPadding: 0 bottomPadding: 0 ListWebView { id: tabs anchors { top: navigation.bottom left: parent.left right: parent.right bottom: parent.bottom } } ErrorHandler { id: errorHandler errorString: currentWebView.errorString errorCode: currentWebView.errorCode anchors { top: navigation.bottom left: parent.left right: parent.right } visible: !navigation.textFocus } // Container for the progress bar Item { id: progressItem height: Math.round(Kirigami.Units.gridUnit / 6) z: navigation.z + 1 anchors { top: tabs.top topMargin: -Math.round(height / 2) left: tabs.left right: tabs.right } opacity: currentWebView.loading ? 1 : 0 Behavior on opacity { NumberAnimation { duration: Kirigami.Units.longDuration; easing.type: Easing.InOutQuad; } } Rectangle { color: Kirigami.Theme.highlightColor width: Math.round((currentWebView.loadProgress / 100) * parent.width) anchors { top: parent.top left: parent.left bottom: parent.bottom } } } // When clicked outside the menu, hide it MouseArea { id: optionsDismisser visible: options.state != "hidden" onClicked: options.state = "hidden" anchors.fill: parent } // The menu at the top right Options { id: options anchors { top: navigation.bottom } } Navigation { id: navigation height: Kirigami.Units.gridUnit * 3 anchors { top: parent.top left: parent.left right: parent.right } onTextChanged: urlFilter.setFilterFixedString(text) } ListView { id: completion property string searchText: navigation.text anchors.top: navigation.bottom anchors.horizontalCenter: navigation.horizontalCenter width: 0.9* navigation.width height: 0.5*parent.height z: 10 visible: navigation.textFocus model: urlFilter delegate: UrlDelegate { showRemove: false onClicked: tabs.forceActiveFocus() highlightText: completion.searchText } clip: true } // Thin line underneath navigation Rectangle { height: webBrowser.borderWidth color: webBrowser.borderColor anchors { left: parent.left bottom: navigation.bottom right: options.left } } } - - - Component.onCompleted: { - if (initialUrl && !initialUrl.isEmpty()) - load(initialUrl) - } } diff --git a/src/main.cpp b/src/main.cpp index 150ead8..5a8bb0e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,57 +1,59 @@ #include #include #include #include #include #include #include #include "browsermanager.h" #include "urlfilterproxymodel.h" Q_DECL_EXPORT int main(int argc, char *argv[]) { QGuiApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication app(argc, argv); QCoreApplication::setOrganizationName("KDE"); QCoreApplication::setOrganizationDomain("kde.org"); QCoreApplication::setApplicationName("angelfish"); // Command line parser QCommandLineParser parser; QCommandLineOption helpOption = parser.addHelpOption(); parser.addPositionalArgument("url", "An url to open", "[url]"); parser.parse(QGuiApplication::arguments()); // QML loading QQmlApplicationEngine engine; QtWebEngine::initialize(); // initial url command line parameter + QString initialUrl; if (!parser.positionalArguments().isEmpty()) - engine.rootContext()->setContextProperty("initialUrl", QUrl::fromUserInput(parser.positionalArguments()[0].toUtf8())); + initialUrl = QUrl::fromUserInput(parser.positionalArguments()[0].toUtf8()).toEncoded(); + engine.rootContext()->setContextProperty("initialUrl", initialUrl); // Browser managger AngelFish::BrowserManager *browserManager = new AngelFish::BrowserManager(engine.rootContext()); engine.rootContext()->setContextProperty("browserManager", browserManager); UrlFilterProxyModel *proxy = new UrlFilterProxyModel(browserManager); proxy->setSourceModel(browserManager->history()); proxy->setFilterCaseSensitivity(Qt::CaseInsensitive); engine.rootContext()->setContextProperty("urlFilter", proxy); qmlRegisterUncreatableType("org.kde.mobile.angelfish", 1, 0, "BrowserManager", ""); qmlRegisterType(); engine.load(QUrl(QStringLiteral("qrc:///webbrowser.qml"))); // Error handling if (engine.rootObjects().isEmpty()) { return -1; } int ret = app.exec(); return ret; } diff --git a/src/resources.qrc b/src/resources.qrc index e9096bf..18fd908 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -1,19 +1,20 @@ contents/ui/Bookmarks.qml contents/ui/ErrorHandler.qml contents/ui/History.qml contents/ui/ListWebView.qml contents/ui/Navigation.qml contents/ui/OptionButton.qml contents/ui/Options.qml contents/ui/OptionsOverview.qml contents/ui/Settings.qml contents/ui/Tabs.qml contents/ui/TabWebView.qml contents/ui/UrlDelegate.qml contents/ui/webbrowser.qml contents/ui/WebView.qml regex-weburl/regex-weburl.js + contents/ui/InputSheet.qml