Browse Source

feat(suggest-path): implement suggestion types and memo path generation

Add shared types (SuggestionType, PathSuggestion, SuggestPathResponse)
and implement generateMemoSuggestion with user-pages-enabled/disabled
path branching. Includes 11 unit tests covering both paths.

Also fix @growi/core vite build on Windows (glob path normalization).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t 1 month ago
parent
commit
56f9087ce8

+ 1 - 1
.kiro/specs/suggest-path/tasks.md

@@ -3,7 +3,7 @@
 ## Phase 1 (MVP)
 
 - [ ] 1. Phase 1 MVP — Shared types and memo path suggestion
-- [ ] 1.1 Define suggestion types and implement memo path generation
+- [x] 1.1 Define suggestion types and implement memo path generation
   - Define the suggestion response types used across both phases: suggestion type discriminator, individual suggestion structure with type/path/label/description/grant fields, and the response wrapper
   - Implement memo path generation: when user pages are enabled (default), generate path under the user's home directory with owner-only grant; when user pages are disabled, generate path under an alternative namespace with hardcoded owner-only grant (actual parent grant resolution deferred to Phase 2 task 2)
   - Enforce directory path format with trailing slash for all generated paths

+ 107 - 0
apps/app/src/server/routes/apiv3/ai-tools/generate-memo-suggestion.spec.ts

@@ -0,0 +1,107 @@
+import { generateMemoSuggestion } from './generate-memo-suggestion';
+
+const mocks = vi.hoisted(() => {
+  return {
+    configManagerMock: {
+      getConfig: vi.fn(),
+    },
+  };
+});
+
+vi.mock('@growi/core', () => ({
+  PageGrant: {
+    GRANT_PUBLIC: 1,
+    GRANT_RESTRICTED: 2,
+    GRANT_OWNER: 4,
+    GRANT_USER_GROUP: 5,
+  },
+}));
+
+vi.mock('@growi/core/dist/utils/page-path-utils', () => ({
+  userHomepagePath: (user: { username: string }) => `/user/${user.username}`,
+}));
+
+vi.mock('~/server/service/config-manager', () => {
+  return { configManager: mocks.configManagerMock };
+});
+
+const GRANT_OWNER = 4;
+
+describe('generateMemoSuggestion', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+  });
+
+  describe('when user pages are enabled (default)', () => {
+    beforeEach(() => {
+      mocks.configManagerMock.getConfig.mockImplementation((key: string) => {
+        if (key === 'security:disableUserPages') return false;
+        return undefined;
+      });
+    });
+
+    it('should return a suggestion with type "memo"', () => {
+      const result = generateMemoSuggestion({ username: 'alice' });
+      expect(result.type).toBe('memo');
+    });
+
+    it('should generate path under user home directory', () => {
+      const result = generateMemoSuggestion({ username: 'alice' });
+      expect(result.path).toBe('/user/alice/memo/');
+    });
+
+    it('should set grant to GRANT_OWNER (4)', () => {
+      const result = generateMemoSuggestion({ username: 'alice' });
+      expect(result.grant).toBe(GRANT_OWNER);
+    });
+
+    it('should include a fixed description', () => {
+      const result = generateMemoSuggestion({ username: 'alice' });
+      expect(result.description).toBe('Save to your personal memo area');
+    });
+
+    it('should include a label', () => {
+      const result = generateMemoSuggestion({ username: 'alice' });
+      expect(result.label).toBe('Save as memo');
+    });
+
+    it('should generate path with trailing slash', () => {
+      const result = generateMemoSuggestion({ username: 'alice' });
+      expect(result.path).toMatch(/\/$/);
+    });
+  });
+
+  describe('when user pages are disabled', () => {
+    beforeEach(() => {
+      mocks.configManagerMock.getConfig.mockImplementation((key: string) => {
+        if (key === 'security:disableUserPages') return true;
+        return undefined;
+      });
+    });
+
+    it('should generate path under alternative namespace', () => {
+      const result = generateMemoSuggestion({ username: 'bob' });
+      expect(result.path).toBe('/memo/bob/');
+    });
+
+    it('should set grant to GRANT_OWNER (4) as hardcoded default in Phase 1', () => {
+      const result = generateMemoSuggestion({ username: 'bob' });
+      expect(result.grant).toBe(GRANT_OWNER);
+    });
+
+    it('should return a suggestion with type "memo"', () => {
+      const result = generateMemoSuggestion({ username: 'bob' });
+      expect(result.type).toBe('memo');
+    });
+
+    it('should generate path with trailing slash', () => {
+      const result = generateMemoSuggestion({ username: 'bob' });
+      expect(result.path).toMatch(/\/$/);
+    });
+
+    it('should include same fixed description as enabled case', () => {
+      const result = generateMemoSuggestion({ username: 'bob' });
+      expect(result.description).toBe('Save to your personal memo area');
+    });
+  });
+});

+ 28 - 0
apps/app/src/server/routes/apiv3/ai-tools/generate-memo-suggestion.ts

@@ -0,0 +1,28 @@
+import { PageGrant } from '@growi/core';
+import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
+
+import { configManager } from '~/server/service/config-manager';
+
+import type { PathSuggestion } from './suggest-path-types';
+import { SuggestionType } from './suggest-path-types';
+
+const MEMO_LABEL = 'Save as memo';
+const MEMO_DESCRIPTION = 'Save to your personal memo area';
+
+export const generateMemoSuggestion = (user: {
+  username: string;
+}): PathSuggestion => {
+  const disableUserPages = configManager.getConfig('security:disableUserPages');
+
+  const path = disableUserPages
+    ? `/memo/${user.username}/`
+    : `${userHomepagePath(user)}/memo/`;
+
+  return {
+    type: SuggestionType.MEMO,
+    path,
+    label: MEMO_LABEL,
+    description: MEMO_DESCRIPTION,
+    grant: PageGrant.GRANT_OWNER,
+  };
+};

+ 20 - 0
apps/app/src/server/routes/apiv3/ai-tools/suggest-path-types.ts

@@ -0,0 +1,20 @@
+export const SuggestionType = {
+  MEMO: 'memo',
+  SEARCH: 'search',
+  CATEGORY: 'category',
+} as const;
+
+export type SuggestionType =
+  (typeof SuggestionType)[keyof typeof SuggestionType];
+
+export type PathSuggestion = {
+  type: SuggestionType;
+  path: string;
+  label: string;
+  description: string;
+  grant: number;
+};
+
+export type SuggestPathResponse = {
+  suggestions: PathSuggestion[];
+};

+ 3 - 1
packages/core/vite.config.ts

@@ -22,7 +22,9 @@ export default defineConfig({
     outDir: 'dist',
     sourcemap: true,
     lib: {
-      entry: glob.sync(path.resolve(__dirname, 'src/**/*.ts'), {
+      entry: glob.sync('src/**/*.ts', {
+        cwd: __dirname,
+        absolute: true,
         ignore: '**/*.spec.ts',
       }),
       name: 'core-libs',