diff --git a/src/servers/api/tasks/task-files.ts b/src/servers/api/tasks/task-files.ts index 2feb542b..c5ef97ca 100644 --- a/src/servers/api/tasks/task-files.ts +++ b/src/servers/api/tasks/task-files.ts @@ -1,6 +1,6 @@ import { readdir, mkdir, rm } from 'node:fs/promises'; import { join, dirname } from 'node:path'; -import { itemsDir } from '../../data-path'; +import { itemsDir, OFFICER_ITEMS_DIR } from '../../data-path'; // File-backed task store. Every task is a directory under $OFFICER_ITEMS_DIR/tasks// with a // TASK.md (metadata + prose body) and, for script-mode tasks, a sibling implementation file. There are @@ -149,6 +149,25 @@ async function readImplementation(dirName: string, language: string | null): Pro return (await file.exists()) ? file.text() : null; } +// categories.yaml at the root of the items store lists the task categories in the order they should +// appear in the file browser's Run Task submenu. It is optional and purely presentational: the +// platform never decides a task's category, only how known ones sort. A missing, empty or malformed +// file leaves everything to alphabetical ordering. +export async function readCategoryOrder(): Promise { + const file = Bun.file(join(OFFICER_ITEMS_DIR, 'categories.yaml')); + if (!(await file.exists())) return []; + try { + const parsed = YAML.parse(await file.text()); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean); + } catch { + return []; + } +} + export async function listTasks(): Promise { let entries; try { diff --git a/src/servers/api/tasks/tasks.ts b/src/servers/api/tasks/tasks.ts index 411ba539..14584eb9 100644 --- a/src/servers/api/tasks/tasks.ts +++ b/src/servers/api/tasks/tasks.ts @@ -1,5 +1,5 @@ import { createRouter } from '../../create-router'; -import { listTasks, getTaskByDirName, createTask, deleteTask } from './task-files'; +import { listTasks, getTaskByDirName, createTask, deleteTask, readCategoryOrder } from './task-files'; type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' }; @@ -20,6 +20,11 @@ tasksRouter.get('/', async (ctx) => { return ctx.json(tasks); }); +// Must be declared before '/:name', which would otherwise match "categories". +tasksRouter.get('/categories', async (ctx) => { + return ctx.json(await readCategoryOrder()); +}); + tasksRouter.get('/:name', async (ctx) => { const name = ctx.req.param('name'); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx index 5d48deac..7e9b409c 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileGrid.tsx @@ -66,7 +66,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { handleExtractAudio, handleExtract, handlePlay, - getMatchingTasks, + getMatchingTaskGroups, handleRunTask, handleCreateDashboard, fileScrollRef: scrollRef, @@ -185,7 +185,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => { onExtractAudio={handleExtractAudio} onExtract={handleExtract} onPlay={handlePlay} - matchingTasks={getMatchingTasks(entry.name, entry.type)} + taskGroups={getMatchingTaskGroups(entry.name, entry.type)} onRunTask={handleRunTask} onCreateDashboard={handleCreateDashboard} /> diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index ba0ed33b..a1ba1587 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -42,7 +42,7 @@ import { } from '@/components/ui/context-menu'; import { cardStyle } from '@/components/Card'; import type { DirEntry } from '../../../../hooks/useFilesAPI'; -import { groupTasksByCategory, type TaskSummary } from '../../useTasks'; +import type { TaskGroup, TaskSummary } from '../../useTasks'; import { getFileType } from '../../../FileViewer'; export type FileItemProps = { @@ -69,7 +69,7 @@ export type FileItemProps = { onExtractAudio: (entry: DirEntry) => void; onExtract: (entry: DirEntry) => void; onPlay: (entry: DirEntry) => void; - matchingTasks: TaskSummary[]; + taskGroups: TaskGroup[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; onCreateDashboard: (entry: DirEntry) => void; }; @@ -104,7 +104,7 @@ type MenuItemsProps = { onPlay: (e: DirEntry) => void; onCut: () => void; onCopy: () => void; - matchingTasks: TaskSummary[]; + taskGroups: TaskGroup[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; onCreateDashboard: (e: DirEntry) => void; }; @@ -125,7 +125,7 @@ const DropdownMenuItems = ({ onPlay, onCut, onCopy, - matchingTasks, + taskGroups, onRunTask, onCreateDashboard, }: MenuItemsProps) => { @@ -137,18 +137,13 @@ const DropdownMenuItems = ({ const showExtract = fileType === 'archive'; const showPlay = fileType === 'audio' || entry.type === 'directory'; + const hasTasks = taskGroups.length > 0; const hasActions = - showPlay || - showReadAloud || - showOcr || - showTranscribe || - showExtractAudio || - showExtract || - matchingTasks.length > 0; - // Only nest when more than one category is present — a file usually matches a single category, - // and Run Task > Video > Convert would just add a hop. - const taskGroups = groupTasksByCategory(matchingTasks); + showPlay || showReadAloud || showOcr || showTranscribe || showExtractAudio || showExtract || hasTasks; + // Only nest when more than one category matched — a file usually matches a single category, and + // Run Task > Video > Convert would just add a hop. const nestTasks = taskGroups.length > 1; + const flatTasks = taskGroups[0]?.tasks ?? []; return ( <> @@ -188,7 +183,7 @@ const DropdownMenuItems = ({ Extract )} - {matchingTasks.length > 0 && ( + {hasTasks && ( @@ -212,7 +207,7 @@ const DropdownMenuItems = ({ )) - : matchingTasks.map((task) => ( + : flatTasks.map((task) => ( onRunTask(task, entry)} @@ -283,7 +278,7 @@ const ContextMenuItems = ({ onPlay, onCut, onCopy, - matchingTasks, + taskGroups, onRunTask, onCreateDashboard, }: MenuItemsProps) => { @@ -295,18 +290,13 @@ const ContextMenuItems = ({ const showExtract = fileType === 'archive'; const showPlay = fileType === 'audio' || entry.type === 'directory'; + const hasTasks = taskGroups.length > 0; const hasActions = - showPlay || - showReadAloud || - showOcr || - showTranscribe || - showExtractAudio || - showExtract || - matchingTasks.length > 0; - // Only nest when more than one category is present — a file usually matches a single category, - // and Run Task > Video > Convert would just add a hop. - const taskGroups = groupTasksByCategory(matchingTasks); + showPlay || showReadAloud || showOcr || showTranscribe || showExtractAudio || showExtract || hasTasks; + // Only nest when more than one category matched — a file usually matches a single category, and + // Run Task > Video > Convert would just add a hop. const nestTasks = taskGroups.length > 1; + const flatTasks = taskGroups[0]?.tasks ?? []; return ( <> @@ -346,7 +336,7 @@ const ContextMenuItems = ({ Extract )} - {matchingTasks.length > 0 && ( + {hasTasks && ( @@ -370,7 +360,7 @@ const ContextMenuItems = ({ )) - : matchingTasks.map((task) => ( + : flatTasks.map((task) => ( onRunTask(task, entry)} className="cursor-pointer"> {task.name} @@ -548,7 +538,7 @@ export const FileItem = ({ onExtractAudio, onExtract, onPlay, - matchingTasks, + taskGroups, onRunTask, onCreateDashboard, }: FileItemProps) => { @@ -633,7 +623,7 @@ export const FileItem = ({ onPlay, onCut, onCopy, - matchingTasks, + taskGroups, onRunTask, onCreateDashboard, }; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index fc9d38a9..1a3d6350 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -59,7 +59,7 @@ export const useFileBrowserApp = ( const [audioOnly, setAudioOnly] = useState(false); const [showDictate, setShowDictate] = useState(false); const dragCounter = useRef(0); - const { getMatchingTasks } = useTasks(); + const { getMatchingTasks, getMatchingTaskGroups } = useTasks(); const searchTimerRef = useRef | null>(null); const searchInputRef = useRef(null); const fileScrollRef = useRef(null); @@ -745,6 +745,7 @@ export const useFileBrowserApp = ( runningTask, setRunningTask, getMatchingTasks, + getMatchingTaskGroups, // Video download showVideoDownload, setShowVideoDownload, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts new file mode 100644 index 00000000..6a5bad7c --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.test.ts @@ -0,0 +1,57 @@ +import { test, expect } from 'bun:test'; +import { groupTasksByCategory, type TaskSummary } from './useTasks'; + +const task = (name: string, category: string | null): TaskSummary => ({ + id: 0, + dirName: name.toLowerCase().replace(/\s+/g, '-'), + name, + description: '', + scope: '', + category, + triggers: [{ type: 'directory' }], + mode: 'script', + userId: null, +}); + +const shape = (tasks: TaskSummary[], order: string[]) => + groupTasksByCategory(tasks, order).map((g) => `${g.category}(${g.tasks.length})`); + +// The point of the grouping: categories.yaml is a sort key, never a source of menu entries. A +// category listed there with nothing in it must not render an empty submenu. +test('categories with no matching tasks do not appear', () => { + const order = ['Video', 'Photos', 'Audio', 'Documents', 'Cleanup']; + const groups = groupTasksByCategory([task('Convert Video', 'Video'), task('Tag Album', 'Audio')], order); + + expect(groups.map((g) => g.category)).toEqual(['Video', 'Audio']); + expect(groups.every((g) => g.tasks.length > 0)).toBe(true); +}); + +test('an empty task list produces no groups at all', () => { + expect(groupTasksByCategory([], ['Video', 'Audio'])).toEqual([]); +}); + +test('order follows categories.yaml', () => { + const tasks = [task('A', 'Audio'), task('V', 'Video'), task('C', 'Cleanup')]; + expect(shape(tasks, ['Video', 'Audio', 'Cleanup'])).toEqual(['Video(1)', 'Audio(1)', 'Cleanup(1)']); + expect(shape(tasks, ['Cleanup', 'Audio', 'Video'])).toEqual(['Cleanup(1)', 'Audio(1)', 'Video(1)']); +}); + +test('a category missing from the file still renders, after the listed ones', () => { + const tasks = [task('Crop', 'Photos'), task('V', 'Video')]; + expect(shape(tasks, ['Video'])).toEqual(['Video(1)', 'Photos(1)']); +}); + +test('uncategorized tasks fall into Other, which sorts last', () => { + const tasks = [task('Mystery', null), task('Blank', ' '), task('V', 'Video')]; + expect(shape(tasks, ['Video'])).toEqual(['Video(1)', 'Other(2)']); +}); + +test('with no order at all, categories sort alphabetically and Other stays last', () => { + const tasks = [task('V', 'Video'), task('A', 'Audio'), task('M', null)]; + expect(shape(tasks, [])).toEqual(['Audio(1)', 'Video(1)', 'Other(1)']); +}); + +test('tasks within a group are sorted by display name', () => { + const tasks = [task('Zebra', 'Video'), task('Alpha', 'Video')]; + expect(groupTasksByCategory(tasks, ['Video'])[0]!.tasks.map((t) => t.name)).toEqual(['Alpha', 'Zebra']); +}); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts index f2af9f51..4d7ca1ab 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/useTasks.ts @@ -20,17 +20,18 @@ export type TaskGroup = { category: string; tasks: TaskSummary[] }; const UNCATEGORIZED = 'Other'; // Every task carries a `directory` trigger, so right-clicking a folder listed all of them at once. -// Grouping by category turns that into Run Task > Video > … Known categories lead in this order; -// anything else follows alphabetically, with Other always last. -const CATEGORY_ORDER = ['Video', 'Audio', 'Cleanup']; - -const categoryRank = (category: string): number => { - const known = CATEGORY_ORDER.indexOf(category); +// Grouping by category turns that into Run Task > Video > … +// +// The order comes from categories.yaml in the items store, not from here — adding a category is a +// change to that file plus the tasks using it, never to this code. Categories absent from the list +// sort alphabetically after the listed ones, and Other is always last. +const categoryRank = (category: string, order: string[]): number => { + const known = order.indexOf(category); if (known !== -1) return known; - return category === UNCATEGORIZED ? Number.MAX_SAFE_INTEGER : CATEGORY_ORDER.length; + return category === UNCATEGORIZED ? Number.MAX_SAFE_INTEGER : order.length; }; -export const groupTasksByCategory = (tasks: TaskSummary[]): TaskGroup[] => { +export const groupTasksByCategory = (tasks: TaskSummary[], order: string[] = []): TaskGroup[] => { const groups = new Map(); for (const task of tasks) { const category = task.category?.trim() || UNCATEGORIZED; @@ -41,7 +42,10 @@ export const groupTasksByCategory = (tasks: TaskSummary[]): TaskGroup[] => { return Array.from(groups.entries()) .map(([category, list]) => ({ category, tasks: [...list].sort((a, b) => a.name.localeCompare(b.name)) })) - .sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.category.localeCompare(b.category)); + .sort( + (a, b) => + categoryRank(a.category, order) - categoryRank(b.category, order) || a.category.localeCompare(b.category), + ); }; export const useTasks = () => { @@ -53,6 +57,12 @@ export const useTasks = () => { staleTime: 60_000, }); + const { data: categoryOrder = [] } = useQuery({ + queryKey: ['task-categories'], + queryFn: () => client.get('/tasks/categories'), + staleTime: 60_000, + }); + const getMatchingTasks = (fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => { if (entryType === 'directory') { return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'directory')); @@ -62,5 +72,9 @@ export const useTasks = () => { return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext))); }; - return { tasks, getMatchingTasks }; + // Grouped for the context menu, ordered by categories.yaml. + const getMatchingTaskGroups = (fileName: string, entryType: 'file' | 'directory'): TaskGroup[] => + groupTasksByCategory(getMatchingTasks(fileName, entryType), categoryOrder); + + return { tasks, categoryOrder, getMatchingTasks, getMatchingTaskGroups }; };