pogen WIP
This commit is contained in:
parent
35bbe6af2d
commit
c26a41ce70
11
packages/pogen/example/proj1/package.json
Normal file
11
packages/pogen/example/proj1/package.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "proj1",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
0
packages/pogen/example/proj1/src/sample.ts
Normal file
0
packages/pogen/example/proj1/src/sample.ts
Normal file
27
packages/pogen/example/proj1/tsconfig.json
Normal file
27
packages/pogen/example/proj1/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compileOnSave": true,
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": false,
|
||||||
|
"target": "ES6",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"sourceMap": true,
|
||||||
|
"lib": ["es6"],
|
||||||
|
"types": ["node"],
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"outDir": "lib",
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"incremental": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"importHelpers": true,
|
||||||
|
"rootDir": "./src",
|
||||||
|
"typeRoots": ["./node_modules/@types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
@ -19,9 +19,10 @@ It has multiple lines, and a trailing empty line.
|
|||||||
*/
|
*/
|
||||||
console.log(/*lol*/i18n.foo`Hello7,${123} World${42}`);
|
console.log(/*lol*/i18n.foo`Hello7,${123} World${42}`);
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
i18n.plural(i18n`one ${"foo"}`, i18`many ${"bar"}`);
|
i18n.plural(i18n`one ${"foo"}`, i18`many ${"bar"}`);
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
i18n.plural(i18n.foo`one bla ${"foo"}`, i18.foo`many bla ${"bar"}`);
|
i18n.plural(i18n.foo`one bla ${"foo"}`, i18.foo`many bla ${"bar"}`);
|
||||||
|
|
||||||
let x = 42;
|
let x = 42;
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@gnu-taler/pogen",
|
"name": "@gnu-taler/pogen",
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"main": "bin/pogen.js",
|
"bin": {
|
||||||
|
"pogen": "lib/pogen.js"
|
||||||
|
},
|
||||||
"author": "Florian Dold",
|
"author": "Florian Dold",
|
||||||
"license": "GPL-2.0+",
|
"license": "GPL-2.0+",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
32
packages/pogen/po2.js
Normal file
32
packages/pogen/po2.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
const ts = require("typescript");
|
||||||
|
|
||||||
|
const configPath = ts.findConfigFile(
|
||||||
|
/*searchPath*/ "./",
|
||||||
|
ts.sys.fileExists,
|
||||||
|
"tsconfig.json"
|
||||||
|
);
|
||||||
|
if (!configPath) {
|
||||||
|
throw new Error("Could not find a valid 'tsconfig.json'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(configPath);
|
||||||
|
|
||||||
|
const cmdline = ts.getParsedCommandLineOfConfigFile(configPath, {}, {
|
||||||
|
fileExists: ts.sys.fileExists,
|
||||||
|
getCurrentDirectory: ts.sys.getCurrentDirectory,
|
||||||
|
onUnRecoverableConfigFileDiagnostic: (e) => console.log(e),
|
||||||
|
readDirectory: ts.sys.readDirectory,
|
||||||
|
readFile: ts.sys.readFile,
|
||||||
|
useCaseSensitiveFileNames: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(cmdline);
|
||||||
|
|
||||||
|
const prog = ts.createProgram({
|
||||||
|
options: cmdline.options,
|
||||||
|
rootNames: cmdline.fileNames,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allFiles = prog.getSourceFiles();
|
||||||
|
|
||||||
|
console.log(allFiles.map(x => x.path));
|
@ -14,7 +14,6 @@
|
|||||||
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate .po file from list of source files.
|
* Generate .po file from list of source files.
|
||||||
*
|
*
|
||||||
@ -24,15 +23,15 @@
|
|||||||
* @author Florian Dold
|
* @author Florian Dold
|
||||||
*/
|
*/
|
||||||
|
|
||||||
"use strict";
|
/**
|
||||||
|
* Imports.
|
||||||
|
*/
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import * as ts from "typescript";
|
import * as ts from "typescript";
|
||||||
|
|
||||||
|
|
||||||
function wordwrap(str: string, width: number = 80): string[] {
|
function wordwrap(str: string, width: number = 80): string[] {
|
||||||
var regex = '.{1,' + width + '}(\\s|$)|\\S+(\\s|$)';
|
var regex = ".{1," + width + "}(\\s|$)|\\S+(\\s|$)";
|
||||||
return str.match(RegExp(regex, 'g'));
|
return str.match(RegExp(regex, "g"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function processFile(sourceFile: ts.SourceFile) {
|
export function processFile(sourceFile: ts.SourceFile) {
|
||||||
@ -51,7 +50,7 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
textFragments.push(`%${(textFragments.length - 1) / 2 + 1}$s`);
|
textFragments.push(`%${(textFragments.length - 1) / 2 + 1}$s`);
|
||||||
textFragments.push(tsp.literal.text.replace(/%/g, "%%"));
|
textFragments.push(tsp.literal.text.replace(/%/g, "%%"));
|
||||||
}
|
}
|
||||||
return textFragments.join('');
|
return textFragments.join("");
|
||||||
default:
|
default:
|
||||||
return "(pogen.ts: unable to parse)";
|
return "(pogen.ts: unable to parse)";
|
||||||
}
|
}
|
||||||
@ -71,7 +70,10 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let candidate = lastComments[lastComments.length - 1];
|
let candidate = lastComments[lastComments.length - 1];
|
||||||
let candidateEndLine = ts.getLineAndCharacterOfPosition(sourceFile, candidate.end).line;
|
let candidateEndLine = ts.getLineAndCharacterOfPosition(
|
||||||
|
sourceFile,
|
||||||
|
candidate.end,
|
||||||
|
).line;
|
||||||
if (candidateEndLine != lc.line - 1) {
|
if (candidateEndLine != lc.line - 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -123,13 +125,15 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
line: number;
|
line: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function processTaggedTemplateExpression(tte: ts.TaggedTemplateExpression): TemplateResult {
|
function processTaggedTemplateExpression(
|
||||||
|
tte: ts.TaggedTemplateExpression,
|
||||||
|
): TemplateResult {
|
||||||
let lc = ts.getLineAndCharacterOfPosition(sourceFile, tte.pos);
|
let lc = ts.getLineAndCharacterOfPosition(sourceFile, tte.pos);
|
||||||
if (lc.line != lastTokLine) {
|
if (lc.line != lastTokLine) {
|
||||||
preLastTokLine = lastTokLine;
|
preLastTokLine = lastTokLine;
|
||||||
lastTokLine = lc.line;
|
lastTokLine = lc.line;
|
||||||
}
|
}
|
||||||
let path = getPath(tte.tag)
|
let path = getPath(tte.tag);
|
||||||
let res: TemplateResult = {
|
let res: TemplateResult = {
|
||||||
path,
|
path,
|
||||||
line: lc.line,
|
line: lc.line,
|
||||||
@ -141,7 +145,7 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
|
|
||||||
function formatMsgComment(line: number, comment?: string) {
|
function formatMsgComment(line: number, comment?: string) {
|
||||||
if (comment) {
|
if (comment) {
|
||||||
for (let cl of comment.split('\n')) {
|
for (let cl of comment.split("\n")) {
|
||||||
console.log(`#. ${cl}`);
|
console.log(`#. ${cl}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -153,7 +157,7 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
// Do escaping, wrap break at newlines
|
// Do escaping, wrap break at newlines
|
||||||
let parts = msg
|
let parts = msg
|
||||||
.match(/(.*\n|.+$)/g)
|
.match(/(.*\n|.+$)/g)
|
||||||
.map((x) => x.replace(/\n/g, '\\n'))
|
.map((x) => x.replace(/\n/g, "\\n"))
|
||||||
.map((p) => wordwrap(p))
|
.map((p) => wordwrap(p))
|
||||||
.reduce((a, b) => a.concat(b));
|
.reduce((a, b) => a.concat(b));
|
||||||
if (parts.length == 1) {
|
if (parts.length == 1) {
|
||||||
@ -166,18 +170,13 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JsxProcessingContext {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function getJsxElementPath(node: ts.Node) {
|
function getJsxElementPath(node: ts.Node) {
|
||||||
let path;
|
let path;
|
||||||
let process = (childNode) => {
|
let process = (childNode) => {
|
||||||
switch (childNode.kind) {
|
switch (childNode.kind) {
|
||||||
case ts.SyntaxKind.JsxOpeningElement:
|
case ts.SyntaxKind.JsxOpeningElement: {
|
||||||
{
|
|
||||||
let e = childNode as ts.JsxOpeningElement;
|
let e = childNode as ts.JsxOpeningElement;
|
||||||
return path = getPath(e.tagName);
|
return (path = getPath(e.tagName));
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@ -189,8 +188,7 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
|
|
||||||
function translateJsxExpression(node: ts.Node, h) {
|
function translateJsxExpression(node: ts.Node, h) {
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
case ts.SyntaxKind.StringLiteral:
|
case ts.SyntaxKind.StringLiteral: {
|
||||||
{
|
|
||||||
let e = node as ts.StringLiteral;
|
let e = node as ts.StringLiteral;
|
||||||
return e.text;
|
return e.text;
|
||||||
}
|
}
|
||||||
@ -208,8 +206,7 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
let holeNum = [1];
|
let holeNum = [1];
|
||||||
let process = (childNode) => {
|
let process = (childNode) => {
|
||||||
switch (childNode.kind) {
|
switch (childNode.kind) {
|
||||||
case ts.SyntaxKind.JsxText:
|
case ts.SyntaxKind.JsxText: {
|
||||||
{
|
|
||||||
let e = childNode as ts.JsxText;
|
let e = childNode as ts.JsxText;
|
||||||
let s = e.getFullText();
|
let s = e.getFullText();
|
||||||
let t = s.split("\n").map(trim).join(" ");
|
let t = s.split("\n").map(trim).join(" ");
|
||||||
@ -226,8 +223,7 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
case ts.SyntaxKind.JsxElement:
|
case ts.SyntaxKind.JsxElement:
|
||||||
fragments.push(`%${holeNum[0]++}$s`);
|
fragments.push(`%${holeNum[0]++}$s`);
|
||||||
break;
|
break;
|
||||||
case ts.SyntaxKind.JsxExpression:
|
case ts.SyntaxKind.JsxExpression: {
|
||||||
{
|
|
||||||
let e = childNode as ts.JsxExpression;
|
let e = childNode as ts.JsxExpression;
|
||||||
fragments.push(translateJsxExpression(e.expression, holeNum));
|
fragments.push(translateJsxExpression(e.expression, holeNum));
|
||||||
break;
|
break;
|
||||||
@ -235,8 +231,17 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
case ts.SyntaxKind.JsxClosingElement:
|
case ts.SyntaxKind.JsxClosingElement:
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
let lc = ts.getLineAndCharacterOfPosition(childNode.getSourceFile(), childNode.getStart());
|
let lc = ts.getLineAndCharacterOfPosition(
|
||||||
console.error(`unrecognized syntax in JSX Element ${ts.SyntaxKind[childNode.kind]} (${childNode.getSourceFile().fileName}:${lc.line+1}:${lc.character+1}`);
|
childNode.getSourceFile(),
|
||||||
|
childNode.getStart(),
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
`unrecognized syntax in JSX Element ${
|
||||||
|
ts.SyntaxKind[childNode.kind]
|
||||||
|
} (${childNode.getSourceFile().fileName}:${lc.line + 1}:${
|
||||||
|
lc.character + 1
|
||||||
|
}`,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -248,8 +253,7 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
let res;
|
let res;
|
||||||
let process = (childNode) => {
|
let process = (childNode) => {
|
||||||
switch (childNode.kind) {
|
switch (childNode.kind) {
|
||||||
case ts.SyntaxKind.JsxElement:
|
case ts.SyntaxKind.JsxElement: {
|
||||||
{
|
|
||||||
let path = getJsxElementPath(childNode);
|
let path = getJsxElementPath(childNode);
|
||||||
if (arrayEq(path, ["i18n", "TranslateSingular"])) {
|
if (arrayEq(path, ["i18n", "TranslateSingular"])) {
|
||||||
res = getJsxContent(childNode);
|
res = getJsxContent(childNode);
|
||||||
@ -267,8 +271,7 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
let res;
|
let res;
|
||||||
let process = (childNode) => {
|
let process = (childNode) => {
|
||||||
switch (childNode.kind) {
|
switch (childNode.kind) {
|
||||||
case ts.SyntaxKind.JsxElement:
|
case ts.SyntaxKind.JsxElement: {
|
||||||
{
|
|
||||||
let path = getJsxElementPath(childNode);
|
let path = getJsxElementPath(childNode);
|
||||||
if (arrayEq(path, ["i18n", "TranslatePlural"])) {
|
if (arrayEq(path, ["i18n", "TranslatePlural"])) {
|
||||||
res = getJsxContent(childNode);
|
res = getJsxContent(childNode);
|
||||||
@ -282,7 +285,6 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function processNode(node: ts.Node) {
|
function processNode(node: ts.Node) {
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
case ts.SyntaxKind.JsxElement:
|
case ts.SyntaxKind.JsxElement:
|
||||||
@ -319,8 +321,7 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case ts.SyntaxKind.CallExpression:
|
case ts.SyntaxKind.CallExpression: {
|
||||||
{
|
|
||||||
// might be i18n.plural(i18n[.X]`...`, i18n[.X]`...`)
|
// might be i18n.plural(i18n[.X]`...`, i18n[.X]`...`)
|
||||||
let ce = <ts.CallExpression>node;
|
let ce = <ts.CallExpression>node;
|
||||||
let path = getPath(ce.expression);
|
let path = getPath(ce.expression);
|
||||||
@ -334,8 +335,12 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let { line } = ts.getLineAndCharacterOfPosition(sourceFile, ce.pos);
|
let { line } = ts.getLineAndCharacterOfPosition(sourceFile, ce.pos);
|
||||||
let t1 = processTaggedTemplateExpression(<ts.TaggedTemplateExpression>ce.arguments[0]);
|
let t1 = processTaggedTemplateExpression(
|
||||||
let t2 = processTaggedTemplateExpression(<ts.TaggedTemplateExpression>ce.arguments[1]);
|
<ts.TaggedTemplateExpression>ce.arguments[0],
|
||||||
|
);
|
||||||
|
let t2 = processTaggedTemplateExpression(
|
||||||
|
<ts.TaggedTemplateExpression>ce.arguments[1],
|
||||||
|
);
|
||||||
let comment = getComment(ce);
|
let comment = getComment(ce);
|
||||||
|
|
||||||
formatMsgComment(line, comment);
|
formatMsgComment(line, comment);
|
||||||
@ -348,10 +353,11 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
// Important: no processing for child i18n expressions here
|
// Important: no processing for child i18n expressions here
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case ts.SyntaxKind.TaggedTemplateExpression:
|
case ts.SyntaxKind.TaggedTemplateExpression: {
|
||||||
{
|
|
||||||
let tte = <ts.TaggedTemplateExpression>node;
|
let tte = <ts.TaggedTemplateExpression>node;
|
||||||
let {comment, template, line, path} = processTaggedTemplateExpression(tte);
|
let { comment, template, line, path } = processTaggedTemplateExpression(
|
||||||
|
tte,
|
||||||
|
);
|
||||||
if (path[0] != "i18n") {
|
if (path[0] != "i18n") {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -367,10 +373,36 @@ export function processFile(sourceFile: ts.SourceFile) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileNames = process.argv.slice(2);
|
function main() {
|
||||||
|
const configPath = ts.findConfigFile(
|
||||||
|
/*searchPath*/ "./",
|
||||||
|
ts.sys.fileExists,
|
||||||
|
"tsconfig.json",
|
||||||
|
);
|
||||||
|
if (!configPath) {
|
||||||
|
throw new Error("Could not find a valid 'tsconfig.json'.");
|
||||||
|
}
|
||||||
|
|
||||||
console.log(
|
const cmdline = ts.getParsedCommandLineOfConfigFile(
|
||||||
`# SOME DESCRIPTIVE TITLE.
|
configPath,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
fileExists: ts.sys.fileExists,
|
||||||
|
getCurrentDirectory: ts.sys.getCurrentDirectory,
|
||||||
|
onUnRecoverableConfigFileDiagnostic: (e) => console.log(e),
|
||||||
|
readDirectory: ts.sys.readDirectory,
|
||||||
|
readFile: ts.sys.readFile,
|
||||||
|
useCaseSensitiveFileNames: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileNames = cmdline.fileNames;
|
||||||
|
|
||||||
|
fileNames.sort();
|
||||||
|
|
||||||
|
const outChunks: string[] = [];
|
||||||
|
|
||||||
|
outChunks.push(`# SOME DESCRIPTIVE TITLE.
|
||||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||||
# This file is distributed under the same license as the PACKAGE package.
|
# This file is distributed under the same license as the PACKAGE package.
|
||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
|
||||||
@ -388,11 +420,19 @@ msgstr ""
|
|||||||
"MIME-Version: 1.0\\n"
|
"MIME-Version: 1.0\\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\\n"
|
"Content-Type: text/plain; charset=UTF-8\\n"
|
||||||
"Content-Transfer-Encoding: 8bit\\n"`);
|
"Content-Transfer-Encoding: 8bit\\n"`);
|
||||||
console.log()
|
|
||||||
|
|
||||||
fileNames.sort();
|
fileNames.forEach((fileName) => {
|
||||||
|
let sourceFile = ts.createSourceFile(
|
||||||
fileNames.forEach(fileName => {
|
fileName,
|
||||||
let sourceFile = ts.createSourceFile(fileName, readFileSync(fileName).toString(), ts.ScriptTarget.ES2016, /*setParentNodes */ true);
|
readFileSync(fileName).toString(),
|
||||||
|
ts.ScriptTarget.ES2016,
|
||||||
|
/*setParentNodes */ true,
|
||||||
|
);
|
||||||
processFile(sourceFile);
|
processFile(sourceFile);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const out = outChunks.join("");
|
||||||
|
console.log(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
@ -4,8 +4,8 @@
|
|||||||
"target": "es5",
|
"target": "es5",
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"outDir": "bin",
|
"outDir": "lib",
|
||||||
"incremental": true,
|
"incremental": true
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"pogen.ts"
|
"pogen.ts"
|
||||||
|
@ -5,5 +5,6 @@ This package implements various utility functionality for GNU Taler.
|
|||||||
|
|
||||||
## When should something be moved to this package?
|
## When should something be moved to this package?
|
||||||
|
|
||||||
The ``@gnu-taler/taler-util`` package should have minimal dependencies
|
The ``@gnu-taler/taler-util`` package should have minimal dependencies and
|
||||||
and as few platform-specific functionality as possible.
|
should not be platform specific.
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user