From 1f381dc5064eda92d83076f09406ec0f949a0511 Mon Sep 17 00:00:00 2001 From: Adam Matthiesen Date: Sun, 22 Dec 2024 07:42:24 -0800 Subject: [PATCH] some basic folder-like based routing utils to get started on that plan --- .../src/sdk-utils/StudioCMSSDK.ts | 173 +++++++++++++----- .../src/sdk-utils/StudioCMSVirtualCache.ts | 120 +++++++++++- .../src/sdk-utils/types/index.ts | 4 + 3 files changed, 250 insertions(+), 47 deletions(-) diff --git a/packages/studiocms_core/src/sdk-utils/StudioCMSSDK.ts b/packages/studiocms_core/src/sdk-utils/StudioCMSSDK.ts index 87d6bd80e..e834709c8 100644 --- a/packages/studiocms_core/src/sdk-utils/StudioCMSSDK.ts +++ b/packages/studiocms_core/src/sdk-utils/StudioCMSSDK.ts @@ -70,7 +70,6 @@ export class StudioCMSSDK { private db: AstroDBVirtualModule['db']; private and: AstroDBVirtualModule['and']; private eq: AstroDBVirtualModule['eq']; - private readonly FolderStructureRootId = '__StudioCMS_Root_Folder__'; constructor(AstroDB: AstroDBVirtualModule) { this.db = AstroDB.db; @@ -104,7 +103,7 @@ export class StudioCMSSDK { * @param folders - An array of folder data to build the folder structure from. * @returns An array of folder nodes representing the folder structure. */ - public buildFolderStructure(folders: tsPageFolderSelect[]): FolderNode[] { + public generateFolderTree(folders: tsPageFolderSelect[]): FolderNode[] { // Create a lookup table const folderMap: Record = {}; @@ -139,49 +138,123 @@ export class StudioCMSSDK { * * @returns A promise that resolves to an array of folder nodes representing the folder structure. */ - public async getFolderStructure(): Promise { + public async buildFolderTree(): Promise { const currentFolders = await this.db.select().from(tsPageFolderStructure); - return this.buildFolderStructure(currentFolders); + return this.generateFolderTree(currentFolders); } /** - * Gets the URL of a page based on the provided page data. - * - * @param page - The page data to get the URL for. - * @returns A promise that resolves to the URL of the page. + * Finds a node in the tree that matches the given URL path. + * @param tree - The root of the folder tree. + * @param path - The URL path to locate. + * @returns The matching node or null if not found. */ - public async getPageUrl(page: tsPageDataSelect): Promise { - const slug = page.slug; - const folderStructure = await this.getFolderStructure(); - const folder = folderStructure.find( - (folder) => folder.id === page.parentFolder || this.FolderStructureRootId - ); - - if (!folder || folder.id === this.FolderStructureRootId) { - return slug; + public findNodeByPath(tree: FolderNode[], path: string[]): FolderNode | null { + if (path.length === 0) return null; + + const [current, ...rest] = path; + + for (const node of tree) { + if (node.name === current) { + if (rest.length === 0) return node; + return this.findNodeByPath(node.children, rest); + } } - return `${folder.name}/${slug}`; + return null; } - public getSlugFromUrl(url: string): string { - if (typeof url !== 'string' || !url.trim()) { - throw new StudioCMS_SDK_Error('Error getting slug from URL: URL must be a non-empty string.'); + /** + * Finds all nodes along the path to a specific node by its ID. + * @param tree - The root of the folder tree. + * @param id - The ID of the target node. + * @returns An array of nodes along the path or an empty array if the node is not found. + */ + public findNodesAlongPathToId(tree: FolderNode[], id: string): FolderNode[] { + const path: FolderNode[] = []; + + function helper(nodes: FolderNode[], targetId: string): boolean { + for (const node of nodes) { + path.push(node); // Add the current node to the path + + if (node.id === targetId) { + return true; // Target found, stop recursion + } + + if (helper(node.children, targetId)) { + return true; // Target found in descendants, propagate success + } + + path.pop(); // Backtrack if target is not found in this branch + } + + return false; // Target not found in this branch } - // Normalize and trim slashes - const trimmedUrl = url.replace(/^\/+|\/+$/g, ''); + helper(tree, id); + return path; + } - // Extract the last segment - const slug = trimmedUrl.split('/').pop(); + /** + * Finds the full path to a node based on its URL. + * @param tree - The root of the folder tree. + * @param path - The URL path to locate. + * @returns The full path as an array of node names. + */ + public getFullPath(tree: FolderNode[], path: string[]): string[] { + const result: string[] = []; - if (!slug) { - throw new StudioCMS_SDK_Error( - 'Error getting slug from URL: No slug found in the provided URL.' - ); + function helper(nodes: FolderNode[], pathParts: string[]): boolean { + if (pathParts.length === 0) return false; + + const [current, ...rest] = pathParts; + + for (const node of nodes) { + if (node.name === current) { + result.push(node.name); + if (rest.length === 0 || helper(node.children, rest)) { + return true; + } + result.pop(); // Backtrack if not found + } + } + + return false; + } + + helper(tree, path); + return result; + } + + /** + * Finds all nodes along the path to a given URL. + * @param tree - The root of the folder tree. + * @param path - The URL path to locate. + * @returns The nodes along the path. + */ + public findNodesAlongPath(tree: FolderNode[], path: string[]): FolderNode[] { + const result: FolderNode[] = []; + + function helper(nodes: FolderNode[], pathParts: string[]): boolean { + if (pathParts.length === 0) return false; + + const [current, ...rest] = pathParts; + + for (const node of nodes) { + if (node.name === current) { + result.push(node); + if (rest.length === 0 || helper(node.children, rest)) { + return true; + } + result.pop(); // Backtrack if not found + } + } + + return false; } - return slug; + helper(tree, path); + return result; } /** @@ -249,7 +322,10 @@ export class StudioCMSSDK { * @returns A promise that resolves to the combined page data. * @throws {StudioCMS_SDK_Error} If an error occurs while collecting page data. */ - public async collectPageData(page: tsPageDataSelect): Promise { + public async collectPageData( + page: tsPageDataSelect, + tree: FolderNode[] + ): Promise { try { const categoryIds = this.parseIdNumberArray(page.categories || []); const categories = await this.collectCategories(categoryIds); @@ -268,16 +344,23 @@ export class StudioCMSSDK { (content) => content.contentLang === page.contentLang ); - const urlRoute = await this.getPageUrl(page); + const safeSlug = page.slug === 'index' ? '/' : `/${page.slug}`; + + let urlRoute = safeSlug; + + if (page.parentFolder) { + const urlParts = this.findNodesAlongPathToId(tree, page.parentFolder); + urlRoute = urlParts.map((part) => part.name).join('/') + safeSlug; + } return { ...page, + urlRoute, categories, tags, contributorIds, multiLangContent: multiLanguageContentData, defaultContent: defaultLanguageContentData, - urlRoute, }; } catch (error) { if (error instanceof Error) { @@ -881,14 +964,15 @@ export class StudioCMSSDK { * @returns A promise that resolves to an array of combined page data. * @throws {StudioCMS_SDK_Error} If an error occurs while getting the pages. */ - pages: async (): Promise => { + pages: async (tree?: FolderNode[]): Promise => { try { const pages: CombinedPageData[] = []; const pagesRaw = await this.db.select().from(tsPageData); + const folders = tree || (await this.buildFolderTree()); for (const page of pagesRaw) { - const PageData = await this.collectPageData(page); + const PageData = await this.collectPageData(page, folders); pages.push(PageData); } @@ -1036,7 +1120,7 @@ export class StudioCMSSDK { * @returns A promise that resolves to the page data. * @throws {StudioCMS_SDK_Error} If an error occurs while getting the page. */ - byId: async (id: string): Promise => { + byId: async (id: string, tree?: FolderNode[]): Promise => { try { const page = await this.db .select() @@ -1045,8 +1129,9 @@ export class StudioCMSSDK { .get(); if (!page) return undefined; + const folders = tree || (await this.buildFolderTree()); - return await this.collectPageData(page); + return await this.collectPageData(page, folders); } catch (error) { if (error instanceof Error) { throw new StudioCMS_SDK_Error( @@ -1066,7 +1151,11 @@ export class StudioCMSSDK { * @returns A promise that resolves to the page data. * @throws {StudioCMS_SDK_Error} If an error occurs while getting the page. */ - bySlug: async (slug: string, pkg?: string): Promise => { + bySlug: async ( + slug: string, + pkg?: string, + tree?: FolderNode[] + ): Promise => { try { const pkgToGet = pkg || 'studiocms'; @@ -1079,8 +1168,9 @@ export class StudioCMSSDK { .get(); if (!page) return undefined; + const folders = tree || (await this.buildFolderTree()); - return await this.collectPageData(page); + return await this.collectPageData(page, folders); } catch (error) { if (error instanceof Error) { throw new StudioCMS_SDK_Error( @@ -1315,7 +1405,7 @@ export class StudioCMSSDK { /** * Retrieves data from the database by package. */ - packagePages: async (packageName: string): Promise => { + packagePages: async (packageName: string, tree?: FolderNode[]): Promise => { try { const pages: CombinedPageData[] = []; @@ -1323,9 +1413,10 @@ export class StudioCMSSDK { .select() .from(tsPageData) .where(this.eq(tsPageData.package, packageName)); + const folders = tree || (await this.buildFolderTree()); for (const page of pagesRaw) { - const PageData = await this.collectPageData(page); + const PageData = await this.collectPageData(page, folders); pages.push(PageData); } diff --git a/packages/studiocms_core/src/sdk-utils/StudioCMSVirtualCache.ts b/packages/studiocms_core/src/sdk-utils/StudioCMSVirtualCache.ts index 091ddc4d3..1f5345bdd 100644 --- a/packages/studiocms_core/src/sdk-utils/StudioCMSVirtualCache.ts +++ b/packages/studiocms_core/src/sdk-utils/StudioCMSVirtualCache.ts @@ -3,6 +3,8 @@ import { StudioCMSCacheError } from './errors'; import type { BaseCacheObject, CombinedPageData, + FolderNode, + FolderTreeCacheObject, PageDataCacheObject, ProcessedCacheConfig, STUDIOCMS_SDK, @@ -28,6 +30,7 @@ import type { export class StudioCMSVirtualCache { private readonly SiteConfigMapID: string = '__StudioCMS_Site_Config'; private readonly VersionMapID: string = '__StudioCMS_Latest_Version'; + private readonly FolderTreeMapID: string = '__StudioCMS_Folder_Tree'; private readonly StudioCMSPkgId: string = 'studiocms'; private readonly CMSSiteConfigId = CMSSiteConfigId; private readonly versionCacheLifetime = versionCacheLifetime; @@ -38,6 +41,7 @@ export class StudioCMSVirtualCache { private pages = new Map(); private siteConfig = new Map(); private version = new Map(); + private folderTree = new Map(); constructor(cacheConfig: ProcessedCacheConfig, sdkCore: STUDIOCMS_SDK) { this.cacheConfig = cacheConfig; @@ -121,6 +125,97 @@ export class StudioCMSVirtualCache { }; } + /** + * Returns a FolderTreeCacheObject containing the provided folder tree data and the current date as the last cache update. + * + * @param data - The folder tree data to be cached. + * @returns An object containing the provided data and the current date as the last cache update. + */ + private folderTreeReturn(data: FolderNode[]): FolderTreeCacheObject { + return { + data, + lastCacheUpdate: new Date(), + }; + } + + // Folder Tree Utils + + /** + * Retrieves the folder tree from the cache or the database. + * + * @returns {Promise} A promise that resolves to the folder tree. + * @throws {StudioCMSCacheError} If the folder tree is not found in the database or if there is an error fetching the folder tree. + */ + public async getFolderTree(): Promise { + try { + if (!this.isEnabled()) { + const folderTree = await this.sdk.buildFolderTree(); + + if (!folderTree) { + throw new StudioCMSCacheError('Folder tree not found in database'); + } + + return this.folderTreeReturn(folderTree); + } + + const tree = this.folderTree.get(this.FolderTreeMapID); + + if (!tree || this.isCacheExpired(tree)) { + const folderTree = await this.sdk.buildFolderTree(); + + if (!folderTree) { + throw new StudioCMSCacheError('Folder tree not found in database'); + } + + this.folderTree.set(this.FolderTreeMapID, this.folderTreeReturn(folderTree)); + + return this.folderTreeReturn(folderTree); + } + + return tree; + } catch (error) { + throw new StudioCMSCacheError('Error fetching folder tree'); + } + } + + /** + * Updates the folder tree in the cache and database. + * + * @returns {Promise} A promise that resolves to the updated folder tree. + * @throws {StudioCMSCacheError} If there is an error updating the folder tree. + */ + public async updateFolderTree(): Promise { + try { + const folderTree = await this.sdk.buildFolderTree(); + + if (!this.isEnabled()) { + return this.folderTreeReturn(folderTree); + } + + this.folderTree.set(this.FolderTreeMapID, this.folderTreeReturn(folderTree)); + + return this.folderTreeReturn(folderTree); + } catch (error) { + throw new StudioCMSCacheError('Error updating folder tree'); + } + } + + /** + * Clears the folder tree from the cache. + */ + public clearFolderTree(): void { + // Check if caching is disabled + if (!this.isEnabled()) { + return; + } + + // Clear the folder tree cache + this.folderTree.clear(); + + // Return void + return; + } + // Version Utils /** @@ -370,10 +465,12 @@ export class StudioCMSVirtualCache { return pages.map((page) => this.pageDataReturn(page)); } + const { data: tree } = await this.getFolderTree(); + // Check if the cache is empty if (this.pages.size === 0) { // Retrieve the data from the database - const updatedData = await this.sdk.GET.database.pages(); + const updatedData = await this.sdk.GET.database.pages(tree); // Check if the data was retrieved successfully if (!updatedData) { @@ -396,7 +493,7 @@ export class StudioCMSVirtualCache { for (const object of cacheMap) { // Check if the cache is expired if (this.isCacheExpired(object)) { - const updatedData = await this.sdk.GET.databaseEntry.pages.byId(object.data.id); + const updatedData = await this.sdk.GET.databaseEntry.pages.byId(object.data.id, tree); if (!updatedData) { throw new StudioCMSCacheError('Cache is expired and could not be updated.'); @@ -433,12 +530,14 @@ export class StudioCMSVirtualCache { return this.pageDataReturn(page); } + const { data: tree } = await this.getFolderTree(); + // Retrieve the cached page const cachedPage = this.pages.get(id); // Check if the page is not cached or the cache is expired if (!cachedPage || this.isCacheExpired(cachedPage)) { - const page = await this.sdk.GET.databaseEntry.pages.byId(id); + const page = await this.sdk.GET.databaseEntry.pages.byId(id, tree); if (!page) { throw new StudioCMSCacheError('Page not found in database'); @@ -479,6 +578,8 @@ export class StudioCMSVirtualCache { return this.pageDataReturn(page); } + const { data: tree } = await this.getFolderTree(); + // Retrieve the cached page const cachedPage = Array.from(this.pages.values()).find( (page) => page.data.slug === slug && page.data.package === pkg @@ -486,7 +587,7 @@ export class StudioCMSVirtualCache { // Check if the page is not cached or the cache is expired if (!cachedPage || this.isCacheExpired(cachedPage)) { - const page = await this.sdk.GET.databaseEntry.pages.bySlug(slug, pkg); + const page = await this.sdk.GET.databaseEntry.pages.bySlug(slug, pkg, tree); if (!page) { throw new StudioCMSCacheError('Page not found in database'); @@ -538,12 +639,14 @@ export class StudioCMSVirtualCache { return this.pageDataReturn(updatedData); } + const { data: tree } = await this.updateFolderTree(); + // Update the page in the database await this.sdk.UPDATE.page(data.pageData); await this.sdk.UPDATE.pageContent(data.pageContent); // Retrieve the updated data - const updatedData = await this.sdk.GET.databaseEntry.pages.byId(id); + const updatedData = await this.sdk.GET.databaseEntry.pages.byId(id, tree); if (!updatedData) { throw new StudioCMSCacheError('Page not found in database'); @@ -594,6 +697,8 @@ export class StudioCMSVirtualCache { return this.pageDataReturn(updatedData); } + const { data: tree } = await this.updateFolderTree(); + // Retrieve the cached page const cachedPage = Array.from(this.pages.values()).find( (page) => page.data.slug === slug && page.data.package === pkg @@ -609,7 +714,7 @@ export class StudioCMSVirtualCache { await this.sdk.UPDATE.pageContent(data.pageContent); // Retrieve the updated data - const updatedData = await this.sdk.GET.databaseEntry.pages.bySlug(slug, pkg); + const updatedData = await this.sdk.GET.databaseEntry.pages.bySlug(slug, pkg, tree); // Check if the data was returned successfully if (!updatedData) { @@ -639,6 +744,7 @@ export class StudioCMSVirtualCache { pages: async () => await this.getAllPages(), siteConfig: async () => await this.getSiteConfig(), latestVersion: async () => await this.getVersion(), + folderTree: async () => await this.getFolderTree(), }, CLEAR: { page: { @@ -647,6 +753,7 @@ export class StudioCMSVirtualCache { }, pages: () => this.clearAllPages(), latestVersion: () => this.clearVersion(), + folderTree: () => this.clearFolderTree(), }, UPDATE: { page: { @@ -662,6 +769,7 @@ export class StudioCMSVirtualCache { }, siteConfig: async (data: SiteConfig) => await this.updateSiteConfig(data), latestVersion: async () => await this.updateVersion(), + folderTree: async () => await this.updateFolderTree(), }, }; } diff --git a/packages/studiocms_core/src/sdk-utils/types/index.ts b/packages/studiocms_core/src/sdk-utils/types/index.ts index 46d549081..b24e59c84 100644 --- a/packages/studiocms_core/src/sdk-utils/types/index.ts +++ b/packages/studiocms_core/src/sdk-utils/types/index.ts @@ -170,6 +170,10 @@ export interface VersionCacheObject extends BaseCacheObject { version: string; } +export interface FolderTreeCacheObject extends BaseCacheObject { + data: FolderNode[]; +} + /** * Represents a cache object that stores pages and site configuration data. */