diff --git a/backend/api/assets.ts b/backend/api/assets.ts index a0556136f..8be9eefa9 100644 --- a/backend/api/assets.ts +++ b/backend/api/assets.ts @@ -27,15 +27,22 @@ const assetIdParam = { * Assets live in the same tree as pages and are addressed by the same rules — a rule over a branch * covers the files in it as well as the pages, which is why the asset permissions are offered * alongside the page ones in the group editor. + * + * Where it sits includes the LOCALE it is in, and the parameter is required for that reason: a rule + * may be limited to particular locales, and `ruleMatchesPage` treats a reference with no locale as + * one no locale restriction applies to — so an omitted locale silently widens every such rule. Every + * asset the API hands around carries one, and the two places that build a destination by hand say + * which locale they mean. */ function mayOnAsset( req: FastifyRequest, permission: string, - asset: { folderPath?: string | null; fileName: string } + asset: { folderPath?: string | null; fileName: string; locale: string } ): boolean { const folder = asset.folderPath ?? '' return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, { - path: folder ? `${folder}/${asset.fileName}` : asset.fileName + path: folder ? `${folder}/${asset.fileName}` : asset.fileName, + locale: asset.locale }) } @@ -151,14 +158,21 @@ async function routes(app: FastifyInstance) { const destination = folder ? [parentPath, folder.fileName].filter(Boolean).join('/') : (folderPath ?? '') + // -> Settled once, since the rule check and the write have to be asking about the same locale + const locale = + req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en' if ( - !mayOnAsset(req, 'write:assets', { folderPath: destination, fileName: req.query.fileName }) + !mayOnAsset(req, 'write:assets', { + folderPath: destination, + fileName: req.query.fileName, + locale + }) ) { return reply.forbidden('You are not allowed to upload a file here.') } const asset = await WIKI.models.assets.upload({ siteId: req.params.siteId, - locale: req.query.locale ?? WIKI.sites[req.params.siteId]?.config?.locales?.primary ?? 'en', + locale, folderId: req.query.folderId, folderPath, fileName: req.query.fileName, @@ -279,9 +293,12 @@ async function routes(app: FastifyInstance) { ) /** - * RENAME ASSET + * RENAME / MOVE ASSET */ - app.patch<{ Params: { siteId: string; assetId: string }; Body: { fileName: string } }>( + app.patch<{ + Params: { siteId: string; assetId: string } + Body: { fileName?: string; folderPath?: string; locale?: string } + }>( '/sites/:siteId/assets/:assetId', { /* @@ -289,26 +306,37 @@ async function routes(app: FastifyInstance) { from a group's RULES, which address the folder the file is in. Checked below. */ schema: { - summary: 'Rename an asset', + summary: 'Rename an asset or move it to another folder', description: - 'The extension is part of the name, and changing it changes the type the file is served as.', + 'Any of the three may be sent on its own, and they are the same operation to a storage target: a file is addressed by its locale, its folder and its name together, so the copy on every target follows. The extension is part of the name, and changing it changes the type the file is served as.\n\nMoving needs `manage:assets` at the destination as well as at the source, since page rules are granted per path and per locale.', tags: ['Assets'], params: assetIdParam, body: { type: 'object', - required: ['fileName'], properties: { fileName: { type: 'string', minLength: 3, maxLength: 255, - description: 'Sanitized, so the stored name may differ from the one sent.' + description: + 'Sanitized, so the stored name may differ from the one sent. Keeps its current name when absent.' + }, + folderPath: { + type: 'string', + maxLength: 255, + description: + 'The folder to move it to, from the site root, empty for the root itself. Created if it does not exist. Stays where it is when absent.' + }, + locale: { + type: 'string', + maxLength: 255, + description: 'The locale to move it to. Stays in its own when absent.' } } }, response: { 200: { - description: 'Asset renamed successfully', + description: 'Asset renamed or moved successfully', type: 'object', properties: { ok: { @@ -329,19 +357,38 @@ async function routes(app: FastifyInstance) { return reply.notFound('This asset does not exist.') } if (!mayOnAsset(req, 'manage:assets', existing)) { - return reply.forbidden('You are not allowed to rename this file.') + return reply.forbidden('You are not allowed to rename or move this file.') + } + /* + And at the destination, when that is somewhere else: rules are granted per path AND per + locale, so a move is a write to a place the mover may have no say over -- which without this + is a way to put a file where they could not have uploaded one. + */ + const destination = { + folderPath: + req.body.folderPath === undefined + ? existing.folderPath + : normalizeFolderPath(req.body.folderPath), + fileName: req.body.fileName ?? existing.fileName, + locale: req.body.locale || existing.locale + } + const isRelocated = + destination.folderPath !== existing.folderPath || destination.locale !== existing.locale + if (isRelocated && !mayOnAsset(req, 'manage:assets', destination)) { + return reply.forbidden('You are not allowed to move this file there.') } - const asset = await WIKI.models.assets.renameAsset( + const asset = await WIKI.models.assets.moveAsset( req.params.siteId, req.params.assetId, - req.body.fileName + req.body, + req.session.user?.id ) if (!asset) { return reply.notFound('This asset does not exist.') } return { ok: true, - message: 'Asset renamed successfully.', + message: isRelocated ? 'Asset moved successfully.' : 'Asset renamed successfully.', asset } } diff --git a/backend/api/schemas/asset.ts b/backend/api/schemas/asset.ts index ec664cdf8..378041521 100644 --- a/backend/api/schemas/asset.ts +++ b/backend/api/schemas/asset.ts @@ -41,6 +41,15 @@ export async function registerSchemas(app: FastifyInstance): Promise { type: 'boolean', description: 'Whether a thumbnail was generated, and `/_thumb/.webp` will serve one.' }, + width: { + type: 'integer', + description: + 'Images only — in pixels, as displayed. Absent when the dimensions could not be read, which is the case for anything uploaded while the Sharp extension was missing.' + }, + height: { + type: 'integer', + description: 'Images only — in pixels, as displayed.' + }, createdAt: { type: 'string', format: 'date-time' diff --git a/backend/api/schemas/tree.ts b/backend/api/schemas/tree.ts index 6ff8dcb1f..9e7ba837e 100644 --- a/backend/api/schemas/tree.ts +++ b/backend/api/schemas/tree.ts @@ -48,6 +48,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { type: 'integer', description: 'Folders only — how many entries the folder holds.' }, + hue: { + type: 'integer', + description: + 'Folders only — how far the folder icon is rotated around the colour wheel, in degrees. Absent on a folder left the colour every folder starts out.' + }, isAncestor: { type: 'boolean', description: @@ -65,6 +70,15 @@ export async function registerSchemas(app: FastifyInstance): Promise { type: 'string', description: 'Assets only.' }, + width: { + type: 'integer', + description: + 'Image assets only — in pixels, as displayed. Absent when the dimensions were never read.' + }, + height: { + type: 'integer', + description: 'Image assets only — in pixels, as displayed.' + }, editor: { type: 'string', description: 'Pages only.' @@ -189,6 +203,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { childrenCount: { type: 'integer' }, + hue: { + type: 'integer', + description: + 'How far the folder icon is rotated around the colour wheel, in degrees. Absent on a folder left the colour every folder starts out.' + }, createdAt: { type: 'string', format: 'date-time' diff --git a/backend/api/tree.ts b/backend/api/tree.ts index 2088ba8ad..40bc16c33 100644 --- a/backend/api/tree.ts +++ b/backend/api/tree.ts @@ -1,6 +1,6 @@ import type { FastifyInstance, FastifyRequest } from 'fastify' -import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy } from '../models/tree.ts' -import { decodeTreePath } from '../helpers/common.ts' +import { TREE_ORDER_BY, type TreeItemType, type TreeOrderBy, type TreeRow } from '../models/tree.ts' +import { decodeTreePath, normalizeFolderPath } from '../helpers/common.ts' import { actorFrom } from './pages.ts' interface TreeQuery { @@ -106,6 +106,24 @@ function visibleTreeItems Absent rather than zero on a folder nobody has coloured; see `setFolderColor` + ...(folder.meta?.hue ? { hue: folder.meta.hue } : {}) + } +} + function folderPathOf(folder: { folderPath?: string | null; fileName: string }): string { const parent = decodeTreePath(folder.folderPath ?? '') ?? '' return parent ? `${parent}/${folder.fileName}` : folder.fileName @@ -118,9 +136,15 @@ function folderPathOf(folder: { folderPath?: string | null; fileName: string }): * branch it opens: a rule denying `read:pages` under `geography` hides the folder as well as the * pages in it, and only somebody who may reorganise pages there may rename or remove it. */ -function mayOnFolder(req: FastifyRequest, permission: string, path: string): boolean { +function mayOnFolder( + req: FastifyRequest, + permission: string, + path: string, + locale: string +): boolean { return WIKI.models.groups.checkAccess(WIKI.models.groups.actorForRequest(req), permission, { - path + path, + locale }) } @@ -447,14 +471,10 @@ async function routes(app: FastifyInstance) { } const folderPath = folderPathOf(folder) // -> Not visible is the same as not there, so it answers as the id had matched nothing - if (!mayOnFolder(req, 'read:pages', folderPath)) { + if (!mayOnFolder(req, 'read:pages', folderPath, folder.locale)) { return reply.notFound('This folder does not exist.') } - return { - ...folder, - folderPath: decodeTreePath(folder.folderPath ?? '') ?? '', - childrenCount: folder.meta?.children ?? 0 - } + return toFolderResponse(folder) } ) @@ -528,12 +548,13 @@ async function routes(app: FastifyInstance) { parentPath = parent ? folderPathOf(parent) : parentPath } const target = [parentPath, req.body.pathName].filter(Boolean).join('/') - if (!mayOnFolder(req, 'manage:pages', target)) { + const locale = req.body.locale ?? defaultLocale(req.params.siteId) + if (!mayOnFolder(req, 'manage:pages', target, locale)) { return reply.forbidden('You are not allowed to create a folder here.') } const folder = await WIKI.models.tree.createFolder({ siteId: req.params.siteId, - locale: req.body.locale ?? defaultLocale(req.params.siteId), + locale, parentId: req.body.parentId, parentPath: req.body.parentPath, pathName: req.body.pathName, @@ -542,11 +563,7 @@ async function routes(app: FastifyInstance) { return { ok: true, message: 'Folder created successfully.', - folder: { - ...folder, - folderPath: decodeTreePath(folder.folderPath ?? '') ?? '', - childrenCount: folder.meta?.children ?? 0 - } + folder: toFolderResponse(folder) } } ) @@ -592,7 +609,7 @@ async function routes(app: FastifyInstance) { if (!existing || existing.siteId !== req.params.siteId) { return reply.notFound('This folder does not exist.') } - if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing))) { + if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing), existing.locale)) { return reply.forbidden('You are not allowed to rename this folder.') } const folder = await WIKI.models.tree.renameFolder({ @@ -604,12 +621,268 @@ async function routes(app: FastifyInstance) { return { ok: true, message: 'Folder renamed successfully.', - folder: { - ...folder, - folderPath: decodeTreePath(folder.folderPath ?? '') ?? '', - childrenCount: folder.meta?.children ?? 0 + folder: toFolderResponse(folder) + } + } + ) + + /** + * MOVE FOLDER + */ + app.put<{ + Params: { siteId: string; folderId: string } + Body: { folderPath: string; locale?: string } + }>( + '/sites/:siteId/tree/folders/:folderId/path', + { + /* + No route-level `permissions`: that hook reads the group-wide list, and page permissions come + from a group's RULES. Checked against the folder's own path below. + */ + schema: { + summary: 'Move a folder to another parent', + description: + 'Everything under the folder moves with it — pages, files, and the folders in between — and every storage target holding a copy follows. Any folder the destination needs is created.\n\nMoving needs `manage:pages` at the destination as well as at the source, since page rules are granted per path and per locale. A folder cannot be moved into its own subtree.', + tags: ['Tree'], + params: folderIdParam, + body: { + type: 'object', + required: ['folderPath'], + properties: { + folderPath: { + type: 'string', + maxLength: 255, + description: + 'The folder to move it into, from the site root, empty for the root itself. Created if it does not exist.' + }, + locale: { + type: 'string', + maxLength: 255, + description: 'The locale to move it to. Stays in its own when absent.' + } + } + }, + response: { + 200: { + description: 'Folder moved successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + folder: { $ref: 'Folder#' } + } + } + } + } + }, + async (req, reply) => { + const existing = await WIKI.models.tree.getFolderById(req.params.folderId) + if (!existing || existing.siteId !== req.params.siteId) { + return reply.notFound('This folder does not exist.') + } + if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing), existing.locale)) { + return reply.forbidden('You are not allowed to move this folder.') + } + /* + And where it is going, which is a whole branch arriving somewhere the mover may have no say + over: rules are granted per path AND per locale, so without this a folder full of pages is a + way to write into a place they could not have created one. + */ + const destination = normalizeFolderPath(req.body.folderPath) + const destinationLocale = req.body.locale || existing.locale + if ( + !mayOnFolder( + req, + 'manage:pages', + [destination, existing.fileName].filter(Boolean).join('/'), + destinationLocale + ) + ) { + return reply.forbidden('You are not allowed to move this folder there.') + } + const folder = await WIKI.models.tree.moveFolder({ + folderId: req.params.folderId, + folderPath: destination, + locale: destinationLocale, + actorId: req.session.user?.id + }) + return { + ok: true, + message: 'Folder moved successfully.', + folder: toFolderResponse(folder) + } + } + ) + + /** + * DUPLICATE FOLDER + */ + app.post<{ + Params: { siteId: string; folderId: string } + Body: { folderPath: string; pathName: string; title: string; locale?: string } + }>( + '/sites/:siteId/tree/folders/:folderId/duplicate', + { + /* + No route-level `permissions`: that hook reads the group-wide list, and page permissions come + from a group's RULES. Checked against the folder's own path below. + */ + schema: { + summary: 'Duplicate a folder', + description: + 'Copies the folder and everything under it — the folders in between, the pages and the files, each as a new entry of its own with its own copy on every storage target. Aliases, translation sets and sidebar overrides are not carried across; folder colours are.\n\nCopying needs `manage:pages` at the destination as well as at the source, since page rules are granted per path and per locale. A folder cannot be copied into its own subtree, and the name must be free where it is going.', + tags: ['Tree'], + params: folderIdParam, + body: { + allOf: [ + { $ref: 'FolderInput#' }, + { type: 'object', required: ['pathName', 'title'] }, + { + type: 'object', + properties: { + folderPath: { + type: 'string', + maxLength: 2048, + description: + 'Slash-separated path of the folder to copy into, empty for the site root. Created if it does not exist.' + }, + locale: { + type: 'string', + maxLength: 10, + description: "The source folder's own locale when absent." + } + } + } + ] + }, + response: { + 200: { + description: 'Folder duplicated successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + folder: { $ref: 'Folder#' } + } + } } } + }, + async (req, reply) => { + const actor = actorFrom(req) + if (!actor) { + return reply.unauthorized('Duplicating a folder requires a logged in user.') + } + const existing = await WIKI.models.tree.getFolderById(req.params.folderId) + if (!existing || existing.siteId !== req.params.siteId) { + return reply.notFound('This folder does not exist.') + } + if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing), existing.locale)) { + return reply.forbidden('You are not allowed to duplicate this folder.') + } + // -> And where the copy is going, which is a whole branch of new pages arriving somewhere the + // copier may have no say over + const destination = normalizeFolderPath(req.body.folderPath) + const destinationLocale = req.body.locale || existing.locale + if ( + !mayOnFolder( + req, + 'manage:pages', + [destination, req.body.pathName].filter(Boolean).join('/'), + destinationLocale + ) + ) { + return reply.forbidden('You are not allowed to duplicate this folder there.') + } + const folder = await WIKI.models.tree.duplicateFolder({ + folderId: req.params.folderId, + folderPath: destination, + pathName: req.body.pathName, + title: req.body.title, + locale: destinationLocale, + actor + }) + return { + ok: true, + message: 'Folder duplicated successfully.', + folder: toFolderResponse(folder) + } + } + ) + + /** + * SET FOLDER COLOR + */ + app.put<{ + Params: { siteId: string; folderId: string } + Body: { hue: number } + }>( + '/sites/:siteId/tree/folders/:folderId/color', + { + /* + No route-level `permissions`: that hook reads the group-wide list, and page permissions come + from a group's RULES. Checked against the folder's own path below. + */ + schema: { + summary: "Set a folder's colour", + description: + 'A hue rotation in degrees applied to the folder icon, rather than a colour: the icon is one image that the interface turns around the colour wheel. Zero is the colour every folder starts out, and clears the setting.', + tags: ['Tree'], + params: folderIdParam, + body: { + type: 'object', + required: ['hue'], + properties: { + hue: { + type: 'integer', + minimum: 0, + maximum: 359, + description: 'Degrees around the colour wheel. Zero puts the folder back to yellow.' + } + } + }, + response: { + 200: { + description: 'Folder colour set successfully', + type: 'object', + properties: { + ok: { + type: 'boolean' + }, + message: { + type: 'string' + }, + folder: { $ref: 'Folder#' } + } + } + } + } + }, + async (req, reply) => { + const existing = await WIKI.models.tree.getFolderById(req.params.folderId) + if (!existing || existing.siteId !== req.params.siteId) { + return reply.notFound('This folder does not exist.') + } + if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing), existing.locale)) { + return reply.forbidden('You are not allowed to change this folder.') + } + const folder = await WIKI.models.tree.setFolderColor({ + folderId: req.params.folderId, + hue: req.body.hue + }) + return { + ok: true, + message: 'Folder colour set successfully.', + folder: toFolderResponse(folder) + } } ) @@ -647,7 +920,7 @@ async function routes(app: FastifyInstance) { if (!existing || existing.siteId !== req.params.siteId) { return reply.notFound('This folder does not exist.') } - if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing))) { + if (!mayOnFolder(req, 'manage:pages', folderPathOf(existing), existing.locale)) { return reply.forbidden('You are not allowed to delete this folder.') } const removed = await WIKI.models.tree.deleteFolder(req.params.folderId) diff --git a/backend/helpers/images.ts b/backend/helpers/images.ts index 85e745e3b..2fdf76a93 100644 --- a/backend/helpers/images.ts +++ b/backend/helpers/images.ts @@ -193,3 +193,51 @@ export async function makeImageThumbnail( return null } } + +/** How big an image is, in pixels, as it is meant to be displayed. */ +export type ImageDimensions = { + width: number + height: number +} + +/** + * Read an image's pixel dimensions, using the Sharp extension. + * + * Recorded once, when the file arrives, so that showing them later costs nothing — the file manager + * lists a folder at a time and must not read every image to describe one. + * + * The dimensions are the ones a viewer will show, not the ones the pixels are stored in: an EXIF + * orientation of 5 or above means the decoder turns the image a quarter turn, and Sharp reports the + * pre-rotation size, so the two are swapped back here. A portrait photo off a phone is exactly this + * case, and reporting it as landscape is worse than reporting nothing. + * + * @returns The dimensions, or null if Sharp is not usable on this system or these bytes are not an + * image it can read + */ +export async function readImageDimensions(data: Buffer): Promise { + const definition = WIKI.models.extensions.getDefinition('sharp') + if (!definition || !(await WIKI.models.extensions.isInstalled(definition))) { + return null + } + const specifier = 'sharp' + // -> Loading Sharp and running it are kept apart, as everywhere else here: whatever a user uploaded + // may simply not be an image Sharp can read, which must not be recorded as Sharp being broken + let sharp: any + try { + ;({ default: sharp } = await import(specifier)) + } catch (err: any) { + WIKI.models.extensions.noteLoadFailure(specifier) + WIKI.logger.warn(`Could not load Sharp to measure an image: ${err.message}`) + return null + } + try { + const { width, height, orientation } = await sharp(data).metadata() + if (!width || !height) { + return null + } + return orientation && orientation >= 5 ? { width: height, height: width } : { width, height } + } catch (err: any) { + WIKI.logger.debug(`Could not read the dimensions of an upload: ${err.message}`) + return null + } +} diff --git a/backend/locales/en.json b/backend/locales/en.json index f979a80d2..257c29cf9 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -1443,6 +1443,7 @@ "common.actions.saveAndClose": "Save and Close", "common.actions.saveChanges": "Save Changes", "common.actions.select": "Select", + "common.actions.setColor": "Set Color", "common.actions.submitEdits": "Submit Edits", "common.actions.suggestEdits": "Suggest Edits", "common.actions.suggestedEdit": "Suggested Edit", @@ -2001,7 +2002,8 @@ "fileman.assetDeleteSuccess": "Asset deleted successfully.", "fileman.assetFileName": "Asset Name", "fileman.assetFileNameHint": "Filename of the asset, including the file extension.", - "fileman.assetRename": "Rename Asset", + "fileman.assetLocaleHint": "Which locale the file belongs to.", + "fileman.assetRenameMove": "Rename / Move Asset", "fileman.aviFileType": "AVI Video File", "fileman.binFileType": "Binary File", "fileman.bz2FileType": "BZIP2 Archive", @@ -2011,6 +2013,7 @@ "fileman.cssFileType": "Cascade Style Sheet", "fileman.csvFileType": "Comma Separated Values Document", "fileman.dataFileType": "Data File", + "fileman.detailsAssetDimensions": "Dimensions", "fileman.detailsAssetSize": "File Size", "fileman.detailsAssetType": "Type", "fileman.detailsPageCreated": "Created", @@ -2024,11 +2027,18 @@ "fileman.exeFileType": "Windows Executable", "fileman.flacFileType": "FLAC Audio File", "fileman.folderChildrenCount": "Empty folder | 1 child | {count} children", + "fileman.folderColor": "Set Folder Color", "fileman.folderCreate": "New Folder", + "fileman.folderDuplicate": "Duplicate Folder To...", + "fileman.folderDuplicating": "Duplicating folder...", + "fileman.folderDuplicatingHint": "Every page and file under it is being copied. This may take a moment for a large folder.", "fileman.folderFileName": "Path Name", "fileman.folderFileNameHint": "URL friendly version of the folder name. Must consist of lowercase alphanumerical or hypen characters only.", "fileman.folderFileNameInvalid": "Invalid Characters in Folder Path Name. Lowercase alphanumerical and hyphen characters only.", "fileman.folderFileNameMissing": "Missing Folder Path Name", + "fileman.folderMove": "Move Folder To...", + "fileman.folderMoving": "Moving folder...", + "fileman.folderMovingHint": "Every page and file under it is moving with it. This may take a moment for a large folder.", "fileman.folderRename": "Rename Folder", "fileman.folderTitle": "Title", "fileman.folderTitleInvalidChars": "Invalid Characters in Folder Name", @@ -2053,13 +2063,15 @@ "fileman.oggFileType": "OGG Audio File", "fileman.otfFileType": "OpenType Font File", "fileman.pdfFileType": "PDF Document", + "fileman.previewActualSize": "Actual Size", + "fileman.previewFailed": "This image could not be loaded.", + "fileman.previewFitToScreen": "Fit to Screen", "fileman.pngFileType": "PNG Image", "fileman.pptxFileType": "Microsoft Powerpoint Presentation", "fileman.psdFileType": "Adobe Photoshop Document", "fileman.rarFileType": "RAR Archive", "fileman.redirectPageType": "Redirection", "fileman.renameAssetInvalid": "Asset name is invalid.", - "fileman.renameAssetSuccess": "Asset renamed successfully", "fileman.renameFolderInvalidData": "One or more fields are invalid.", "fileman.renameFolderSuccess": "Folder renamed successfully.", "fileman.searchFolder": "Search folder...", diff --git a/backend/models/assets.ts b/backend/models/assets.ts index f338a35cd..3af621384 100644 --- a/backend/models/assets.ts +++ b/backend/models/assets.ts @@ -3,8 +3,14 @@ import path from 'node:path' import mime from 'mime' import { and, desc, eq, inArray, isNotNull, sql } from 'drizzle-orm' import { assets as assetsTable, tree as treeTable } from '../db/schema.ts' -import { CustomError, decodeTreePath, encodeTreePath } from '../helpers/common.ts' -import { makeImageThumbnail } from '../helpers/images.ts' +import { + CustomError, + decodeTreePath, + encodeTreePath, + normalizeFolderPath +} from '../helpers/common.ts' +import { makeImageThumbnail, readImageDimensions } from '../helpers/images.ts' +import type { ImageDimensions } from '../helpers/images.ts' import type { Readable } from 'node:stream' import type { DeletedEntry } from './tree.ts' import type { StorageAssetRef } from './storage.ts' @@ -101,6 +107,13 @@ export interface Asset { locale: string title: string hasPreview: boolean + /** + * How big the image is, in pixels. Absent for anything that is not an image, and for an image whose + * dimensions could not be read when it arrived — Sharp does the reading, and it is an optional + * dependency, so a file uploaded while it was missing has none and never will. + */ + width?: number + height?: number createdAt: Date updatedAt: Date } @@ -149,6 +162,26 @@ function extensionOf(fileName: string): string { return path.extname(fileName).replace(/^\./, '').toLowerCase() } +/** + * What the dimensions contribute to a stored `meta` object, on the asset row and on the tree row + * alike. + * + * Spread rather than assigned, so that a file with no dimensions to record carries no keys for them + * rather than a pair of nulls: an absent key is the honest shape for "never measured", and it is what + * keeps a listing from claiming a document is zero pixels across. + */ +function dimensionMeta(dimensions: ImageDimensions | null): Record { + return dimensions ? { width: dimensions.width, height: dimensions.height } : {} +} + +/** The pair back out of anything carrying it, or null when only one of the two is there to read. */ +function dimensionsOf(source: { + width?: number | null + height?: number | null +}): ImageDimensions | null { + return source.width && source.height ? { width: source.width, height: source.height } : null +} + function kindOf(mimeType: string, fileExt: string): AssetKind { if (mimeType.startsWith('image/')) { return 'image' @@ -335,6 +368,9 @@ class Assets { kind === 'image' ? await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height) : null + // -> Read now, while the bytes are in hand: a folder listing describes a screenful of files at a + // time, and could not open each of them to say how big its image is + const dimensions = kind === 'image' ? await readImageDimensions(data) : null // -> What is already at this name, if anything, and what the site says to do about it. Asked // before any row is touched, since two of the three answers write nothing new at all. @@ -378,6 +414,7 @@ class Assets { mimeType: resolvedMime, data, preview, + dimensions, authorId }) } @@ -395,7 +432,8 @@ class Assets { meta: { fileSize: data.length, fileExt, - mimeType: resolvedMime + mimeType: resolvedMime, + ...dimensionMeta(dimensions) } }) const storedName = entry.fileName @@ -412,6 +450,7 @@ class Assets { kind, mimeType: resolvedMime, fileSize: data.length, + meta: dimensionMeta(dimensions), preview, authorId, siteId @@ -456,6 +495,7 @@ class Assets { locale, title: entry.title, hasPreview: Boolean(preview), + ...dimensionMeta(dimensions), createdAt: entry.createdAt, updatedAt: entry.updatedAt } @@ -489,6 +529,7 @@ class Assets { mimeType, data, preview, + dimensions, authorId }: { id: string @@ -502,6 +543,7 @@ class Assets { mimeType: string data: Buffer preview: Buffer | null + dimensions: ImageDimensions | null authorId: string }): Promise { await WIKI.models.storage.putAsset( @@ -515,6 +557,10 @@ class Assets { kind, mimeType, fileSize: data.length, + // -> Set outright rather than merged, since these describe the bytes that just arrived: a + // replacement of another size overwrites the old measurements, and one that could not be + // measured at all leaves none behind + meta: dimensionMeta(dimensions), preview, authorId, updatedAt: sql`now()` @@ -523,7 +569,10 @@ class Assets { // -> The tree carries its own copy of these, and it is what a folder listing reads await WIKI.db .update(treeTable) - .set({ meta: { fileSize: data.length, fileExt, mimeType }, updatedAt: sql`now()` }) + .set({ + meta: { fileSize: data.length, fileExt, mimeType, ...dimensionMeta(dimensions) }, + updatedAt: sql`now()` + }) .where(eq(treeTable.id, id)) // -> The path resolves to the same asset as before, but to different metadata: the ETag is the @@ -557,6 +606,7 @@ class Assets { locale, title, hasPreview: Boolean(preview), + ...dimensionMeta(dimensions), createdAt: new Date(), updatedAt: new Date() } @@ -575,6 +625,7 @@ class Assets { kind: assetsTable.kind, mimeType: assetsTable.mimeType, fileSize: assetsTable.fileSize, + meta: assetsTable.meta, createdAt: assetsTable.createdAt, updatedAt: assetsTable.updatedAt, folderPath: treeTable.folderPath, @@ -593,11 +644,15 @@ class Assets { if (!row) { return null } + // -> `meta` is where the row keeps the dimensions and is not itself part of an asset as the API + // describes one, so it is unpacked here rather than passed along + const { meta, ...rest } = row return { - ...row, + ...rest, fileSize: row.fileSize ?? 0, folderPath: decodeTreePath(row.folderPath ?? '') ?? '', - hasPreview: Boolean(row.hasPreview) + hasPreview: Boolean(row.hasPreview), + ...dimensionMeta(dimensionsOf(meta as Record)) } as Asset } @@ -628,6 +683,7 @@ class Assets { kind: assetsTable.kind, mimeType: assetsTable.mimeType, fileSize: assetsTable.fileSize, + meta: assetsTable.meta, createdAt: assetsTable.createdAt, updatedAt: assetsTable.updatedAt, folderPath: treeTable.folderPath, @@ -652,11 +708,15 @@ class Assets { if (!row) { return null } + // -> `meta` is where the row keeps the dimensions and is not itself part of an asset as the API + // describes one, so it is unpacked here rather than passed along + const { meta, ...rest } = row return { - ...row, + ...rest, fileSize: row.fileSize ?? 0, folderPath: decodeTreePath(row.folderPath ?? '') ?? '', - hasPreview: Boolean(row.hasPreview) + hasPreview: Boolean(row.hasPreview), + ...dimensionMeta(dimensionsOf(meta as Record)) } as AssetAtPath } @@ -883,6 +943,7 @@ class Assets { kind === 'image' ? await makeImageThumbnail(data, THUMBNAIL_SIZE.width, THUMBNAIL_SIZE.height) : null + const dimensions = kind === 'image' ? await readImageDimensions(data) : null if (occupant) { return this.replace({ @@ -899,6 +960,7 @@ class Assets { mimeType, data, preview, + dimensions, authorId }) } @@ -909,7 +971,7 @@ class Assets { title: safeName, locale, siteId, - meta: { fileSize: data.length, fileExt, mimeType } + meta: { fileSize: data.length, fileExt, mimeType, ...dimensionMeta(dimensions) } }) try { @@ -920,6 +982,7 @@ class Assets { kind, mimeType, fileSize: data.length, + meta: dimensionMeta(dimensions), preview, authorId, siteId @@ -950,6 +1013,7 @@ class Assets { locale, title: entry.title, hasPreview: Boolean(preview), + ...dimensionMeta(dimensions), createdAt: entry.createdAt, updatedAt: entry.updatedAt } @@ -1226,16 +1290,35 @@ class Assets { } /** - * Rename an asset, in both of the rows that describe it. + * Rename an asset, move it to another folder, or both. + * + * One operation rather than two, because to a storage target they are the same one: a file's path + * under a target is its folder and its name together, so either half changing is the same copy + * moved to a new place. The wiki's own rows are what decide where that is, and this rewrites them + * before asking every target to follow. * + * A locale is part of that address too -- a target brackets its tree by locale unless the site says + * otherwise -- so moving between locales is the same operation again, and the folder the file lands + * in is that locale's, created if that locale does not have one yet. + * + * @param fileName What to call it, sanitized. Keeps its current name when absent. + * @param folderPath Which folder to put it in, from the site root, empty for the root itself. + * Created if it does not exist. Stays where it is when absent. + * @param locale Which locale's tree to put it in. Stays in its own when absent. * @returns The updated metadata, or null if there is no such asset on this site */ - async renameAsset(siteId: string, id: string, fileName: string): Promise { + async moveAsset( + siteId: string, + id: string, + { fileName, folderPath, locale }: { fileName?: string; folderPath?: string; locale?: string }, + actorId?: string + ): Promise { const asset = await this.getAsset(siteId, id) - if (!asset) { + const entry = await WIKI.models.tree.getById(id) + if (!asset || !entry) { return null } - const safeName = sanitizeFileName(fileName) + const safeName = fileName === undefined ? asset.fileName : sanitizeFileName(fileName) if (!safeName) { throw new CustomError('assetInvalidFileName', 'This file name cannot be used.') } @@ -1243,41 +1326,101 @@ class Assets { if (!fileExt) { throw new CustomError('assetInvalidFileName', 'The file name must keep a file extension.') } - // -> The same two rules an upload is held to: renaming is another way of arriving at a name, and - // `readme.pdf` renamed to `readme.md` would land on the page of that name just as squarely - const entry = await WIKI.models.tree.getById(id) - if (entry) { - await this.guardAgainstPageCollision({ + const destination = + folderPath === undefined ? asset.folderPath : normalizeFolderPath(folderPath) + const destinationLocale = locale || entry.locale + const isRenamed = safeName !== asset.fileName + // -> One question rather than two: a file in another locale's tree is somewhere else as surely as + // one in another folder, and everything below has to treat the pair as the destination + const isRelocated = destination !== asset.folderPath || destinationLocale !== entry.locale + if (!isRenamed && !isRelocated) { + return asset + } + + // -> The same two rules an upload is held to, asked of where it is GOING: renaming is another way + // of arriving at a name, and `readme.pdf` renamed to `readme.md` -- or carried into the folder + // holding the page `readme` -- would land on that page just as squarely + await this.guardAgainstPageCollision({ + siteId, + locale: destinationLocale, + folderPath: destination, + fileName: safeName, + fileExt + }) + + let storedName = safeName + if (isRelocated) { + /* + Asked before anything is written, because the write cannot take it back: the tree entry is + MOVED rather than rewritten -- deleted and re-added, so that the folders the destination needs + get created and the ones it leaves stop counting it -- and a name refused halfway through that + would leave the asset row with no entry pointing at it. `addAsset` still settles a name that + was taken in between by suffixing it, which keeps the two rows consistent where failing would + not; `storedName` is read back off the row rather than assumed for exactly that case. + */ + const occupant = await WIKI.models.tree.getEntryAt({ siteId, - locale: entry.locale, - folderPath: decodeTreePath(entry.folderPath ?? '') ?? '', + locale: destinationLocale, + parentPath: destination, + fileName: safeName + }) + if (occupant) { + throw new CustomError( + 'assetNameTakenByEntry', + `A ${occupant.type} with this name already exists there.`, + 409 + ) + } + await WIKI.models.tree.deleteEntry(id) + const moved = await WIKI.models.tree.addAsset({ + id, + parentPath: destination, fileName: safeName, - fileExt + title: safeName, + locale: destinationLocale, + siteId, + tags: entry.tags, + meta: entry.meta }) + storedName = moved.fileName + } else { + await WIKI.models.tree.renameEntry({ id, fileName: safeName, title: safeName }) } - const resolvedMime = mime.getType(safeName) ?? asset.mimeType + // -> Off the stored name rather than off the requested one, since the two differ where a move had + // to settle a collision + const storedExt = extensionOf(storedName) + const resolvedMime = mime.getType(storedName) ?? asset.mimeType - await WIKI.models.tree.renameEntry({ id, fileName: safeName, title: safeName }) await WIKI.db .update(assetsTable) .set({ - fileName: safeName, - fileExt, + fileName: storedName, + fileExt: storedExt, mimeType: resolvedMime, - kind: kindOf(resolvedMime, fileExt), + kind: kindOf(resolvedMime, storedExt), updatedAt: sql`now()` }) .where(eq(assetsTable.id, id)) - // -> The tree carries its own copy of these, and it is what a folder listing reads + // -> The tree carries its own copy of these, and it is what a folder listing reads. The bytes are + // untouched by either half of this, so whatever was measured of them is carried across rather + // than rebuilt: nothing here has the file in hand to measure it again. await WIKI.db .update(treeTable) - .set({ meta: { fileSize: asset.fileSize, fileExt, mimeType: resolvedMime } }) + .set({ + meta: { + fileSize: asset.fileSize, + fileExt: storedExt, + mimeType: resolvedMime, + ...dimensionMeta(dimensionsOf(asset)) + } + }) .where(eq(treeTable.id, id)) // -> Every target holding this asset lays its copy out by path, so each of them has a file to // move now that the tree rows have been rewritten - if (entry) { - await this.relocateAssets(siteId, [ + await this.relocateAssets( + siteId, + [ { id, previous: { @@ -1286,20 +1429,24 @@ class Assets { fileName: asset.fileName } } - ]) - } + ], + actorId + ) // -> Both ends of the move: the name it left, and the name it took, which something else may have // been resolved at before it was freed up this.forgetPath(siteId, asset.folderPath, asset.fileName) - this.forgetPath(siteId, asset.folderPath, safeName) + this.forgetPath(siteId, destination, storedName) await this.dropCachedContent([id]) WIKI.models.hooks.emit('asset:rename', { id, - fileName: safeName, + fileName: storedName, previousFileName: asset.fileName, - folderPath: asset.folderPath, + folderPath: destination, + previousFolderPath: asset.folderPath, + locale: destinationLocale, + previousLocale: entry.locale, siteId }) diff --git a/backend/models/navigation.ts b/backend/models/navigation.ts index ff11dca20..905d1861f 100644 --- a/backend/models/navigation.ts +++ b/backend/models/navigation.ts @@ -318,6 +318,69 @@ class Navigation { return { navigationMode: mode, navigationId: navId } } + + /** + * Repoint the sidebars of a subtree that has just moved. + * + * A menu is inherited from where an entry SITS -- the nearest ancestor that overrides or hides, and + * the site menu of its locale when none does -- so a folder that changes parents, or locales, takes + * a subtree full of entries pointing at a menu that is no longer above them. Left alone, a French + * page would show the English sidebar, which is the one thing a translated wiki cannot do. + * + * Only entries still on `inherit` are repointed, and only those not sitting under an override or a + * hide WITHIN the moved subtree: a menu written for a branch belongs to that branch's own entry, is + * keyed by its id, and travels with it. + * + * Called after the rows are at their destination, since what is above them is what decides this. + * + * @param folderId The moved folder, whose own mode is considered along with its descendants' + * @param locale The locale it now sits in + * @param folderPath Encoded ltree path of its new parent, empty at the site root + * @param fileName Its path name, which with `folderPath` is what its descendants sit under + */ + async repointMovedSubtree({ + siteId, + folderId, + locale, + folderPath, + fileName + }: { + siteId: string + folderId: string + locale: string + folderPath: string + fileName: string + }): Promise { + const inherited = await this.ancestorNavId(siteId, locale, folderPath) + const fullPath = folderPath ? `${folderPath}.${fileName}` : fileName + + // -> The folder itself first: it is not under its own path, so the cascade below passes it over + await WIKI.db + .update(treeTable) + .set({ navigationId: inherited }) + .where(and(eq(treeTable.id, folderId), eq(treeTable.navigationMode, 'inherit'))) + + // -> The same walk `updateNavigation` does when a mode changes, over the subtree that moved + await WIKI.db.execute(sql` + UPDATE tree tt + SET "navigationId" = ${inherited} + WHERE tt."siteId" = ${siteId} + AND tt."locale" = ${locale} + AND tt.tree IN ('page', 'folder') + AND tt."folderPath" <@ ${fullPath}::ltree + AND tt."navigationMode" = 'inherit' + AND NOT EXISTS ( + SELECT 1 + FROM tree tc + WHERE tc."siteId" = ${siteId} + AND tc."locale" = ${locale} + AND tc.tree IN ('page', 'folder') + AND tc."folderPath" <@ ${fullPath}::ltree + AND (tc."folderPath" || tc."fileName") @> tt."folderPath" + AND tc."navigationMode" IN ('override', 'hide') + ) + `) + } } export const navigation = new Navigation() diff --git a/backend/models/pages.ts b/backend/models/pages.ts index c83c80e29..f3cd45d6f 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -633,6 +633,23 @@ class Pages { } } + /** + * Take a set of pages out of their locale groups, for a folder that moved to another locale. + * + * The same rule `movePage` applies to one page, applied to everything under a folder that crossed + * locales at once. It has to happen before their `locale` column is rewritten: a group holds one + * page per locale and the index enforcing that would refuse the second arrival otherwise. + * + * One page at a time rather than in bulk, because the groups they belong to are not the same group + * and each has to be looked at for whether it still has anybody left in it. A folder move is a rare + * and deliberate act, so the queries are worth the plainness. + */ + async detachFromLocaleGroups(siteId: string, ids: string[]): Promise { + for (const id of ids) { + await this.detachFromLocaleGroup(siteId, id) + } + } + /** * Take one page out of its locale group, leaving the rest of the set related to each other. * diff --git a/backend/models/tree.ts b/backend/models/tree.ts index 353f5af3f..a52611eb5 100644 --- a/backend/models/tree.ts +++ b/backend/models/tree.ts @@ -7,8 +7,10 @@ import { encodeTreePath, generateHash, generatePathHash, + normalizeFolderPath, normalizePagePath } from '../helpers/common.ts' +import type { PageActor } from './pages.ts' /** What a tree entry can be. Mirrors the `treeType` enum in the schema. */ export type TreeItemType = 'folder' | 'page' | 'asset' @@ -39,12 +41,22 @@ export interface TreeItem { updatedAt: Date /** Folders only — how many entries the folder holds. */ childrenCount?: number + /** + * Folders only — how far the folder icon is rotated around the colour wheel, in degrees. + * + * Absent on a folder nobody has coloured, which is the same thing as zero: the icon is drawn + * yellow and a rotation of nothing leaves it there. + */ + hue?: number /** Folders only — whether this folder is a parent of the one being listed, not a child of it. */ isAncestor?: boolean /** Assets only. */ fileSize?: number fileExt?: string mimeType?: string + /** Image assets only — in pixels, as displayed. Absent when the dimensions were never read. */ + width?: number + height?: number /** Pages only. */ editor?: string description?: string @@ -168,6 +180,9 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem { updatedAt: row.updatedAt, ...(row.type === 'folder' && { childrenCount: row.meta?.children ?? 0, + // -> No default: an uncoloured folder carries no hue rather than a zero, which is what lets the + // interface draw it with no filter at all + ...(row.meta?.hue ? { hue: row.meta.hue } : {}), // -> Shorter than the folder being listed means it sits above it, so it came from // `includeAncestors` / `includeRootFolders` rather than from the listing itself isAncestor: folderPath.length < parentPath.length @@ -175,7 +190,12 @@ function toTreeItem(row: TreeRow, depth: number, parentPath: string): TreeItem { ...(row.type === 'asset' && { fileSize: row.meta?.fileSize ?? 0, fileExt: row.meta?.fileExt ?? '', - mimeType: row.meta?.mimeType ?? '' + mimeType: row.meta?.mimeType ?? '', + // -> No default, unlike the rest: a file whose dimensions were never read has none rather than + // being zero pixels across, and the file manager shows the row only for one that has them + ...(row.meta?.width && row.meta?.height + ? { width: row.meta.width, height: row.meta.height } + : {}) }), ...(row.type === 'page' && { editor: row.meta?.editor ?? '', @@ -809,7 +829,7 @@ class Tree { siteId, meta: { children: 0 } }) - await this.countTowardsFolderAt(siteId, ancestor.folderPath, 1) + await this.countTowardsFolderAt(siteId, effectiveLocale, ancestor.folderPath, 1) } } @@ -828,7 +848,7 @@ class Tree { }) .returning() - await this.countTowardsFolderAt(siteId, path, 1) + await this.countTowardsFolderAt(siteId, effectiveLocale, path, 1) WIKI.logger.debug(`Created folder ${inserted[0].id} successfully.`) return inserted[0] as TreeRow @@ -908,19 +928,35 @@ class Tree { WIKI.logger.debug(`Renaming folder ${folder.id} from ${oldPath} to ${newPath}...`) - // -> Direct children carry the old path verbatim; deeper ones carry it as a prefix, and keep - // whatever they had below it + /* + Direct children carry the old path verbatim; deeper ones carry it as a prefix, and keep whatever + they had below it. + + Scoped to this folder's locale, as everything below is: the tree holds every translation side by + side, so a path names one folder per locale, and without this renaming the English `/guides` + dragged the French one's children to a path their own folder row did not have. + */ await WIKI.db .update(treeTable) .set({ folderPath: newPath }) - .where(and(eq(treeTable.siteId, folder.siteId), eq(treeTable.folderPath, oldPath))) + .where( + and( + eq(treeTable.siteId, folder.siteId), + eq(treeTable.locale, folder.locale), + eq(treeTable.folderPath, oldPath) + ) + ) await WIKI.db .update(treeTable) .set({ folderPath: sql`${newPath}::ltree || subpath(${treeTable.folderPath}, nlevel(${newPath}::ltree))` }) .where( - and(eq(treeTable.siteId, folder.siteId), sql`${treeTable.folderPath} <@ ${oldPath}::ltree`) + and( + eq(treeTable.siteId, folder.siteId), + eq(treeTable.locale, folder.locale), + sql`${treeTable.folderPath} <@ ${oldPath}::ltree` + ) ) const fullPath = folder.folderPath ? `${decodeTreePath(folder.folderPath)}/${name}` : name @@ -930,7 +966,7 @@ class Tree { .where(eq(treeTable.id, folder.id)) .returning() - const movedPages = await this.refreshDescendantPaths(folder.siteId, newPath) + const movedPages = await this.refreshDescendantPaths(folder.siteId, folder.locale, newPath) // -> Only moved, never rewritten: none of these pages changed, so the copy a target holds is // still the right contents at the wrong name @@ -963,6 +999,7 @@ class Tree { .where( and( eq(treeTable.siteId, folder.siteId), + eq(treeTable.locale, folder.locale), eq(treeTable.type, 'asset'), sql`${treeTable.folderPath} <@ ${newPath}::ltree` ) @@ -992,6 +1029,488 @@ class Tree { return updated[0] as TreeRow } + /** + * Copy a folder, and everything under it, to another parent — another locale, and another name. + * + * Unlike a move, nothing here is a rewrite: every folder, page and file under the source gets a new + * row of its own, so the two trees go their separate ways from this moment. That is also why each + * copy goes through the model that owns it rather than through an INSERT ... SELECT — a copied page + * is rendered, indexed, given its own history and written to every storage target, and a copied file + * gets its own thumbnail and its own bytes on every target. A folder of a few hundred pages is + * therefore a slow request, and deliberately so: half a copy is worse than a slow one. + * + * What is deliberately not carried across: + * + * - **Aliases**, which are unique per site: a copy cannot have the original's, and inventing one is + * not this operation's business. + * - **Translation sets**: a copy is a new page, not another language's version of an existing one. + * - **Sidebar overrides**, so the copies inherit the menu of wherever they land — which for a copy + * into another locale is the only sensible answer anyway. + * + * Folder colours ARE carried across, since they are how somebody has arranged their tree. + * + * @param folderPath Slash-separated path of the folder to copy into, empty for the site root. + * @param pathName What to call the copy. The name it collides on, and the one that must be free. + * @param title The copy's title. + * @param locale The locale to copy into. The source's own when absent. + */ + async duplicateFolder({ + folderId, + folderPath, + pathName, + title, + locale, + actor + }: { + folderId: string + folderPath: string + pathName: string + title: string + locale?: string + /** Who is copying, which is who the copied pages and files are authored by. */ + actor: PageActor + }): Promise { + const source = await this.getFolderById(folderId) + if (!source) { + throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404) + } + const siteId = source.siteId + const destinationLocale = locale || source.locale + const requested = normalizeFolderPath(folderPath) + const sourcePath = childPathOf(source) + const ownPath = decodeTreePath(sourcePath) ?? '' + + // -> On the paths, before anything is created: a folder copied into its own subtree would be + // copying into what it is still reading from + if ( + destinationLocale === source.locale && + (requested === ownPath || requested.startsWith(`${ownPath}/`)) + ) { + throw new CustomError( + 'treeFolderIntoItself', + 'A folder cannot be copied into itself or into one of its own subfolders.', + 400 + ) + } + + /* + Read before the copy starts, and in one go: the walk below creates folders as it goes, and a + scan that ran alongside it would find them. Shallowest first, so each entry's own parent has + already been created by the time it is reached. + */ + const descendants = await WIKI.db + .select({ + id: treeTable.id, + type: treeTable.type, + folderPath: treeTable.folderPath, + fileName: treeTable.fileName, + title: treeTable.title, + // -> Cast because Drizzle types a jsonb column as `{}` until something says otherwise, and + // what this reads out of it is the folder's colour + meta: sql>`${treeTable.meta}` + }) + .from(treeTable) + .where( + and( + eq(treeTable.siteId, siteId), + eq(treeTable.locale, source.locale), + sql`${treeTable.folderPath} <@ ${sourcePath}::ltree` + ) + ) + .orderBy(sql`nlevel(${treeTable.folderPath})`, treeTable.fileName) + + // -> `createFolder` is what refuses a name already taken there, so this is also the collision + // check -- and it makes it before creating anything, including the folders the path needs + const copy = await this.createFolder({ + siteId, + locale: destinationLocale, + parentPath: requested, + pathName, + title + }) + if (source.meta?.hue) { + await this.setFolderColor({ folderId: copy.id, hue: source.meta.hue }) + } + + const copyPath = childPathOf(copy) + const newPrefix = decodeTreePath(copyPath) ?? '' + /** Where an entry under the source sits under the copy, as a slash-separated folder path. */ + const mapFolder = (path: string | null) => + `${newPrefix}${(decodeTreePath(path ?? '') ?? '').slice(ownPath.length)}` + + WIKI.logger.debug( + `Copying folder ${source.id} and ${descendants.length} descendant(s) to ${destinationLocale}:${copyPath}...` + ) + + for (const entry of descendants) { + const parentPath = mapFolder(entry.folderPath) + switch (entry.type) { + case 'folder': { + const folderCopy = await this.createFolder({ + siteId, + locale: destinationLocale, + parentPath, + pathName: entry.fileName, + title: entry.title + }) + if (entry.meta?.hue) { + await this.setFolderColor({ folderId: folderCopy.id, hue: entry.meta.hue }) + } + break + } + case 'page': { + const page = await WIKI.models.pages.getPage({ + siteId, + id: entry.id, + withContent: true, + withPassword: true + }) + if (!page) { + break + } + await WIKI.models.pages.createPage( + siteId, + { + path: parentPath ? `${parentPath}/${entry.fileName}` : entry.fileName, + locale: destinationLocale, + title: page.title, + description: page.description ?? '', + icon: page.icon ?? '', + editor: page.editor, + content: page.content ?? '', + render: page.render, + publishState: page.publishState, + publishStartDate: page.publishStartDate?.toISOString() ?? null, + publishEndDate: page.publishEndDate?.toISOString() ?? null, + isBrowsable: page.isBrowsable, + isSearchable: page.isSearchable, + password: page.password ?? '', + relations: page.relations, + tags: page.tags, + allowComments: page.allowComments, + allowContributions: page.allowContributions, + allowRatings: page.allowRatings, + showSidebar: page.showSidebar, + showTags: page.showTags + }, + actor + ) + break + } + case 'asset': { + const content = await WIKI.models.assets.getContent(entry.id) + if (!content) { + // -> Its bytes are gone, which is a broken file rather than a reason to fail the copy + WIKI.logger.warn(`Skipped copying ${entry.fileName}: it has no content.`) + break + } + await WIKI.models.assets.upload({ + siteId, + locale: destinationLocale, + folderPath: parentPath, + fileName: entry.fileName, + mimeType: content.mimeType, + data: content.data, + authorId: actor.id + }) + break + } + } + } + + WIKI.logger.debug(`Copied folder ${source.id} successfully.`) + // -> Read back rather than returned as created: the copy was an empty folder at that point, and + // answering with a child count of zero for a folder that now holds a branch is a lie the + // caller would draw + return (await this.getFolderById(copy.id)) ?? copy + } + + /** + * Colour a folder's icon, or put it back to the colour every folder starts out. + * + * Stored as a hue rotation in degrees rather than as a colour, because that is what is actually + * applied: the folder icon is one yellow image and the interface turns it around the colour wheel, + * so a wiki that restyles that icon keeps every folder's choice meaningful. Zero is therefore not a + * colour but the absence of one, and is stored by removing the key -- a folder nobody has coloured + * and one put back to yellow are the same folder. + * + * `meta` also carries the folder's child count, so this edits the one key rather than replacing the + * object: the count is maintained in postgres by whoever adds or removes an entry, and a + * read-modify-write here would lose whatever landed in between. + * + * @param hue Degrees around the colour wheel, 0 to 359. Zero clears it. + */ + async setFolderColor({ folderId, hue }: { folderId: string; hue: number }): Promise { + const folder = await this.getFolderById(folderId) + if (!folder) { + throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404) + } + if (!Number.isInteger(hue) || hue < 0 || hue > 359) { + throw new CustomError('treeInvalidHue', 'A folder colour must be a hue between 0 and 359.') + } + const updated = await WIKI.db + .update(treeTable) + .set({ + meta: hue + ? sql`jsonb_set(${treeTable.meta}, '{hue}', to_jsonb(${hue}::int))` + : sql`${treeTable.meta} - 'hue'`, + updatedAt: sql`now()` + }) + .where(eq(treeTable.id, folder.id)) + .returning() + return updated[0] as TreeRow + } + + /** + * Move a folder to another parent, another locale, or both — everything under it going along. + * + * The same rewrite a rename does, with the folder's own parent changing rather than its name, and + * with a locale that may change too. That makes it several operations at once, and the order below + * is what keeps them consistent: + * + * 1. **Nothing is created before the move is known to be legal.** The destination is resolved with + * `createIfMissing`, so a folder asked to move inside itself would otherwise leave a new folder + * behind on the way to being refused. Both refusals are therefore decided on the paths alone, + * before anything is looked up. + * 2. **Pages leave their translation sets before their locale is rewritten**, since a set holds one + * page per locale and the index enforcing it would refuse the second arrival. + * 3. **The rows move, then everything derived from them is rebuilt** — the hashes an entry is found + * by, the second copy of its path each page keeps, the sidebar each entry inherits, and the copy + * every storage target holds. Each of those reads the tree back rather than being computed from + * the move, so there is one answer to where a thing is and it is the row. + * + * A collision cannot come from below: the destination is refused if anything but a page is already + * called this there, and nothing can sit under a folder path that does not exist — so once the + * folder itself fits, every descendant does. + * + * @param folderPath Slash-separated path of the folder to move into, empty for the site root. + * Created if it does not exist. + * @param locale The locale to move it to. Stays in its own when absent. + */ + async moveFolder({ + folderId, + folderPath, + locale, + actorId + }: { + folderId: string + folderPath: string + locale?: string + /** Who is moving it, for a target that records who moved a file. */ + actorId?: string + }): Promise { + const folder = await this.getFolderById(folderId) + if (!folder) { + throw new CustomError('treeInvalidFolder', 'This folder does not exist.', 404) + } + const siteId = folder.siteId + const destinationLocale = locale || folder.locale + const oldParentPath = folder.folderPath ?? '' + const oldPath = childPathOf(folder) + const requested = normalizeFolderPath(folderPath) + const ownPath = decodeTreePath(oldPath) ?? '' + + // -> Decided on the paths, before the destination is resolved: resolving it would create it, and + // a folder moved inside itself would take its own subtree out of the tree entirely + if ( + destinationLocale === folder.locale && + (requested === ownPath || requested.startsWith(`${ownPath}/`)) + ) { + throw new CustomError( + 'treeFolderIntoItself', + 'A folder cannot be moved into itself or into one of its own subfolders.', + 400 + ) + } + + const parent = requested + ? await this.getFolder({ + path: requested, + locale: destinationLocale, + siteId, + createIfMissing: true + }) + : null + const newParentPath = parent ? childPathOf(parent) : '' + if (newParentPath === oldParentPath && destinationLocale === folder.locale) { + return folder + } + + // -> As on the way in: a page may share the name of the folder holding the pages below it, an + // asset may not, and neither may another folder + const existing = await WIKI.db + .select({ type: treeTable.type }) + .from(treeTable) + .where( + and( + ne(treeTable.id, folder.id), + eq(treeTable.siteId, siteId), + eq(treeTable.locale, destinationLocale), + eq(treeTable.folderPath, newParentPath), + eq(treeTable.fileName, folder.fileName), + ne(treeTable.type, 'page') + ) + ) + .limit(1) + if (existing.length > 0) { + throw new CustomError( + 'treeFolderDuplicate', + existing[0].type === 'folder' + ? 'A folder with this path name already exists there.' + : 'A file with this path name already exists there.', + 409 + ) + } + + const newPath = newParentPath ? `${newParentPath}.${folder.fileName}` : folder.fileName + const isLocaleChange = destinationLocale !== folder.locale + + WIKI.logger.debug( + `Moving folder ${folder.id} from ${folder.locale}:${oldPath} to ${destinationLocale}:${newPath}...` + ) + + const descendants = await WIKI.db + .select({ id: treeTable.id, type: treeTable.type }) + .from(treeTable) + .where( + and( + eq(treeTable.siteId, siteId), + eq(treeTable.locale, folder.locale), + sql`${treeTable.folderPath} <@ ${oldPath}::ltree` + ) + ) + const pageIds = descendants.filter((row) => row.type === 'page').map((row) => row.id) + + if (isLocaleChange && pageIds.length > 0) { + await WIKI.models.pages.detachFromLocaleGroups(siteId, pageIds) + } + + // -> Direct children carry the old path verbatim; deeper ones carry it as a prefix, and keep + // whatever they had below it + const movedLocale = isLocaleChange ? { locale: destinationLocale } : {} + await WIKI.db + .update(treeTable) + .set({ folderPath: newPath, ...movedLocale }) + .where( + and( + eq(treeTable.siteId, siteId), + eq(treeTable.locale, folder.locale), + eq(treeTable.folderPath, oldPath) + ) + ) + await WIKI.db + .update(treeTable) + .set({ + // -> What is dropped is however deep the OLD path was, which is what the row carries; a rename + // can use either because the two are the same depth, and a move cannot + folderPath: sql`${newPath}::ltree || subpath(${treeTable.folderPath}, nlevel(${oldPath}::ltree))`, + ...movedLocale + }) + .where( + and( + eq(treeTable.siteId, siteId), + eq(treeTable.locale, folder.locale), + sql`${treeTable.folderPath} <@ ${oldPath}::ltree` + ) + ) + if (isLocaleChange && pageIds.length > 0) { + // -> A page keeps its own copy of the locale, as it does of its path, and it is the one a + // reader's request resolves against + await WIKI.db + .update(pagesTable) + .set({ locale: destinationLocale }) + .where(and(eq(pagesTable.siteId, siteId), inArray(pagesTable.id, pageIds))) + } + + const fullPath = newParentPath + ? `${decodeTreePath(newParentPath)}/${folder.fileName}` + : folder.fileName + const updated = await WIKI.db + .update(treeTable) + .set({ + folderPath: newParentPath, + locale: destinationLocale, + hash: generateHash(fullPath), + updatedAt: sql`now()` + }) + .where(eq(treeTable.id, folder.id)) + .returning() + + const movedPages = await this.refreshDescendantPaths(siteId, destinationLocale, newPath) + + // -> Only moved, never rewritten: none of these pages changed, so the copy a target holds is + // still the right contents at the wrong place + for (const page of movedPages) { + await WIKI.models.storage.relocatePage( + { + id: page.id, + siteId, + actorId, + locale: destinationLocale, + path: page.path, + contentType: page.contentType + }, + { locale: folder.locale, path: page.previousPath } + ) + } + + // -> A storage target that lays its content out by path has every one of those files to move. + // Asked for after the rows are correct, so that where each file belongs is read off the tree + // rather than recomputed from the move. + const movedAssets = await WIKI.db + .select({ + id: treeTable.id, + folderPath: treeTable.folderPath, + fileName: treeTable.fileName + }) + .from(treeTable) + .where( + and( + eq(treeTable.siteId, siteId), + eq(treeTable.locale, destinationLocale), + eq(treeTable.type, 'asset'), + sql`${treeTable.folderPath} <@ ${newPath}::ltree` + ) + ) + const newPrefix = decodeTreePath(newPath)! + const oldPrefix = decodeTreePath(oldPath)! + await WIKI.models.assets.relocateAssets( + siteId, + movedAssets.map((row) => ({ + id: row.id, + previous: { + locale: folder.locale, + // -> Where it was: the same place it is now, with the moved folder's path put back. Sliced + // rather than replaced, since the segment that moved can occur again further down. + folderPath: `${oldPrefix}${(decodeTreePath(row.folderPath ?? '') ?? '').slice(newPrefix.length)}`, + fileName: row.fileName + } + })), + actorId + ) + + // -> Every asset under it is served from a different path now, and nothing about the assets + // themselves changed for the file cache to notice + WIKI.models.assets.forgetAllPaths() + + // -> What a sidebar is inherited from is where an entry SITS, and everything under here now sits + // somewhere else + await WIKI.models.navigation.repointMovedSubtree({ + siteId, + folderId: folder.id, + locale: destinationLocale, + folderPath: newParentPath, + fileName: folder.fileName + }) + + // -> The folder it left holds one fewer entry, and the one it arrived in holds one more + await this.countTowardsFolderAt(siteId, folder.locale, oldParentPath, -1) + await this.countTowardsFolderAt(siteId, destinationLocale, newParentPath, 1) + + WIKI.logger.debug(`Moved folder ${folder.id} successfully.`) + return updated[0] as TreeRow + } + /** * Rewrite where everything at or below a folder now sits. * @@ -1008,11 +1527,17 @@ class Tree { * from here. What is deliberately not touched is `updatedAt`: the folder moved, the pages under it * did not change, and marking a few hundred of them as freshly edited would say otherwise. * + * Scoped to ONE LOCALE, and every caller has to say which. The tree holds every translation side by + * side under the same paths, so `folderPath <@ 'guides'` is the English subtree and the French one + * and every other — and a rewrite that took them all in would report another locale's pages as + * having moved, sending a storage target to move files that never went anywhere. + * * @returns Where each page moved from and to, for the copies a storage target keeps of them. The * old path is only knowable from here — a moment later the row no longer says where it was. */ private async refreshDescendantPaths( siteId: string, + locale: string, path: string ): Promise< { id: string; locale: string; previousPath: string; path: string; contentType: string }[] @@ -1029,7 +1554,13 @@ class Tree { }) .from(treeTable) .leftJoin(pagesTable, eq(pagesTable.id, treeTable.id)) - .where(and(eq(treeTable.siteId, siteId), sql`${treeTable.folderPath} <@ ${path}::ltree`)) + .where( + and( + eq(treeTable.siteId, siteId), + eq(treeTable.locale, locale), + sql`${treeTable.folderPath} <@ ${path}::ltree` + ) + ) const movedPages = [] for (const row of rows) { @@ -1076,12 +1607,22 @@ class Tree { const path = childPathOf(folder) WIKI.logger.debug(`Deleting folder ${folder.id} at path ${path}...`) - // -> `<@` is "at or below", and the folder itself is not under its own child path, so this takes - // the descendants and leaves the row that owns them + /* + `<@` is "at or below", and the folder itself is not under its own child path, so this takes the + descendants and leaves the row that owns them. + + Scoped to this folder's locale, as the rename and the move beside it are: the tree holds every + translation side by side under the same paths, so without it deleting the English `/guides` + took the French one's pages and files with it and left its folder rows behind, empty. + */ const deleted = await WIKI.db .delete(treeTable) .where( - and(eq(treeTable.siteId, folder.siteId), sql`${treeTable.folderPath} <@ ${path}::ltree`) + and( + eq(treeTable.siteId, folder.siteId), + eq(treeTable.locale, folder.locale), + sql`${treeTable.folderPath} <@ ${path}::ltree` + ) ) .returning({ id: treeTable.id, @@ -1096,7 +1637,7 @@ class Tree { // -> Any of them may have owned a sidebar menu keyed by its own id, the folder included await WIKI.models.navigation.deleteNavForEntries([...deleted.map((n) => n.id), folder.id]) - await this.countTowardsFolderAt(folder.siteId, folder.folderPath ?? '', -1) + await this.countTowardsFolderAt(folder.siteId, folder.locale, folder.folderPath ?? '', -1) WIKI.logger.debug(`Deleted folder ${folder.id} and ${deleted.length} descendant(s).`) @@ -1272,7 +1813,7 @@ class Tree { return false } await WIKI.db.delete(treeTable).where(eq(treeTable.id, id)) - await this.countTowardsFolderAt(entry.siteId, entry.folderPath ?? '', -1) + await this.countTowardsFolderAt(entry.siteId, entry.locale, entry.folderPath ?? '', -1) return true } @@ -1342,7 +1883,7 @@ class Tree { }) .returning() - await this.countTowardsFolderAt(siteId, path, 1) + await this.countTowardsFolderAt(siteId, locale, path, 1) return inserted[0] as TreeRow } @@ -1429,7 +1970,12 @@ class Tree { * * An empty path is the site root, which is not a folder and has nothing to count. */ - private async countTowardsFolderAt(siteId: string, path: string, delta: number): Promise { + private async countTowardsFolderAt( + siteId: string, + locale: string, + path: string, + delta: number + ): Promise { if (!path) { return } @@ -1442,6 +1988,10 @@ class Tree { .where( and( eq(treeTable.siteId, siteId), + // -> The tree holds every translation side by side, so a path names one folder PER LOCALE: + // without this, adding a file to the English `/guides` also counted it against the French + // one, and a folder that moved between locales decremented a folder it never sat in + eq(treeTable.locale, locale), eq(treeTable.folderPath, location.folderPath), eq(treeTable.fileName, location.fileName), eq(treeTable.type, 'folder') diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index dd0dfeb4c..dc6c5b5c5 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -5,7 +5,7 @@ never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or removing an icon; `check-icons.mjs` fails the build if this drifts. - 270 icons. + 272 icons. */ export const BUNDLED_ICONS = { "la:angle-right": {"body":"","width":32,"height":32}, @@ -110,6 +110,8 @@ export const BUNDLED_ICONS = { "la:redo-alt": {"body":"","width":32,"height":32}, "la:ruler-vertical": {"body":"","width":32,"height":32}, "la:search": {"body":"","width":32,"height":32}, + "la:search-minus": {"body":"","width":32,"height":32}, + "la:search-plus": {"body":"","width":32,"height":32}, "la:server": {"body":"","width":32,"height":32}, "la:share": {"body":"","width":32,"height":32}, "la:sign-in-alt": {"body":"","width":32,"height":32}, diff --git a/frontend/src/components/AssetPreviewDialog.vue b/frontend/src/components/AssetPreviewDialog.vue new file mode 100644 index 000000000..87bb6c1ab --- /dev/null +++ b/frontend/src/components/AssetPreviewDialog.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/frontend/src/components/AssetRenameDialog.vue b/frontend/src/components/AssetRenameDialog.vue deleted file mode 100644 index 010dde7f4..000000000 --- a/frontend/src/components/AssetRenameDialog.vue +++ /dev/null @@ -1,140 +0,0 @@ - - - diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index 198f4bd0d..07822f61f 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -124,12 +124,21 @@