* Support for graphics pasting (with most code in the frontend's GuiClipboard)

git-svn-id: svn://svn.lyx.org/lyx/lyx-devel/trunk@22762 a592a061-630c-0410-9148-cb99ea01b6c8
This commit is contained in:
Stefan Schimanski 2008-02-03 10:43:03 +00:00
parent dd714bfce8
commit cf333f5ab4
8 changed files with 503 additions and 32 deletions

View File

@ -133,6 +133,11 @@ Menuset
Separator
Item "Selection|S" "primary-selection-paste"
Item "Selection, Join Lines|i" "primary-selection-paste paragraph"
Separator
Item "Paste As LinkBack PDF" "paste linkback"
Item "Paste As PDF" "paste pdf"
Item "Paste As PNG" "paste png"
Item "Paste As JPEG" "paste jpeg"
End
Menu "edit_pasterecent"

View File

@ -38,6 +38,8 @@
#include "Undo.h"
#include "insets/InsetFlex.h"
#include "insets/InsetGraphics.h"
#include "insets/InsetGraphicsParams.h"
#include "insets/InsetTabular.h"
#include "mathed/MathData.h"
@ -60,6 +62,7 @@
using namespace std;
using namespace lyx::support;
using lyx::frontend::Clipboard;
namespace lyx {
@ -734,7 +737,7 @@ void pasteFromStack(Cursor & cur, ErrorList & errorList, size_t sel_index)
}
void pasteClipboard(Cursor & cur, ErrorList & errorList, bool asParagraphs)
void pasteClipboardText(Cursor & cur, ErrorList & errorList, bool asParagraphs)
{
// Use internal clipboard if it is the most recent one
if (theClipboard().isInternal()) {
@ -772,6 +775,26 @@ void pasteClipboard(Cursor & cur, ErrorList & errorList, bool asParagraphs)
}
void pasteClipboardGraphics(Cursor & cur, ErrorList & errorList,
Clipboard::GraphicsType preferedType)
{
BOOST_ASSERT(theClipboard().hasGraphicsContents(preferedType));
// get picture from clipboard
FileName filename = theClipboard().getAsGraphics(cur, preferedType);
if (filename.empty())
return;
// create inset for graphic
InsetGraphics * inset = new InsetGraphics;
InsetGraphicsParams params;
params.filename = EmbeddedFile(filename.absFilename(), cur.buffer().filePath());
inset->setParams(params);
cur.recordUndo();
cur.insert(inset);
}
void pasteSelection(Cursor & cur, ErrorList & errorList)
{
if (selectionBuffer.empty())

View File

@ -19,8 +19,12 @@
#include "support/types.h"
#include "support/docstring.h"
#include "frontends/Clipboard.h"
#include <vector>
using lyx::frontend::Clipboard;
namespace lyx {
class Buffer;
@ -82,10 +86,15 @@ void clearCutStack();
/// Paste the current selection at \p cur
/// Does handle undo. Does only work in text, not mathed.
void pasteSelection(Cursor & cur, ErrorList &);
/// Replace the current selection with the clipboard contents (internal or
/// external: which is newer)
/// Replace the current selection with the clipboard contents as text
/// (internal or external: which is newer).
/// Does handle undo. Does only work in text, not mathed.
void pasteClipboard(Cursor & cur, ErrorList & errorList, bool asParagraphs = true);
void pasteClipboardText(Cursor & cur, ErrorList & errorList,
bool asParagraphs = true);
/// Replace the current selection with the clipboard contents as graphic.
/// Does handle undo. Does only work in text, not mathed.
void pasteClipboardGraphics(Cursor & cur, ErrorList & errorList,
Clipboard::GraphicsType preferedType = Clipboard::AnyGraphicsType);
/// Replace the current selection with cut buffer \c sel_index
/// Does handle undo. Does only work in text, not mathed.
void pasteFromStack(Cursor & cur, ErrorList & errorList, size_t sel_index);

View File

@ -25,6 +25,11 @@
#include "support/os.h"
#include "support/Systemcall.h"
// FIXME: Q_WS_MACX is not available, it's in Qt
#ifdef USE_MACOSX_PACKAGING
#include "support/linkback/LinkBackProxy.h"
#endif
using namespace std;
using namespace lyx::support;
@ -322,6 +327,18 @@ bool Formats::edit(Buffer const & buffer, FileName const & filename,
return false;
}
// LinkBack files look like PDF, but have the .linkback extension
string const ext = getExtension(filename.absFilename());
if (format_name == "pdf" && ext == "linkback") {
#ifdef USE_MACOSX_PACKAGING
return editLinkBackFile(filename.absFilename().c_str());
#else
Alert::error(_("Cannot edit file"),
_("LinkBack files can only be edited on Apple Mac OSX."));
return false;
#endif // USE_MACOSX_PACKAGING
}
Format const * format = getFormat(format_name);
if (format && format->editor().empty() &&
format->isChildFormat())
@ -334,6 +351,7 @@ bool Formats::edit(Buffer const & buffer, FileName const & filename,
prettyName(format_name)));
return false;
}
// editor is 'auto'
if (format->editor() == "auto") {
if (os::autoOpenFile(filename.absFilename(), os::EDIT))

View File

@ -81,7 +81,8 @@ namespace lyx {
using cap::copySelection;
using cap::cutSelection;
using cap::pasteFromStack;
using cap::pasteClipboard;
using cap::pasteClipboardText;
using cap::pasteClipboardGraphics;
using cap::replaceSelection;
// globals...
@ -907,22 +908,44 @@ void Text::dispatch(Cursor & cur, FuncRequest & cmd)
charsTranspose(cur);
break;
case LFUN_PASTE:
case LFUN_PASTE: {
cur.message(_("Paste"));
cap::replaceSelection(cur);
if (cmd.argument().empty() && !theClipboard().isInternal())
pasteClipboard(cur, bv->buffer().errorList("Paste"));
else {
string const arg(to_utf8(cmd.argument()));
// without argument?
string const arg = to_utf8(cmd.argument());
if (arg.empty()) {
if (theClipboard().isInternal())
pasteFromStack(cur, bv->buffer().errorList("Paste"), 0);
else if (theClipboard().hasGraphicsContents())
pasteClipboardGraphics(cur, bv->buffer().errorList("Paste"));
else
pasteClipboardText(cur, bv->buffer().errorList("Paste"));
} else if (isStrUnsignedInt(arg)) {
// we have a numerical argument
pasteFromStack(cur, bv->buffer().errorList("Paste"),
isStrUnsignedInt(arg) ?
convert<unsigned int>(arg) :
0);
convert<unsigned int>(arg));
} else {
Clipboard::GraphicsType type;
if (arg == "pdf")
type = Clipboard::PdfGraphicsType;
else if (arg == "png")
type = Clipboard::PngGraphicsType;
else if (arg == "jpeg")
type = Clipboard::JpegGraphicsType;
else if (arg == "linkback")
type = Clipboard::LinkBackGraphicsType;
else
BOOST_ASSERT(false);
pasteClipboardGraphics(cur, bv->buffer().errorList("Paste"), type);
}
bv->buffer().errors("Paste");
cur.clearSelection(); // bug 393
cur.finishUndo();
break;
}
case LFUN_CUT:
cutSelection(cur, true, true);
@ -1016,7 +1039,7 @@ void Text::dispatch(Cursor & cur, FuncRequest & cmd)
case LFUN_CLIPBOARD_PASTE:
cur.clearSelection();
pasteClipboard(cur, bv->buffer().errorList("Paste"),
pasteClipboardText(cur, bv->buffer().errorList("Paste"),
cmd.argument() == "paragraph");
bv->buffer().errors("Paste");
break;
@ -2000,22 +2023,37 @@ bool Text::getStatus(Cursor & cur, FuncRequest const & cmd,
enable = cur.selection();
break;
case LFUN_PASTE:
case LFUN_PASTE: {
if (cmd.argument().empty()) {
if (theClipboard().isInternal())
enable = cap::numberOfSelections() > 0;
else
enable = !theClipboard().empty();
} else {
break;
}
// we have an argument
string const arg = to_utf8(cmd.argument());
if (isStrUnsignedInt(arg)) {
// it's a number and therefore means the internal stack
unsigned int n = convert<unsigned int>(arg);
enable = cap::numberOfSelections() > n;
} else
break;
}
// explicit graphics type?
if ((arg == "pdf" && theClipboard().hasGraphicsContents(Clipboard::PdfGraphicsType))
|| (arg == "png" && theClipboard().hasGraphicsContents(Clipboard::PngGraphicsType))
|| (arg == "jpeg" && theClipboard().hasGraphicsContents(Clipboard::JpegGraphicsType))
|| (arg == "linkback" && theClipboard().hasGraphicsContents(Clipboard::LinkBackGraphicsType))) {
enable = true;
break;
}
// unknown argument
enable = false;
}
break;
}
case LFUN_CLIPBOARD_PASTE:
enable = !theClipboard().empty();

View File

@ -14,8 +14,13 @@
#ifndef BASE_CLIPBOARD_H
#define BASE_CLIPBOARD_H
#include "Cursor.h"
#include "support/FileName.h"
#include "support/strfwd.h"
using lyx::support::FileName;
namespace lyx {
namespace frontend {
@ -27,6 +32,14 @@ class Clipboard
public:
virtual ~Clipboard() {}
enum GraphicsType {
PdfGraphicsType,
PngGraphicsType,
JpegGraphicsType,
LinkBackGraphicsType,
AnyGraphicsType,
};
/**
* Get the system clipboard contents. The format is as written in
* .lyx files (may even be an older version than ours if it comes
@ -38,6 +51,9 @@ public:
virtual std::string const getAsLyX() const = 0;
/// Get the contents of the window system clipboard in plain text format.
virtual docstring const getAsText() const = 0;
/// Get the contents of the window system clipboard as graphics file.
virtual FileName getAsGraphics(Cursor const & cur, GraphicsType type) const = 0;
/**
* Fill the system clipboard. The format of \p lyx is as written in
* .lyx files, the format of \p text is plain text.
@ -51,6 +67,8 @@ public:
/// Does the clipboard contain LyX contents?
virtual bool hasLyXContents() const = 0;
/// Does the clipboard contain graphics contents of a certain type?
virtual bool hasGraphicsContents(GraphicsType type = AnyGraphicsType) const = 0;
/// state of clipboard.
/// \returns true if the system clipboard has been set within LyX
/// (document contents, dialogs count as external here).
@ -60,7 +78,7 @@ public:
virtual bool hasInternal() const = 0;
/// Is the clipboard empty?
/// \returns true if both the LyX and the plaintext versions of the
/// clipboard are empty.
/// clipboard are empty, and no supported graphics format is available.
virtual bool empty() const = 0;
};

View File

@ -12,33 +12,136 @@
#include <config.h>
#include "Buffer.h"
#include "BufferView.h"
#include "Cursor.h"
#include "GuiClipboard.h"
#include "qt_helpers.h"
#include "support/debug.h"
#include "boost/assert.hpp"
#include <QApplication>
#include <QBuffer>
#include <QClipboard>
#include <QDataStream>
#include <QFile>
#include <QImage>
#include <QMacPasteboardMime>
#include <QMimeData>
#include <QString>
#include <QStringList>
#include "support/convert.h"
#include "support/debug.h"
#include "support/filetools.h"
#include "support/FileFilterList.h"
#include "support/gettext.h"
#include "support/lstrings.h"
#include "frontends/alert.h"
#include "frontends/FileDialog.h"
#include <map>
#ifdef Q_WS_MACX
#include "support/linkback/LinkBackProxy.h"
#endif // Q_WS_MACX
using namespace std;
using namespace lyx::support;
static char const * const mime_type = "application/x-lyx";
static char const * const lyx_mime_type = "application/x-lyx";
static char const * const pdf_mime_type = "application/pdf";
namespace lyx {
namespace frontend {
#ifdef Q_WS_MACX
class QMacPasteboardMimeGraphics : public QMacPasteboardMime {
public:
QMacPasteboardMimeGraphics()
: QMacPasteboardMime(MIME_QT_CONVERTOR|MIME_ALL) {}
~QMacPasteboardMimeGraphics() {}
QString convertorName();
QString flavorFor(const QString &mime);
QString mimeFor(QString flav);
bool canConvert(const QString &mime, QString flav);
QVariant convertToMime(const QString &mime, QList<QByteArray> data, QString flav);
QList<QByteArray> convertFromMime(const QString &mime, QVariant data, QString flav);
};
QString QMacPasteboardMimeGraphics::convertorName()
{
return "Graphics";
}
QString QMacPasteboardMimeGraphics::flavorFor(const QString &mime)
{
LYXERR(Debug::ACTION, "flavorFor " << fromqstr(mime));
if (mime == QLatin1String(pdf_mime_type))
return QLatin1String("com.adobe.pdf");
return QString();
}
QString QMacPasteboardMimeGraphics::mimeFor(QString flav)
{
LYXERR(Debug::ACTION, "mimeFor " << fromqstr(flav));
if (flav == QLatin1String("com.adobe.pdf"))
return QLatin1String(pdf_mime_type);
return QString();
}
bool QMacPasteboardMimeGraphics::canConvert(const QString &mime, QString flav)
{
return mimeFor(flav) == mime;
}
QVariant QMacPasteboardMimeGraphics::convertToMime(const QString &mime, QList<QByteArray> data, QString)
{
if(data.count() > 1)
qWarning("QMacPasteboardMimeGraphics: Cannot handle multiple member data");
return data.first();
}
QList<QByteArray> QMacPasteboardMimeGraphics::convertFromMime(const QString &mime, QVariant data, QString)
{
QList<QByteArray> ret;
ret.append(data.toByteArray());
return ret;
}
static QMacPasteboardMimeGraphics * graphicsPasteboardMime;
#endif // Q_WS_MACX
GuiClipboard::GuiClipboard()
{
connect(qApp->clipboard(), SIGNAL(dataChanged()),
this, SLOT(on_dataChanged()));
// initialize clipboard status.
on_dataChanged();
#ifdef Q_WS_MACX
if (!graphicsPasteboardMime)
graphicsPasteboardMime = new QMacPasteboardMimeGraphics();
#endif // Q_WS_MACX
}
GuiClipboard::~GuiClipboard()
{
#ifdef Q_WS_MACX
closeAllLinkBackLinks();
#endif // Q_WS_MACX
}
@ -53,9 +156,10 @@ string const GuiClipboard::getAsLyX() const
LYXERR(Debug::ACTION, "' (no QMimeData)");
return string();
}
if (source->hasFormat(mime_type)) {
if (source->hasFormat(lyx_mime_type)) {
// data from ourself or some other LyX instance
QByteArray const ar = source->data(mime_type);
QByteArray const ar = source->data(lyx_mime_type);
string const s(ar.data(), ar.count());
LYXERR(Debug::ACTION, s << "'");
return s;
@ -65,6 +169,204 @@ string const GuiClipboard::getAsLyX() const
}
FileName GuiClipboard::getPastedGraphicsFileName(Cursor const & cur,
Clipboard::GraphicsType & type) const
{
// create file dialog filter according to the existing types in the clipboard
vector<Clipboard::GraphicsType> types;
if (hasGraphicsContents(Clipboard::LinkBackGraphicsType))
types.push_back(Clipboard::LinkBackGraphicsType);
if (hasGraphicsContents(Clipboard::PdfGraphicsType))
types.push_back(Clipboard::PdfGraphicsType);
if (hasGraphicsContents(Clipboard::PngGraphicsType))
types.push_back(Clipboard::PngGraphicsType);
if (hasGraphicsContents(Clipboard::JpegGraphicsType))
types.push_back(Clipboard::JpegGraphicsType);
BOOST_ASSERT(!types.empty());
// select prefered type if AnyGraphicsType was passed
if (type == Clipboard::AnyGraphicsType)
type = types.front();
// which extension?
map<Clipboard::GraphicsType, string> extensions;
map<Clipboard::GraphicsType, docstring> typeNames;
extensions[Clipboard::LinkBackGraphicsType] = "linkback";
extensions[Clipboard::PdfGraphicsType] = "pdf";
extensions[Clipboard::PngGraphicsType] = "png";
extensions[Clipboard::JpegGraphicsType] = "jpeg";
typeNames[Clipboard::LinkBackGraphicsType] = _("LinkBack PDF");
typeNames[Clipboard::PdfGraphicsType] = _("PDF");
typeNames[Clipboard::PngGraphicsType] = _("PNG");
typeNames[Clipboard::JpegGraphicsType] = _("JPEG");
// find unused filename with primary extension
string document_path = cur.buffer().fileName().onlyPath().absFilename();
unsigned newfile_number = 0;
FileName filename;
do {
++newfile_number;
filename
= FileName(addName(document_path,
to_utf8(_("pasted"))
+ convert<string>(newfile_number) + "."
+ extensions[type]));
} while (filename.isReadableFile());
while (true) {
// create file type filter, putting the prefered on to the front
docstring filterSpec;
for (unsigned i = 0; i < types.size(); ++i) {
docstring s = bformat(_("%1$s Files"), typeNames[types[i]])
+ " (*." + from_ascii(extensions[types[i]]) + ")";
if (types[i] == type)
filterSpec = s + filterSpec;
else
filterSpec += ";;" + s;
}
FileFilterList const filter(filterSpec);
// show save dialog for the graphic
FileDialog dlg(_("Choose a filename to save the pasted graphic as"));
FileDialog::Result result =
dlg.save(from_utf8(filename.onlyPath().absFilename()), filter,
from_utf8(filename.onlyFileName()));
if (result.first == FileDialog::Later)
return FileName();
string newFilename = to_utf8(result.second);
if (newFilename.empty()) {
cur.bv().message(_("Canceled."));
return FileName();
}
filename.set(newFilename);
// check the extension (the user could have changed it)
if (!suffixIs(ascii_lowercase(filename.absFilename()),
"." + extensions[type])) {
// the user changed the extension. Check if the type is available
unsigned i;
for (i = 1; i < types.size(); ++i) {
if (suffixIs(ascii_lowercase(filename.absFilename()),
"." + extensions[types[i]])) {
type = types[i];
break;
}
}
// invalid extension found, or none at all. In the latter
// case set the default extensions.
if (i == types.size()
&& filename.onlyFileName().find('.') == string::npos) {
filename.changeExtension("." + extensions[type]);
}
}
// check whether the file exists and warn the user
if (!filename.exists())
break;
int ret = frontend::Alert::prompt(
_("Overwrite external file?"),
bformat(_("File %1$s already exists, do you want to overwrite it"),
from_utf8(filename.absFilename())), 1, 1, _("&Overwrite"), _("&Cancel"));
if (ret == 0)
// overwrite, hence break the dialog loop
break;
// not overwrite, hence show the dialog again (i.e. loop)
}
return filename;
}
FileName GuiClipboard::getAsGraphics(Cursor const & cur, GraphicsType type) const
{
// get the filename from the user
FileName filename = getPastedGraphicsFileName(cur, type);
if (filename.empty())
return FileName();
// handle image cases first
if (type == PngGraphicsType || type == JpegGraphicsType) {
// get image from QImage from clipboard
QImage image = qApp->clipboard()->image();
if (image.isNull()) {
LYXERR(Debug::ACTION, "No image in clipboard");
return FileName();
}
// convert into graphics format
QByteArray ar;
QBuffer buffer(&ar);
buffer.open(QIODevice::WriteOnly);
if (type == PngGraphicsType)
image.save(toqstr(filename.absFilename()), "PNG");
else if (type == JpegGraphicsType)
image.save(toqstr(filename.absFilename()), "JPEG");
else
BOOST_ASSERT(false);
return filename;
}
// get mime data
QMimeData const * source =
qApp->clipboard()->mimeData(QClipboard::Clipboard);
if (!source) {
LYXERR(Debug::ACTION, "0 bytes (no QMimeData)");
return FileName();
}
// get mime for type
QString mime;
switch (type) {
case PdfGraphicsType: mime = pdf_mime_type; break;
case LinkBackGraphicsType: mime = pdf_mime_type; break;
default: BOOST_ASSERT(false);
}
// get data
if (!source->hasFormat(mime))
return FileName();
// data from ourself or some other LyX instance
QByteArray const ar = source->data(mime);
LYXERR(Debug::ACTION, "Getting from clipboard: mime = " << mime.data()
<< "length = " << ar.count());
QFile f(toqstr(filename.absFilename()));
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
LYXERR(Debug::ACTION, "Error opening file "
<< filename.absFilename() << " for writing");
return FileName();
}
// write the (LinkBack) PDF data
f.write(ar);
if (type == LinkBackGraphicsType) {
#ifdef Q_WS_MACX
void const * linkBackData;
unsigned linkBackLen;
getLinkBackData(&linkBackData, &linkBackLen);
f.write((char *)linkBackData, linkBackLen);
quint32 pdfLen = ar.size();
QDataStream ds(&f);
ds << pdfLen; // big endian by default
#else
// only non-Mac this should never happen
BOOST_ASSERT(false);
#endif // Q_WS_MACX
}
f.close();
return filename;
}
docstring const GuiClipboard::getAsText() const
{
// text data from other applications
@ -87,7 +389,7 @@ void GuiClipboard::put(string const & lyx, docstring const & text)
QMimeData * data = new QMimeData;
if (!lyx.empty()) {
QByteArray const qlyx(lyx.c_str(), lyx.size());
data->setData(mime_type, qlyx);
data->setData(lyx_mime_type, qlyx);
}
// Don't test for text.empty() since we want to be able to clear the
// clipboard.
@ -101,7 +403,50 @@ bool GuiClipboard::hasLyXContents() const
{
QMimeData const * const source =
qApp->clipboard()->mimeData(QClipboard::Clipboard);
return source && source->hasFormat(mime_type);
return source && source->hasFormat(lyx_mime_type);
}
bool GuiClipboard::hasGraphicsContents(Clipboard::GraphicsType type) const
{
if (type == AnyGraphicsType) {
return hasGraphicsContents(PdfGraphicsType)
|| hasGraphicsContents(PngGraphicsType)
|| hasGraphicsContents(JpegGraphicsType)
|| hasGraphicsContents(LinkBackGraphicsType);
}
QMimeData const * const source =
qApp->clipboard()->mimeData(QClipboard::Clipboard);
// handle image cases first
if (type == PngGraphicsType || type == JpegGraphicsType)
return source->hasImage();
// handle LinkBack for Mac
#ifdef Q_WS_MACX
if (type == LinkBackGraphicsType)
return isLinkBackDataInPasteboard();
#else
if (type == LinkBackGraphicsType)
return false;
#endif // Q_WS_MACX
// get mime data
QStringList const & formats = source->formats();
LYXERR(Debug::ACTION, "We found " << formats.size() << " formats");
for (int i = 0; i < formats.size(); ++i) {
LYXERR(Debug::ACTION, "Found format " << fromqstr(formats[i]));
}
// compute mime for type
QString mime;
switch (type) {
case PdfGraphicsType: mime = pdf_mime_type; break;
default: BOOST_ASSERT(false);
}
return source && source->hasFormat(mime);
}
@ -130,10 +475,19 @@ bool GuiClipboard::hasInternal() const
void GuiClipboard::on_dataChanged()
{
QMimeData const * const source =
qApp->clipboard()->mimeData(QClipboard::Clipboard);
QStringList l = source->formats();
LYXERR(Debug::ACTION, "Qt Clipboard changed. We found the following mime types:");
for (int i = 0; i < l.count(); i++) {
LYXERR(Debug::ACTION, fromqstr(l.value(i)));
}
text_clipboard_empty_ = qApp->clipboard()->
text(QClipboard::Clipboard).isEmpty();
has_lyx_contents_ = hasLyXContents();
has_graphics_contents_ = hasGraphicsContents();
}
@ -145,7 +499,7 @@ bool GuiClipboard::empty() const
// clipboard does not come from LyX.
if (!text_clipboard_empty_)
return false;
return !has_lyx_contents_;
return !has_lyx_contents_ && !has_graphics_contents_;
}
} // namespace frontend

View File

@ -29,26 +29,32 @@ class GuiClipboard: public QObject, public Clipboard
Q_OBJECT
public:
GuiClipboard();
virtual ~GuiClipboard() {}
virtual ~GuiClipboard();
/** Clipboard overloaded methods
*/
//@{
std::string const getAsLyX() const;
FileName getAsGraphics(Cursor const & cur, GraphicsType type) const;
docstring const getAsText() const;
void put(std::string const & lyx, docstring const & text);
bool hasLyXContents() const;
bool hasGraphicsContents(GraphicsType type = AnyGraphicsType) const;
bool isInternal() const;
bool hasInternal() const;
bool empty() const;
//@}
FileName getPastedGraphicsFileName(Cursor const & cur,
Clipboard::GraphicsType & type) const;
private Q_SLOTS:
void on_dataChanged();
private:
bool text_clipboard_empty_;
bool has_lyx_contents_;
bool has_graphics_contents_;
};
} // namespace frontend