From cda217ac76643a2cd182f46d9893bb4803d6d994 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Tue, 9 Sep 2025 19:14:38 +0200 Subject: [PATCH] Enhance StructureView to normalize folder paths and improve page assignment logic --- .../components/Contents/StructureView.tsx | 122 ++++++++++++------ src/dashboardWebView/models/Page.ts | 7 +- src/services/PagesParser.ts | 2 + 3 files changed, 87 insertions(+), 44 deletions(-) diff --git a/src/dashboardWebView/components/Contents/StructureView.tsx b/src/dashboardWebView/components/Contents/StructureView.tsx index 819f22a8..30b9c083 100644 --- a/src/dashboardWebView/components/Contents/StructureView.tsx +++ b/src/dashboardWebView/components/Contents/StructureView.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { useMemo } from 'react'; import { Page } from '../../models'; import { StructureItem } from './StructureItem'; +import { parseWinPath } from '../../../helpers/parseWinPath'; export interface IStructureViewProps { pages: Page[]; @@ -19,7 +20,7 @@ interface FolderNode { export const StructureView: React.FunctionComponent = ({ pages }: React.PropsWithChildren) => { - + const folderTree = useMemo(() => { const root: FolderNode = { name: '', @@ -31,23 +32,63 @@ export const StructureView: React.FunctionComponent = ({ const folderMap = new Map(); folderMap.set('', root); - // First pass: create all folder nodes - pages.forEach(page => { + // Helper to compute the normalized folder path for a page. + // It ensures the page's folder starts with the `fmFolder` segment and + // preserves any subpaths after that segment (so subfolders are created). + const computeNormalizedFolderPath = (page: Page): string => { if (!page.fmFolder) { - return; + return ''; } - - const folderPath = page.fmFolder; - // Normalize path separators and remove leading/trailing slashes - const normalizedPath = folderPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + + const fmFolder = page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + + // If we have a file path, use its directory (exclude the filename) to compute + // the relative path. This avoids treating filenames as folder segments. + const filePath = page.fmFilePath ? parseWinPath(page.fmFilePath).replace(/^\/+|\/+$/g, '') : ''; + const fileDir = filePath && filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')).replace(/^\/+|\/+$/g, '') : ''; + + if (fileDir) { + // If the content folder is known, and the file directory starts with it, + // replace that root with the fmFolder (preserving subfolders after it). + if (page.fmPageFolder?.path) { + const contentFolderPath = parseWinPath(page.fmPageFolder.path).replace(/^\/+|\/+$/g, ''); + if (fileDir.startsWith(contentFolderPath)) { + const rel = fileDir.substring(contentFolderPath.length).replace(/^\/+|\/+$/g, ''); + return rel ? `${fmFolder}/${rel}` : fmFolder; + } + } + + // Otherwise try to find fmFolder as a directory segment in the fileDir + const segments = fileDir.split('/').filter(Boolean); + const fmIndex = segments.indexOf(fmFolder); + if (fmIndex >= 0) { + return segments.slice(fmIndex).join('/'); + } + } + + // Fallback: just use the fmFolder name + return fmFolder; + }; + + // First pass: create all folder nodes (ensure nodes exist even if a page lacks fmFilePath) + for (const page of pages) { + if (!page.fmFolder) { + continue; + } + + const normalizedPath = computeNormalizedFolderPath(page).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + if (!normalizedPath) { + continue; + } + const parts = normalizedPath.split('/').filter(part => part.length > 0); - + let currentPath = ''; let currentNode = root; - - parts.forEach(part => { + + for (const part of parts) { const fullPath = currentPath ? `${currentPath}/${part}` : part; - + if (!folderMap.has(fullPath)) { const newNode: FolderNode = { name: part, @@ -58,31 +99,31 @@ export const StructureView: React.FunctionComponent = ({ folderMap.set(fullPath, newNode); currentNode.children.push(newNode); } - + const nextNode = folderMap.get(fullPath); if (nextNode) { currentNode = nextNode; } currentPath = fullPath; - }); - }); + } + } - // Second pass: assign pages to their folders - pages.forEach(page => { + // Second pass: assign pages to their exact folder node (including subfolders) + for (const page of pages) { if (!page.fmFolder) { root.pages.push(page); - } else { - // Normalize the folder path for lookup - const normalizedPath = page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); - const folderNode = folderMap.get(normalizedPath); - if (folderNode) { - folderNode.pages.push(page); - } else { - // If folder not found, add to root as fallback - root.pages.push(page); - } + continue; } - }); + + const normalizedPath = computeNormalizedFolderPath(page).replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + const folderNode = normalizedPath ? folderMap.get(normalizedPath) : folderMap.get(page.fmFolder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')); + if (folderNode) { + folderNode.pages.push(page); + } else { + // If folder not found, add to root as fallback + root.pages.push(page); + } + } // Sort folders and pages const sortNode = (node: FolderNode) => { @@ -90,15 +131,15 @@ export const StructureView: React.FunctionComponent = ({ node.pages.sort((a, b) => a.title.localeCompare(b.title)); node.children.forEach(sortNode); }; - + sortNode(root); - + return root; }, [pages]); const renderFolderNode = (node: FolderNode, depth = 0): React.ReactNode => { const hasContent = node.pages.length > 0 || node.children.length > 0; - + if (!hasContent) { return null; } @@ -110,6 +151,9 @@ export const StructureView: React.FunctionComponent = ({ // For root node, render children and pages directly return (
+ {/* Root level folders */} + {node.children.map(child => renderFolderNode(child, depth + 1))} + {/* Root level pages */} {node.pages.length > 0 && (
@@ -125,9 +169,6 @@ export const StructureView: React.FunctionComponent = ({
)} - - {/* Root level folders */} - {node.children.map(child => renderFolderNode(child, depth + 1))}
); } @@ -137,14 +178,13 @@ export const StructureView: React.FunctionComponent = ({ {({ open }) => ( <> - @@ -158,6 +198,9 @@ export const StructureView: React.FunctionComponent = ({ + {/* Child folders */} + {node.children.map(child => renderFolderNode(child, depth + 1))} + {/* Pages in this folder */} {node.pages.length > 0 && (
    @@ -168,9 +211,6 @@ export const StructureView: React.FunctionComponent = ({ ))}
)} - - {/* Child folders */} - {node.children.map(child => renderFolderNode(child, depth + 1))}
)} diff --git a/src/dashboardWebView/models/Page.ts b/src/dashboardWebView/models/Page.ts index 034bdb66..29e784c0 100644 --- a/src/dashboardWebView/models/Page.ts +++ b/src/dashboardWebView/models/Page.ts @@ -1,4 +1,4 @@ -import { I18nConfig } from '../../models'; +import { ContentFolder, I18nConfig } from '../../models'; export interface Page { // Properties for caching @@ -20,15 +20,16 @@ export interface Page { fmCategories: string[]; fmContentType: string; fmDateFormat: string | undefined; + fmPageFolder: ContentFolder | undefined; // i18n fields fmDefaultLocale?: boolean; fmLocale?: I18nConfig; - fmTranslations?: { + fmTranslations?: { [locale: string]: { locale: I18nConfig; path: string; - } + }; }; title: string; diff --git a/src/services/PagesParser.ts b/src/services/PagesParser.ts index bd2e339f..8813ecdd 100644 --- a/src/services/PagesParser.ts +++ b/src/services/PagesParser.ts @@ -219,6 +219,7 @@ export class PagesParser { const isDefaultLanguage = await i18n.isDefaultLanguage(filePath); const locale = await i18n.getLocale(filePath); const translations = await i18n.getTranslations(filePath); + const pageFolder = await Folders.getPageFolderByFilePath(filePath); const page: Page = { ...article.data, @@ -241,6 +242,7 @@ export class PagesParser { fmContentType: contentType.name || DEFAULT_CONTENT_TYPE_NAME, fmBody: article?.content || '', fmDateFormat: dateFormat, + fmPageFolder: pageFolder, // i18n properties fmDefaultLocale: isDefaultLanguage, fmLocale: locale,