fix: menu editor crashed category meta data

This commit is contained in:
2026-02-22 07:36:27 +01:00
parent 9dacd6fca5
commit 0d86fe1c9d
7 changed files with 196 additions and 44 deletions

View File

@@ -172,7 +172,11 @@ function parseOutlineNode(node: OpmlOutlineNode): MenuItemData {
: rawType === 'home' : rawType === 'home'
? 'home' ? 'home'
: 'page'; : 'page';
const title = normalizeNonEmptyString(node['@_text']) || normalizeNonEmptyString(node['@_title']) || 'Untitled'; const textTitle = normalizeNonEmptyString(node['@_text']);
const explicitTitle = normalizeNonEmptyString(node['@_title']);
const title = kind === 'category-archive'
? explicitTitle || textTitle || 'Untitled'
: textTitle || explicitTitle || 'Untitled';
return { return {
id: normalizeNonEmptyString(node['@_id']) || generateMenuItemId(), id: normalizeNonEmptyString(node['@_id']) || generateMenuItemId(),

View File

@@ -2,11 +2,17 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import '../TagInput/TagInput.css'; import '../TagInput/TagInput.css';
import './CategoryInput.css'; import './CategoryInput.css';
export interface CategoryOption {
name: string;
title: string;
}
interface CategoryInputProps { interface CategoryInputProps {
categories: string[]; categories: CategoryOption[];
onSelectCategory: (categoryName: string) => void; onSelectCategory: (category: CategoryOption) => void;
placeholder?: string; placeholder?: string;
createCategoryArchiveLabel: string; createCategoryArchiveLabel: string;
allowCreate?: boolean;
disabled?: boolean; disabled?: boolean;
autoFocus?: boolean; autoFocus?: boolean;
inlinePlain?: boolean; inlinePlain?: boolean;
@@ -17,6 +23,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
onSelectCategory, onSelectCategory,
placeholder = '', placeholder = '',
createCategoryArchiveLabel, createCategoryArchiveLabel,
allowCreate = true,
disabled = false, disabled = false,
autoFocus = false, autoFocus = false,
inlinePlain = false, inlinePlain = false,
@@ -34,7 +41,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
const query = inputValue.toLowerCase().trim(); const query = inputValue.toLowerCase().trim();
return categories return categories
.filter((categoryName) => categoryName.toLowerCase().includes(query)) .filter((category) => category.title.toLowerCase().includes(query) || category.name.toLowerCase().includes(query))
.slice(0, 8); .slice(0, 8);
}, [categories, inputValue]); }, [categories, inputValue]);
@@ -62,23 +69,33 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [autoFocus, disabled]); }, [autoFocus, disabled]);
const createArchive = (label: string): void => { const selectCategory = (category: CategoryOption): void => {
const trimmed = label.trim(); onSelectCategory(category);
if (!trimmed) { setInputValue('');
setShowSuggestions(false);
setSelectedIndex(-1);
};
const createArchiveFromInput = (label: string): void => {
const trimmedName = label.trim();
if (!trimmedName) {
return; return;
} }
onSelectCategory(trimmed); onSelectCategory({
name: trimmedName,
title: trimmedName,
});
setInputValue(''); setInputValue('');
setShowSuggestions(false); setShowSuggestions(false);
setSelectedIndex(-1); setSelectedIndex(-1);
}; };
const exactMatchExists = inputValue.trim() const exactMatchExists = inputValue.trim()
? suggestions.some((item) => item.toLowerCase() === inputValue.trim().toLowerCase()) ? suggestions.some((item) => item.title.toLowerCase() === inputValue.trim().toLowerCase() || item.name.toLowerCase() === inputValue.trim().toLowerCase())
: false; : false;
const showCreateOption = inputValue.trim() && !exactMatchExists; const showCreateOption = allowCreate && Boolean(inputValue.trim()) && !exactMatchExists;
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => { const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
@@ -97,15 +114,18 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
if (selectedIndex >= 0 && selectedIndex < suggestions.length) { if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
createArchive(suggestions[selectedIndex]); selectCategory(suggestions[selectedIndex]);
} else if (selectedIndex === suggestions.length && showCreateOption) { } else if (selectedIndex === suggestions.length && showCreateOption) {
createArchive(inputValue); createArchiveFromInput(inputValue);
} else { } else {
const exactMatch = categories.find((categoryName) => categoryName.toLowerCase() === inputValue.trim().toLowerCase()); const exactMatch = categories.find((category) => {
const query = inputValue.trim().toLowerCase();
return category.name.toLowerCase() === query || category.title.toLowerCase() === query;
});
if (exactMatch) { if (exactMatch) {
createArchive(exactMatch); selectCategory(exactMatch);
} else if (inputValue.trim()) { } else if (allowCreate && inputValue.trim()) {
createArchive(inputValue); createArchiveFromInput(inputValue);
} }
} }
return; return;
@@ -144,14 +164,14 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
{showSuggestions && (suggestions.length > 0 || showCreateOption) && ( {showSuggestions && (suggestions.length > 0 || showCreateOption) && (
<div className="tag-suggestions"> <div className="tag-suggestions">
{suggestions.map((categoryName, index) => ( {suggestions.map((category, index) => (
<button <button
key={categoryName} key={category.name}
type="button" type="button"
className={`tag-suggestion ${selectedIndex === index ? 'selected' : ''}`} className={`tag-suggestion ${selectedIndex === index ? 'selected' : ''}`}
onClick={() => createArchive(categoryName)} onClick={() => selectCategory(category)}
> >
<span className="tag-suggestion-name">{categoryName}</span> <span className="tag-suggestion-name">{category.title}</span>
</button> </button>
))} ))}
@@ -159,7 +179,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
<button <button
type="button" type="button"
className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`} className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`}
onClick={() => createArchive(inputValue)} onClick={() => createArchiveFromInput(inputValue)}
> >
<span className="tag-suggestion-icon">+</span> <span className="tag-suggestion-icon">+</span>
<span>{createCategoryArchiveLabel}</span> <span>{createCategoryArchiveLabel}</span>

View File

@@ -1 +1,2 @@
export { CategoryInput } from './CategoryInput'; export { CategoryInput } from './CategoryInput';
export type { CategoryOption } from './CategoryInput';

View File

@@ -1,10 +1,13 @@
.menu-editor-view { .menu-editor-view {
padding: 1rem; padding: 1rem;
height: 100%; height: 100%;
flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
position: relative; position: relative;
overflow: hidden;
} }
.menu-editor-header { .menu-editor-header {
@@ -33,6 +36,7 @@
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
overflow: hidden;
} }
.menu-editor-tree-wrap { .menu-editor-tree-wrap {

View File

@@ -4,7 +4,7 @@ import { useI18n } from '../../i18n';
import { showToast } from '../Toast'; import { showToast } from '../Toast';
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi'; import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
import { PageInput } from '../PageInput'; import { PageInput } from '../PageInput';
import { CategoryInput } from '../CategoryInput'; import { CategoryInput, type CategoryOption } from '../CategoryInput';
import { createAutoExpandController } from './menuAutoExpand'; import { createAutoExpandController } from './menuAutoExpand';
import { resolveInsertTarget } from './menuInsertTarget'; import { resolveInsertTarget } from './menuInsertTarget';
import { isPickerCloseKey } from './menuPagePicker'; import { isPickerCloseKey } from './menuPagePicker';
@@ -191,7 +191,7 @@ export const MenuEditorView: React.FC = () => {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isLoadingPages, setIsLoadingPages] = useState(false); const [isLoadingPages, setIsLoadingPages] = useState(false);
const [pagePosts, setPagePosts] = useState<PostData[]>([]); const [pagePosts, setPagePosts] = useState<PostData[]>([]);
const [categories, setCategories] = useState<string[]>([]); const [categories, setCategories] = useState<CategoryOption[]>([]);
const [isLoadingCategories, setIsLoadingCategories] = useState(false); const [isLoadingCategories, setIsLoadingCategories] = useState(false);
const [editingEntryId, setEditingEntryId] = useState<string | null>(null); const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null); const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null);
@@ -354,8 +354,16 @@ export const MenuEditorView: React.FC = () => {
setIsLoadingCategories(true); setIsLoadingCategories(true);
try { try {
const nextCategories = await window.electronAPI.meta.getCategories(); const [nextCategories, projectMetadata] = await Promise.all([
setCategories(nextCategories); window.electronAPI.meta.getCategories(),
window.electronAPI.meta.getProjectMetadata().catch(() => null),
]);
const categoryMetadata = projectMetadata?.categoryMetadata ?? {};
setCategories(nextCategories.map((categoryName) => ({
name: categoryName,
title: categoryMetadata[categoryName]?.title?.trim() || categoryName,
})));
} catch (error) { } catch (error) {
console.error('Failed to load categories:', error); console.error('Failed to load categories:', error);
showToast.error(tr('menuEditor.categoryPicker.loadError')); showToast.error(tr('menuEditor.categoryPicker.loadError'));
@@ -414,33 +422,60 @@ export const MenuEditorView: React.FC = () => {
setEditingEntryType(null); setEditingEntryType(null);
}; };
const setDraftAsCategoryArchive = (categoryName: string): void => { const setDraftAsCategoryArchive = (category: CategoryOption): void => {
if (!editingEntryId) { if (!editingEntryId) {
return; return;
} }
const trimmed = categoryName.trim(); const draftEntryId = editingEntryId;
if (!trimmed) {
void (async () => {
const trimmedName = category.name.trim();
const trimmedTitle = category.title.trim();
if (!trimmedName) {
return; return;
} }
let nextCategoryName = trimmedName;
const exists = categories.some((item) => item.name.toLowerCase() === trimmedName.toLowerCase());
if (!exists) {
try {
const updatedCategories = await window.electronAPI.meta.addCategory(trimmedName);
const matched = updatedCategories.find((item) => item.toLowerCase() === trimmedName.toLowerCase());
nextCategoryName = matched || trimmedName;
setCategories((previous) => {
if (previous.some((item) => item.name.toLowerCase() === nextCategoryName.toLowerCase())) {
return previous;
}
return [...previous, {
name: nextCategoryName,
title: trimmedTitle || nextCategoryName,
}];
});
} catch (error) {
console.error('Failed to create category from menu editor:', error);
}
}
setItems((previous) => mapItems(previous, (item) => { setItems((previous) => mapItems(previous, (item) => {
if (item.id !== editingEntryId) { if (item.id !== draftEntryId) {
return item; return item;
} }
return { return {
...item, ...item,
title: trimmed, title: trimmedTitle || nextCategoryName,
kind: 'category-archive', kind: 'category-archive',
pageId: undefined, pageId: undefined,
pageSlug: undefined, pageSlug: undefined,
categoryName: trimmed, categoryName: nextCategoryName,
}; };
})); }));
setEditingEntryId(null); setEditingEntryId(null);
setEditingEntryType(null); setEditingEntryType(null);
})();
}; };
const startCreateEntry = async (): Promise<void> => { const startCreateEntry = async (): Promise<void> => {

View File

@@ -88,6 +88,24 @@ describe('MenuEngine', () => {
}); });
}); });
it('preserves custom title for category archive entries when OPML includes both text and title', async () => {
const menuPath = normalizePath(`${menuEngine.getMetaDir()}/menu.opml`);
mockFiles.set(
menuPath,
`<?xml version="1.0" encoding="UTF-8"?>\n<opml version="2.0">\n <head><title>Blog Menu</title></head>\n <body>\n <outline id="menu-home" text="Home" type="home" pageSlug="home"/>\n <outline id="cat-news" text="news" title="Editorial Highlights" type="category-archive" categoryName="news"/>\n </body>\n</opml>`,
);
const result = await menuEngine.getMenu();
expect(result.items).toHaveLength(2);
expect(result.items[1]).toMatchObject({
id: 'cat-news',
kind: 'category-archive',
categoryName: 'news',
title: 'Editorial Highlights',
});
});
it('writes menu state as OPML and can read it back', async () => { it('writes menu state as OPML and can read it back', async () => {
const saved = await menuEngine.saveMenu({ const saved = await menuEngine.saveMenu({
items: [ items: [
@@ -130,4 +148,27 @@ describe('MenuEngine', () => {
expect(saved.items.some((item) => item.id === 'menu-home')).toBe(true); expect(saved.items.some((item) => item.id === 'menu-home')).toBe(true);
}); });
it('persists category archives as menu references without category metadata fields', async () => {
await menuEngine.saveMenu({
items: [
{
id: 'cat-news',
title: 'Newsroom',
kind: 'category-archive',
categoryName: 'news',
children: [],
},
],
});
const menuPath = normalizePath(`${menuEngine.getMetaDir()}/menu.opml`);
const xml = mockFiles.get(menuPath);
expect(xml).toBeDefined();
expect(xml).toContain('categoryName="news"');
expect(xml).toContain('text="Newsroom"');
expect(xml).not.toContain('renderInLists');
expect(xml).not.toContain('showTitle');
});
}); });

View File

@@ -27,6 +27,13 @@ describe('MenuEditorView entry editor', () => {
meta: { meta: {
...(window as any).electronAPI?.meta, ...(window as any).electronAPI?.meta,
getCategories: vi.fn().mockResolvedValue(['news', 'tech']), getCategories: vi.fn().mockResolvedValue(['news', 'tech']),
getProjectMetadata: vi.fn().mockResolvedValue({
name: 'Project 1',
categoryMetadata: {
news: { title: 'Newsroom', renderInLists: true, showTitle: true },
tech: { title: 'Technology', renderInLists: true, showTitle: true },
},
}),
}, },
posts: { posts: {
...(window as any).electronAPI?.posts, ...(window as any).electronAPI?.posts,
@@ -228,4 +235,44 @@ describe('MenuEditorView entry editor', () => {
expect(screen.queryByText(/^submenu$/i)).not.toBeInTheDocument(); expect(screen.queryByText(/^submenu$/i)).not.toBeInTheDocument();
}); });
it('uses category titles for suggestions and outline while saving category slug internally', async () => {
render(<MenuEditorView />);
const categoryButton = await screen.findByRole('button', { name: /add category archive/i });
fireEvent.click(categoryButton);
const input = await screen.findByPlaceholderText(/type a category name/i);
fireEvent.input(input, { target: { value: 'room' } });
const suggestion = await screen.findByRole('button', { name: /newsroom/i });
fireEvent.click(suggestion);
expect(screen.getByText('Newsroom')).toBeInTheDocument();
const saveButton = screen.getByRole('button', { name: /^save menu$/i });
fireEvent.click(saveButton);
const saveMock = (window as any).electronAPI.menu.save;
expect(saveMock).toHaveBeenCalled();
const payload = saveMock.mock.calls[0][0];
const categoryItem = payload.items.find((item: any) => item.kind === 'category-archive');
expect(categoryItem).toBeDefined();
expect(categoryItem.title).toBe('Newsroom');
expect(categoryItem.categoryName).toBe('news');
});
it('allows creating a new category archive from free text', async () => {
const { container } = render(<MenuEditorView />);
const categoryButton = await screen.findByRole('button', { name: /add category archive/i });
fireEvent.click(categoryButton);
const input = await screen.findByPlaceholderText(/type a category name/i);
fireEvent.input(input, { target: { value: 'not-existing-category' } });
const createSuggestion = container.querySelector('.tag-suggestion.create-new');
expect(createSuggestion).not.toBeNull();
});
}); });