2010-10-20 22:45:52 +02:00
|
|
|
/*
|
|
|
|
|
|
|
|
Copyright 2010, Google Inc.
|
|
|
|
All rights reserved.
|
|
|
|
|
|
|
|
Redistribution and use in source and binary forms, with or without
|
|
|
|
modification, are permitted provided that the following conditions are
|
|
|
|
met:
|
|
|
|
|
|
|
|
* Redistributions of source code must retain the above copyright
|
|
|
|
notice, this list of conditions and the following disclaimer.
|
|
|
|
* Redistributions in binary form must reproduce the above
|
|
|
|
copyright notice, this list of conditions and the following disclaimer
|
|
|
|
in the documentation and/or other materials provided with the
|
|
|
|
distribution.
|
|
|
|
* Neither the name of Google Inc. nor the names of its
|
|
|
|
contributors may be used to endorse or promote products derived from
|
|
|
|
this software without specific prior written permission.
|
|
|
|
|
|
|
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
|
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
|
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
|
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
|
|
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
|
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
|
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
|
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
|
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
|
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
|
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
2010-09-22 19:04:10 +02:00
|
|
|
package com.google.refine;
|
2010-05-05 01:24:48 +02:00
|
|
|
|
2010-05-30 20:18:59 +02:00
|
|
|
import java.io.File;
|
2010-05-05 01:24:48 +02:00
|
|
|
import java.io.IOException;
|
2011-08-11 02:35:01 +02:00
|
|
|
import java.net.HttpURLConnection;
|
|
|
|
import java.net.URLConnection;
|
2010-09-23 01:59:57 +02:00
|
|
|
import java.util.ArrayList;
|
2010-05-05 01:24:48 +02:00
|
|
|
import java.util.HashMap;
|
2010-09-23 01:59:57 +02:00
|
|
|
import java.util.List;
|
2010-05-05 01:24:48 +02:00
|
|
|
import java.util.Map;
|
2013-08-18 17:31:03 +02:00
|
|
|
import java.util.concurrent.Executors;
|
|
|
|
import java.util.concurrent.ScheduledExecutorService;
|
|
|
|
import java.util.concurrent.TimeUnit;
|
2010-05-05 01:24:48 +02:00
|
|
|
|
|
|
|
import javax.servlet.ServletException;
|
|
|
|
import javax.servlet.http.HttpServletRequest;
|
|
|
|
import javax.servlet.http.HttpServletResponse;
|
|
|
|
|
|
|
|
import org.slf4j.Logger;
|
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
|
2010-09-22 19:04:10 +02:00
|
|
|
import com.google.refine.commands.Command;
|
2011-08-02 05:34:47 +02:00
|
|
|
import com.google.refine.importing.ImportingManager;
|
2010-09-22 19:04:10 +02:00
|
|
|
import com.google.refine.io.FileProjectManager;
|
2010-05-05 01:24:48 +02:00
|
|
|
|
2010-06-05 02:50:18 +02:00
|
|
|
import edu.mit.simile.butterfly.Butterfly;
|
2010-08-22 08:16:13 +02:00
|
|
|
import edu.mit.simile.butterfly.ButterflyModule;
|
2010-06-05 02:50:18 +02:00
|
|
|
|
2010-09-22 19:46:39 +02:00
|
|
|
public class RefineServlet extends Butterfly {
|
2017-02-10 21:55:58 +01:00
|
|
|
static private String ASSIGNED_VERSION = "2.7";
|
2010-10-18 06:18:09 +02:00
|
|
|
|
2010-09-29 03:50:57 +02:00
|
|
|
static public String VERSION = "";
|
|
|
|
static public String REVISION = "";
|
2010-10-18 06:18:09 +02:00
|
|
|
static public String FULL_VERSION = "";
|
2012-10-19 01:40:31 +02:00
|
|
|
static public String FULLNAME = "OpenRefine ";
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2012-10-19 01:40:31 +02:00
|
|
|
|
|
|
|
static public final String AGENT_ID = "/en/google_refine"; // TODO: Unused? Freebase ID
|
2010-09-29 03:50:57 +02:00
|
|
|
|
|
|
|
static final long serialVersionUID = 2386057901503517403L;
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-09-29 03:50:57 +02:00
|
|
|
static private final String JAVAX_SERVLET_CONTEXT_TEMPDIR = "javax.servlet.context.tempdir";
|
2012-10-13 17:58:44 +02:00
|
|
|
private File tempDir = null;
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-09-22 19:46:39 +02:00
|
|
|
static private RefineServlet s_singleton;
|
2010-09-29 03:50:57 +02:00
|
|
|
static private File s_dataDir;
|
2010-07-06 02:14:07 +02:00
|
|
|
|
2010-05-19 09:09:40 +02:00
|
|
|
static final private Map<String, Command> commands = new HashMap<String, Command>();
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-05-05 01:24:48 +02:00
|
|
|
// timer for periodically saving projects
|
2013-08-18 17:31:03 +02:00
|
|
|
static private ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
|
2010-05-05 01:24:48 +02:00
|
|
|
|
2010-09-29 03:50:57 +02:00
|
|
|
static final Logger logger = LoggerFactory.getLogger("refine");
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2013-08-18 17:31:03 +02:00
|
|
|
static final protected long AUTOSAVE_PERIOD = 5; // 5 minutes
|
2010-09-29 03:50:57 +02:00
|
|
|
|
2013-08-18 17:31:03 +02:00
|
|
|
static protected class AutoSaveTimerTask implements Runnable {
|
2011-08-02 21:30:23 +02:00
|
|
|
@Override
|
2010-05-09 06:34:36 +02:00
|
|
|
public void run() {
|
2010-05-12 11:02:41 +02:00
|
|
|
try {
|
|
|
|
ProjectManager.singleton.save(false); // quick, potentially incomplete save
|
2013-08-18 17:31:03 +02:00
|
|
|
} catch (final Throwable e) {
|
|
|
|
// Not the best, but we REALLY want this to keep trying
|
2010-05-12 11:02:41 +02:00
|
|
|
}
|
2010-05-09 06:34:36 +02:00
|
|
|
}
|
|
|
|
}
|
2010-05-05 01:24:48 +02:00
|
|
|
|
|
|
|
@Override
|
|
|
|
public void init() throws ServletException {
|
2010-06-05 02:50:18 +02:00
|
|
|
super.init();
|
2010-09-29 03:50:57 +02:00
|
|
|
|
|
|
|
VERSION = getInitParameter("refine.version");
|
|
|
|
REVISION = getInitParameter("refine.revision");
|
2010-10-18 06:18:09 +02:00
|
|
|
|
|
|
|
if (VERSION.equals("$VERSION")) {
|
|
|
|
VERSION = ASSIGNED_VERSION;
|
|
|
|
}
|
|
|
|
if (REVISION.equals("$REVISION")) {
|
|
|
|
REVISION = "TRUNK";
|
|
|
|
}
|
|
|
|
|
|
|
|
FULL_VERSION = VERSION + " [" + REVISION + "]";
|
|
|
|
FULLNAME += FULL_VERSION;
|
2010-09-29 03:50:57 +02:00
|
|
|
|
|
|
|
logger.info("Starting " + FULLNAME + "...");
|
2010-07-06 02:14:07 +02:00
|
|
|
|
|
|
|
s_singleton = this;
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-05-05 01:24:48 +02:00
|
|
|
logger.trace("> initialize");
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-09-22 20:36:33 +02:00
|
|
|
String data = getInitParameter("refine.data");
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-05-30 20:18:59 +02:00
|
|
|
if (data == null) {
|
2010-09-22 20:36:33 +02:00
|
|
|
throw new ServletException("can't find servlet init config 'refine.data', I have to give up initializing");
|
2010-05-30 20:18:59 +02:00
|
|
|
}
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-07-09 01:22:29 +02:00
|
|
|
s_dataDir = new File(data);
|
|
|
|
FileProjectManager.initialize(s_dataDir);
|
2011-08-02 05:34:47 +02:00
|
|
|
ImportingManager.initialize(this);
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2013-08-18 17:31:03 +02:00
|
|
|
service.scheduleWithFixedDelay(new AutoSaveTimerTask(), AUTOSAVE_PERIOD,
|
|
|
|
AUTOSAVE_PERIOD, TimeUnit.MINUTES);
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-05-05 01:24:48 +02:00
|
|
|
logger.trace("< initialize");
|
|
|
|
}
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-05-05 01:24:48 +02:00
|
|
|
@Override
|
|
|
|
public void destroy() {
|
|
|
|
logger.trace("> destroy");
|
|
|
|
|
2010-06-15 21:34:40 +02:00
|
|
|
// cancel automatic periodic saving and force a complete save.
|
2010-05-05 01:24:48 +02:00
|
|
|
if (_timer != null) {
|
|
|
|
_timer.cancel();
|
|
|
|
_timer = null;
|
|
|
|
}
|
|
|
|
if (ProjectManager.singleton != null) {
|
2010-08-22 07:06:36 +02:00
|
|
|
ProjectManager.singleton.dispose();
|
2010-05-05 01:24:48 +02:00
|
|
|
ProjectManager.singleton = null;
|
|
|
|
}
|
2010-05-30 20:18:59 +02:00
|
|
|
|
2010-05-05 01:24:48 +02:00
|
|
|
logger.trace("< destroy");
|
2010-06-05 02:50:18 +02:00
|
|
|
|
|
|
|
super.destroy();
|
2010-05-05 01:24:48 +02:00
|
|
|
}
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-06-05 02:50:18 +02:00
|
|
|
@Override
|
|
|
|
public void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
|
2010-08-22 08:16:13 +02:00
|
|
|
if (request.getPathInfo().startsWith("/command/")) {
|
|
|
|
String commandKey = getCommandKey(request);
|
|
|
|
Command command = commands.get(commandKey);
|
2010-06-05 02:50:18 +02:00
|
|
|
if (command != null) {
|
|
|
|
if (request.getMethod().equals("GET")) {
|
2011-09-01 20:26:45 +02:00
|
|
|
if (!logger.isTraceEnabled() && command.logRequests()) {
|
2011-08-03 00:21:47 +02:00
|
|
|
logger.info("GET {}", request.getPathInfo());
|
|
|
|
}
|
2010-08-22 08:16:13 +02:00
|
|
|
logger.trace("> GET {}", commandKey);
|
2010-06-05 02:50:18 +02:00
|
|
|
command.doGet(request, response);
|
2010-08-22 08:16:13 +02:00
|
|
|
logger.trace("< GET {}", commandKey);
|
2010-06-05 02:50:18 +02:00
|
|
|
} else if (request.getMethod().equals("POST")) {
|
2011-09-01 20:26:45 +02:00
|
|
|
if (!logger.isTraceEnabled() && command.logRequests()) {
|
2011-08-03 00:21:47 +02:00
|
|
|
logger.info("POST {}", request.getPathInfo());
|
|
|
|
}
|
2010-08-22 08:16:13 +02:00
|
|
|
logger.trace("> POST {}", commandKey);
|
2010-06-05 02:50:18 +02:00
|
|
|
command.doPost(request, response);
|
2010-08-22 08:16:13 +02:00
|
|
|
logger.trace("< POST {}", commandKey);
|
2011-11-25 04:48:03 +01:00
|
|
|
} else if (request.getMethod().equals("PUT")) {
|
|
|
|
if (!logger.isTraceEnabled() && command.logRequests()) {
|
|
|
|
logger.info("PUT {}", request.getPathInfo());
|
|
|
|
}
|
|
|
|
logger.trace("> PUT {}", commandKey);
|
|
|
|
command.doPut(request, response);
|
|
|
|
logger.trace("< PUT {}", commandKey);
|
|
|
|
} else if (request.getMethod().equals("DELETE")) {
|
|
|
|
if (!logger.isTraceEnabled() && command.logRequests()) {
|
|
|
|
logger.info("DELETE {}", request.getPathInfo());
|
|
|
|
}
|
|
|
|
logger.trace("> DELETE {}", commandKey);
|
|
|
|
command.doDelete(request, response);
|
|
|
|
logger.trace("< DELETE {}", commandKey);
|
2010-06-05 02:50:18 +02:00
|
|
|
} else {
|
|
|
|
response.sendError(405);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
response.sendError(404);
|
|
|
|
}
|
2010-05-05 01:24:48 +02:00
|
|
|
} else {
|
2010-06-05 02:50:18 +02:00
|
|
|
super.service(request, response);
|
2010-05-05 01:24:48 +02:00
|
|
|
}
|
|
|
|
}
|
2010-10-13 06:51:01 +02:00
|
|
|
|
|
|
|
public ButterflyModule getModule(String name) {
|
|
|
|
return _modulesByName.get(name);
|
|
|
|
}
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-08-22 08:16:13 +02:00
|
|
|
protected String getCommandKey(HttpServletRequest request) {
|
|
|
|
// A command path has this format: /command/module-name/command-name/...
|
|
|
|
|
|
|
|
String path = request.getPathInfo().substring("/command/".length());
|
|
|
|
|
|
|
|
int slash1 = path.indexOf('/');
|
|
|
|
if (slash1 >= 0) {
|
|
|
|
int slash2 = path.indexOf('/', slash1 + 1);
|
|
|
|
if (slash2 > 0) {
|
|
|
|
path = path.substring(0, slash2);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return path;
|
2010-05-05 01:24:48 +02:00
|
|
|
}
|
2010-06-15 21:34:40 +02:00
|
|
|
|
|
|
|
public File getTempDir() {
|
2010-05-30 20:18:59 +02:00
|
|
|
if (tempDir == null) {
|
2012-10-13 17:58:44 +02:00
|
|
|
tempDir = (File) _config.getServletContext().getAttribute(JAVAX_SERVLET_CONTEXT_TEMPDIR);
|
2010-05-30 20:18:59 +02:00
|
|
|
if (tempDir == null) {
|
|
|
|
throw new RuntimeException("This app server doesn't support temp directories");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return tempDir;
|
|
|
|
}
|
2010-06-15 21:34:40 +02:00
|
|
|
|
2010-05-30 20:18:59 +02:00
|
|
|
public File getTempFile(String name) {
|
|
|
|
return new File(getTempDir(), name);
|
|
|
|
}
|
2010-07-09 01:22:29 +02:00
|
|
|
|
|
|
|
public File getCacheDir(String name) {
|
|
|
|
File dir = new File(new File(s_dataDir, "cache"), name);
|
|
|
|
dir.mkdirs();
|
|
|
|
|
|
|
|
return dir;
|
|
|
|
}
|
2010-05-30 20:18:59 +02:00
|
|
|
|
|
|
|
public String getConfiguration(String name, String def) {
|
|
|
|
return null;
|
|
|
|
}
|
2010-07-06 02:14:07 +02:00
|
|
|
|
|
|
|
/**
|
2010-08-22 08:16:13 +02:00
|
|
|
* Register a single command.
|
2010-07-06 02:14:07 +02:00
|
|
|
*
|
2010-08-22 08:16:13 +02:00
|
|
|
* @param module the module the command belongs to
|
|
|
|
* @param name command verb for command
|
|
|
|
* @param commandObject object implementing the command
|
2010-07-06 02:14:07 +02:00
|
|
|
* @return true if command was loaded and registered successfully
|
|
|
|
*/
|
2010-08-22 08:16:13 +02:00
|
|
|
protected boolean registerOneCommand(ButterflyModule module, String name, Command commandObject) {
|
|
|
|
return registerOneCommand(module.getName() + "/" + name, commandObject);
|
2010-07-06 02:14:07 +02:00
|
|
|
}
|
|
|
|
|
2010-05-19 09:09:40 +02:00
|
|
|
/**
|
|
|
|
* Register a single command.
|
2010-06-15 21:34:40 +02:00
|
|
|
*
|
2010-08-22 08:16:13 +02:00
|
|
|
* @param path path for command
|
|
|
|
* @param commandObject object implementing the command
|
2010-05-19 09:09:40 +02:00
|
|
|
* @return true if command was loaded and registered successfully
|
|
|
|
*/
|
2010-08-22 08:16:13 +02:00
|
|
|
protected boolean registerOneCommand(String path, Command commandObject) {
|
|
|
|
if (commands.containsKey(path)) {
|
2010-05-19 09:09:40 +02:00
|
|
|
return false;
|
|
|
|
}
|
2010-07-06 02:14:07 +02:00
|
|
|
|
|
|
|
commandObject.init(this);
|
2010-08-22 08:16:13 +02:00
|
|
|
commands.put(path, commandObject);
|
2010-07-06 02:14:07 +02:00
|
|
|
|
2010-05-19 09:09:40 +02:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Currently only for test purposes
|
2010-05-30 20:18:59 +02:00
|
|
|
protected boolean unregisterCommand(String verb) {
|
2010-05-19 09:09:40 +02:00
|
|
|
return commands.remove(verb) != null;
|
|
|
|
}
|
2010-07-06 02:14:07 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Register a single command. Used by extensions.
|
|
|
|
*
|
2010-08-22 08:16:13 +02:00
|
|
|
* @param module the module the command belongs to
|
|
|
|
* @param name command verb for command
|
|
|
|
* @param commandObject object implementing the command
|
2010-07-06 02:14:07 +02:00
|
|
|
*
|
|
|
|
* @return true if command was loaded and registered successfully
|
|
|
|
*/
|
2010-08-22 08:16:13 +02:00
|
|
|
static public boolean registerCommand(ButterflyModule module, String commandName, Command commandObject) {
|
|
|
|
return s_singleton.registerOneCommand(module, commandName, commandObject);
|
2010-07-06 02:14:07 +02:00
|
|
|
}
|
2010-08-04 01:01:18 +02:00
|
|
|
|
2010-09-23 01:59:57 +02:00
|
|
|
static private class ClassMapping {
|
|
|
|
final String from;
|
|
|
|
final String to;
|
|
|
|
|
|
|
|
ClassMapping(String from, String to) {
|
|
|
|
this.from = from;
|
|
|
|
this.to = to;
|
2010-08-04 01:01:18 +02:00
|
|
|
}
|
2010-09-23 01:59:57 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
static final private List<ClassMapping> classMappings = new ArrayList<ClassMapping>();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add a mapping that determines how old class names can be updated to newer
|
|
|
|
* class names. Such updates are desirable as the Java code changes from version
|
|
|
|
* to version. If the "from" argument ends with *, then it's considered a prefix;
|
|
|
|
* otherwise, it's an exact string match.
|
|
|
|
*
|
|
|
|
* @param from
|
|
|
|
* @param to
|
|
|
|
*/
|
|
|
|
static public void registerClassMapping(String from, String to) {
|
|
|
|
classMappings.add(new ClassMapping(from, to.endsWith("*") ? to.substring(0, to.length() - 1) : to));
|
|
|
|
}
|
|
|
|
|
|
|
|
static {
|
|
|
|
registerClassMapping("com.metaweb.*", "com.google.*");
|
|
|
|
registerClassMapping("com.google.gridworks.*", "com.google.refine.*");
|
|
|
|
}
|
|
|
|
|
|
|
|
static final private Map<String, String> classMappingsCache = new HashMap<String, String>();
|
2010-10-08 03:54:00 +02:00
|
|
|
static final private Map<String, Class<?>> classCache = new HashMap<String, Class<?>>();
|
|
|
|
|
|
|
|
// TODO(dfhuynh): Temporary solution until we figure out why cross butterfly module class resolution
|
|
|
|
// doesn't entirely work
|
|
|
|
static public void cacheClass(Class<?> klass) {
|
|
|
|
classCache.put(klass.getName(), klass);
|
|
|
|
}
|
|
|
|
|
2010-09-23 01:59:57 +02:00
|
|
|
static public Class<?> getClass(String className) throws ClassNotFoundException {
|
|
|
|
String toClassName = classMappingsCache.get(className);
|
|
|
|
if (toClassName == null) {
|
|
|
|
toClassName = className;
|
|
|
|
|
|
|
|
for (ClassMapping m : classMappings) {
|
|
|
|
if (m.from.endsWith("*")) {
|
|
|
|
if (toClassName.startsWith(m.from.substring(0, m.from.length() - 1))) {
|
|
|
|
toClassName = m.to + toClassName.substring(m.from.length() - 1);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (m.from.equals(toClassName)) {
|
|
|
|
toClassName = m.to;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
classMappingsCache.put(className, toClassName);
|
2010-09-22 20:36:33 +02:00
|
|
|
}
|
2010-09-23 01:59:57 +02:00
|
|
|
|
2010-10-08 03:54:00 +02:00
|
|
|
Class<?> klass = classCache.get(toClassName);
|
|
|
|
if (klass == null) {
|
|
|
|
klass = Class.forName(toClassName);
|
|
|
|
classCache.put(toClassName, klass);
|
|
|
|
}
|
|
|
|
return klass;
|
2010-08-04 01:01:18 +02:00
|
|
|
}
|
2011-08-11 02:35:01 +02:00
|
|
|
|
|
|
|
static public void setUserAgent(URLConnection urlConnection) {
|
|
|
|
if (urlConnection instanceof HttpURLConnection) {
|
|
|
|
setUserAgent((HttpURLConnection) urlConnection);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static public void setUserAgent(HttpURLConnection httpConnection) {
|
2013-08-03 00:13:41 +02:00
|
|
|
httpConnection.addRequestProperty("User-Agent", getUserAgent());
|
2011-08-11 02:35:01 +02:00
|
|
|
}
|
|
|
|
|
2013-08-03 00:13:41 +02:00
|
|
|
static public String getUserAgent() {
|
|
|
|
return "OpenRefine/" + FULL_VERSION;
|
|
|
|
}
|
2017-02-10 21:55:58 +01:00
|
|
|
}
|