Compare commits

..

8 Commits

Author SHA1 Message Date
shamoon
1cdd8d9ba8 Clarify repo maintenance rules 2025-09-21 16:32:21 -07:00
shamoon
4449dbadb5 Merge branch 'main' into dev 2025-09-21 16:10:00 -07:00
shamoon
43b4f36026 Documentation: add note about logo file visibility and exif data 2025-09-21 16:07:29 -07:00
shamoon
0e35acaef5 Fix: add extra error handling to _consume for file checks (#10897) 2025-09-21 13:21:40 -07:00
shamoon
19ff339804 Fix: show children in tag list when filtering (#10899) 2025-09-21 10:09:05 -07:00
shamoon
6b868a5ecb Fix: restore str celery beat schedule filename (#10893) 2025-09-20 18:54:56 -07:00
shamoon
3e4aa87cc5 Fix formatting 2025-09-12 15:55:22 -07:00
shamoon
fc95d42b35 Documentation: add guidance for feature PRs in CONTRIBUTING.md 2025-09-12 15:51:49 -07:00
11 changed files with 166 additions and 124 deletions

View File

@@ -241,6 +241,7 @@ jobs:
) { ) {
nodes { nodes {
id, id,
createdAt,
number, number,
updatedAt, updatedAt,
upvoteCount, upvoteCount,

View File

@@ -2,9 +2,11 @@
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome. If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
If you want to implement something big: If you want to implement something big:
- Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together. - As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project. - When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change. - Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
- Please see the [paperless-ngx merge process](#merging-prs) below. - Please see the [paperless-ngx merge process](#merging-prs) below.
@@ -133,7 +135,7 @@ community members. That said, in an effort to keep the repository organized and
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity. - Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
- Discussions with a marked answer will be automatically closed. - Discussions with a marked answer will be automatically closed.
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity. - Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years. - Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years.
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns. In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features. Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.

View File

@@ -1759,6 +1759,11 @@ started by the container.
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg` : Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
!!! note
The logo file will be viewable by anyone with access to the Paperless instance login page,
so consider your choice of logo carefully and removing exif data from images before uploading.
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK} #### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
!!! note !!! note

View File

@@ -166,13 +166,10 @@
</div> </div>
<div class="nav-group mt-3 mb-1"> <div class="nav-group mt-3 mb-1">
<h6 class="sidebar-heading px-3 text-muted d-flex align-items-center"> <h6 class="sidebar-heading px-3 text-muted">
<span i18n>Manage</span> <span i18n>Manage</span>
<button class="btn btn-link p-2 py-0" (click)="manageCollapse.toggle()">
<i-bs width="0.9em" height="0.9em" [name]="isManageMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs>
</button>
</h6> </h6>
<ul class="nav flex-column mb-2" #manageCollapse="ngbCollapse" [(ngbCollapse)]="isManageMenuCollapsed"> <ul class="nav flex-column mb-2">
<li class="nav-item app-link" <li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"> *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
@@ -246,124 +243,117 @@
</div> </div>
<div class="nav-group mt-auto mb-1"> <div class="nav-group mt-auto mb-1">
<h6 class="sidebar-heading px-3 pt-4 text-muted d-flex align-items-center"> <h6 class="sidebar-heading px-3 pt-4 text-muted">
<span i18n>Administration</span> <span i18n>Administration</span>
<button class="btn btn-link p-2 py-0" (click)="adminCollapse.toggle()">
<i-bs width="0.9em" height="0.9em" [name]="isAdminMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs>
</button>
</h6> </h6>
<div class="mb-2"> <ul class="nav flex-column mb-2">
<ul class="nav flex-column" #adminCollapse="ngbCollapse" [(ngbCollapse)]="isAdminMenuCollapsed"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" tourAnchor="tour.settings">
tourAnchor="tour.settings"> <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
<i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span> </a>
</a> </li>
</li> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }"> <a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span>
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span> </a>
</a> </li>
</li> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }"> <a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span>
<i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span> </a>
</a> </li>
</li> <li class="nav-item app-link"
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" tourAnchor="tour.file-tasks">
tourAnchor="tour.file-tasks"> <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
<i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) { <span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span> }</span>
}</span> @if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) { <span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span> }
} </a>
</a> </li>
</li> @if (permissionsService.isAdmin()) {
@if (permissionsService.isAdmin()) { <li class="nav-item app-link">
<li class="nav-item app-link"> <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a>
</li>
}
</ul>
<ul class="nav flex-column">
<li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span> <i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> }
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap"> <li class="nav-item mt-2" tourAnchor="tour.outro">
<div class="me-3"> <a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span>
{{ versionString }} </a>
</a> </li>
</div> <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) { <div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
<div class="version-check"> <div class="me-3">
<ng-template #updateAvailablePopContent> <a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span> [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
</ng-template> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<ng-template #updateCheckingNotEnabledPopContent> {{ versionString }}
<p class="small mb-2"> </a>
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container> </div>
</p> @if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
<div class="btn-group btn-group-xs flex-fill w-100"> <div class="version-check">
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button> <ng-template #updateAvailablePopContent>
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button> <span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
</div> available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
<p class="small mb-0 mt-2"> </ng-template>
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n> <ng-template #updateCheckingNotEnabledPopContent>
How does this work? <p class="small mb-2">
</a> <ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
</p> </p>
</ng-template> <div class="btn-group btn-group-xs flex-fill w-100">
@if (settingsService.updateCheckingIsSet) { <button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
@if (appRemoteVersion.update_available) { <button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer" </div>
href="https://github.com/paperless-ngx/paperless-ngx/releases" <p class="small mb-0 mt-2">
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave" <a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
container="body"> How does this work?
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> </a>
@if (appRemoteVersion?.update_available) { </p>
&nbsp;<ng-container i18n>Update available</ng-container> </ng-template>
} @if (settingsService.updateCheckingIsSet) {
</a> @if (appRemoteVersion.update_available) {
} <a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
} @else { href="https://github.com/paperless-ngx/paperless-ngx/releases"
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking" [ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
container="body"> container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@if (appRemoteVersion?.update_available) {
&nbsp;<ng-container i18n>Update available</ng-container>
}
</a> </a>
} }
</div> } @else {
} <a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
</div> [ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
</li> container="body">
</ul> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</div> </a>
}
</div>
}
</div>
</li>
</ul>
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -89,8 +89,6 @@ export class AppFrameComponent
appRemoteVersion: AppRemoteVersion appRemoteVersion: AppRemoteVersion
isMenuCollapsed: boolean = true isMenuCollapsed: boolean = true
isManageMenuCollapsed: boolean = false
isAdminMenuCollapsed: boolean = false
slimSidebarAnimating: boolean = false slimSidebarAnimating: boolean = false

View File

@@ -71,4 +71,20 @@ describe('TagListComponent', () => {
'Do you really want to delete the tag "Tag1"?' 'Do you really want to delete the tag "Tag1"?'
) )
}) })
it('should filter out child tags if name filter is empty, otherwise show all', () => {
const tags = [
{ id: 1, name: 'Tag1', parent: null },
{ id: 2, name: 'Tag2', parent: 1 },
{ id: 3, name: 'Tag3', parent: null },
]
component['_nameFilter'] = null // Simulate empty name filter
const filtered = component.filterData(tags as any)
expect(filtered.length).toBe(2)
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
const filteredWithName = component.filterData(tags as any)
expect(filteredWithName.length).toBe(3)
})
}) })

View File

@@ -62,6 +62,8 @@ export class TagListComponent extends ManagementListComponent<Tag> {
} }
filterData(data: Tag[]) { filterData(data: Tag[]) {
return data.filter((tag) => !tag.parent) return this.nameFilter?.length
? [...data]
: data.filter((tag) => !tag.parent)
} }
} }

View File

@@ -55,9 +55,7 @@ import {
checkLg, checkLg,
chevronDoubleLeft, chevronDoubleLeft,
chevronDoubleRight, chevronDoubleRight,
chevronDown,
chevronRight, chevronRight,
chevronUp,
clipboard, clipboard,
clipboardCheck, clipboardCheck,
clipboardCheckFill, clipboardCheckFill,
@@ -269,9 +267,7 @@ const icons = {
checkLg, checkLg,
chevronDoubleLeft, chevronDoubleLeft,
chevronDoubleRight, chevronDoubleRight,
chevronDown,
chevronRight, chevronRight,
chevronUp,
clipboard, clipboard,
clipboardCheck, clipboardCheck,
clipboardCheckFill, clipboardCheckFill,

View File

@@ -82,6 +82,13 @@ def _is_ignored(filepath: Path) -> bool:
def _consume(filepath: Path) -> None: def _consume(filepath: Path) -> None:
# Check permissions early
try:
filepath.stat()
except (PermissionError, OSError):
logger.warning(f"Not consuming file {filepath}: Permission denied.")
return
if filepath.is_dir() or _is_ignored(filepath): if filepath.is_dir() or _is_ignored(filepath):
return return
@@ -323,7 +330,12 @@ class Command(BaseCommand):
# Also make sure the file exists still, some scanners might write a # Also make sure the file exists still, some scanners might write a
# temporary file first # temporary file first
file_still_exists = filepath.exists() and filepath.is_file() try:
file_still_exists = filepath.exists() and filepath.is_file()
except (PermissionError, OSError): # pragma: no cover
# If we can't check, let it fail in the _consume function
file_still_exists = True
continue
if waited_long_enough and file_still_exists: if waited_long_enough and file_still_exists:
_consume(filepath) _consume(filepath)

View File

@@ -209,6 +209,26 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase):
# assert that we have an error logged with this invalid file. # assert that we have an error logged with this invalid file.
error_logger.assert_called_once() error_logger.assert_called_once()
@mock.patch("documents.management.commands.document_consumer.logger.warning")
def test_permission_error_on_prechecks(self, warning_logger):
filepath = Path(self.dirs.consumption_dir) / "selinux.txt"
filepath.touch()
original_stat = Path.stat
def raising_stat(self, *args, **kwargs):
if self == filepath:
raise PermissionError("Permission denied")
return original_stat(self, *args, **kwargs)
with mock.patch("pathlib.Path.stat", new=raising_stat):
document_consumer._consume(filepath)
warning_logger.assert_called_once()
(args, _) = warning_logger.call_args
self.assertIn("Permission denied", args[0])
self.consume_file_mock.assert_not_called()
@override_settings(CONSUMPTION_DIR="does_not_exist") @override_settings(CONSUMPTION_DIR="does_not_exist")
def test_consumption_directory_invalid(self): def test_consumption_directory_invalid(self):
self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot") self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot")

View File

@@ -922,7 +922,7 @@ CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"]
CELERY_BEAT_SCHEDULE = _parse_beat_schedule() CELERY_BEAT_SCHEDULE = _parse_beat_schedule()
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename # https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename
CELERY_BEAT_SCHEDULE_FILENAME = DATA_DIR / "celerybeat-schedule.db" CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db")
# Cachalot: Database read cache. # Cachalot: Database read cache.