mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: add purge empty folders utility
This commit is contained in:
5 files changed
+220
-1
No files matched your search
@@ -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',
|
||||
|
||||
+103
-1
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user