Folders¶
A folder is a named directory sitting between the books/ root and one or more
book folders. Folders nest inside each other to whatever depth you want:
books/
├─ {book}.bookpkg/ # book at the root (no folder)
├─ Fiction/
│ ├─ {bookA}.bookpkg/ # book inside the "Fiction" folder
│ └─ Classics/
│ └─ {bookB}.bookpkg/ # book nested two levels deep
└─ Non-Fiction/
└─ {bookC}.bookpkg/
They are real directories, so they survive a server restart and can be browsed
in any file manager. The books/ root is itself the top-level group: a book
sitting directly under it is in no folder.
Create folders, move books between them, and delete empty ones from the web interface — none of it needs the filesystem touched by hand. A folder change you make in PlainShelf appears in the very next listing; one made outside PlainShelf appears after the next shelf scan. See Shelf cache and disk I/O.
Rules¶
- A folder cannot be deleted while it still holds books. Move or delete the books first.
- A book belongs to exactly one folder, because it lives in exactly one directory.
- Moving a book between folders does not change its ID, so reading progress and bookmarks follow it.
Ignored directories¶
Some directories inside books/ are not created by you, and PlainShelf does not
treat them as folders. They are skipped when scanning for folders and for books,
they never reach the exported book cache the Android client reads, and you
cannot create a folder with one of these names.
- Any directory whose name starts with a dot — for example
.git,.stfolder(Syncthing),.dropbox.cache,.Spotlight-V100,.fseventsd, and.TemporaryItems. This one is a fixed rule. @eaDir(Synology index and thumbnails),#recycle(Synology network recycle bin),$RECYCLE.BIN(Windows recycle bin over SMB), andlost+found. These four are the defaults, and a shelf can replace them — see below.
A book package placed inside one of these directories is invisible to PlainShelf, so deleting a book on a NAS does not bring it back through the recycle bin.
Trying to create a folder with one of these names is refused with a message that says which name and why, rather than the generic "invalid folder name" reported for a name that is malformed.
Choosing the list for one shelf¶
The four names above are defaults, not a fixed list. A shelf can name its own
directories: create a shelf.json at the shelf root —
beside books/, not inside it — and list what it should skip.
{
"schema_version": 1,
"scan": {
"ignored_dirs": [
{ "name": "@eaDir" },
{ "name": "#recycle" },
{ "name": "$RECYCLE.BIN" },
{ "name": "lost+found" },
{ "name": "@Snapshot", "reason": "Synology snapshot directory" }
]
}
}
This is for the directories your own storage creates that the defaults do not
know about — a NAS snapshot directory, a photo-tool thumbnail cache, a backup
tool's working directory sitting in the middle of your library. Every entry is an
object with a name: one directory name, matched wherever it appears in the tree
and without regard to case, never a path and never a pattern. The reason is
optional and is what PlainShelf quotes back to you when it refuses a folder of
that name.
The list replaces the defaults rather than adding to them. That is why the
example above repeats the four default names: keep the ones you want. A list that
leaves out @eaDir means a Synology share's index directories become ordinary
folders and your library grows a second copy of its own folder tree — PlainShelf
warns in the log when your list drops a default, but it does not overrule you.
An empty list, "ignored_dirs": [], means exactly what it says: nothing is
skipped but hidden directories.
A name you skip behaves like a default one: it is not a folder, the books inside it are not listed, and PlainShelf refuses to create a folder with that name. Take the name back out of the file and the directory returns after the next scan — nothing on disk was moved or deleted in the meantime.
Because the setting lives in the shelf, every PlainShelf reading that shelf applies it, the Android client reading it from pCloud included. The file is read when the shelf is opened, so restart the server after editing it. There is no settings page for it yet; it is a file you write by hand.
Marking a folder as adult content¶
A folder has no settings file of its own — it is a directory, and nothing else —
so a mark that applies to a whole folder lives in
shelf.json, under content.nsfw_folders:
{
"schema_version": 1,
"content": {
"nsfw_folders": [
{ "path": "Fiction/成人", "reason": "adult shelf" }
]
}
}
A path names one folder under books/, and it marks that folder and every
folder below it. The path is matched without regard to case, and leading,
trailing and doubled / make no difference, so Fiction/成人, fiction/成人
and /Fiction/成人/ all name the same folder. Only whole names match: marking
Fiction/成人 does not mark Fiction/成人漫畫.
A single book can also be marked on its own, with "nsfw": true in its
book.json. That one is editable in the app: open the book's metadata editor
and turn on Adult content. The two only ever add together:
In shelf.json |
In the book's book.json |
Marked? |
|---|---|---|
| folder not listed | absent or false |
No |
| folder not listed | true |
Yes |
| folder listed | anything, including false |
Yes |
The last row is deliberate: "nsfw": false on a book cannot take it out of a
marked folder. Missing a book that should have been marked is the worse mistake
of the two, so the folder wins. To exempt one book, move it out of the folder.
The metadata editor shows this: for a book inside a marked folder the switch is
on and disabled, and names the rule that marks it.
The mark travels with the shelf and is written into each entry of the exported
book cache the Android client reads, so every PlainShelf reading the shelf gets
the same answer without a central list to keep in sync. Editing shelf.json
takes effect when the shelf is next opened, so restart the server afterwards.
Whether a marked book is shown¶
Marking a book says what it is; whether this server shows it is a separate
setting, show_nsfw. It is off by default, so a freshly marked book disappears
from the library as soon as the shelf is reopened.
The filtering happens on the server, not in the browser: with show_nsfw off, a
marked book is absent from the library listing, from the duplicate and similarity
pages and from the dashboard's totals, and every address that names it — the
book, its cover, its content, its sources — answers 404, exactly as a book
that is not on the shelf does. Naming it some other way does not get round that:
a batch move or trash refuses the ID the same way, and the background sweeps
(content statistics, source fingerprints) and the fingerprint coverage count
leave it out too, so none of their totals can report that it exists. A folder
disappears with its books: a marked
folder is not listed, and neither is one left with nothing but marked books, so
it cannot show up in a breadcrumb or in the destination list when moving a book.
Deleting a book does not take it out of the setting's reach. The trash page leaves out a marked book — its title, its authors and the folder it was deleted from are all withheld — and restoring or permanently deleting it answers 404, the same as an ID that was never issued. The folder rule still applies there: the trash remembers where each book was deleted from, so a book deleted out of a marked folder stays marked while it sits in the trash.
Moving a marked folder¶
A folder rule marks a path, so moving or renaming the folder out from under
that path unmarks everything below it at once — rename Fiction/成人 to
Fiction/一般, or move Fiction somewhere else, and every book beneath it is
served from the next request on. With show_nsfw off that would be a folder's
worth of books going from hidden to public in one action nobody described, so
the folder routes ask first.
While show_nsfw is off, a change that would take marked content out of a marked
subtree is refused with 409 and this body, and nothing happens:
{
"error": "nsfw_reveal_requires_confirmation",
"message": "this folder holds content the shelf marks as adult ...",
"hidden_books": 3
}
hidden_books counts the books that would become visible. It is 0 when the
whole disclosure is a folder name — an empty marked folder is hidden too — so it
does not mean "nothing would change". Repeat the same request with ?confirm=1
to go ahead:
curl -X POST 'http://127.0.0.1:20000/api/shelves/default_shelf/folder-moves?confirm=1' \
-H "X-PlainShelf-Token: $PLAINSHELF_TOKEN" \
--data '{"folder":["Fiction"],"target_folder":["Archive"]}'
Three routes ask: POST .../folder-moves, PATCH .../folders/{path} (the
rename) and POST .../folder-transfers. In the web UI the same question is a
dialog naming the count, and the change goes through when you accept it.
Only what actually becomes visible counts. A book marked in its own book.json
keeps that mark wherever it goes, and a folder left holding nothing but such
books stays out of the folder tree on its own account — so moving it asks
nothing. Losing the folder mark is not by itself a disclosure.
Nor does a change that was never going to happen get to ask: a read-only shelf, or a destination the shelf already holds, is refused as itself rather than behind a confirmation.
The cross-shelf transfer is judged on the source shelf alone, and asks for a copy
as well as a move: a copy leaves this shelf untouched but publishes the same
titles on the other one. Only a book's own nsfw travels with it, because that
is written in its book.json; shelf.json stays behind, so whether the target
shelf happens to mark the same path is not this shelf's answer to give.
Nothing here rewrites the mark. shelf.json is yours and PlainShelf only reads
it, so after a confirmed move the folder really is unmarked — the confirmation is
all this adds. With show_nsfw on there is nothing to reveal, and all three
routes behave exactly as they did before this existed.
Three boundaries are deliberate:
- The exported book cache is always a complete mirror, whatever this setting says. It records what the shelf says, and the client reading it applies its own setting; pruning it here would quietly become that client's whole library.
- The setting is per server, not per shelf. It sits beside
cover_to_jpgin the application store, so it applies to every shelf this server serves. - Empty trash erases every book in the trash, marked ones included. It is one
command over the whole trash rather than a list of books, and leaving the
marked ones behind would be worse than the disclosure: they would reappear the
next time the setting is turned on, long after the trash looked empty. So that
the confirmation cannot understate what it is about to erase, a filtered
listing says so in an
X-PlainShelf-Trash-Partial: trueheader and the web UI then asks about "everything in the trash" rather than naming a count. The header reports only that something was withheld, never what or how much.
Switch it in Settings → Adult content. Turning it on or off refetches the library listing and the folder tree straight away, so the sidebar and the book list agree with the setting without a page reload.
It is also reachable from the API, which is how a script or a headless server sets it:
curl -X POST http://127.0.0.1:20000/api/setting/show_nsfw \
-H "X-PlainShelf-Token: $PLAINSHELF_TOKEN" --data true
GET the same address to read it, and DELETE it to return to the default of
hidden.
A book already open in a browser tab is not a way round the setting: reloading the page, or pasting the book's address into a new tab, lands on the same "failed to load" screen any unknown book gives, because the API answers 404.
A shelf read without a server¶
The Android client can read a shelf straight out of pCloud, with no PlainShelf
server between it and the files. There is no show_nsfw to ask for on that path,
so the app keeps its own answer: Settings → Adult content on such a client
offers a Show adult content on this device switch, off by default and stored
only on that device.
It hides the same things the server setting hides, computed from the same two
marks the shelf carries — the book's own nsfw and the content.nsfw_folders
rules, which the app reads out of shelf.json itself rather than trusting the
exported book cache's precomputed answer. A marked book is absent from the
library listing, its address answers "not found", a marked folder and one left
holding nothing but marked books drop out of the folder tree, and a book already
downloaded for offline reading is withheld by the offline cache itself — its
entry, its text, its cover, its sources and its illustrations — so it is gone
from Downloaded books and unreadable even in flight mode. Being on the device
is not a way past the switch; the stored copy is kept, because the switch hides
books rather than deleting downloads.
The two settings are deliberately not synchronised. They are separate
machines' decisions, and syncing them would turn "not on this phone" into "on no
client at all". So a phone pointed at a PlainShelf server rather than at pCloud
ignores its own switch entirely: the server has already filtered what it served,
and that server's show_nsfw stays the only authority on that path.
Seeing which books are marked¶
While show_nsfw is on, a marked book carries an NSFW badge in the library
list and card views and on its detail page. That is what the badge is for: it
says which books leave when the setting is turned back off. With the setting off
there is nothing to badge — those books are not served at all.
Editing the folder list is still a shelf.json edit. There is no UI for it, and
it needs the server restarted; the mark belongs to the shelf, and it changes
rarely, while marking one book is an everyday action and has a control.
Example use cases¶
| Use case | Folder structure |
|---|---|
| Genre classification | Fiction/, Non-Fiction/, Poetry/ |
| Reading status | To Read/, Reading/, Done/ |
| Language | English/, 中文/, Français/ |
| Mixed | Fiction/English/, Fiction/中文/ |