From b11bf4f601101a3940763e626d2c6514c3aa5d0b Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 12 Sep 2026 22:52:57 -0400 Subject: [PATCH] feat: add purge empty folders utility --- backend/api/system.ts | 54 +++++++++++++ backend/locales/en.json | 7 ++ backend/models/auditLog.ts | 1 + backend/models/tree.ts | 104 +++++++++++++++++++++++++- frontend/src/pages/AdminUtilities.vue | 55 ++++++++++++++ 5 files changed, 220 insertions(+), 1 deletion(-) diff --git a/backend/api/system.ts b/backend/api/system.ts index a60b36f9c..4d6bca836 100644 --- a/backend/api/system.ts +++ b/backend/api/system.ts @@ -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 */ diff --git a/backend/locales/en.json b/backend/locales/en.json index 428a4b087..3df115359 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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.", diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts index 8064a93bc..9e39eb3d8 100644 --- a/backend/models/auditLog.ts +++ b/backend/models/auditLog.ts @@ -122,6 +122,7 @@ export const AUDIT_ACTIONS = { 'invalidateSessions', 'purgePageHistory', 'purgeSampleContent', + 'purgeEmptyFolders', 'checkForUpdate', 'createUser', 'updateUser', diff --git a/backend/models/tree.ts b/backend/models/tree.ts index a52611eb5..6a3b40eda 100644 --- a/backend/models/tree.ts +++ b/backend/models/tree.ts @@ -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 { + 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() + /** 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. * diff --git a/frontend/src/pages/AdminUtilities.vue b/frontend/src/pages/AdminUtilities.vue index bb9f7dc14..1c027e66e 100644 --- a/frontend/src/pages/AdminUtilities.vue +++ b/frontend/src/pages/AdminUtilities.vue @@ -31,6 +31,22 @@
+ + + + {{ t(`admin.utilities.purgeEmptyFolders`) }} + {{ t(`admin.utilities.purgeEmptyFoldersHint`) }} + + + + + @@ -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. *