Zum Inhalt

Architecture

← README · Security design · Roadmap · Releases

Paperless Unified Search is a Nextcloud app in PHP. It adds a provider to Nextcloud's unified search that asks Paperless-ngx and shows the documents whose synchronized files the searching user can read, and offers the same search in the smart picker of Talk, Text, Deck and Mail. It stores no documents and no index of its own, and has no server, daemon or port of its own.

Components

---
config:
  flowchart:
    wrappingWidth: 400
    nodeSpacing: 30
---
flowchart TB
    accTitle: The components
    accDescr: Inside Nextcloud, the unified search calls the search provider of the app, and the administration settings call its settings page. The smart picker finds Paperless documents through the reference provider of the app, which names the search provider, and then calls the search provider as the unified search does. occ runs the commands of the app, and the overview of the administration settings runs its setup check. The search provider uses the Paperless client, the file locator and the configuration; the settings page and the commands use the Paperless client and the configuration, and the commands also the explainer, which asks the file locator. The setup check reads only the configuration. The Paperless client reaches /api/documents/ of Paperless-ngx through Nextcloud's HTTP client, the file locator looks into the folders of the users, and the configuration keeps its values in the app configuration and the token in the credentials manager.

    subgraph nextcloud ["Nextcloud"]
        search["Unified<br>search"]
        picker["Smart<br>picker"]
        page["Administration<br>settings"]
        occ["occ"]
        overview["Overview of the<br>administration<br>settings"]
        subgraph app ["Paperless Unified Search"]
            reference["Reference<br>provider"]
            provider["Search<br>provider"]
            controller["Settings<br>page"]
            commands["Commands<br>of occ"]
            setupcheck["Setup<br>check"]
            explainer["Explainer"]
            client["Paperless<br>client"]
            locator["File<br>locator"]
            config["Configuration"]
        end
        http["HTTP<br>client"]
        folders[("Folders<br>of the<br>users")]
        appconfig[("App<br>configuration")]
        credentials[("Credentials<br>manager")]
    end
    paperless[("Paperless-ngx<br>/api/documents/")]

    search --> provider
    picker -. "lists" .-> reference
    reference -. "names" .-> provider
    picker --> provider
    page --> controller
    occ --> commands
    overview --> setupcheck
    provider --> client
    provider --> locator
    provider --> config
    controller --> client
    controller --> config
    commands --> client
    commands --> explainer --> locator
    commands --> config
    setupcheck --> config
    client --> http --> paperless
    locator --> folders
    config --> appconfig
    config --> credentials

    classDef app fill:#526cfe2e,stroke:#526cfe,stroke-width:2px
    class reference,provider,controller,commands,setupcheck,explainer,client,locator,config app
Component Files What it does
App lib/AppInfo/, lib/Migration/, appinfo/info.xml Registers the search provider, the reference provider, the setup check and the dashboard widget with Nextcloud under the one ID of the app in AppConstants, names the commands of occ, and names the repair step that Nextcloud runs after every update, which deletes the keys of the app configuration that version 0.3.0 left
Search provider lib/Search/PaperlessSearchProvider.php, lib/Search/PaperlessPager.php Answers a search of Nextcloud: asks Paperless, keeps the results with a readable file, fills the page of results from as many pages of Paperless as it takes, within a budget, and builds each entry with the preview of its file or the icon of its type, its title, a line with its date in the language of the user, its correspondent, its type and an excerpt as text without a tag, and the link that opens the file in the browser, the iOS app or the Android app
Dashboard widget lib/Dashboard/RecentDocumentsWidget.php, lib/Service/RecentDocuments.php New in Paperless on Nextcloud's dashboard: the documents that Paperless added last, from Nextcloud's distributed cache for a minute for all users, of which each user sees those whose file they can open, with the same picture, names and rules as a result of the search (The dashboard)
Pictures and text lib/Service/FilePictures.php, lib/Service/PlainText.php The preview of a file or the icon of its type, and text of Paperless on one line without a tag of HTML, the same for a result of the search and an item of the dashboard
Names lib/Service/PaperlessNames.php Names the correspondents and the types of the documents of a page, which Paperless sends as IDs: from Nextcloud's distributed cache for ten minutes, and the others with one request per kind; a lookup that fails leaves the names out and fails nothing
Reference provider lib/Reference/PaperlessReferenceProvider.php Lists Paperless documents in the smart picker with the icon of the app and names the search provider, through which the picker searches; it resolves no link, so that Nextcloud renders the inserted link of a file with the permissions of whoever reads it
Paperless client lib/Service/PaperlessApiService.php Sends the search term to the full-text search of Paperless through Nextcloud's HTTP client, asks only for the fields that a result shows, and checks the shape of the answer; asks for the names of correspondents and types by their IDs, and for the newest documents of the dashboard
File locator lib/Service/NextcloudFileLocator.php, lib/Search/Files/ Finds the files of the archive account whose names carry the markers [P<ID>] of the documents of a page, in one search of the folders that the searching user can read
Configuration lib/Service/ConfigService.php, lib/Model/PublicConfig.php Checks and stores the URL of Paperless, the archive account and the switch Always include Paperless in global search; the API token goes to Nextcloud's credentials manager, is read from it once per request and is sent only to the server it was saved for; without an archive account of its own it takes the account of Paperless Sync, and it uses the ID of either as Nextcloud spells it
Settings page lib/Settings/, lib/Controller/SettingsController.php, templates/settings.php, js/settings.js The page under Administration settings → Paperless Unified Search, a section of its own: save after a test of the connection, reset, the history of the problems of the search, and Try a search, which asks the explainer why a document doesn't show to a user; Nextcloud lets only administrators call its routes and checks the CSRF token of every request
Commands of occ lib/Command/ setup, test, search and check of occ paperless_unified_search:: the settings page without a browser, with the token from the standard input or the environment, a test of the connection for monitoring, a search as a user that says why a document doesn't show, and a check of the whole set-up (occ and the overview)
Explainer lib/Service/SearchExplainer.php Says for each document of a search whether it shows to a user, and with --explain or in Try a search of the settings why not, with one search of the file cache per question
Setup check lib/SetupCheck/PaperlessSetupCheck.php The state of the search in the overview of the administration settings and in occ setupchecks, from what the app stored, without a request to Paperless
Problems lib/Exception/, lib/Service/SettingsMessages.php Why a setting can't be saved or a request to Paperless failed, as a code: the settings page and occ name each one with a message of its own in the language of the administrator
Translations l10n/, scripts/check-l10n.mjs German in two forms, de with du and de_DE with Sie, for PHP and for the browser; the check makes sure that every text of the code has its translation in each, with its placeholders

Data flow

---
config:
  sequence:
    wrap: true
    width: 120
    actorMargin: 10
---
sequenceDiagram
    accTitle: The data flow of a search
    accDescr: A user searches for invoice. The unified search passes the term, the cursor, the limit, the date filter and the user to the search provider. Without a term, an archive account or a connection, the provider answers with no results and doesn't ask Paperless. Otherwise, until the page of results is full, the Paperless client asks Paperless-ngx with the token for the page of 25 hits that holds the next hit, within the days of the date filter, and only for the fields that a result shows, and Paperless answers with the ID, the title, the date, the IDs of the correspondent and the type and the excerpt of documents 412, 389, 371 and more, and whether a next page exists. One call of the file locator covers all documents of that page and returns for each a file of the archive account with its marker, or none. For the documents with a file, the names ask Paperless, through the client, for the correspondents and the types that the cache doesn't hold, with one request per kind, and keep their names for ten minutes. This repeats for at most 4 pages of Paperless or 3 seconds. The provider answers with an entry for every document with a file, up to the limit, with the preview of the file or the icon of its type, the title, the date in the language of the user, the correspondent, the type and the excerpt, and the cursor of the first hit that it didn't go through.

    actor User
    participant Search as Unified search
    participant Provider as Search provider
    participant Client as Paperless client
    participant Paperless as Paperless-ngx
    participant Locator as File locator
    participant Names as Names
    User->>Search: invoice
    Search->>Provider: term, cursor, limit,<br>date filter and user
    alt no term, no archive account or no connection
        Provider-->>Search: no results, without asking Paperless
    else
        loop until the page of results is full, for at most 4 pages of Paperless or 3 seconds
            Provider->>Client: term, the days of the date filter,<br>the page of 25 hits with the next hit
            Client->>Paperless: GET /api/documents/<br>?query=invoice&page=1<br>&page_size=25&fields=…<br>&created__date__gte=…,<br>with the token
            Paperless-->>Client: ID, title, date, correspondent,<br>type and excerpt of documents<br>412, 389, 371, …, and whether<br>a next page exists
            Provider->>Locator: the documents of the page,<br>user, archive account
            Locator-->>Provider: per document a file of the<br>archive account with its marker,<br>or none
            Provider->>Names: the correspondents and types<br>of the documents with a file
            Names->>Paperless: through the client, for the IDs<br>that the cache lacks: GET<br>/api/correspondents/?id__in=3,5<br>and /api/document_types/?id__in=1,2
            Paperless-->>Names: their names, which the cache<br>keeps for ten minutes
            Names-->>Provider: the names
        end
        Provider-->>Search: an entry for every document with a file, up to the limit: preview or icon, title, date, correspondent, type and excerpt, and the cursor of the next hit
    end
  1. A user searches in Nextcloud. Nextcloud asks the provider when the user has switched on Search connected services, or always when an administrator has marked Paperless as trusted. A client that asks for this search by its ID through Nextcloud's OCS search API, such as a script, gets it without the switch.
  2. The provider sends the term to the full-text search of Paperless, for a page of 25 hits, and asks only for the fields that a result shows: the ID, the title, the date, the correspondent and the type of each document, and the excerpt of the text that Paperless found. Without them, Paperless would send the whole OCR text of every document, its notes and its permissions as well, half a megabyte for a long document. A date filter of the search goes along on every page, as created__date__gte and created__date__lte for the first and the last day of the span: Nextcloud sends each bound as a moment, whose day in the timezone of the user is the day that the user picked. The provider names the filters since and until to Nextcloud (IFilteringProvider); Nextcloud's web search sends its date filter only to such a provider, and lists any other one under Partial matches, searched without it.
  3. The file locator looks for the files of all documents of the answer at once, in one search of the file cache: the files of the archive account with the marker [P<ID>] of one of the documents, in the folders of the searching user. Of several files of a document, the one with the first path counts. A document without such a file is left out, and without an archive account Paperless isn't asked at all. A file that fails to answer costs only its own document, not the whole search, and the log notes how many files failed. When the search then shows no document at all, the history of the diagnostics notes it as a failed search of the files.
  4. Each remaining document becomes an entry: the preview of its file or the icon of its type, its title, a line with its date, its correspondent, its type and an excerpt of the text that Paperless found, and the link that opens the file in Nextcloud, as Drawing a result describes. The provider takes the hits in the order of Paperless until the page of results holds as many entries as Nextcloud asked for, and asks Paperless for its next page when a page runs out first, as Filling a page of results describes.
  5. A request to Paperless that fails before it has a connection, because the name of the host doesn't resolve, whether cURL or Nextcloud's own lookup reports it, or the connection is refused or not set up within the 3 seconds to connect, is sent a second time: such failures often pass within a moment. A request that fails later, after 3.5 seconds, had a connection and waited for Paperless in vain, as for a Paperless that hangs while it consumes documents or builds its index anew, and gets no second try, which would make the user wait twice as long and load the struggling Paperless twice. Neither does an address that Nextcloud refuses as one of the local network, nor any answer of Paperless, whatever its status. When the second try gets an answer, the client notes the first error in the history of the diagnostics (decision 0006).
  6. Paperless 3 refuses a query in its advanced syntax that it can't parse with HTTP 400 and the errors of the field query, such as created:[2026 to while the user still types the range of dates, and every Paperless refuses a page past the last one with HTTP 404, as when the hits change between two pages of a search. Both answer what the user typed, while Paperless works: on HTTP 400 from Paperless 3 or later, the client asks once more with the same term in the simple mode of the search, the parameter text, which takes any input and finds the documents of its words; otherwise the search finds nothing more. Neither writes the history of the diagnostics or ends a failure in it, and the log notes them at the level debug. Paperless 2 never gets text, which it ignores, listing all documents. The client knows the version from the headers X-Version and X-Api-Version of the answers: the test of the connection stores it with the settings, and a search stores it again only when it changed, as after an update of Paperless.
  7. A search that still fails returns no results, as an empty search does. The provider logs the kind of error and notes the failure in the history: the time, the step, asking Paperless or looking for the files, the kind and the message of the error, and how long it took. The first search that works afterwards notes when searches work again. When a later page of Paperless fails, the page of results keeps the entries of the pages before it, the log notes the error, and the cursor of the next page of results asks for the failed page again.
---
config:
  flowchart:
    wrappingWidth: 400
    nodeSpacing: 30
---
flowchart TB
    accTitle: What a request to Paperless ends in
    accDescr: The Paperless client asks Paperless for a page of documents. A request without an answer gets one second try only when it failed within 3.5 seconds, before it had a connection, and Nextcloud didn't refuse its address; a request that waited for a Paperless that hangs fails the search at once. An answer of 2xx brings the documents. HTTP 400 for the query, as for an advanced query that the user is still typing, or HTTP 404 for a page after the first, is no problem of the search, and the history stays untouched; on Paperless 3, the client asks once more with the same term in the simple mode of the search, which brings the documents of its words, finds nothing when refused again, and fails the search with any other answer. Every other answer fails the search, which the history notes.

    request(["`**Ask Paperless**`"])
    answered{"Did Paperless<br>answer?"}
    early{"Within 3.5 s, before<br>a connection, and no<br>refused local address?"}
    second["One second try;<br>its answer notes a retry"]
    status{"Which status?"}
    documents["The documents"]
    paperless3{"Paperless 3<br>and HTTP 400?"}
    simple["The same term in the<br>simple mode, text"]
    nothing["Nothing found,<br>the history untouched"]
    failed["The search failed,<br>noted in the history"]

    request --> answered
    answered -- "no" --> early
    early -- "yes" --> second
    early -- "no" --> failed
    second -- "no answer" --> failed
    second -- "answer" --> status
    answered -- "yes" --> status
    status -- "2xx" --> documents
    status -- "400 for the query,<br>404 for a later page" --> paperless3
    status -- "any other" --> failed
    paperless3 -- "yes" --> simple
    paperless3 -- "no" --> nothing
    simple -- "2xx" --> documents
    simple -- "refused again" --> nothing
    simple -- "any other" --> failed

    classDef yes fill:#16a34a2e,stroke:#16a34a,stroke-width:2px
    classDef no fill:#dc26262e,stroke:#dc2626,stroke-width:2px
    class documents,nothing yes
    class failed no

Filling a page of results

Paperless ranks every document that matches, also those whose files the searching user can't open, so a page of Paperless and a page of results rarely hold the same documents. Nextcloud's web search asks for 5 results, shows a provider only when its answer holds some, and offers Load more results only when a page holds as many results as it asked for; then it asks again from the start for 5 more. The apps of Nextcloud ask for the next page with the cursor of the page before. So the provider fills each page:

---
config:
  flowchart:
    wrappingWidth: 400
    nodeSpacing: 30
---
flowchart TB
    accTitle: Filling a page of results
    accDescr: The cursor of a page of results is the offset of its first hit in the ranking of Paperless, 0 for the first page. The provider asks Paperless for the page of 25 hits that holds this hit and the file locator for the files of its documents, in one search. It goes through the hits in their order and makes an entry of each document with a file, until the page of results is full. When no hit is left at Paperless, the results are the last page. When the page of results is full, the results go back with the cursor of the first hit that the provider didn't go through. Otherwise it asks Paperless for its next page, as long as it asked for fewer than 4 pages and for less than 3 seconds; after that, the results go back with the cursor as well.

    cursor["Cursor of the page of results<br>the offset of its first hit<br>in the ranking, 0 at first"]
    ask["Paperless: the page of 25 hits<br>that holds the next hit"]
    files["File locator: the files of<br>its documents, in one search"]
    take["An entry for each document<br>with a file, in the order of<br>Paperless, until the page is full"]
    hits{"Hits left<br>at Paperless?"}
    full{"Page of results<br>full?"}
    budget{"Fewer than 4 pages<br>and 3 seconds?"}
    next(["The results, and the cursor<br>of the first hit not gone through"])
    last(["The results,<br>the last page"])

    cursor --> ask --> files --> take --> hits
    hits -- "no" --> last
    hits -- "yes" --> full
    full -- "yes" --> next
    full -- "no" --> budget
    budget -- "yes, the next page" --> ask
    budget -- "no" --> next

    classDef app fill:#526cfe2e,stroke:#526cfe,stroke-width:2px
    classDef yes fill:#16a34a2e,stroke:#16a34a,stroke-width:2px
    class ask,files,take app
    class next,last yes
  • The cursor is an offset. It counts the hits of Paperless that the pages before went through, documents without a file among them, so the next page goes on with the first hit that the page before didn't go through, also in the middle of a page of Paperless, without repeating or leaving out a document. lib/Search/PaperlessPager.php does this arithmetic. Nextcloud and its apps send the cursor back as they got it.
  • Pages of Paperless have one size. The provider asks for 25 hits at a time, whatever the limit of the page of results, which cost little since a hit carries only the fields that a result shows and the files of a page take one search.
  • A budget bounds each search. It asks Paperless for at most 4 pages, 100 hits, and for no further page once it has taken 3 seconds; a page that isn't full then goes back with the cursor, so that the apps can go on. A search pages through the first 10,000 hits; a cursor past them finds nothing and asks Paperless nothing.
  • One search is one search. However many pages of Paperless it asks for, it counts once in the history of the diagnostics.

Finding the files

---
config:
  flowchart:
    wrappingWidth: 400
    nodeSpacing: 30
---
flowchart TB
    accTitle: Finding the files of a page of documents
    accDescr: Paperless answers with documents 412, 389 and 371. One search of the file cache looks in the folders of the searching user for the names that carry any of their markers. Of the files it finds, only a file of the archive account with the exact marker of a document stands for it, and of several such files the one with the first path. Documents 412 and 371 get their file; document 389 has none and is left out.

    page["Documents of a page<br>412, 389, 371"]
    query["One search of the file cache<br>name LIKE '%[P412]%'<br>or '%[P389]%' or '%[P371]%'"]
    found["Files with one of the markers<br>in the folders of the user"]
    check{"A file of the<br>archive account, with the<br>exact marker?"}
    first["Per document, the file<br>with the first path<br>412 and 371"]
    none["Left out<br>389"]
    page --> query --> found --> check
    check -- "yes" --> first
    check -- "no" --> none

    classDef app fill:#526cfe2e,stroke:#526cfe,stroke-width:2px
    classDef yes fill:#16a34a2e,stroke:#16a34a,stroke-width:2px
    classDef no fill:#dc26262e,stroke:#dc2626,stroke-width:2px
    class query,check app
    class first yes
    class none no

The database has no index for a part of a name, so every search of the file cache reads the names of all files that the user can see, on every storage and in every share. One search per page of Paperless keeps that to one such scan per page and at most 4 per keystroke, within the budget of a search, however many documents a page holds, where a search per document took five in the web search and up to 50 in the apps or after Load more. The file locator checks the marker of each file it finds once more, so that [P1] never counts for [P12], nor [p12] for [P12].

Diagnostics

Users see no difference between a failed search and one without documents, and some hosters keep the log of Nextcloud from administrators. So the settings page shows the history of the problems of the search under Search problems.

---
config:
  flowchart:
    wrappingWidth: 400
    nodeSpacing: 30
---
flowchart TB
    accTitle: The history of the problems of the search
    accDescr: A second try that gets an answer notes a retried request, a failed search notes a failure, and the first search that works after a failure notes the recovery. The history keeps since when it counts, the counts, the last failure, the recovery and the latest 20 events, and the settings page shows it under Search problems. Disconnect and Clear history forget it.

    retry["A second try<br>answers"]
    search["A search<br>fails"]
    success["A search<br>works again"]
    history[("Diagnostics<br>since, counts, last failure,<br>recovery, latest 20 events")]
    page["Settings page<br>Search problems"]
    clear["Disconnect or<br>Clear history<br>forget it"]

    retry -- "retried" --> history
    search -- "failed" --> history
    success -- "recovery" --> history
    history --> page
    %% A plain dotted line keeps Clear history below the history. An arrow from it
    %% would rank it with the three causes, too wide for a phone, and Mermaid 11
    %% draws no arrowhead on "<-.-" and breaks on a label of a reversed edge.
    history -.- clear

    classDef yes fill:#16a34a2e,stroke:#16a34a,stroke-width:2px
    classDef no fill:#dc26262e,stroke:#dc2626,stroke-width:2px
    class success yes
    class search no

The history is one value of the app configuration of Nextcloud, loaded only when it is needed: a search that works reads it only while a value that Nextcloud holds in memory anyway, diagnostics_pending, says that a failure may wait for its recovery. A failure that writes the history removes that value after the history, so that a recovery saved meanwhile can't hide the failure, and the recovery sets it to false before it reads the history. It counts the failed searches and the requests that only their second try answered since it began, and keeps the latest 20 of these events, newest first, each with its time, its kind, its step, the kind and the message of the error and how long it took: for a failed search the whole search, for a second try the first request that got no answer. Only problems write to it, and a search that works writes only once after a failure, so searches that keep working write nothing. Problems of one kind write at most once in 30 seconds: during an outage every keystroke of every user fails, and every write of the app configuration empties Nextcloud's cache of it, so the problems in between only count, in Nextcloud's distributed cache, until the next write or the recovery takes them along. A search that works again doesn't end the 30 seconds, so a Paperless that fails every other request writes one failure and one recovery in them, not one of each per request. A query or a page that Paperless refuses, as one that the user is still typing, is no problem and writes nothing. A search whose files fail to answer and that shows no document counts as failed, since the user sees none; one that still shows the documents of other files works, and only the log notes the files it left out. A search that asks Paperless for several pages counts once, and so does its failure. Two problems at the same moment may keep only one of them, which a history for diagnosis can afford.

The message loses the API token and the search term when it has at least three characters, in any case, also of letters beyond ASCII, Ärger for ärger as well, by the simple case folding of Unicode, which leaves STRASSE for straße, cURL's pointer to the page of its error codes, and the query and the fragment of every URL, and is cut to 300 characters. A test of properties checks this on 500 random messages and terms of a fixed seed.

Drawing a result

---
config:
  flowchart:
    wrappingWidth: 400
    nodeSpacing: 30
---
flowchart TB
    accTitle: What a result shows
    accDescr: The search of Paperless-ngx sends the title of document 412, its date 2026-08-12, the IDs of its correspondent and its type, 3 and 1, and the words around the term. Paperless names correspondent 3 Northstar Energy and type 1 Bill, with one request per kind for all documents of the page, which the app keeps for ten minutes. The file of the document in Nextcloud gives the preview, when Nextcloud draws previews of its type, or else the icon of the type, as the search of files shows them. The result shows that picture, the title Electricity invoice - August 2026, and below it Aug 12, 2026 · Northstar Energy · Bill — Invoice E-2026-0812. Total due 84.30 EUR by September 2, with the date in the language of the user, such as 12.08.2026 in German.

    paperless[("Paperless-ngx")]
    search["The search: the title, the date 2026-08-12,<br>correspondent 3, type 1<br>and the words around the term"]
    names["The names: correspondent 3 is Northstar Energy,<br>type 1 is Bill, with one request per kind<br>for the page, kept for ten minutes"]
    file["The file in Nextcloud: its preview,<br>when Nextcloud draws previews of its type,<br>or else the icon of its type"]
    result(["`**Electricity invoice - August 2026**
    Aug 12, 2026 · Northstar Energy · Bill —
    Invoice E-2026-0812. Total due 84.30 EUR by September 2
    *the date in the language of the user: 12.08.2026 in German*`"])

    paperless --> search
    paperless --> names
    search --> result
    names --> result
    file --> result

    classDef app fill:#526cfe2e,stroke:#526cfe,stroke-width:2px
    classDef yes fill:#16a34a2e,stroke:#16a34a,stroke-width:2px
    class names app
    class result yes
  • The picture. An entry links the preview of its file, 64 pixels square, when Nextcloud draws previews of the type of the file, as Nextcloud's search of files does, with mimeFallback, so that a preview that fails to draw answers with the icon of the type; for another type, it links the icon of the type, and the icon of the app only when Nextcloud has none. The picture is an absolute URL, which the apps for phones load as well, in the thumbnail of the entry, which every client shows. The icon of the entry stays empty: the smart picker shows an icon in place of the thumbnail, as the name of a class of CSS, which a URL isn't. Nextcloud answers a preview only to a user who can open the file.
  • The date. It is the day that Paperless names, formatted by Nextcloud in the language of the user, Aug 12, 2026 or 12.08.2026, in no other timezone: API versions before 9 send the start of the day in the timezone of Paperless, such as 2026-08-12T00:00:00+02:00, which the timezone of a user west of it would turn into the day before. A text that is no such day stays as it is.
  • The correspondent and the type. Paperless sends their IDs. PaperlessNames takes the names of the documents with a file from Nextcloud's distributed cache, and asks Paperless for the others with one request per kind, /api/correspondents/?id__in=3,5 and /api/document_types/?id__in=1,2, which Paperless-ngx 2 and 3 filter; the cache keeps each name for ten minutes, an ID that Paperless doesn't name as well, by a hash of the URL of Paperless and the ID. A list that Paperless refuses to the token, with HTTP 401 or 403, stays unasked for as long. The request gets one try within 3 seconds and no place in the history of the diagnostics, and after a request without answer the other kind isn't asked for, and that list not for a minute, so that the searches of every keystroke don't wait for it again: when the names fail, the entries show without them.
  • The excerpt. It is the excerpt of the text, or else, for a search that matched only a note, the excerpt of the notes, marked Note:. Paperless marks up the words of the term with tags, <b> in Paperless-ngx 3 and <span class="match"> in 2, and escapes the text of the document as HTML around them, so the tags go first and the entities become characters again; the excerpt then runs on one line and is cut to 180 characters. A < before a letter, /, ! or ? in the whole line, where a tag would start, gets a space after it: the line holds no tag of HTML, whatever the document says, and keeps its text, where removing the tags after decoding would lose <GmbH> and everything after x<y. The answers of real Paperless-ngx 3.3.0 and 2.20.13 in tests/Unit/fixtures/ show the markup, and the unit tests check the line for each of them.

Opening a result

The link of an entry depends on who searches, which the provider tells from the user agent of the request:

---
config:
  flowchart:
    wrappingWidth: 400
    nodeSpacing: 30
---
flowchart TB
    accTitle: The link of a result
    accDescr: The entry of document 412 carries the ID of its file and its path in the folders of the user. When the iOS app searches, its link is nextcloud://open-file with the user and the link /f/412. Otherwise its link is /f/412, which a browser opens in Nextcloud's viewer; the Android app opens the file in its own viewer by the ID and the path.

    entry["Entry of document 412,<br>with the ID and the path<br>of its file"] --> ios{"The iOS app<br>searches?"}
    ios -- "yes" --> deep["nextcloud://<br>open-file with the<br>user and /f/412"]
    ios -- "no" --> web["/f/412"]
    web -- "a browser" --> viewer["Nextcloud's<br>viewer"]
    web -- "the Android app" --> android["Its own viewer,<br>by the ID<br>and the path"]

Every entry carries the ID of the file and its path in the folders of the user as well; the Android app opens the file with them.

The smart picker

The smart picker is the menu that / opens in Text and Collectives and that a button opens in Talk, Deck and Mail. It lists the reference providers of the apps; the one of this app, Paperless documents, names the search provider, and the picker searches through it as the unified search does, with the same rules of who sees which document:

---
config:
  sequence:
    wrap: true
    width: 120
    actorMargin: 10
---
sequenceDiagram
    accTitle: Linking a document from the smart picker
    accDescr: jamie types / in Text and chooses Paperless documents. The picker sends the term invoice to the search provider of the app, which asks Paperless-ngx and answers with the documents whose file jamie can open, each with the link /f/412 of its file. jamie picks a result, and the picker inserts that link. When someone reads the page, Nextcloud's own provider of file links renders the link: as the preview of the file for a reader who can open the file, and as the bare link for anyone else.

    actor jamie
    participant picker as Smart picker<br>in Text
    participant provider as Search provider
    participant paperless as Paperless-ngx
    participant files as Nextcloud's provider<br>of file links
    actor reader as A reader

    jamie->>picker: / and Paperless documents
    picker->>provider: invoice
    provider->>paperless: invoice
    paperless-->>provider: document 412, …
    provider-->>picker: the documents whose file jamie can open,<br>each with the link /f/412 of its file
    jamie->>picker: picks document 412
    picker-->>jamie: inserts /f/412
    reader->>files: reads the page with /f/412
    files-->>reader: the preview of the file if the reader can open it,<br>the bare link otherwise

The term reaches Paperless only after the user chose Paperless documents, so the picker needs no switch like Search connected services. The inserted link is the link of the result, /f/<ID> of the file, which opens it in Nextcloud's viewer.

Saving the settings

---
config:
  sequence:
    wrap: true
    width: 120
    actorMargin: 10
---
sequenceDiagram
    accTitle: Saving the settings
    accDescr: The administrator enters the URL, whether Paperless runs in the local network, the token and the archive account. When the password wasn't confirmed in the last 30 minutes, Nextcloud asks for it first. The settings page sends them with the CSRF token to the settings controller, which checks the URL, the token and the archive account: a blank token keeps the stored one only for its server, the same scheme, host and port, and otherwise the answer is HTTP 400. Then the controller has the Paperless client test the connection with one request to Paperless-ngx, which Nextcloud lets reach an address of the local network only with the switch, and then without redirects. Only when Paperless answers does the configuration save them, the URL, the switches and the archive account in the app configuration and the token in the credentials manager. The page gets the settings back, without the token.

    actor Admin as Administrator
    participant Page as Settings page
    participant Controller as Settings controller
    participant Client as Paperless client
    participant Config as Configuration
    participant Paperless as Paperless-ngx
    Admin->>Page: URL, local network, token, archive account
    opt the password wasn't confirmed in the last 30 minutes
        Page->>Admin: Nextcloud asks for the password
    end
    Page->>Controller: POST /apps/<br>paperless_unified_search<br>/settings, with the<br>CSRF token
    Controller->>Config: checks the URL, the token and the archive account
    Note over Controller,Config: a blank token keeps the stored one only for its server (the same scheme, host and port), else HTTP 400
    Controller->>Client: test the connection
    Note over Client,Paperless: an address of the local network only with the switch, then without redirects
    Client->>Paperless: GET /api/documents/<br>?page_size=1&fields=id
    Paperless-->>Client: 200
    Controller->>Config: save
    Config->>Config: URL, switches and archive account to the app configuration, the token to the credentials manager
    Controller-->>Page: the settings, without the token

A test of the connection that fails names its cause (decision 0005): the Paperless client throws a PaperlessRequestException with the reason, from the status of the answer, its content or the error of cURL, and the controller answers HTTP 400 with the code of the reason and a message in the language of the administrator that says what to correct, and logs a warning with the error, without the token and the query of the URL. A setting that can't be saved answers the same way, with the code of its problem. The README lists every message.

Paperless runs in my local network lets the test of the connection, and then every search, reach the Paperless URL at an address of the local network, which Nextcloud refuses otherwise, without lifting that protection for anything else (decision 0004). A token left blank keeps the stored one, but only for the server it was saved for (decision 0003); the page marks the token field as required as soon as the URL names another server. The archive account is stored as Nextcloud spells its ID. A stored token that the credentials manager can't decrypt counts as none, and the page says so. Saving and Disconnect ask for the password when it wasn't confirmed in the last 30 minutes. Disconnect deletes every setting, the token included, and the history of the diagnostics as well; saving keeps the history.

The page (js/settings.js) shows one sentence for every answer, also when Nextcloud answers instead of the app, as in maintenance mode, after the session ended or behind a proxy, and when the browser gets no answer at all. It reads an answer as JSON only when Nextcloud says that it is JSON, so that the HTML page of an error never becomes the message. Each message has the colour and the icon of its kind, pending, success or error. After a save or Disconnect, the page shows the settings as Nextcloud stored them, such as the URL without the slash at its end, and Disconnect is on only while a token or a URL is stored.

flowchart TB
    accTitle: What the settings page shows for an answer
    accDescr: The settings page sends a save or Disconnect to Nextcloud and shows one sentence for whatever comes back. When the browser gets no answer, the sentence says that Nextcloud could not be reached. HTTP 401 or 412 means that the session expired, and HTTP 503 with the headers that Nextcloud sends in maintenance mode and before an upgrade that Nextcloud is in maintenance mode. A refusal of the app brings its own message in the language of the administrator. An answer of the app shows the settings as Nextcloud stored them, with the URL in its stored form, and the message of success. An error without a message of the app, such as the HTML page of a proxy or a 503 without those headers, gives the request failed with its HTTP status, and a success that isn't JSON, such as a login page in front of Nextcloud, says that Nextcloud gave an unexpected answer. Every message has the colour and the icon of its kind, and Disconnect is on only while a token or a URL is stored.

    send(["`**Test connection and save**<br>or **Disconnect**`"])
    reached{"Does the browser<br>get an answer?"}
    session{"HTTP 401 or 412?"}
    maintenance{"HTTP 503 that says<br>maintenance mode?"}
    app{"JSON of the app?"}
    unreached["Nextcloud could not be reached"]
    expired["Your session has expired"]
    upgrade["Nextcloud is in maintenance mode"]
    refusal["The message of the app,<br>such as Paperless rejected<br>the API token"]
    stored["The settings as stored,<br>the URL in its stored form:<br>Connection successful"]
    failed["The request failed (HTTP 502)"]
    unexpected["Nextcloud gave an<br>unexpected answer"]
    disconnect["Disconnect on only while<br>a token or a URL is stored"]

    send --> reached
    reached -- "no" --> unreached
    reached -- "yes" --> session
    session -- "yes" --> expired
    session -- "no" --> maintenance
    maintenance -- "yes" --> upgrade
    maintenance -- "no" --> app
    app -- "a refusal, with its message" --> refusal
    app -- "the settings" --> stored
    app -- "an error without a message,<br>such as the page of a proxy" --> failed
    app -- "a success that isn't JSON,<br>such as a login page" --> unexpected
    stored --> disconnect
    refusal --> disconnect

    classDef yes fill:#16a34a2e,stroke:#16a34a,stroke-width:2px
    classDef no fill:#dc26262e,stroke:#dc2626,stroke-width:2px
    class stored yes
    class unreached,expired,upgrade,refusal,failed,unexpected no

The Node.js tests in tests/js/settings.test.mjs run js/settings.js against a stand-in for the page and every kind of answer, and each line and branch of the script runs in one of them; tests/Unit/Templates/SettingsTemplateTest.php renders templates/settings.php in every state with values that would break out of the page if they weren't escaped.

occ and the overview

Four commands of occ and a setup check serve the administrators who script their set-up or look for the cause of a problem (decision 0007). setup runs what saving the settings runs: it checks the URL, the token and the archive account with the configuration, has the Paperless client test the connection and saves only when Paperless answers, with the messages of the settings page. It takes the token from its standard input or from an environment variable and never from an argument, and an option that is left out keeps its stored value. test and check test the connection with the stored settings and store the version of the answer, which the overview then shows; check also asks the file locator for the IDs of all documents that a file of the archive account in its own folders stands for, in one search of the file cache, and compares them with the IDs that Paperless lists in the field all of its answer.

search asks Paperless for the first page of hits, as a search does, and has the explainer decide for each document whether it shows to the user. Without --explain, that is one search of the file cache, as in the search. With it, each further question costs one more search of the file cache, only for the documents that are still unexplained:

---
config:
  flowchart:
    wrappingWidth: 400
    nodeSpacing: 30
---
flowchart TB
    accTitle: Why a document does not show
    accDescr: Paperless finds document 389 when jamie searches, and occ paperless_unified_search:search jamie invoice --explain asks three questions in turn. Does jamie see a file of the archive account with [P389] in its name? Then the document shows. Otherwise, does the archive account have such a file in its own folders? Then it is not shared with jamie, and sharing the file or its folder read-only with jamie shows it. Otherwise, does jamie see a file with the marker of another account? Then it is not owned, since only files of the archive account stand for documents. Otherwise there is no file, and the synchronization has yet to write one with the marker in its name.

    hit["Paperless finds document 389<br>when jamie searches"] --> visible{"Does jamie see a file of<br>the archive account<br>with [P389] in its name?"}
    visible -- "yes" --> shown["shown"]
    visible -- "no" --> archived{"Does the archive account<br>have such a file in its<br>own folders?"}
    archived -- "yes" --> unshared["not shared: share the<br>file or its folder<br>read-only with jamie"]
    archived -- "no" --> other{"Does jamie see a file<br>with [P389] of another<br>account?"}
    other -- "yes" --> unowned["not owned: only files<br>of the archive account<br>stand for documents"]
    other -- "no" --> missing["no file: let the<br>synchronization write<br>one with the marker"]

    classDef yes fill:#16a34a2e,stroke:#16a34a,stroke-width:2px
    classDef no fill:#dc26262e,stroke:#dc2626,stroke-width:2px
    class shown yes
    class unshared,unowned,missing no

Try a search of the settings page asks the same questions for the first 20 documents, through the route POST /settings/try, for administrators without a shell. Its answer carries the ID, the title and the verdict of each document and nothing more, neither the path nor the owner of a file, and the audit log of the app Auditing / Logging (admin_audit) records which administrator tried a search as which user, whatever the log level, as does the log of Nextcloud at level info.

The search itself never runs these questions: it keeps its one search of the file cache per page of Paperless, and the explainer's lookups exist only for occ and Try a search.

The setup check runs whenever an administrator opens the overview, so it reads only what the app stored: a token that can't be decrypted, the archive account, the history of the problems of the search and the version of the last answer of Paperless. It warns, with a link to the settings of the app, when the search can show no Paperless documents for a reason that an administrator corrects, and passes otherwise, also when the app isn't connected yet, which Nextcloud shows only in occ setupchecks.

The dashboard

---
config:
  flowchart:
    wrappingWidth: 400
    nodeSpacing: 30
---
flowchart TB
    accTitle: The newest documents on the dashboard
    accDescr: The dashboard of jamie asks the widget New in Paperless for its items. The app takes the 25 documents that Paperless-ngx added last from Nextcloud's distributed cache, or else asks Paperless for them, newest first, and keeps them for a minute for every user, since every request carries the token of the settings. Of these, jamie sees the documents whose file of the archive account jamie can open, found by their markers in one search of the file cache, at most seven, with the names of their correspondents and types. Each item shows the preview of its file, its title, and the day that Paperless added it, its correspondent and its type, such as Sep 3, 2026 · Example Motors · Receipt, and opens the file in Files.

    dashboard["The dashboard of jamie asks<br>New in Paperless for its items"]
    cache{"The newest documents<br>in the cache of Nextcloud?"}
    paperless[("Paperless-ngx: the 25 documents<br>added last, newest first,<br>at most once a minute for every user")]
    files["The files of the archive account<br>that jamie can open, by their markers,<br>in one search of the file cache"]
    names["The names of their correspondents and types,<br>kept for ten minutes"]
    widget(["`**The newest documents of jamie**, at most seven:
    the preview of the file, the title,
    Sep 3, 2026 · Example Motors · Receipt
    *each opens its file in Files*`"])

    dashboard --> cache
    cache -- "no" --> paperless
    paperless --> files
    cache -- "yes" --> files
    files --> names
    names --> widget

    classDef you fill:#f59e0b2e,stroke:#f59e0b,stroke-width:2px
    classDef app fill:#526cfe2e,stroke:#526cfe,stroke-width:2px
    classDef yes fill:#16a34a2e,stroke:#16a34a,stroke-width:2px
    class dashboard you
    class cache,files,names app
    class widget yes
  • Offered where the search works. The widget is a conditional one (IConditionalWidget): Nextcloud asks it, as it builds the dashboard, whether the settings name a Paperless with a token and an archive account that exists, and offers it only then, without a request to Paperless.
  • Items without a script. It answers version 2 of the dashboard's API (IAPIWidgetV2), from which Nextcloud's dashboard draws the widget itself, and which its apps read as well; the icon is the dark one of the app, which the dark theme inverts (IIconWidget).
  • One request a minute. Every request to Paperless carries the token of the settings, so the newest documents are the same for every user: RecentDocuments asks Paperless for the 25 that it added last, ordering=-added, with only the ID, the title, the moment that Paperless added it and the IDs of the correspondent and the type, and keeps them in Nextcloud's distributed cache for a minute, by a hash of the URL of Paperless. Without a memory cache, Nextcloud gives the app a cache that keeps nothing, so every dashboard asks Paperless, and a failure doesn't rest either. The dashboard waits for the answer, so the request gets one try within 5 seconds and no place in the history of the diagnostics, which is about searches; a Paperless that fails rests for a minute, and the widget says so.
  • What a user may open. What the cache holds goes through the file locator for each user, in one search of the file cache, as a page of results does: only the documents with a file of the archive account that the user can open become items, at most as many as the dashboard asks for, 7 by default. A new share shows at once; the cache holds no answer of a user.
  • An item is drawn like a result. The preview of the file or the icon of its type comes from FilePictures, the names of the correspondent and the type from PaperlessNames, and the line below the title, the day that Paperless added the document, formatted by Nextcloud in the language and the timezone of the user, and the names, goes through PlainText, so that it holds no tag of HTML either. The item links the file in Files, and its sinceId is the moment that Paperless added the document, after which the dashboard can ask for newer ones. A file that fails to answer costs only its own item.
  • More in Files. The link opens the files that others share with the user (IButtonWidget); the archive account, which owns every file, gets none. Nextcloud's web dashboard draws it only once the list is full, and then shows one item less, six of the seven; the apps and the other clients of the API get the seven items and the link.

Design decisions

  • Nextcloud decides who sees a file. The app shows a document only through a file that the searching user can read in Nextcloud, and opens that file, not Paperless.
  • Paperless does the searching. Its OCR and full-text index answer the search; the app keeps no index of its own.
  • Full pages of results. Each page of results takes as many pages of Paperless as it needs, within a budget of 4 pages and 3 seconds, so that documents that the user can't open don't hide the others, and Load more results of Nextcloud's web search keeps working (Filling a page of results).
  • One search of the file cache per page, for the markers of all its documents, since each search reads the names of all files of the user (Finding the files). Nextcloud's public API has only the interfaces of such a search, so the app brings its own small classes for it in lib/Search/Files/, and never uses Nextcloud's private ones. Nextcloud adds methods to these interfaces even in a maintenance release, and PHP refuses a class that lacks one with a fatal error that no catch sees. So before each search the file locator compares the methods of the interfaces with those of its classes, without loading them, and when Nextcloud asks for more, it searches once per document by text, as before, and logs a warning: slower, but every search still answers.
  • The marker [P<ID>] in the file name ties a file of the archive account to its document. Paperless Sync writes such files, and the two apps work together without depending on each other's releases.
  • The owner, not the name, decides. Only files of the archive account count, because anyone can give a file the marker of a document (decision 0002).
  • Links of the smart picker are links of files. The app resolves no link of its own: Nextcloud's provider of file links renders the inserted link for each reader with that reader's permissions, so a link reveals no document to a reader who can't open its file.
  • Only a request that never reached Paperless gets a second try, and a query that Paperless refuses while the user types finds nothing instead of counting as a failure (decision 0006). A Paperless that hangs costs one wait per keystroke, not two, and the history shows only real problems.
  • occ takes no token as an argument, where the history of the shell and the list of processes would show it, and the overview asks Paperless nothing, so that it opens as fast as without the app (decision 0007).
  • External by default. Like every provider that sends search terms to another server, the app searches Paperless only when the user asks for it, with the switch in the search, the choice of Paperless documents in the smart picker or a request for this search through the API, unless an administrator marks Paperless as trusted.