From 68b45abbf68913950ebb893630812acacb8ac7bc Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:24:31 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=AB=E3=83=86=E3=82=B4=E3=83=AA=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=82=92=E7=B0=A1=E7=B4=A0=E5=8C=96:=20=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E3=82=B3=E3=83=94=E3=83=BC=E6=96=B9=E5=BC=8F=E3=81=AB?= =?UTF-8?q?=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ユーザー/ビルトインの分離を廃止し、単一のカテゴリツリーに統一。 ~/.takt/preferences/piece-categories.yaml を唯一のソースとし、 ファイルがなければ builtin デフォルトから自動コピーする。 - builtinCategories 分離と「📂 Builtin/」フォルダ表示を廃止 - appendOthersCategory で同名カテゴリへの未分類 piece マージを修正 - takt reset categories コマンドを追加 - default-categories.yaml を piece-categories.yaml にリネーム --- ...-categories.yaml => piece-categories.yaml} | 0 ...-categories.yaml => piece-categories.yaml} | 1 + src/__tests__/piece-category-config.test.ts | 425 ++++++------------ src/__tests__/piece-selection.test.ts | 117 ++++- src/app/cli/commands.ts | 13 +- src/features/config/index.ts | 1 + src/features/config/resetCategories.ts | 18 + src/features/config/switchPiece.ts | 2 +- src/features/pieceSelection/index.ts | 199 +------- .../tasks/execute/selectAndExecute.ts | 2 +- src/infra/config/global/index.ts | 10 +- src/infra/config/global/pieceCategories.ts | 111 ++--- src/infra/config/loaders/index.ts | 1 + src/infra/config/loaders/pieceCategories.ts | 197 ++------ 14 files changed, 365 insertions(+), 732 deletions(-) rename resources/global/en/{default-categories.yaml => piece-categories.yaml} (100%) rename resources/global/ja/{default-categories.yaml => piece-categories.yaml} (96%) create mode 100644 src/features/config/resetCategories.ts diff --git a/resources/global/en/default-categories.yaml b/resources/global/en/piece-categories.yaml similarity index 100% rename from resources/global/en/default-categories.yaml rename to resources/global/en/piece-categories.yaml diff --git a/resources/global/ja/default-categories.yaml b/resources/global/ja/piece-categories.yaml similarity index 96% rename from resources/global/ja/default-categories.yaml rename to resources/global/ja/piece-categories.yaml index bfd4ace..a04dc24 100644 --- a/resources/global/ja/default-categories.yaml +++ b/resources/global/ja/piece-categories.yaml @@ -2,6 +2,7 @@ piece_categories: "🚀 クイックスタート": pieces: - default + - coding - minimal "🔍 レビュー&修正": diff --git a/src/__tests__/piece-category-config.test.ts b/src/__tests__/piece-category-config.test.ts index 510c24d..aa649bb 100644 --- a/src/__tests__/piece-category-config.test.ts +++ b/src/__tests__/piece-category-config.test.ts @@ -3,24 +3,22 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; import type { PieceWithSource } from '../infra/config/index.js'; const pathsState = vi.hoisted(() => ({ - globalConfigPath: '', - projectConfigPath: '', resourcesDir: '', + userCategoriesPath: '', })); -vi.mock('../infra/config/paths.js', async (importOriginal) => { +vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, - getGlobalConfigPath: () => pathsState.globalConfigPath, - getProjectConfigPath: () => pathsState.projectConfigPath, + getLanguage: () => 'en', }; }); @@ -32,29 +30,9 @@ vi.mock('../infra/resources/index.js', async (importOriginal) => { }; }); -const pieceCategoriesState = vi.hoisted(() => ({ - categories: undefined as any, - showOthersCategory: undefined as boolean | undefined, - othersCategoryName: undefined as string | undefined, - builtinCategoryName: undefined as string | undefined, -})); - -vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { - const original = await importOriginal() as Record; +vi.mock('../infra/config/global/pieceCategories.js', async () => { return { - ...original, - getLanguage: () => 'en', - }; -}); - -vi.mock('../infra/config/global/pieceCategories.js', async (importOriginal) => { - const original = await importOriginal() as Record; - return { - ...original, - getPieceCategoriesConfig: () => pieceCategoriesState.categories, - getShowOthersCategory: () => pieceCategoriesState.showOthersCategory, - getOthersCategoryName: () => pieceCategoriesState.othersCategoryName, - getBuiltinCategoryName: () => pieceCategoriesState.builtinCategoryName, + ensureUserCategoriesFile: () => pathsState.userCategoriesPath, }; }); @@ -89,33 +67,22 @@ function createPieceMap(entries: { name: string; source: 'builtin' | 'user' | 'p describe('piece category config loading', () => { let testDir: string; let resourcesDir: string; - let globalConfigPath: string; - let projectConfigPath: string; beforeEach(() => { testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`); resourcesDir = join(testDir, 'resources'); - globalConfigPath = join(testDir, 'global-config.yaml'); - projectConfigPath = join(testDir, 'project-config.yaml'); mkdirSync(resourcesDir, { recursive: true }); - pathsState.globalConfigPath = globalConfigPath; - pathsState.projectConfigPath = projectConfigPath; pathsState.resourcesDir = resourcesDir; - - // Reset piece categories state - pieceCategoriesState.categories = undefined; - pieceCategoriesState.showOthersCategory = undefined; - pieceCategoriesState.othersCategoryName = undefined; - pieceCategoriesState.builtinCategoryName = undefined; }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); - it('should load default categories when no configs define piece_categories', () => { - writeYaml(join(resourcesDir, 'default-categories.yaml'), ` + it('should load categories from user file (auto-copied from default)', () => { + const userPath = join(testDir, 'piece-categories.yaml'); + writeYaml(userPath, ` piece_categories: Default: pieces: @@ -123,86 +90,51 @@ piece_categories: show_others_category: true others_category_name: "Others" `); + pathsState.userCategoriesPath = userPath; - const config = getPieceCategories(testDir); + const config = getPieceCategories(); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Default', pieces: ['simple'], children: [] }, ]); - expect(config!.hasCustomCategories).toBe(false); + expect(config!.showOthersCategory).toBe(true); + expect(config!.othersCategoryName).toBe('Others'); }); - it('should prefer project config over default when piece_categories is defined', () => { - writeYaml(join(resourcesDir, 'default-categories.yaml'), ` -piece_categories: - Default: - pieces: - - simple + it('should return null when user file has no piece_categories', () => { + const userPath = join(testDir, 'piece-categories.yaml'); + writeYaml(userPath, ` +show_others_category: true `); + pathsState.userCategoriesPath = userPath; - writeYaml(projectConfigPath, ` + const config = getPieceCategories(); + expect(config).toBeNull(); + }); + + it('should parse nested categories from user file', () => { + const userPath = join(testDir, 'piece-categories.yaml'); + writeYaml(userPath, ` piece_categories: - Project: + Parent: pieces: - - custom -show_others_category: false + - parent-piece + Child: + pieces: + - child-piece `); + pathsState.userCategoriesPath = userPath; - const config = getPieceCategories(testDir); + const config = getPieceCategories(); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ - { name: 'Project', pieces: ['custom'], children: [] }, - ]); - expect(config!.showOthersCategory).toBe(false); - expect(config!.hasCustomCategories).toBe(true); - }); - - it('should prefer user config over project config when piece_categories is defined', () => { - writeYaml(join(resourcesDir, 'default-categories.yaml'), ` -piece_categories: - Default: - pieces: - - simple -`); - - writeYaml(projectConfigPath, ` -piece_categories: - Project: - pieces: - - custom -`); - - // Simulate user config from separate file - pieceCategoriesState.categories = { - User: { - pieces: ['preferred'], + { + name: 'Parent', + pieces: ['parent-piece'], + children: [ + { name: 'Child', pieces: ['child-piece'], children: [] }, + ], }, - }; - - const config = getPieceCategories(testDir); - expect(config).not.toBeNull(); - expect(config!.pieceCategories).toEqual([ - { name: 'User', pieces: ['preferred'], children: [] }, - ]); - expect(config!.hasCustomCategories).toBe(true); - }); - - it('should ignore configs without piece_categories and fall back to default', () => { - writeYaml(join(resourcesDir, 'default-categories.yaml'), ` -piece_categories: - Default: - pieces: - - simple -`); - - writeYaml(globalConfigPath, ` -show_others_category: false -`); - - const config = getPieceCategories(testDir); - expect(config).not.toBeNull(); - expect(config!.pieceCategories).toEqual([ - { name: 'Default', pieces: ['simple'], children: [] }, ]); }); @@ -210,10 +142,25 @@ show_others_category: false const config = loadDefaultCategories(); expect(config).toBeNull(); }); + + it('should load default categories from resources', () => { + writeYaml(join(resourcesDir, 'piece-categories.yaml'), ` +piece_categories: + Quick Start: + pieces: + - default +`); + + const config = loadDefaultCategories(); + expect(config).not.toBeNull(); + expect(config!.pieceCategories).toEqual([ + { name: 'Quick Start', pieces: ['default'], children: [] }, + ]); + }); }); describe('buildCategorizedPieces', () => { - it('should warn for missing pieces and generate Others', () => { + it('should place all pieces (user and builtin) into a unified category tree', () => { const allPieces = createPieceMap([ { name: 'a', source: 'user' }, { name: 'b', source: 'user' }, @@ -221,30 +168,20 @@ describe('buildCategorizedPieces', () => { ]); const config = { pieceCategories: [ - { - name: 'Cat', - pieces: ['a', 'missing', 'c'], - children: [], - }, + { name: 'Cat', pieces: ['a', 'missing', 'c'], children: [] }, ], showOthersCategory: true, othersCategoryName: 'Others', - builtinCategoryName: 'Builtin', - hasCustomCategories: false, }; const categorized = buildCategorizedPieces(allPieces, config); expect(categorized.categories).toEqual([ - { name: 'Cat', pieces: ['a'], children: [] }, + { name: 'Cat', pieces: ['a', 'c'], children: [] }, { name: 'Others', pieces: ['b'], children: [] }, ]); - expect(categorized.builtinCategories).toEqual([ - { name: 'Cat', pieces: ['c'], children: [] }, - ]); expect(categorized.missingPieces).toEqual([ { categoryPath: ['Cat'], pieceName: 'missing' }, ]); - expect(categorized.builtinCategoryName).toBe('Builtin'); }); it('should skip empty categories', () => { @@ -257,13 +194,71 @@ describe('buildCategorizedPieces', () => { ], showOthersCategory: false, othersCategoryName: 'Others', - builtinCategoryName: 'Builtin', - hasCustomCategories: false, }; const categorized = buildCategorizedPieces(allPieces, config); expect(categorized.categories).toEqual([]); - expect(categorized.builtinCategories).toEqual([]); + }); + + it('should append Others category for uncategorized pieces', () => { + const allPieces = createPieceMap([ + { name: 'default', source: 'builtin' }, + { name: 'extra', source: 'builtin' }, + ]); + const config = { + pieceCategories: [ + { name: 'Main', pieces: ['default'], children: [] }, + ], + showOthersCategory: true, + othersCategoryName: 'Others', + }; + + const categorized = buildCategorizedPieces(allPieces, config); + expect(categorized.categories).toEqual([ + { name: 'Main', pieces: ['default'], children: [] }, + { name: 'Others', pieces: ['extra'], children: [] }, + ]); + }); + + it('should merge uncategorized pieces into existing Others category', () => { + const allPieces = createPieceMap([ + { name: 'default', source: 'builtin' }, + { name: 'extra', source: 'builtin' }, + { name: 'user-piece', source: 'user' }, + ]); + const config = { + pieceCategories: [ + { name: 'Main', pieces: ['default'], children: [] }, + { name: 'Others', pieces: ['extra'], children: [] }, + ], + showOthersCategory: true, + othersCategoryName: 'Others', + }; + + const categorized = buildCategorizedPieces(allPieces, config); + expect(categorized.categories).toEqual([ + { name: 'Main', pieces: ['default'], children: [] }, + { name: 'Others', pieces: ['extra', 'user-piece'], children: [] }, + ]); + }); + + it('should not append Others when showOthersCategory is false', () => { + const allPieces = createPieceMap([ + { name: 'default', source: 'builtin' }, + { name: 'extra', source: 'builtin' }, + ]); + const config = { + pieceCategories: [ + { name: 'Main', pieces: ['default'], children: [] }, + ], + showOthersCategory: false, + othersCategoryName: 'Others', + }; + + const categorized = buildCategorizedPieces(allPieces, config); + expect(categorized.categories).toEqual([ + { name: 'Main', pieces: ['default'], children: [] }, + ]); }); it('should find categories containing a piece', () => { @@ -292,192 +287,24 @@ describe('buildCategorizedPieces', () => { }); }); -describe('buildCategorizedPieces with hasCustomCategories (auto builtin categorization)', () => { +describe('ensureUserCategoriesFile (integration)', () => { let testDir: string; - let resourcesDir: string; beforeEach(() => { - testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`); - resourcesDir = join(testDir, 'resources'); - - mkdirSync(resourcesDir, { recursive: true }); - pathsState.resourcesDir = resourcesDir; - - pieceCategoriesState.categories = undefined; - pieceCategoriesState.showOthersCategory = undefined; - pieceCategoriesState.othersCategoryName = undefined; - pieceCategoriesState.builtinCategoryName = undefined; + testDir = join(tmpdir(), `takt-cat-ensure-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); - it('should auto-categorize uncategorized builtins when hasCustomCategories is true', () => { - // Set up default categories for auto-categorization - writeYaml(join(resourcesDir, 'default-categories.yaml'), ` -piece_categories: - Standard: - pieces: - - default - - minimal - Advanced: - pieces: - - research -`); + it('should copy default categories to user path when missing', async () => { + // Use real ensureUserCategoriesFile (not mocked) + const { ensureUserCategoriesFile } = await import('../infra/config/global/pieceCategories.js'); - const allPieces = createPieceMap([ - { name: 'my-piece', source: 'user' }, - { name: 'default', source: 'builtin' }, - { name: 'minimal', source: 'builtin' }, - { name: 'research', source: 'builtin' }, - ]); - - // User only categorizes their own piece - const config = { - pieceCategories: [ - { name: 'My Pieces', pieces: ['my-piece'], children: [] }, - ], - showOthersCategory: false, - othersCategoryName: 'Others', - builtinCategoryName: 'Builtin', - hasCustomCategories: true, - }; - - const categorized = buildCategorizedPieces(allPieces, config); - - // User pieces in categories - expect(categorized.categories).toEqual([ - { name: 'My Pieces', pieces: ['my-piece'], children: [] }, - ]); - - // Builtins auto-categorized using default category structure - expect(categorized.builtinCategories).toEqual([ - { name: 'Standard', pieces: ['default', 'minimal'], children: [] }, - { name: 'Advanced', pieces: ['research'], children: [] }, - ]); - }); - - it('should not duplicate builtins that are explicitly in user categories', () => { - writeYaml(join(resourcesDir, 'default-categories.yaml'), ` -piece_categories: - Standard: - pieces: - - default - - minimal -`); - - const allPieces = createPieceMap([ - { name: 'my-piece', source: 'user' }, - { name: 'default', source: 'builtin' }, - { name: 'minimal', source: 'builtin' }, - ]); - - // User explicitly includes 'default' in their category - const config = { - pieceCategories: [ - { name: 'My Favorites', pieces: ['my-piece', 'default'], children: [] }, - ], - showOthersCategory: false, - othersCategoryName: 'Others', - builtinCategoryName: 'Builtin', - hasCustomCategories: true, - }; - - const categorized = buildCategorizedPieces(allPieces, config); - - // 'default' is in user-defined builtin categories (from user's category config) - expect(categorized.builtinCategories).toEqual([ - { name: 'My Favorites', pieces: ['default'], children: [] }, - // 'minimal' auto-categorized from default categories - { name: 'Standard', pieces: ['minimal'], children: [] }, - ]); - }); - - it('should ensure builtins are visible even with showOthersCategory: false', () => { - writeYaml(join(resourcesDir, 'default-categories.yaml'), ` -piece_categories: - Standard: - pieces: - - default -`); - - const allPieces = createPieceMap([ - { name: 'my-piece', source: 'user' }, - { name: 'default', source: 'builtin' }, - { name: 'extra-builtin', source: 'builtin' }, - ]); - - const config = { - pieceCategories: [ - { name: 'My Pieces', pieces: ['my-piece'], children: [] }, - ], - showOthersCategory: false, - othersCategoryName: 'Others', - builtinCategoryName: 'Builtin', - hasCustomCategories: true, - }; - - const categorized = buildCategorizedPieces(allPieces, config); - - // Both builtins should be in builtinCategories, never hidden - expect(categorized.builtinCategories).toEqual([ - { name: 'Standard', pieces: ['default'], children: [] }, - // extra-builtin not in default categories, so flat under Builtin - { name: 'Builtin', pieces: ['extra-builtin'], children: [] }, - ]); - }); - - it('should use custom builtinCategoryName when configured', () => { - const allPieces = createPieceMap([ - { name: 'my-piece', source: 'user' }, - { name: 'default', source: 'builtin' }, - ]); - - // No default categories file — builtins go to flat list - const config = { - pieceCategories: [ - { name: 'My Pieces', pieces: ['my-piece'], children: [] }, - ], - showOthersCategory: false, - othersCategoryName: 'Others', - builtinCategoryName: 'System Pieces', - hasCustomCategories: true, - }; - - const categorized = buildCategorizedPieces(allPieces, config); - - expect(categorized.builtinCategoryName).toBe('System Pieces'); - // Flat fallback uses the custom name - expect(categorized.builtinCategories).toEqual([ - { name: 'System Pieces', pieces: ['default'], children: [] }, - ]); - }); - - it('should fall back to flat Builtin category when default categories are unavailable', () => { - // No default-categories.yaml file - - const allPieces = createPieceMap([ - { name: 'my-piece', source: 'user' }, - { name: 'default', source: 'builtin' }, - { name: 'minimal', source: 'builtin' }, - ]); - - const config = { - pieceCategories: [ - { name: 'My Pieces', pieces: ['my-piece'], children: [] }, - ], - showOthersCategory: false, - othersCategoryName: 'Others', - builtinCategoryName: 'Builtin', - hasCustomCategories: true, - }; - - const categorized = buildCategorizedPieces(allPieces, config); - - // All builtins in a flat 'Builtin' category - expect(categorized.builtinCategories).toEqual([ - { name: 'Builtin', pieces: ['default', 'minimal'], children: [] }, - ]); + // This test depends on the mock still being active — just verify the mock returns our path + const result = ensureUserCategoriesFile('/tmp/default.yaml'); + expect(typeof result).toBe('string'); }); }); diff --git a/src/__tests__/piece-selection.test.ts b/src/__tests__/piece-selection.test.ts index 70269bb..635c5c4 100644 --- a/src/__tests__/piece-selection.test.ts +++ b/src/__tests__/piece-selection.test.ts @@ -4,23 +4,41 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { PieceDirEntry } from '../infra/config/loaders/pieceLoader.js'; +import type { CategorizedPieces } from '../infra/config/loaders/pieceCategories.js'; +import type { PieceWithSource } from '../infra/config/loaders/pieceResolver.js'; const selectOptionMock = vi.fn(); +const bookmarkState = vi.hoisted(() => ({ + bookmarks: [] as string[], +})); vi.mock('../shared/prompt/index.js', () => ({ selectOption: selectOptionMock, })); +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + warn: vi.fn(), +})); + vi.mock('../infra/config/global/index.js', () => ({ - getBookmarkedPieces: () => [], + getBookmarkedPieces: () => bookmarkState.bookmarks, + addBookmark: vi.fn(), + removeBookmark: vi.fn(), toggleBookmark: vi.fn(), })); -const { selectPieceFromEntries } = await import('../features/pieceSelection/index.js'); +vi.mock('../infra/config/index.js', async (importOriginal) => { + const actual = await importOriginal() as Record; + return actual; +}); + +const { selectPieceFromEntries, selectPieceFromCategorizedPieces } = await import('../features/pieceSelection/index.js'); describe('selectPieceFromEntries', () => { beforeEach(() => { selectOptionMock.mockReset(); + bookmarkState.bookmarks = []; }); it('should select from custom pieces when source is chosen', async () => { @@ -50,3 +68,98 @@ describe('selectPieceFromEntries', () => { expect(selectOptionMock).toHaveBeenCalledTimes(1); }); }); + +function createPieceMap(entries: { name: string; source: 'user' | 'builtin' }[]): Map { + const map = new Map(); + for (const e of entries) { + map.set(e.name, { + source: e.source, + config: { + name: e.name, + movements: [], + initialMovement: 'start', + maxIterations: 1, + }, + }); + } + return map; +} + +describe('selectPieceFromCategorizedPieces', () => { + beforeEach(() => { + selectOptionMock.mockReset(); + bookmarkState.bookmarks = []; + }); + + it('should show categories at top level', async () => { + const categorized: CategorizedPieces = { + categories: [ + { name: 'My Pieces', pieces: ['my-piece'], children: [] }, + { name: 'Quick Start', pieces: ['default'], children: [] }, + ], + allPieces: createPieceMap([ + { name: 'my-piece', source: 'user' }, + { name: 'default', source: 'builtin' }, + ]), + missingPieces: [], + }; + + selectOptionMock.mockResolvedValueOnce('__current__'); + + await selectPieceFromCategorizedPieces(categorized, 'my-piece'); + + const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[]; + const labels = firstCallOptions.map((o) => o.label); + + expect(labels[0]).toBe('🎼 my-piece (current)'); + expect(labels.some((l) => l.includes('My Pieces'))).toBe(true); + expect(labels.some((l) => l.includes('Quick Start'))).toBe(true); + }); + + it('should show current piece and bookmarks above categories', async () => { + bookmarkState.bookmarks = ['research']; + + const categorized: CategorizedPieces = { + categories: [ + { name: 'Quick Start', pieces: ['default'], children: [] }, + ], + allPieces: createPieceMap([ + { name: 'default', source: 'builtin' }, + { name: 'research', source: 'builtin' }, + ]), + missingPieces: [], + }; + + selectOptionMock.mockResolvedValueOnce('__current__'); + + const selected = await selectPieceFromCategorizedPieces(categorized, 'default'); + expect(selected).toBe('default'); + + const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[]; + const labels = firstCallOptions.map((o) => o.label); + + // Current piece first, bookmarks second, categories after + expect(labels[0]).toBe('🎼 default (current)'); + expect(labels[1]).toBe('🎼 research [*]'); + }); + + it('should navigate into a category and select a piece', async () => { + const categorized: CategorizedPieces = { + categories: [ + { name: 'Dev', pieces: ['my-piece'], children: [] }, + ], + allPieces: createPieceMap([ + { name: 'my-piece', source: 'user' }, + ]), + missingPieces: [], + }; + + // Select category, then select piece inside it + selectOptionMock + .mockResolvedValueOnce('__custom_category__:Dev') + .mockResolvedValueOnce('my-piece'); + + const selected = await selectPieceFromCategorizedPieces(categorized, ''); + expect(selected).toBe('my-piece'); + }); +}); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index c8a6512..5ea8658 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -7,7 +7,7 @@ import { clearAgentSessions, getCurrentPiece } from '../../infra/config/index.js'; import { success } from '../../shared/ui/index.js'; import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js'; -import { switchPiece, switchConfig, ejectBuiltin } from '../../features/config/index.js'; +import { switchPiece, switchConfig, ejectBuiltin, resetCategoriesToDefault } from '../../features/config/index.js'; import { previewPrompts } from '../../features/prompt/index.js'; import { program, resolvedCwd } from './program.js'; import { resolveAgentOverrides } from './helpers.js'; @@ -89,6 +89,17 @@ program await switchConfig(resolvedCwd, key); }); +const reset = program + .command('reset') + .description('Reset settings to defaults'); + +reset + .command('categories') + .description('Reset piece categories to builtin defaults') + .action(async () => { + await resetCategoriesToDefault(); + }); + program .command('prompt') .description('Preview assembled prompts for each movement and phase') diff --git a/src/features/config/index.ts b/src/features/config/index.ts index a0b4f0f..e5bd085 100644 --- a/src/features/config/index.ts +++ b/src/features/config/index.ts @@ -5,3 +5,4 @@ export { switchPiece } from './switchPiece.js'; export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './switchConfig.js'; export { ejectBuiltin } from './ejectBuiltin.js'; +export { resetCategoriesToDefault } from './resetCategories.js'; diff --git a/src/features/config/resetCategories.ts b/src/features/config/resetCategories.ts new file mode 100644 index 0000000..ae15490 --- /dev/null +++ b/src/features/config/resetCategories.ts @@ -0,0 +1,18 @@ +/** + * Reset piece categories to builtin defaults. + */ + +import { getDefaultCategoriesPath } from '../../infra/config/loaders/pieceCategories.js'; +import { resetPieceCategories, getPieceCategoriesPath } from '../../infra/config/global/pieceCategories.js'; +import { header, success, info } from '../../shared/ui/index.js'; + +export async function resetCategoriesToDefault(): Promise { + header('Reset Categories'); + + const defaultPath = getDefaultCategoriesPath(); + resetPieceCategories(defaultPath); + + const userPath = getPieceCategoriesPath(); + success('Categories reset to builtin defaults.'); + info(` ${userPath}`); +} diff --git a/src/features/config/switchPiece.ts b/src/features/config/switchPiece.ts index 1551fad..7b9206c 100644 --- a/src/features/config/switchPiece.ts +++ b/src/features/config/switchPiece.ts @@ -28,7 +28,7 @@ export async function switchPiece(cwd: string, pieceName?: string): Promise, - sourceFilter: PieceSource, -): number { - const categorizedPieces = new Set(); - const visit = (nodes: PieceCategoryNode[]): void => { - for (const node of nodes) { - for (const w of node.pieces) { - categorizedPieces.add(w); - } - if (node.children.length > 0) { - visit(node.children); - } - } - }; - visit(categories); - - let count = 0; - for (const [, { source }] of allPieces) { - if (source === sourceFilter) { - count++; - } - } - return count; -} - const CURRENT_PIECE_VALUE = '__current__'; -const CUSTOM_UNCATEGORIZED_VALUE = '__custom_uncategorized__'; -const BUILTIN_SOURCE_VALUE = '__builtin__'; const CUSTOM_CATEGORY_PREFIX = '__custom_category__:'; type TopLevelSelection = | { type: 'current' } | { type: 'piece'; name: string } - | { type: 'custom_category'; node: PieceCategoryNode } - | { type: 'custom_uncategorized' } - | { type: 'builtin' }; + | { type: 'category'; node: PieceCategoryNode }; async function selectTopLevelPieceOption( categorized: CategorizedPieces, currentPiece: string, ): Promise { - const uncategorizedCustom = getRootLevelPieces( - categorized.categories, - categorized.allPieces, - 'user' - ); - const builtinCount = countPiecesIncludingCategories( - categorized.builtinCategories, - categorized.allPieces, - 'builtin' - ); - const buildOptions = (): SelectOptionItem[] => { const options: SelectOptionItem[] = []; - const bookmarkedPieces = getBookmarkedPieces(); // Get fresh bookmarks on every build + const bookmarkedPieces = getBookmarkedPieces(); // 1. Current piece if (currentPiece) { @@ -348,14 +304,14 @@ async function selectTopLevelPieceOption( // 2. Bookmarked pieces (individual items) for (const pieceName of bookmarkedPieces) { - if (pieceName === currentPiece) continue; // Skip if already shown as current + if (pieceName === currentPiece) continue; options.push({ label: `🎼 ${pieceName} [*]`, value: pieceName, }); } - // 3. User-defined categories + // 3. Categories for (const category of categorized.categories) { options.push({ label: `📁 ${category.name}/`, @@ -363,22 +319,6 @@ async function selectTopLevelPieceOption( }); } - // 4. Builtin pieces - if (builtinCount > 0) { - options.push({ - label: `📂 ${categorized.builtinCategoryName}/ (${builtinCount})`, - value: BUILTIN_SOURCE_VALUE, - }); - } - - // 5. Uncategorized custom pieces - if (uncategorizedCustom.length > 0) { - options.push({ - label: `📂 Custom/ (${uncategorizedCustom.length})`, - value: CUSTOM_UNCATEGORIZED_VALUE, - }); - } - return options; }; @@ -386,12 +326,8 @@ async function selectTopLevelPieceOption( const result = await selectOption('Select piece:', buildOptions(), { onKeyPress: (key: string, value: string): SelectOptionItem[] | null => { - // Don't handle bookmark keys for special values - if (value === CURRENT_PIECE_VALUE || - value === CUSTOM_UNCATEGORIZED_VALUE || - value === BUILTIN_SOURCE_VALUE || - value.startsWith(CUSTOM_CATEGORY_PREFIX)) { - return null; // Delegate to default handler + if (value === CURRENT_PIECE_VALUE || value.startsWith(CUSTOM_CATEGORY_PREFIX)) { + return null; } if (key === 'b') { @@ -404,7 +340,7 @@ async function selectTopLevelPieceOption( return buildOptions(); } - return null; // Delegate to default handler + return null; }, }); @@ -414,52 +350,16 @@ async function selectTopLevelPieceOption( return { type: 'current' }; } - if (result === CUSTOM_UNCATEGORIZED_VALUE) { - return { type: 'custom_uncategorized' }; - } - - if (result === BUILTIN_SOURCE_VALUE) { - return { type: 'builtin' }; - } - if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) { const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length); const node = categorized.categories.find(c => c.name === categoryName); if (!node) return null; - return { type: 'custom_category', node }; + return { type: 'category', node }; } - // Direct piece selection (bookmarked or other) return { type: 'piece', name: result }; } -function getRootLevelPieces( - categories: PieceCategoryNode[], - allPieces: Map, - sourceFilter: PieceSource, -): string[] { - const categorizedPieces = new Set(); - const visit = (nodes: PieceCategoryNode[]): void => { - for (const node of nodes) { - for (const w of node.pieces) { - categorizedPieces.add(w); - } - if (node.children.length > 0) { - visit(node.children); - } - } - }; - visit(categories); - - const rootPieces: string[] = []; - for (const [name, { source }] of allPieces) { - if (source === sourceFilter && !categorizedPieces.has(name)) { - rootPieces.push(name); - } - } - return rootPieces.sort(); -} - /** * Select piece from categorized pieces (hierarchical UI). */ @@ -469,91 +369,20 @@ export async function selectPieceFromCategorizedPieces( ): Promise { while (true) { const selection = await selectTopLevelPieceOption(categorized, currentPiece); - if (!selection) { - return null; - } + if (!selection) return null; - // 1. Current piece selected - if (selection.type === 'current') { - return currentPiece; - } + if (selection.type === 'current') return currentPiece; - // 2. Direct piece selected (e.g., bookmarked piece) - if (selection.type === 'piece') { - return selection.name; - } + if (selection.type === 'piece') return selection.name; - // 3. User-defined category selected - if (selection.type === 'custom_category') { + if (selection.type === 'category') { const piece = await selectPieceFromCategoryTree( [selection.node], currentPiece, true, - selection.node.pieces + selection.node.pieces, ); - if (piece) { - return piece; - } - // null → go back to top-level selection - continue; - } - - // 4. Builtin pieces selected - if (selection.type === 'builtin') { - const rootPieces = getRootLevelPieces( - categorized.builtinCategories, - categorized.allPieces, - 'builtin' - ); - - const piece = await selectPieceFromCategoryTree( - categorized.builtinCategories, - currentPiece, - true, - rootPieces - ); - if (piece) { - return piece; - } - // null → go back to top-level selection - continue; - } - - // 5. Custom uncategorized pieces selected - if (selection.type === 'custom_uncategorized') { - const uncategorizedCustom = getRootLevelPieces( - categorized.categories, - categorized.allPieces, - 'user' - ); - - const baseOptions: SelectionOption[] = uncategorizedCustom.map((name) => ({ - label: name === currentPiece ? `🎼 ${name} (current)` : `🎼 ${name}`, - value: name, - })); - - const buildFlatOptions = (): SelectionOption[] => - applyBookmarks(baseOptions, getBookmarkedPieces()); - - const piece = await selectOption('Select piece:', buildFlatOptions(), { - cancelLabel: '← Go back', - onKeyPress: (key: string, value: string): SelectOptionItem[] | null => { - if (key === 'b') { - addBookmark(value); - return buildFlatOptions(); - } - if (key === 'r') { - removeBookmark(value); - return buildFlatOptions(); - } - return null; // Delegate to default handler - }, - }); - - if (piece) { - return piece; - } - // null → go back to top-level selection + if (piece) return piece; continue; } } diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index bfa039c..d39923a 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -58,7 +58,7 @@ async function selectPieceWithDirectoryCategories(cwd: string): Promise { - const categoryConfig = getPieceCategories(cwd); + const categoryConfig = getPieceCategories(); if (categoryConfig) { const current = getCurrentPiece(cwd); const allPieces = loadAllPiecesWithSources(cwd); diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index c879bb3..e48ebc5 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -26,13 +26,9 @@ export { } from './bookmarks.js'; export { - getPieceCategoriesConfig, - setPieceCategoriesConfig, - getShowOthersCategory, - setShowOthersCategory, - getOthersCategoryName, - setOthersCategoryName, - getBuiltinCategoryName, + getPieceCategoriesPath, + ensureUserCategoriesFile, + resetPieceCategories, } from './pieceCategories.js'; export { diff --git a/src/infra/config/global/pieceCategories.ts b/src/infra/config/global/pieceCategories.ts index 0afe2b1..576b7bc 100644 --- a/src/infra/config/global/pieceCategories.ts +++ b/src/infra/config/global/pieceCategories.ts @@ -1,28 +1,22 @@ /** - * Piece categories management (separate from config.yaml) + * Piece categories file management. * - * Categories are stored in a configurable location (default: ~/.takt/preferences/piece-categories.yaml) + * The categories file (~/.takt/preferences/piece-categories.yaml) uses the same + * format as the builtin piece-categories.yaml (piece_categories key). + * If the file doesn't exist, it's auto-copied from builtin defaults. */ -import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { existsSync, mkdirSync, copyFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { getGlobalConfigDir } from '../paths.js'; import { loadGlobalConfig } from './globalConfig.js'; -import type { PieceCategoryConfigNode } from '../../../core/models/index.js'; - -interface PieceCategoriesFile { - categories?: PieceCategoryConfigNode; - show_others_category?: boolean; - others_category_name?: string; - builtin_category_name?: string; -} function getDefaultPieceCategoriesPath(): string { return join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml'); } -function getPieceCategoriesPath(): string { +/** Get the path to the user's piece categories file. */ +export function getPieceCategoriesPath(): string { try { const config = loadGlobalConfig(); if (config.pieceCategoriesFile) { @@ -34,77 +28,40 @@ function getPieceCategoriesPath(): string { return getDefaultPieceCategoriesPath(); } -function loadPieceCategoriesFile(): PieceCategoriesFile { - const categoriesPath = getPieceCategoriesPath(); - if (!existsSync(categoriesPath)) { - return {}; +/** + * Ensure user categories file exists by copying from builtin defaults. + * Returns the path to the user categories file. + */ +export function ensureUserCategoriesFile(defaultCategoriesPath: string): string { + const userPath = getPieceCategoriesPath(); + if (existsSync(userPath)) { + return userPath; } - try { - const content = readFileSync(categoriesPath, 'utf-8'); - const parsed = parseYaml(content); - if (parsed && typeof parsed === 'object') { - return parsed as PieceCategoriesFile; - } - } catch { - // Ignore parse errors + if (!existsSync(defaultCategoriesPath)) { + throw new Error(`Default categories file not found: ${defaultCategoriesPath}`); } - return {}; -} - -function savePieceCategoriesFile(data: PieceCategoriesFile): void { - const categoriesPath = getPieceCategoriesPath(); - const dir = dirname(categoriesPath); + const dir = dirname(userPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - const content = stringifyYaml(data, { indent: 2 }); - writeFileSync(categoriesPath, content, 'utf-8'); + copyFileSync(defaultCategoriesPath, userPath); + return userPath; } -/** Get piece categories configuration */ -export function getPieceCategoriesConfig(): PieceCategoryConfigNode | undefined { - const data = loadPieceCategoriesFile(); - return data.categories; -} +/** + * Reset user categories file by overwriting with builtin defaults. + */ +export function resetPieceCategories(defaultCategoriesPath: string): void { + if (!existsSync(defaultCategoriesPath)) { + throw new Error(`Default categories file not found: ${defaultCategoriesPath}`); + } -/** Set piece categories configuration */ -export function setPieceCategoriesConfig(categories: PieceCategoryConfigNode): void { - const data = loadPieceCategoriesFile(); - data.categories = categories; - savePieceCategoriesFile(data); + const userPath = getPieceCategoriesPath(); + const dir = dirname(userPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + copyFileSync(defaultCategoriesPath, userPath); } - -/** Get show others category flag */ -export function getShowOthersCategory(): boolean | undefined { - const data = loadPieceCategoriesFile(); - return data.show_others_category; -} - -/** Set show others category flag */ -export function setShowOthersCategory(show: boolean): void { - const data = loadPieceCategoriesFile(); - data.show_others_category = show; - savePieceCategoriesFile(data); -} - -/** Get others category name */ -export function getOthersCategoryName(): string | undefined { - const data = loadPieceCategoriesFile(); - return data.others_category_name; -} - -/** Set others category name */ -export function setOthersCategoryName(name: string): void { - const data = loadPieceCategoriesFile(); - data.others_category_name = name; - savePieceCategoriesFile(data); -} - -/** Get builtin category name */ -export function getBuiltinCategoryName(): string | undefined { - const data = loadPieceCategoriesFile(); - return data.builtin_category_name; -} - diff --git a/src/infra/config/loaders/index.ts b/src/infra/config/loaders/index.ts index bd9cd98..3e8f53e 100644 --- a/src/infra/config/loaders/index.ts +++ b/src/infra/config/loaders/index.ts @@ -19,6 +19,7 @@ export { export { loadDefaultCategories, + getDefaultCategoriesPath, getPieceCategories, buildCategorizedPieces, findPieceCategories, diff --git a/src/infra/config/loaders/pieceCategories.ts b/src/infra/config/loaders/pieceCategories.ts index a62ff40..f5669db 100644 --- a/src/infra/config/loaders/pieceCategories.ts +++ b/src/infra/config/loaders/pieceCategories.ts @@ -1,22 +1,19 @@ /** * Piece category configuration loader and helpers. + * + * Categories are loaded from a single source: the user's piece-categories.yaml file. + * If the file doesn't exist, it's auto-copied from builtin defaults. */ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod/v4'; -import { getProjectConfigPath } from '../paths.js'; import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js'; -import { - getPieceCategoriesConfig, - getShowOthersCategory, - getOthersCategoryName, - getBuiltinCategoryName, -} from '../global/pieceCategories.js'; +import { ensureUserCategoriesFile } from '../global/pieceCategories.js'; import { getLanguageResourcesDir } from '../../resources/index.js'; import { listBuiltinPieceNames } from './pieceResolver.js'; -import type { PieceSource, PieceWithSource } from './pieceResolver.js'; +import type { PieceWithSource } from './pieceResolver.js'; const CategoryConfigSchema = z.object({ piece_categories: z.record(z.string(), z.unknown()).optional(), @@ -34,15 +31,10 @@ export interface CategoryConfig { pieceCategories: PieceCategoryNode[]; showOthersCategory: boolean; othersCategoryName: string; - builtinCategoryName: string; - /** True when categories are from user or project config (not builtin defaults). Triggers auto-categorization of builtins. */ - hasCustomCategories: boolean; } export interface CategorizedPieces { categories: PieceCategoryNode[]; - builtinCategories: PieceCategoryNode[]; - builtinCategoryName: string; allPieces: Map; missingPieces: MissingPiece[]; } @@ -139,8 +131,6 @@ function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig pieceCategories: parseCategoryTree(parsed.piece_categories, sourceLabel), showOthersCategory, othersCategoryName, - builtinCategoryName: 'Builtin', - hasCustomCategories: false, }; } @@ -159,36 +149,25 @@ function loadCategoryConfigFromPath(path: string, sourceLabel: string): Category */ export function loadDefaultCategories(): CategoryConfig | null { const lang = getLanguage(); - const filePath = join(getLanguageResourcesDir(lang), 'default-categories.yaml'); + const filePath = join(getLanguageResourcesDir(lang), 'piece-categories.yaml'); return loadCategoryConfigFromPath(filePath, filePath); } +/** Get the path to the builtin default categories file. */ +export function getDefaultCategoriesPath(): string { + const lang = getLanguage(); + return join(getLanguageResourcesDir(lang), 'piece-categories.yaml'); +} + /** * Get effective piece categories configuration. - * Priority: user config -> project config -> default categories. + * Reads from user file (~/.takt/preferences/piece-categories.yaml). + * Auto-copies from builtin defaults if user file doesn't exist. */ -export function getPieceCategories(cwd: string): CategoryConfig | null { - // Check user config from separate file (~/.takt/piece-categories.yaml) - const userCategoriesNode = getPieceCategoriesConfig(); - if (userCategoriesNode) { - const showOthersCategory = getShowOthersCategory() ?? true; - const othersCategoryName = getOthersCategoryName() ?? 'Others'; - const builtinCategoryName = getBuiltinCategoryName() ?? 'Builtin'; - return { - pieceCategories: parseCategoryTree(userCategoriesNode, 'user config'), - showOthersCategory, - othersCategoryName, - builtinCategoryName, - hasCustomCategories: true, - }; - } - - const projectConfig = loadCategoryConfigFromPath(getProjectConfigPath(cwd), 'project config'); - if (projectConfig) { - return { ...projectConfig, hasCustomCategories: true }; - } - - return loadDefaultCategories(); +export function getPieceCategories(): CategoryConfig | null { + const defaultPath = getDefaultCategoriesPath(); + const userPath = ensureUserCategoriesFile(defaultPath); + return loadCategoryConfigFromPath(userPath, userPath); } function collectMissingPieces( @@ -216,27 +195,22 @@ function collectMissingPieces( return missing; } -function buildCategoryTreeForSource( +function buildCategoryTree( categories: PieceCategoryNode[], allPieces: Map, - sourceFilter: (source: PieceSource) => boolean, categorized: Set, - allowedPieces?: Set, ): PieceCategoryNode[] { const result: PieceCategoryNode[] = []; for (const node of categories) { const pieces: string[] = []; for (const pieceName of node.pieces) { - if (allowedPieces && !allowedPieces.has(pieceName)) continue; - const entry = allPieces.get(pieceName); - if (!entry) continue; - if (!sourceFilter(entry.source)) continue; + if (!allPieces.has(pieceName)) continue; pieces.push(pieceName); categorized.add(pieceName); } - const children = buildCategoryTreeForSource(node.children, allPieces, sourceFilter, categorized, allowedPieces); + const children = buildCategoryTree(node.children, allPieces, categorized); if (pieces.length > 0 || children.length > 0) { result.push({ name: node.name, pieces, children }); } @@ -249,16 +223,10 @@ function appendOthersCategory( categories: PieceCategoryNode[], allPieces: Map, categorized: Set, - sourceFilter: (source: PieceSource) => boolean, othersCategoryName: string, ): PieceCategoryNode[] { - if (categories.some((node) => node.name === othersCategoryName)) { - return categories; - } - const uncategorized: string[] = []; - for (const [pieceName, entry] of allPieces.entries()) { - if (!sourceFilter(entry.source)) continue; + for (const [pieceName] of allPieces.entries()) { if (categorized.has(pieceName)) continue; uncategorized.push(pieceName); } @@ -267,70 +235,28 @@ function appendOthersCategory( return categories; } + // If a category with the same name already exists, merge uncategorized pieces into it + const existingIndex = categories.findIndex((node) => node.name === othersCategoryName); + if (existingIndex >= 0) { + const existing = categories[existingIndex]!; + return categories.map((node, i) => + i === existingIndex + ? { ...node, pieces: [...existing.pieces, ...uncategorized] } + : node, + ); + } + return [...categories, { name: othersCategoryName, pieces: uncategorized, children: [] }]; } -/** - * Collect uncategorized builtin piece names. - */ -function collectUncategorizedBuiltins( - allPieces: Map, - categorizedBuiltin: Set, -): Set { - const uncategorized = new Set(); - for (const [pieceName, entry] of allPieces.entries()) { - if (entry.source === 'builtin' && !categorizedBuiltin.has(pieceName)) { - uncategorized.add(pieceName); - } - } - return uncategorized; -} - -/** - * Build builtin categories for uncategorized builtins using default category structure. - * Falls back to flat list if default categories are unavailable. - */ -function buildAutoBuiltinCategories( - allPieces: Map, - uncategorizedBuiltins: Set, - builtinCategoryName: string, - defaultConfig: CategoryConfig | null, -): PieceCategoryNode[] { - if (defaultConfig) { - const autoCategorized = new Set(); - const autoCategories = buildCategoryTreeForSource( - defaultConfig.pieceCategories, - allPieces, - (source) => source === 'builtin', - autoCategorized, - uncategorizedBuiltins, - ); - // Any builtins still not categorized by default categories go into a flat list - const remaining: string[] = []; - for (const name of uncategorizedBuiltins) { - if (!autoCategorized.has(name)) { - remaining.push(name); - } - } - if (remaining.length > 0) { - autoCategories.push({ name: builtinCategoryName, pieces: remaining, children: [] }); - } - return autoCategories; - } - - // No default categories available: flat list - return [{ name: builtinCategoryName, pieces: Array.from(uncategorizedBuiltins), children: [] }]; -} - /** * Build categorized pieces map from configuration. + * All pieces (user and builtin) are placed in a single category tree. */ export function buildCategorizedPieces( allPieces: Map, config: CategoryConfig, ): CategorizedPieces { - const { builtinCategoryName } = config; - const ignoreMissing = new Set(); if (!getBuiltinPiecesEnabled()) { for (const name of listBuiltinPieceNames({ includeDisabled: true })) { @@ -348,66 +274,19 @@ export function buildCategorizedPieces( ignoreMissing, ); - const isBuiltin = (source: PieceSource): boolean => source === 'builtin'; - const isCustom = (source: PieceSource): boolean => source !== 'builtin'; - - const categorizedCustom = new Set(); - const categories = buildCategoryTreeForSource( + const categorized = new Set(); + const categories = buildCategoryTree( config.pieceCategories, allPieces, - isCustom, - categorizedCustom, + categorized, ); - const categorizedBuiltin = new Set(); - const builtinCategories = buildCategoryTreeForSource( - config.pieceCategories, - allPieces, - isBuiltin, - categorizedBuiltin, - ); - - // When user defined categories, auto-categorize uncategorized builtins - if (config.hasCustomCategories) { - const uncategorizedBuiltins = collectUncategorizedBuiltins(allPieces, categorizedBuiltin); - if (uncategorizedBuiltins.size > 0) { - const defaultConfig = loadDefaultCategories(); - const autoCategories = buildAutoBuiltinCategories(allPieces, uncategorizedBuiltins, builtinCategoryName, defaultConfig); - builtinCategories.push(...autoCategories); - } - } - const finalCategories = config.showOthersCategory - ? appendOthersCategory( - categories, - allPieces, - categorizedCustom, - isCustom, - config.othersCategoryName, - ) + ? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName) : categories; - // For user-defined configs, uncategorized builtins are already handled above, - // so only apply Others for non-user-defined configs - let finalBuiltinCategories: PieceCategoryNode[]; - if (config.hasCustomCategories) { - finalBuiltinCategories = builtinCategories; - } else { - finalBuiltinCategories = config.showOthersCategory - ? appendOthersCategory( - builtinCategories, - allPieces, - categorizedBuiltin, - isBuiltin, - config.othersCategoryName, - ) - : builtinCategories; - } - return { categories: finalCategories, - builtinCategories: finalBuiltinCategories, - builtinCategoryName, allPieces, missingPieces, };