mirror of
https://kevinblog.sytes.net/Code/Jibo-Revival-Group/RoboCommander.git
synced 2026-06-15 15:06:05 +00:00
Initial commit
This commit is contained in:
44
node_modules/node-blockly/blockly/appengine/README.txt
generated
vendored
Normal file
44
node_modules/node-blockly/blockly/appengine/README.txt
generated
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
Running an App Engine server
|
||||
|
||||
This directory contains the files needed to setup the optional Blockly server.
|
||||
Although Blockly itself is 100% client-side, the server enables cloud storage
|
||||
and sharing. Store your programs in Datastore and get a unique URL that allows
|
||||
you to load the program on any computer.
|
||||
|
||||
To run your own App Engine instance you'll need to create this directory
|
||||
structure:
|
||||
|
||||
blockly/
|
||||
|- app.yaml
|
||||
|- index.yaml
|
||||
|- index_redirect.py
|
||||
|- README.txt
|
||||
|- storage.js
|
||||
|- storage.py
|
||||
|- closure-library/ (Optional)
|
||||
`- static/
|
||||
|- blocks/
|
||||
|- core/
|
||||
|- demos/
|
||||
|- generators/
|
||||
|- media/
|
||||
|- msg/
|
||||
|- tests/
|
||||
|- blockly_compressed.js
|
||||
|- blockly_uncompressed.js (Optional)
|
||||
|- blocks_compressed.js
|
||||
|- dart_compressed.js
|
||||
|- javascript_compressed.js
|
||||
|- lua_compressed.js
|
||||
|- php_compressed.js
|
||||
`- python_compressed.js
|
||||
|
||||
Instructions for fetching the optional Closure library may be found here:
|
||||
https://developers.google.com/blockly/guides/modify/web/closure
|
||||
|
||||
Go to https://appengine.google.com/ and create your App Engine application.
|
||||
Modify the 'application' name of app.yaml to your App Engine application name.
|
||||
|
||||
Finally, upload this directory structure to your App Engine account,
|
||||
wait a minute, then go to http://YOURAPPNAME.appspot.com/
|
||||
87
node_modules/node-blockly/blockly/appengine/app.yaml
generated
vendored
Normal file
87
node_modules/node-blockly/blockly/appengine/app.yaml
generated
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
application: blockly-demo
|
||||
version: 1
|
||||
runtime: python27
|
||||
api_version: 1
|
||||
threadsafe: no
|
||||
|
||||
handlers:
|
||||
# Redirect obsolete URLs.
|
||||
# Blockly files moved from /blockly to /static on 5 Dec 2012.
|
||||
- url: /blockly/.*
|
||||
static_files: redirect.html
|
||||
upload: redirect.html
|
||||
# Code, Maze and Turtle moved from demos on 29 Dec 2012.
|
||||
- url: /static/demos/(maze|turtle)/.*
|
||||
static_files: redirect.html
|
||||
upload: redirect.html
|
||||
# Apps was disbanded on 20 Nov 2014.
|
||||
- url: /static/apps/.*
|
||||
static_files: redirect.html
|
||||
upload: redirect.html
|
||||
|
||||
|
||||
# Storage API.
|
||||
- url: /storage
|
||||
script: storage.py
|
||||
secure: always
|
||||
- url: /storage\.js
|
||||
static_files: storage.js
|
||||
upload: storage\.js
|
||||
secure: always
|
||||
|
||||
# Blockly files.
|
||||
- url: /static
|
||||
static_dir: static
|
||||
secure: always
|
||||
|
||||
# Closure library for uncompressed Blockly.
|
||||
- url: /closure-library
|
||||
static_dir: closure-library
|
||||
secure: always
|
||||
|
||||
# Redirect for root directory.
|
||||
- url: /
|
||||
script: index_redirect.py
|
||||
secure: always
|
||||
|
||||
# Favicon.
|
||||
- url: /favicon\.ico
|
||||
static_files: favicon.ico
|
||||
upload: favicon\.ico
|
||||
secure: always
|
||||
expiration: "30d"
|
||||
|
||||
# Apple icon.
|
||||
- url: /apple-touch-icon\.png
|
||||
static_files: apple-touch-icon.png
|
||||
upload: apple-touch-icon\.png
|
||||
secure: always
|
||||
expiration: "30d"
|
||||
|
||||
# robot.txt
|
||||
- url: /robots\.txt
|
||||
static_files: robots.txt
|
||||
upload: robots\.txt
|
||||
secure: always
|
||||
|
||||
|
||||
skip_files:
|
||||
# App Engine default patterns.
|
||||
- ^(.*/)?#.*#$
|
||||
- ^(.*/)?.*~$
|
||||
- ^(.*/)?.*\.py[co]$
|
||||
- ^(.*/)?.*/RCS/.*$
|
||||
- ^(.*/)?\..*$
|
||||
# Custom skip patterns.
|
||||
- ^static/appengine/.*$
|
||||
- ^static/demos/plane/soy/.+\.jar$
|
||||
- ^static/demos/plane/template.soy$
|
||||
- ^static/demos/plane/xlf/.*$
|
||||
- ^static/i18n/.*$
|
||||
- ^static/msg/json/.*$
|
||||
- ^.+\.soy$
|
||||
- ^closure-library/.*_test.html$
|
||||
- ^closure-library/.*_test.js$
|
||||
- ^closure-library/closure/bin/.*$
|
||||
- ^closure-library/doc/.*$
|
||||
- ^closure-library/scripts/.*$
|
||||
BIN
node_modules/node-blockly/blockly/appengine/apple-touch-icon.png
generated
vendored
Normal file
BIN
node_modules/node-blockly/blockly/appengine/apple-touch-icon.png
generated
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
node_modules/node-blockly/blockly/appengine/favicon.ico
generated
vendored
Normal file
BIN
node_modules/node-blockly/blockly/appengine/favicon.ico
generated
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
11
node_modules/node-blockly/blockly/appengine/index.yaml
generated
vendored
Normal file
11
node_modules/node-blockly/blockly/appengine/index.yaml
generated
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
indexes:
|
||||
|
||||
# AUTOGENERATED
|
||||
|
||||
# This index.yaml is automatically updated whenever the dev_appserver
|
||||
# detects that a new type of query is run. If you want to manage the
|
||||
# index.yaml file manually, remove the above marker line (the line
|
||||
# saying "# AUTOGENERATED"). If you want to manage some indexes
|
||||
# manually, move them above the marker line. The index.yaml file is
|
||||
# automatically uploaded to the admin console when you next deploy
|
||||
# your application using appcfg.py.
|
||||
2
node_modules/node-blockly/blockly/appengine/index_redirect.py
generated
vendored
Normal file
2
node_modules/node-blockly/blockly/appengine/index_redirect.py
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
print("Status: 302")
|
||||
print("Location: /static/demos/index.html")
|
||||
68
node_modules/node-blockly/blockly/appengine/redirect.html
generated
vendored
Normal file
68
node_modules/node-blockly/blockly/appengine/redirect.html
generated
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
var loc = location.href;
|
||||
|
||||
// Blockly files moved from /blockly to /static on 5 Dec 2012.
|
||||
if (loc.match('/blockly/')) {
|
||||
loc = loc.replace('/blockly/', '/static/');
|
||||
}
|
||||
|
||||
// Maze and Turtle moved from demos to apps on 29 Dec 2012.
|
||||
if (loc.match(/\/demos\/(maze|turtle)\//)) {
|
||||
loc = loc.replace('/demos/', '/apps/');
|
||||
}
|
||||
|
||||
// Vietnamese apps moved from vn to vi on 9 Jun 2012.
|
||||
if (loc.match('/vn.html')) {
|
||||
loc = loc.replace('/vn.html', '/vi.html');
|
||||
}
|
||||
|
||||
if (loc.match('/code/code.html')) {
|
||||
// Code moved to index.html on 7 Aug 2013.
|
||||
loc = loc.replace('/code.html', '/index.html');
|
||||
} else if (loc.match('/apps/code/zh_tw.html')) {
|
||||
// zh-tw was changed to zh-hans on 25 Nov 2013.
|
||||
loc = loc.replace('/zh_tw.html', '/index.html?lang=zh-hans');
|
||||
} else if (loc.match('/apps/code/index.html')) {
|
||||
// NOP.
|
||||
} else if (loc.match(/\/apps\/code\/[-a-z]+\.html/)) {
|
||||
// Code became language-agnostic on 20 Jul 2013.
|
||||
loc = loc.replace(/\/([-a-z]+)\.html/, '/index.html?lang=$1');
|
||||
}
|
||||
|
||||
if (loc.match('/apps/plane/plane.html')) {
|
||||
// Plane moved to index.html on 7 Aug 2013.
|
||||
loc = loc.replace('/plane.html', '/index.html');
|
||||
} else if (loc.match('/apps/code/plane.html')) {
|
||||
// NOP.
|
||||
} else if (loc.match(/\/apps\/plane\/[\d_]*[-a-z]+\.html/)) {
|
||||
// Plane became language-agnostic on 20 Jul 2013.
|
||||
loc = loc.replace('vn.html', 'vi.html');
|
||||
if (location.search) {
|
||||
loc = loc.replace(/\/[\d_]*([-a-z]+)\.html\?/, '/index.html?lang=$1&');
|
||||
} else {
|
||||
loc = loc.replace(/\/[\d_]*([-a-z]+)\.html/, '/index.html?lang=$1');
|
||||
}
|
||||
}
|
||||
|
||||
if (loc.match('/apps/puzzle/')) {
|
||||
// Puzzle moved to Blockly Games on 15 Oct 2014.
|
||||
loc = 'https://blockly-games.appspot.com/puzzle';
|
||||
} else if (loc.match('/apps/maze/')) {
|
||||
// Maze moved to Blockly Games on 10 Nov 2014.
|
||||
loc = 'https://blockly-games.appspot.com/maze';
|
||||
} else if (loc.match('/apps/turtle/')) {
|
||||
// Turtle moved to Blockly Games on 10 Nov 2014.
|
||||
loc = 'https://blockly-games.appspot.com/turtle';
|
||||
} else if (loc.match('/apps/')) {
|
||||
// Remaining apps moved to demos on 20 Nov 2014.
|
||||
loc = loc.replace('/apps/', '/demos/');
|
||||
}
|
||||
|
||||
location = loc;
|
||||
|
||||
</script>
|
||||
</head>
|
||||
</html>
|
||||
2
node_modules/node-blockly/blockly/appengine/robots.txt
generated
vendored
Normal file
2
node_modules/node-blockly/blockly/appengine/robots.txt
generated
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /storage
|
||||
203
node_modules/node-blockly/blockly/appengine/storage.js
generated
vendored
Normal file
203
node_modules/node-blockly/blockly/appengine/storage.js
generated
vendored
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @license
|
||||
* Visual Blocks Editor
|
||||
*
|
||||
* Copyright 2012 Google Inc.
|
||||
* https://developers.google.com/blockly/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Loading and saving blocks with localStorage and cloud storage.
|
||||
* @author q.neutron@gmail.com (Quynh Neutron)
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
// Create a namespace.
|
||||
var BlocklyStorage = {};
|
||||
|
||||
/**
|
||||
* Backup code blocks to localStorage.
|
||||
* @param {!Blockly.WorkspaceSvg} workspace Workspace.
|
||||
* @private
|
||||
*/
|
||||
BlocklyStorage.backupBlocks_ = function(workspace) {
|
||||
if ('localStorage' in window) {
|
||||
var xml = Blockly.Xml.workspaceToDom(workspace);
|
||||
// Gets the current URL, not including the hash.
|
||||
var url = window.location.href.split('#')[0];
|
||||
window.localStorage.setItem(url, Blockly.Xml.domToText(xml));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Bind the localStorage backup function to the unload event.
|
||||
* @param {Blockly.WorkspaceSvg=} opt_workspace Workspace.
|
||||
*/
|
||||
BlocklyStorage.backupOnUnload = function(opt_workspace) {
|
||||
var workspace = opt_workspace || Blockly.getMainWorkspace();
|
||||
window.addEventListener('unload',
|
||||
function() {BlocklyStorage.backupBlocks_(workspace);}, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Restore code blocks from localStorage.
|
||||
* @param {Blockly.WorkspaceSvg=} opt_workspace Workspace.
|
||||
*/
|
||||
BlocklyStorage.restoreBlocks = function(opt_workspace) {
|
||||
var url = window.location.href.split('#')[0];
|
||||
if ('localStorage' in window && window.localStorage[url]) {
|
||||
var workspace = opt_workspace || Blockly.getMainWorkspace();
|
||||
var xml = Blockly.Xml.textToDom(window.localStorage[url]);
|
||||
Blockly.Xml.domToWorkspace(xml, workspace);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save blocks to database and return a link containing key to XML.
|
||||
* @param {Blockly.WorkspaceSvg=} opt_workspace Workspace.
|
||||
*/
|
||||
BlocklyStorage.link = function(opt_workspace) {
|
||||
var workspace = opt_workspace || Blockly.getMainWorkspace();
|
||||
var xml = Blockly.Xml.workspaceToDom(workspace, true);
|
||||
// Remove x/y coordinates from XML if there's only one block stack.
|
||||
// There's no reason to store this, removing it helps with anonymity.
|
||||
if (workspace.getTopBlocks(false).length == 1 && xml.querySelector) {
|
||||
var block = xml.querySelector('block');
|
||||
if (block) {
|
||||
block.removeAttribute('x');
|
||||
block.removeAttribute('y');
|
||||
}
|
||||
}
|
||||
var data = Blockly.Xml.domToText(xml);
|
||||
BlocklyStorage.makeRequest_('/storage', 'xml', data, workspace);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve XML text from database using given key.
|
||||
* @param {string} key Key to XML, obtained from href.
|
||||
* @param {Blockly.WorkspaceSvg=} opt_workspace Workspace.
|
||||
*/
|
||||
BlocklyStorage.retrieveXml = function(key, opt_workspace) {
|
||||
var workspace = opt_workspace || Blockly.getMainWorkspace();
|
||||
BlocklyStorage.makeRequest_('/storage', 'key', key, workspace);
|
||||
};
|
||||
|
||||
/**
|
||||
* Global reference to current AJAX request.
|
||||
* @type {XMLHttpRequest}
|
||||
* @private
|
||||
*/
|
||||
BlocklyStorage.httpRequest_ = null;
|
||||
|
||||
/**
|
||||
* Fire a new AJAX request.
|
||||
* @param {string} url URL to fetch.
|
||||
* @param {string} name Name of parameter.
|
||||
* @param {string} content Content of parameter.
|
||||
* @param {!Blockly.WorkspaceSvg} workspace Workspace.
|
||||
* @private
|
||||
*/
|
||||
BlocklyStorage.makeRequest_ = function(url, name, content, workspace) {
|
||||
if (BlocklyStorage.httpRequest_) {
|
||||
// AJAX call is in-flight.
|
||||
BlocklyStorage.httpRequest_.abort();
|
||||
}
|
||||
BlocklyStorage.httpRequest_ = new XMLHttpRequest();
|
||||
BlocklyStorage.httpRequest_.name = name;
|
||||
BlocklyStorage.httpRequest_.onreadystatechange =
|
||||
BlocklyStorage.handleRequest_;
|
||||
BlocklyStorage.httpRequest_.open('POST', url);
|
||||
BlocklyStorage.httpRequest_.setRequestHeader('Content-Type',
|
||||
'application/x-www-form-urlencoded');
|
||||
BlocklyStorage.httpRequest_.send(name + '=' + encodeURIComponent(content));
|
||||
BlocklyStorage.httpRequest_.workspace = workspace;
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback function for AJAX call.
|
||||
* @private
|
||||
*/
|
||||
BlocklyStorage.handleRequest_ = function() {
|
||||
if (BlocklyStorage.httpRequest_.readyState == 4) {
|
||||
if (BlocklyStorage.httpRequest_.status != 200) {
|
||||
BlocklyStorage.alert(BlocklyStorage.HTTPREQUEST_ERROR + '\n' +
|
||||
'httpRequest_.status: ' + BlocklyStorage.httpRequest_.status);
|
||||
} else {
|
||||
var data = BlocklyStorage.httpRequest_.responseText.trim();
|
||||
if (BlocklyStorage.httpRequest_.name == 'xml') {
|
||||
window.location.hash = data;
|
||||
BlocklyStorage.alert(BlocklyStorage.LINK_ALERT.replace('%1',
|
||||
window.location.href));
|
||||
} else if (BlocklyStorage.httpRequest_.name == 'key') {
|
||||
if (!data.length) {
|
||||
BlocklyStorage.alert(BlocklyStorage.HASH_ERROR.replace('%1',
|
||||
window.location.hash));
|
||||
} else {
|
||||
BlocklyStorage.loadXml_(data, BlocklyStorage.httpRequest_.workspace);
|
||||
}
|
||||
}
|
||||
BlocklyStorage.monitorChanges_(BlocklyStorage.httpRequest_.workspace);
|
||||
}
|
||||
BlocklyStorage.httpRequest_ = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start monitoring the workspace. If a change is made that changes the XML,
|
||||
* clear the key from the URL. Stop monitoring the workspace once such a
|
||||
* change is detected.
|
||||
* @param {!Blockly.WorkspaceSvg} workspace Workspace.
|
||||
* @private
|
||||
*/
|
||||
BlocklyStorage.monitorChanges_ = function(workspace) {
|
||||
var startXmlDom = Blockly.Xml.workspaceToDom(workspace);
|
||||
var startXmlText = Blockly.Xml.domToText(startXmlDom);
|
||||
function change() {
|
||||
var xmlDom = Blockly.Xml.workspaceToDom(workspace);
|
||||
var xmlText = Blockly.Xml.domToText(xmlDom);
|
||||
if (startXmlText != xmlText) {
|
||||
window.location.hash = '';
|
||||
workspace.removeChangeListener(bindData);
|
||||
}
|
||||
}
|
||||
var bindData = workspace.addChangeListener(change);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load blocks from XML.
|
||||
* @param {string} xml Text representation of XML.
|
||||
* @param {!Blockly.WorkspaceSvg} workspace Workspace.
|
||||
* @private
|
||||
*/
|
||||
BlocklyStorage.loadXml_ = function(xml, workspace) {
|
||||
try {
|
||||
xml = Blockly.Xml.textToDom(xml);
|
||||
} catch (e) {
|
||||
BlocklyStorage.alert(BlocklyStorage.XML_ERROR + '\nXML: ' + xml);
|
||||
return;
|
||||
}
|
||||
// Clear the workspace to avoid merge.
|
||||
workspace.clear();
|
||||
Blockly.Xml.domToWorkspace(xml, workspace);
|
||||
};
|
||||
|
||||
/**
|
||||
* Present a text message to the user.
|
||||
* Designed to be overridden if an app has custom dialogs, or a butter bar.
|
||||
* @param {string} message Text to alert.
|
||||
*/
|
||||
BlocklyStorage.alert = function(message) {
|
||||
window.alert(message);
|
||||
};
|
||||
85
node_modules/node-blockly/blockly/appengine/storage.py
generated
vendored
Normal file
85
node_modules/node-blockly/blockly/appengine/storage.py
generated
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Blockly Demo: Storage
|
||||
|
||||
Copyright 2012 Google Inc.
|
||||
https://developers.google.com/blockly/
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
"""
|
||||
|
||||
"""Store and retrieve XML with App Engine.
|
||||
"""
|
||||
|
||||
__author__ = "q.neutron@gmail.com (Quynh Neutron)"
|
||||
|
||||
import cgi
|
||||
from random import randint
|
||||
from google.appengine.ext import db
|
||||
from google.appengine.api import memcache
|
||||
import logging
|
||||
|
||||
print "Content-Type: text/plain\n"
|
||||
|
||||
def keyGen():
|
||||
# Generate a random string of length KEY_LEN.
|
||||
KEY_LEN = 6
|
||||
CHARS = "abcdefghijkmnopqrstuvwxyz23456789" # Exclude l, 0, 1.
|
||||
max_index = len(CHARS) - 1
|
||||
return "".join([CHARS[randint(0, max_index)] for x in range(KEY_LEN)])
|
||||
|
||||
class Xml(db.Model):
|
||||
# A row in the database.
|
||||
xml_hash = db.IntegerProperty()
|
||||
xml_content = db.TextProperty()
|
||||
|
||||
forms = cgi.FieldStorage()
|
||||
if "xml" in forms:
|
||||
# Store XML and return a generated key.
|
||||
xml_content = forms["xml"].value
|
||||
xml_hash = hash(xml_content)
|
||||
lookup_query = db.Query(Xml)
|
||||
lookup_query.filter("xml_hash =", xml_hash)
|
||||
lookup_result = lookup_query.get()
|
||||
if lookup_result:
|
||||
xml_key = lookup_result.key().name()
|
||||
else:
|
||||
trials = 0
|
||||
result = True
|
||||
while result:
|
||||
trials += 1
|
||||
if trials == 100:
|
||||
raise Exception("Sorry, the generator failed to get a key for you.")
|
||||
xml_key = keyGen()
|
||||
result = db.get(db.Key.from_path("Xml", xml_key))
|
||||
xml = db.Text(xml_content, encoding="utf_8")
|
||||
row = Xml(key_name = xml_key, xml_hash = xml_hash, xml_content = xml)
|
||||
row.put()
|
||||
print xml_key
|
||||
|
||||
if "key" in forms:
|
||||
# Retrieve stored XML based on the provided key.
|
||||
key_provided = forms["key"].value
|
||||
# Normalize the string.
|
||||
key_provided = key_provided.lower().strip()
|
||||
# Check memcache for a quick match.
|
||||
xml = memcache.get("XML_" + key_provided)
|
||||
if xml is None:
|
||||
# Check datastore for a definitive match.
|
||||
result = db.get(db.Key.from_path("Xml", key_provided))
|
||||
if not result:
|
||||
xml = ""
|
||||
else:
|
||||
xml = result.xml_content
|
||||
# Save to memcache for next hit.
|
||||
if not memcache.add("XML_" + key_provided, xml, 3600):
|
||||
logging.error("Memcache set failed.")
|
||||
print xml.encode("utf-8")
|
||||
Reference in New Issue
Block a user