Replace dsc import calls with Docspell API
This commit is contained in:
@@ -5,7 +5,7 @@ A small command-line helper for managing Docspell import folders, archiving file
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Scan one or more configured import directories.
|
- Scan one or more configured import directories.
|
||||||
- Check every file with `dsc file-exists` to see whether it is already known to Docspell.
|
- Check every file with Docspell's API to see whether it is already known to Docspell.
|
||||||
- Move files that already exist in Docspell and are in the `confirmed` state into a local archive.
|
- Move files that already exist in Docspell and are in the `confirmed` state into a local archive.
|
||||||
- Preserve useful archive structure by Docspell folder and item date.
|
- Preserve useful archive structure by Docspell folder and item date.
|
||||||
- Optionally upload files that do not yet exist in Docspell.
|
- Optionally upload files that do not yet exist in Docspell.
|
||||||
@@ -14,7 +14,6 @@ A small command-line helper for managing Docspell import folders, archiving file
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- [Docspell Command Line Client (`dsc`)](https://docspell.org/docs/tools/cli/) for the import/archive command.
|
|
||||||
- A running Docspell instance.
|
- A running Docspell instance.
|
||||||
- A password command, for example [`pass`](https://www.passwordstore.org/), that prints the Docspell password to stdout.
|
- A password command, for example [`pass`](https://www.passwordstore.org/), that prints the Docspell password to stdout.
|
||||||
- FUSE support for the `mount` command, including `fusermount` or `fusermount3` for unmounting.
|
- FUSE support for the `mount` command, including `fusermount` or `fusermount3` for unmounting.
|
||||||
@@ -64,7 +63,7 @@ Run the `import` subcommand:
|
|||||||
docspell-cli import
|
docspell-cli import
|
||||||
```
|
```
|
||||||
|
|
||||||
The command logs in with `dsc`, scans every configured import directory, and checks each file against Docspell.
|
The command logs in to Docspell via the REST API, scans every configured import directory, and checks each file against Docspell by SHA-256 checksum.
|
||||||
|
|
||||||
If a file already exists in Docspell, the tool fetches the item details and prints the Docspell item URL. The local file is moved only when the Docspell item is `confirmed` and has an item date. Files that are still unconfirmed or missing a date are left untouched.
|
If a file already exists in Docspell, the tool fetches the item details and prints the Docspell item URL. The local file is moved only when the Docspell item is `confirmed` and has an item date. Files that are still unconfirmed or missing a date are left untouched.
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,17 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -60,6 +65,23 @@ type ItemAttachment struct {
|
|||||||
Position int `json:"position"`
|
Position int `json:"position"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BasicResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadMeta struct {
|
||||||
|
Multiple bool `json:"multiple"`
|
||||||
|
Direction string `json:"direction,omitempty"`
|
||||||
|
Folder string `json:"folder,omitempty"`
|
||||||
|
SkipDuplicates bool `json:"skipDuplicates,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
FileFilter string `json:"fileFilter,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
AttachmentsOnly bool `json:"attachmentsOnly,omitempty"`
|
||||||
|
FlattenArchives bool `json:"flattenArchives,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type DocspellTag struct {
|
type DocspellTag struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -137,6 +159,86 @@ func (c *DocspellClient) GetItem(ctx context.Context, id string) (*SearchItem, e
|
|||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *DocspellClient) FileExists(ctx context.Context, filePath string) (FileExistsResult, error) {
|
||||||
|
var result FileExistsResult
|
||||||
|
hash, err := fileSHA256(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if err := c.doJSON(ctx, http.MethodGet, "/api/v1/sec/checkfile/"+url.PathEscape(hash), nil, &result); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(filePath); err == nil {
|
||||||
|
result.File = abs
|
||||||
|
} else {
|
||||||
|
result.File = filePath
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DocspellClient) UploadFile(ctx context.Context, filePath string, meta UploadMeta) (BasicResult, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return BasicResult{}, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
metaPart, err := writer.CreatePart(textPartHeader("meta", "application/json"))
|
||||||
|
if err != nil {
|
||||||
|
return BasicResult{}, err
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(metaPart).Encode(meta); err != nil {
|
||||||
|
return BasicResult{}, err
|
||||||
|
}
|
||||||
|
filePart, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
||||||
|
if err != nil {
|
||||||
|
return BasicResult{}, err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(filePart, file); err != nil {
|
||||||
|
return BasicResult{}, err
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return BasicResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url("/api/v1/sec/upload/item"), &body)
|
||||||
|
if err != nil {
|
||||||
|
return BasicResult{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
data, err := c.do(req)
|
||||||
|
if err != nil {
|
||||||
|
return BasicResult{}, err
|
||||||
|
}
|
||||||
|
var out BasicResult
|
||||||
|
if err := json.Unmarshal(data, &out); err != nil {
|
||||||
|
return BasicResult{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSHA256(filePath string) (string, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
hash := sha256.New()
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textPartHeader(name, contentType string) textproto.MIMEHeader {
|
||||||
|
header := make(textproto.MIMEHeader)
|
||||||
|
header.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"`, name))
|
||||||
|
header.Set("Content-Type", contentType)
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DocspellClient) AttachmentSize(ctx context.Context, id string) (int64, error) {
|
func (c *DocspellClient) AttachmentSize(ctx context.Context, id string) (int64, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.url("/api/v1/sec/attachment/"+url.PathEscape(id)), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, c.url("/api/v1/sec/attachment/"+url.PathEscape(id)), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,3 +63,102 @@ func TestSearchItemsContinuesWhenServerCapsLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileExistsUsesAuthenticatedChecksumEndpoint(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "doc.txt")
|
||||||
|
content := []byte("hello docspell")
|
||||||
|
if err := os.WriteFile(file, content, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantHash := fmt.Sprintf("%x", sha256.Sum256(content))
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Fatalf("expected GET, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/v1/sec/checkfile/"+wantHash {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get(defaultDocspellAuthHeader) != "token-1" {
|
||||||
|
t.Fatalf("missing auth header: %q", r.Header.Get(defaultDocspellAuthHeader))
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(FileExistsResult{
|
||||||
|
Exists: true,
|
||||||
|
Items: []DocspellItem{{ID: "item-1", Name: "Invoice", State: "confirmed", ItemDate: 1720396800000}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewDocspellClient(server.URL, "")
|
||||||
|
client.Token = "token-1"
|
||||||
|
got, err := client.FileExists(context.Background(), file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.Exists || got.Items[0].ID != "item-1" {
|
||||||
|
t.Fatalf("unexpected result: %#v", got)
|
||||||
|
}
|
||||||
|
if got.File == "" {
|
||||||
|
t.Fatal("expected local file path to be populated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileUsesAuthenticatedMultipartEndpoint(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "upload.txt")
|
||||||
|
if err := os.WriteFile(file, []byte("upload body"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/api/v1/sec/upload/item" {
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get(defaultDocspellAuthHeader) != "token-1" {
|
||||||
|
t.Fatalf("missing auth header: %q", r.Header.Get(defaultDocspellAuthHeader))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
|
||||||
|
t.Fatalf("expected multipart content type, got %q", r.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var meta UploadMeta
|
||||||
|
if err := json.Unmarshal([]byte(r.FormValue("meta")), &meta); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !meta.Multiple {
|
||||||
|
t.Fatalf("expected multiple upload meta, got %#v", meta)
|
||||||
|
}
|
||||||
|
parts := r.MultipartForm.File["file"]
|
||||||
|
if len(parts) != 1 || parts[0].Filename != "upload.txt" {
|
||||||
|
t.Fatalf("unexpected file parts: %#v", parts)
|
||||||
|
}
|
||||||
|
opened, err := parts[0].Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer opened.Close()
|
||||||
|
data, err := io.ReadAll(opened)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) != "upload body" {
|
||||||
|
t.Fatalf("unexpected upload body %q", string(data))
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(BasicResult{Success: true, Message: "ok"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewDocspellClient(server.URL, "")
|
||||||
|
client.Token = "token-1"
|
||||||
|
got, err := client.UploadFile(context.Background(), file, UploadMeta{Multiple: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !got.Success {
|
||||||
|
t.Fatalf("expected success, got %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -35,7 +36,7 @@ type DocspellItem struct {
|
|||||||
Direction string `json:"direction"`
|
Direction string `json:"direction"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
Created int64 `json:"created"`
|
Created int64 `json:"created"`
|
||||||
ItemDate int64 `json:"item_date"`
|
ItemDate int64 `json:"itemDate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -164,8 +165,9 @@ func runImportCommand(cfg config, password string, args []string) {
|
|||||||
|
|
||||||
validateConfig(cfg)
|
validateConfig(cfg)
|
||||||
|
|
||||||
loginCmd := exec.Command("dsc", "login", "--user", cfg.User, "--password", password)
|
ctx := context.Background()
|
||||||
if err := loginCmd.Run(); err != nil {
|
client := NewDocspellClient(cfg.DocspellURL, defaultDocspellAuthHeader)
|
||||||
|
if err := client.Login(ctx, cfg.User, password); err != nil {
|
||||||
fmt.Println("Login failed:", err)
|
fmt.Println("Login failed:", err)
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
@@ -201,21 +203,14 @@ func runImportCommand(cfg config, password string, args []string) {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s:\n", info.Name())
|
fmt.Printf("%s:\n", info.Name())
|
||||||
|
|
||||||
cmd := exec.Command("dsc", "-f", "json", "file-exists", path)
|
fileExistsResponse, err := client.FileExists(ctx, path)
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf(" ERROR %v\n", err)
|
fmt.Printf(" ERROR %v\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileExistsResponse []FileExistsResult
|
if fileExistsResponse.Exists {
|
||||||
if err := json.Unmarshal(output, &fileExistsResponse); err != nil {
|
err := handleExistingFile(ctx, client, cfg, fileExistsResponse)
|
||||||
fmt.Printf(" ERROR parsing response: %v\n", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fileExistsResponse) > 0 && fileExistsResponse[0].Exists {
|
|
||||||
err := handleExistingFile(cfg, fileExistsResponse[0])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(" ERROR", err)
|
fmt.Println(" ERROR", err)
|
||||||
}
|
}
|
||||||
@@ -223,23 +218,15 @@ func runImportCommand(cfg config, password string, args []string) {
|
|||||||
fmt.Println(" Files does not exist, yet")
|
fmt.Println(" Files does not exist, yet")
|
||||||
if options.uploadMissing {
|
if options.uploadMissing {
|
||||||
fmt.Print(" ...uploading file..")
|
fmt.Print(" ...uploading file..")
|
||||||
cmd = exec.Command("dsc", "-f", "json", "upload", path)
|
uploadResult, err := client.UploadFile(ctx, path, UploadMeta{Multiple: true})
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("\n ERROR uploading: %v\n", err)
|
fmt.Printf("\n ERROR uploading: %v\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if uploadResult.Success {
|
||||||
var uploadResult map[string]interface{}
|
|
||||||
if err := json.Unmarshal(output, &uploadResult); err != nil {
|
|
||||||
fmt.Printf("\n ERROR parsing upload result: %v\n", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if uploadResult["success"].(bool) {
|
|
||||||
fmt.Println(". done")
|
fmt.Println(". done")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("\n ERROR %v\n", uploadResult)
|
fmt.Printf("\n ERROR %s\n", uploadResult.Message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,25 +255,22 @@ func parseImportOptions(args []string) (importOptions, error) {
|
|||||||
return options, parser.ParseArgs(args)
|
return options, parser.ParseArgs(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleExistingFile(cfg config, fileExistsResponse FileExistsResult) error {
|
func handleExistingFile(ctx context.Context, client *DocspellClient, cfg config, fileExistsResponse FileExistsResult) error {
|
||||||
// File exists in Docspell
|
// File exists in Docspell
|
||||||
items := fileExistsResponse.Items
|
items := fileExistsResponse.Items
|
||||||
|
if len(items) == 0 {
|
||||||
|
return fmt.Errorf("file exists response contained no items")
|
||||||
|
}
|
||||||
item := items[0]
|
item := items[0]
|
||||||
itemID := item.ID
|
itemID := item.ID
|
||||||
itemName := item.Name
|
itemName := item.Name
|
||||||
|
|
||||||
// Get item details
|
// Get item details
|
||||||
cmd := exec.Command("dsc", "-f", "json", "item", "get", itemID)
|
itemDetails, err := client.GetItem(ctx, itemID)
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get item details: %w", err)
|
return fmt.Errorf("get item details: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var itemDetails DocspellItemDetails
|
|
||||||
if err := json.Unmarshal(output, &itemDetails); err != nil {
|
|
||||||
return fmt.Errorf("parse item details: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
folder := "null"
|
folder := "null"
|
||||||
if itemDetails.Folder != nil {
|
if itemDetails.Folder != nil {
|
||||||
folder = itemDetails.Folder.Name
|
folder = itemDetails.Folder.Name
|
||||||
@@ -294,10 +278,10 @@ func handleExistingFile(cfg config, fileExistsResponse FileExistsResult) error {
|
|||||||
extension := filepath.Ext(fileExistsResponse.File)[1:]
|
extension := filepath.Ext(fileExistsResponse.File)[1:]
|
||||||
|
|
||||||
var corr string
|
var corr string
|
||||||
if itemDetails.CorrespondingOrganisation != nil && itemDetails.CorrespondingOrganisation.Name != "" {
|
if itemDetails.CorrOrg != nil && itemDetails.CorrOrg.Name != "" {
|
||||||
corr = itemDetails.CorrespondingOrganisation.Name
|
corr = itemDetails.CorrOrg.Name
|
||||||
} else if itemDetails.CorrespondingPerson != nil && itemDetails.CorrespondingPerson.Name != "" {
|
} else if itemDetails.CorrPerson != nil && itemDetails.CorrPerson.Name != "" {
|
||||||
corr = itemDetails.CorrespondingPerson.Name
|
corr = itemDetails.CorrPerson.Name
|
||||||
}
|
}
|
||||||
fmt.Printf(" File already exists: %s\n", itemName)
|
fmt.Printf(" File already exists: %s\n", itemName)
|
||||||
fmt.Printf(" URL: %s/app/item/%s\n", cfg.DocspellURL, itemID)
|
fmt.Printf(" URL: %s/app/item/%s\n", cfg.DocspellURL, itemID)
|
||||||
@@ -345,13 +329,6 @@ func validateConfig(cfg config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocspellItemDetails struct {
|
|
||||||
DocspellItem
|
|
||||||
CorrespondingOrganisation *DocspellEntity `json:"corr-org"`
|
|
||||||
CorrespondingPerson *DocspellEntity `json:"corr-person"`
|
|
||||||
Folder *DocspellEntity `json:"folder"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DocspellEntity struct {
|
type DocspellEntity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
Reference in New Issue
Block a user