Uploads documents to Paperless-ngx from the command line or from a Send to Paperless item in Nautilus.
  • Zig 75%
  • Nix 25%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie d72ed8b23a
All checks were successful
test / test (push) Successful in 22m29s
test / paperless (push) Successful in 23m27s
use an icon Adwaita has for an added document
emblem-ok-symbolic is not in the Adwaita icon theme, so rows for added
documents showed the missing-image placeholder. object-select-symbolic
is its checkmark.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017wLGgvLQgBNCNLz7ZDEYR3
2026-10-24 16:50:08 -05:00
.forgejo/workflows config file, Nautilus extension, and NixOS and home-manager modules 2026-10-05 22:55:06 -05:00
dbus config file, Nautilus extension, and NixOS and home-manager modules 2026-10-05 22:55:06 -05:00
LICENSES port to Zig 0.17 and set up the project 2026-10-02 17:54:29 -05:00
nautilus use an icon Adwaita has for an added document 2026-10-24 16:50:08 -05:00
nix expand environment variables in token_file 2026-10-06 02:36:20 -05:00
src expand environment variables in token_file 2026-10-06 02:36:20 -05:00
tests/nixos config file, Nautilus extension, and NixOS and home-manager modules 2026-10-05 22:55:06 -05:00
.gitignore port to Zig 0.17 and set up the project 2026-10-02 17:54:29 -05:00
build.zig config file, Nautilus extension, and NixOS and home-manager modules 2026-10-05 22:55:06 -05:00
build.zig.zon config file, Nautilus extension, and NixOS and home-manager modules 2026-10-05 22:55:06 -05:00
build.zig.zon.nix config file, Nautilus extension, and NixOS and home-manager modules 2026-10-05 22:55:06 -05:00
flake.lock config file, Nautilus extension, and NixOS and home-manager modules 2026-10-05 22:55:06 -05:00
flake.nix config file, Nautilus extension, and NixOS and home-manager modules 2026-10-05 22:55:06 -05:00
package.nix config file, Nautilus extension, and NixOS and home-manager modules 2026-10-05 22:55:06 -05:00
README.md expand environment variables in token_file 2026-10-06 02:36:20 -05:00
REUSE.toml config file, Nautilus extension, and NixOS and home-manager modules 2026-10-05 22:55:06 -05:00

paperless-upload

Sends documents to a Paperless-ngx server and waits for each one to be consumed, in two ways:

  • paperless-upload, a command-line tool;
  • a Nautilus extension that adds Send to Paperless… to the menu you get by right-clicking files in GNOME Files, uploads them itself, and shows how each one is going in a small window.

Both are written in Zig 0.17. The HTTP and the Paperless API are zig-paperless, the notifications go over D-Bus through a client zig-dbus-service generates, and the extension's Nautilus, GTK 4 and libadwaita bindings are generated by zig-gobject from their GObject introspection data.

Where this lives

The repository lives in three places that carry the same history. The Forgejo instance at https://git.jcollie.dev/jeff/paperless-upload is the web-visible one, and is where the continuous integration runs:

$ git clone https://git.jcollie.dev/jeff/paperless-upload.git

It is mirrored on Tangled at https://tangled.org/jcollie.dev/paperless-upload, and it is also on the Radicle network, where a repository is found by its identifier and nothing else:

rad:z2zEu2VT4wLxhGgqYVyRbxfp2FHRr

rad clone rad:z2zEu2VT4wLxhGgqYVyRbxfp2FHRr fetches it from any node that seeds it. Any of the three is the whole project.

Configuration

The server and the API token are read from a ZON file, and from nowhere else: not the environment and not the command line.

.{
    .url = "https://paperless.example.com",
    .token = "0123456789abcdef0123456789abcdef01234567",
}

It is paperless-upload/config.zon in your configuration directory, as known-folders finds it. On Linux that is $XDG_CONFIG_HOME, or ~/.config when that is unset, as the XDG Base Directory Specification describes. On macOS it is ~/Library/Application Support. --config PATH names another file for the command-line tool.

url is the server's address, including any path prefix it is served under. The token is created in the Paperless web interface, under your user's profile. To keep it out of the configuration file (in a file a secrets manager writes, say), name that file with token_file instead of giving token:

.{
    .url = "https://paperless.example.com",
    .token_file = "/run/user/1000/secrets/paperless-token",
}

Exactly one of token and token_file must be given. $NAME and ${NAME} in token_file are replaced with that environment variable, because secrets managers hand out paths like /run/user/$UID/secrets/paperless-token (homeage) or ${XDG_RUNTIME_DIR}/agenix/paperless-token (agenix) and leave the expansion to a shell. Shells set UID without exporting it, so when the environment has no UID the process's own user ID is used; any other variable that is not set is an error. A relative token_file is relative to the directory the configuration file is in. Whitespace around the token, such as a final newline, is ignored, but anything more than the token is an error.

Whichever file holds the token is a credential, so keep it to yourself:

$ chmod 600 ~/.config/paperless-upload/config.zon

Both the tool and the extension warn when other users can read it. A field that is missing or misspelled is an error that names the line and column.

The command-line tool

$ paperless-upload scan-001.pdf receipt.jpg
scan-001.pdf  document 812
receipt.jpg  already in Paperless as document 790

While it works, and when stderr is a terminal, it draws Zig's std.Progress display below those lines. It shows how many files are done, which file is being sent, and what is happening to it: kibibytes sent while uploading, then the wait while Paperless consumes it.

[1/2] Sending to Paperless
└─ [0/2] receipt.jpg
   └─ [1432/2918] uploading (KiB)
usage: paperless-upload [OPTION]... FILE...

  -c, --config PATH   the configuration file
  -t, --tag ID        add the tag with this ID; may be repeated
      --title TITLE   the document's title (only with a single FILE)
      --no-wait       print each task ID instead of waiting
  -h, --help          show the help and exit

Each file is streamed from disk rather than read into memory. A file that fails does not stop the ones after it. The exit status is 0 when every file was consumed (or queued, with --no-wait), 1 when any was not, and 2 for a usage or configuration error.

The Nautilus extension

Select one or more files in Nautilus, right-click, and choose Send to Paperless…. The item appears only when every selected file is a local file of a kind Paperless consumes. It does not appear for folders, unsupported types, or files without a local path (the trash, a phone over MTP, an unmounted network share).

Choosing it opens a small window with a row for each file. Each row shows whether the file is waiting, uploading, or being consumed by Paperless, and then how it went:

  • Added, with the new document's number and a button that opens it in Paperless.
  • Already in Paperless, when the server refuses duplicates, with the number of the document that has it and the same button.
  • Not added, with Paperless's reason and a button that copies it.

A missing or broken configuration file is shown the same way. Cancel skips the files that have not started yet. A file already being sent or consumed cannot be recalled from Paperless. Once everything is done, the button becomes Close.

The uploads run on a thread of their own inside Nautilus, so neither Nautilus nor the window ever waits on them. Closing the window does not stop them; whatever finishes after it is closed is reported as a desktop notification instead. An upload still running when Nautilus quits (nautilus -q, or logging out) is abandoned.

The extension is lib/nautilus/extensions-4/libpaperless-upload-nautilus.so in the package. Nautilus loads extensions from a single directory, which on NixOS is the system profile's lib/nautilus/extensions-4 (that is where NAUTILUS_4_EXTENSION_DIR points). That is why the extension is installed by the NixOS module, below, and not by the home-manager module. Restart Nautilus with nautilus -q after installing it. To try a build without installing it:

$ nix build
$ nautilus -q
$ NAUTILUS_4_EXTENSION_DIR=$PWD/result/lib/nautilus/extensions-4 nautilus

Elsewhere, copy the library into the directory your distribution's Nautilus loads extensions from, usually /usr/lib64/nautilus/extensions-4 or /usr/lib/x86_64-linux-gnu/nautilus/extensions-4.

Installing with Nix

The flake has two modules, since the parts belong in two places. The NixOS module installs the package system-wide, which puts the extension where Nautilus looks. The home-manager module installs the tool for a user and writes that user's configuration file.

{
  inputs.paperless-upload.url = "git+https://git.jcollie.dev/jeff/paperless-upload.git";
}

In the NixOS configuration:

{
  imports = [ inputs.paperless-upload.nixosModules.default ];
  programs.paperless-upload.enable = true;
}

It also sets NAUTILUS_4_EXTENSION_DIR to the system profile's extension directory, as a default, for Nautilus run outside GNOME, where nothing else sets it.

In the home-manager configuration:

{
  imports = [ inputs.paperless-upload.homeModules.default ];
  programs.paperless-upload = {
    enable = true;
    settings = {
      url = "https://paperless.example.com";
      tokenFile = "/run/user/1000/secrets/paperless-token";
    };
  };
}

The module writes the configuration with token_file and never token. The configuration file is in the Nix store, which anyone on the machine can read, so the token has to live in a file of its own: a sops-nix or agenix secret, or a file you keep with chmod 600. tokenFile is a string rather than a Nix path for the same reason, since a path would be copied into the store.

What Paperless will take

The kinds Paperless-ngx 3.2.1's parsers consume, by extension. Anything else is refused before it is sent:

Kind Extensions
PDF and images pdf, png, jpg/jpeg/jpe, tif/tiff, gif, bmp, webp, heic
Text txt, text, csv
Mail eml
Office documents doc, docx, xls, xlsx, ppt, pptx, ppsx, odt, ods, odp, odg, rtf

Mail and office documents are consumed only by a server with Tika (and Gotenberg) enabled. A server without them refuses those files, and its reason is what gets reported. Paperless also checks the content itself, so a file whose name does not match its contents can still be refused: a gzip stream named .pdf comes back as BadRequest: {"document":["File type application/gzip not supported"]}.

Building

The Nix flake's development shell has Zig 0.17.0 (the release binary, from zig-overlay), pkg-config, the Nautilus, GTK 4, libadwaita and GLib headers, their GIR files (named in GIR_PATH, which the binding generator reads), xsltproc, reuse and zon2nix:

$ nix develop
$ zig build

That writes zig-out/bin/paperless-upload and zig-out/lib/nautilus/extensions-4/libpaperless-upload-nautilus.so. -Dnautilus=false leaves the extension out. It is off by default on anything but Linux. nix build builds the package.

Development

$ zig build test --summary all
$ zig build check
$ zig fmt --check --exclude zig-pkg .
$ reuse lint
$ nix flake check
  • test runs the unit tests: the configuration file, the table of file kinds, the argument parsing, and the paths an upload takes before anything reaches a server.
  • check compiles everything, including the extension, which test does not build.
  • nix flake check boots Paperless-ngx from the NixOS module (Redis, Celery and the consumer) in a virtual machine. paperless-upload is installed there with this project's NixOS module, and root's configuration is written by its home-manager module. The test obtains a token, writes it to the file the configuration names, and runs the tool against the server. The run covers where the two modules put things, a document consumed, a duplicate, a file the server refuses, a file refused locally, --no-wait, a missing configuration or token file, a token given in the configuration itself, and the warnings about readable ones.

The Forgejo workflow in .forgejo/workflows/test.yaml runs all of it on every push, with the virtual machine on a runner that has KVM.

After changing a dependency in build.zig.zon, regenerate the Nix expression for it:

$ nix develop -c zon2nix --17 --nix=build.zig.zon.nix build.zig.zon

Dependencies

  • zig-paperless: the Paperless-ngx REST API client.
  • known-folders: finds the configuration directory.
  • zig-dbus-service and mdbus: the generated org.freedesktop.Notifications client and the D-Bus transport under it.
  • zig-gobject (the zig-0.17 branch): generates the Nautilus, libadwaita, GTK 4 and GLib bindings at build time. Its GIR fixes include one this extension depends on. Nautilus calls get_file_items with a null list whenever nothing is selected, which is every time a folder is opened, and Nautilus's introspection data does not mark the list as nullable. An extension built against bindings without that fix crashes Nautilus.
  • libnautilus-extension-4, libadwaita, GTK 4 and GLib, through pkg-config, for the extension. These have to be the very libraries the Nautilus that loads it uses, since two copies of GLib in one process is a crash. That is why the NixOS and home-manager modules build the package with the nixpkgs they are evaluated with (through overlays.default), rather than taking the flake's own packages.

License

MIT. The project follows the REUSE specification. The license text is in LICENSES/MIT.txt.

References cited