feat: add purge empty folders utility

scarlett
NGPixel 20 hours ago
parent ff9aa13e3d
commit b11bf4f601
No known key found for this signature in database

@ -1385,6 +1385,60 @@ async function routes(app: FastifyInstance) {
}
)
/**
* PURGE EMPTY FOLDERS
*
* Housekeeping rather than a destruction: a folder is created for whatever is put in it and stays
* behind when that is deleted or moved away, so a wiki that has been reorganised a few times
* accumulates folders nobody can see anything in.
*
* Every site at once, deliberately. What is empty is a question about the tree and not about a
* particular site, and an administrator clearing up after a reorganisation would otherwise run this
* once per site.
*/
app.post(
'/empty-folders/purge',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Delete every folder with no page or asset in it',
description:
'On every site and in every locale. A folder counts as empty when nothing sits below it at any depth, so a folder holding only empty folders goes as well, and with it the branch above it once its last folder is gone. Nothing that is not a folder is ever deleted — the emptiness check is part of the statement that does the deleting, so a page or an asset written while this runs keeps the folder it was put in.',
tags: ['System'],
response: {
200: {
description: 'Empty folders purged successfully',
type: 'object',
properties: {
ok: {
type: 'boolean'
},
message: {
type: 'string'
},
count: {
type: 'number',
description: 'Folders deleted.'
}
}
}
}
}
},
async (req) => {
const count = await WIKI.models.tree.deleteEmptyFolders()
await audit(req, 'admin', 'purgeEmptyFolders', { count })
return {
ok: true,
message: `Deleted ${count} empty folder(s).`,
count
}
}
)
/**
* CHECK FOR UPDATE
*/

@ -165,6 +165,7 @@
"admin.audit.actions.moveFolder": "Moved a folder",
"admin.audit.actions.movePage": "Moved or renamed a page",
"admin.audit.actions.purgeApiKeys": "Purged the revoked API keys",
"admin.audit.actions.purgeEmptyFolders": "Deleted the empty folders",
"admin.audit.actions.purgePageHistory": "Purged page history",
"admin.audit.actions.purgeSampleContent": "Purged the sample content",
"admin.audit.actions.rebuildSearchIndex": "Rebuilt the search index",
@ -1383,6 +1384,12 @@
"admin.utilities.invalidSessionSecretConfirmWarn": "Everyone is logged out immediately, you included — you will have to sign in again. The new secret is only used for signing once each server has been restarted. API keys are unaffected.",
"admin.utilities.invalidSessionSecretFailed": "Failed to rotate the user sessions secret.",
"admin.utilities.invalidSessionSecretHint": "Rotate the secret used to sign session cookies and end every open session. Everyone is logged out.",
"admin.utilities.purgeEmptyFolders": "Delete Empty Folders",
"admin.utilities.purgeEmptyFoldersConfirm": "Every folder holding no page and no asset will be deleted, on every site.",
"admin.utilities.purgeEmptyFoldersConfirmWarn": "A folder containing only empty folders goes too, and so does the branch above it once its last folder is gone. Nothing but folders is deleted: a folder holding a page, an asset or a draft is left exactly as it is.",
"admin.utilities.purgeEmptyFoldersFailed": "The empty folders could not be deleted.",
"admin.utilities.purgeEmptyFoldersHint": "Delete every folder with no page or asset anywhere below it, on every site. A folder left holding only empty folders is deleted as well.",
"admin.utilities.purgeEmptyFoldersSuccess": "There was no empty folder to delete. | Deleted 1 empty folder. | Deleted {count} empty folders.",
"admin.utilities.purgeHistory": "Purge History",
"admin.utilities.purgeHistoryConfirm": "Every page version older than **{timeframe}** will be deleted, on every site.",
"admin.utilities.purgeHistoryConfirmWarn": "Pages keep the content they have now, but a discarded version cannot be brought back. Any deleted page older than the selected timeframe cannot be recovered.",

@ -122,6 +122,7 @@ export const AUDIT_ACTIONS = {
'invalidateSessions',
'purgePageHistory',
'purgeSampleContent',
'purgeEmptyFolders',
'checkForUpdate',
'createUser',
'updateUser',

@ -1,4 +1,4 @@
import { and, asc, desc, eq, exists, inArray, ne, or, sql, type SQL } from 'drizzle-orm'
import { and, asc, desc, eq, exists, inArray, ne, not, or, sql, type SQL } from 'drizzle-orm'
import { alias, type PgColumn } from 'drizzle-orm/pg-core'
import { pages as pagesTable, tree as treeTable } from '../db/schema.ts'
import {
@ -1653,6 +1653,108 @@ class Tree {
}
}
/**
* Delete every folder holding no page and no asset, on every site.
*
* A folder is empty when nothing at all sits below it, at any depth so a branch of folders with
* no content anywhere in it goes in its entirety, not just the leaf. That is the whole point of it:
* emptying a folder usually leaves the one above it empty as well, and a pass that only took the
* leaves would have to be run again until it stopped finding things.
*
* Done as repeated passes of one statement rather than as a walk in memory, and each pass asks what
* is empty NOW: a folder whose only child was an empty folder becomes empty the moment that child
* goes, and the next pass takes it. It ends when a pass deletes nothing, which cannot be longer
* than the tree is deep.
*
* Two things follow from the emptiness check living inside the DELETE rather than beside it. A page
* or an asset written while this runs keeps its folder the statement that would have deleted it
* no longer matches and nothing this runs can delete anything that is not a folder.
*
* @returns How many folders were deleted.
*/
async deleteEmptyFolders(): Promise<number> {
const child = alias(treeTable, 'childEntry')
/*
Whether the folder holds nothing pages, assets and other folders alike. Folders count, because
an empty one is what the next pass is for; anything else and the branch stays.
The folder's own path is built from its row, as `browse` does above: `foo.bar` + `.` + `baz`,
and `baz` alone at the root, where `folderPath` is the empty path rather than an absent one.
*/
const holdsNothing = not(
exists(
WIKI.db
.select({ one: sql`1` })
.from(child)
.where(
and(
eq(child.siteId, treeTable.siteId),
eq(child.locale, treeTable.locale),
sql`${child.folderPath} <@ (COALESCE(NULLIF(${treeTable.folderPath}::text, '') || '.', '') || ${treeTable.fileName})::ltree`
)
)
)
)
const deletedIds: string[] = []
/** Each deleted folder's OWN path, which is what its children carry — i.e. what a parent is. */
const deletedPaths = new Set<string>()
/** How many children each folder lost, keyed the same way, applied once the passes are done. */
const losses = new Map<
string,
{ siteId: string; locale: string; path: string; count: number }
>()
for (;;) {
const deleted = await WIKI.db
.delete(treeTable)
.where(and(eq(treeTable.type, 'folder'), holdsNothing))
.returning({
id: treeTable.id,
siteId: treeTable.siteId,
locale: treeTable.locale,
folderPath: treeTable.folderPath,
fileName: treeTable.fileName
})
if (deleted.length < 1) {
break
}
for (const row of deleted) {
deletedIds.push(row.id)
deletedPaths.add(`${row.siteId}|${row.locale}|${childPathOf(row)}`)
const parentPath = row.folderPath ?? ''
if (!parentPath) {
continue
}
const key = `${row.siteId}|${row.locale}|${parentPath}`
const loss = losses.get(key)
if (loss) {
loss.count++
} else {
losses.set(key, { siteId: row.siteId, locale: row.locale, path: parentPath, count: 1 })
}
}
}
/*
The children count lives on the folder, so every parent that survived has to be told what it
lost in one update each rather than one per child, since a run can take a great many folders.
A parent that went in a later pass is skipped: there is no row left to correct.
*/
for (const [key, loss] of losses) {
if (deletedPaths.has(key)) {
continue
}
await this.countTowardsFolderAt(loss.siteId, loss.locale, loss.path, -loss.count)
}
// -> Any of them may have owned a sidebar menu keyed by its own id
await WIKI.models.navigation.deleteNavForEntries(deletedIds)
WIKI.logger.debug(`Deleted ${deletedIds.length} empty folder(s).`)
return deletedIds.length
}
/**
* Add a page entry to the tree.
*

@ -31,6 +31,22 @@
<div class="p-4 gap-4">
<w-card>
<w-list separator>
<w-item>
<blueprint-icon icon="opened-folder" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{ t(`admin.utilities.purgeEmptyFolders`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.utilities.purgeEmptyFoldersHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
class="acrylic-btn"
flat
icon="la:arrow-circle-right"
color="primary"
@click="purgeEmptyFolders"
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="disconnected" :hue-rotate="45" />
<w-item-section>
@ -402,6 +418,45 @@ function invalidateSessionSecret() {
})
}
/**
* Delete every folder that holds no page and no asset, on every site.
*
* Confirmed, but not coloured as a destruction: what goes is a container with nothing in it, and the
* server will not delete anything that is not a folder. The confirmation says the part that is not
* obvious that a folder holding only empty folders counts as empty too, so a whole branch can go at
* once.
*/
function purgeEmptyFolders() {
confirm({
title: t('admin.utilities.purgeEmptyFolders'),
message: t('admin.utilities.purgeEmptyFoldersConfirm'),
caption: t('admin.utilities.purgeEmptyFoldersConfirmWarn'),
cancel: true,
persistent: true,
okLabel: t('common.actions.proceed')
}).onOk(async () => {
loading.show()
try {
const resp = await API_CLIENT.post('system/empty-folders/purge').json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
const count = resp.count ?? 0
notify({
type: 'positive',
message: t('admin.utilities.purgeEmptyFoldersSuccess', count, { count })
})
} catch (err) {
notify({
type: 'negative',
message: t('admin.utilities.purgeEmptyFoldersFailed'),
caption: apiErrorMessage(err)
})
}
loading.hide()
})
}
/**
* Delete every page version older than the selected timeframe, on every site.
*

Loading…
Cancel
Save