feat(v029): NoteRepository.requeueDisabled + countByAiStatus + AiProviderSection 처리 버튼

This commit is contained in:
altair823
2026-05-09 16:35:53 +09:00
parent c21fca57dd
commit 6070562358
10 changed files with 208 additions and 5 deletions

View File

@@ -169,7 +169,9 @@ app.whenReady().then(async () => {
registerInboxApi({
repo, continuity, capture, health, intent,
getInboxWindow, settings: settingsSvc, providerHolder,
paths: { profileDir: paths.profileDir }
paths: { profileDir: paths.profileDir },
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신.
enqueue: (id) => worker.enqueue(id)
});
// registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가
// 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동.

View File

@@ -25,6 +25,10 @@ export interface InboxIpcDeps {
providerHolder: ProviderHolder;
// v0.2.8 Cut A — `inbox:open-media` 의 path traversal 검사 baseline.
paths: { profileDir: string };
// v0.2.9 Cut B Task 16 — disabled 메모 일괄 처리 시 in-memory worker queue 갱신.
// 미주입 시 fire-and-forget skip (다음 launch 의 loadFromDb 가 처리). 본 hook 은
// AiWorker 인스턴스 직접 주입을 피해 IPC 모듈이 worker import 를 갖지 않도록 분리.
enqueue?: (noteId: string) => Promise<void>;
}
export function registerInboxApi(deps: InboxIpcDeps): void {
@@ -226,6 +230,29 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
});
});
// v0.2.9 Cut B Task 16 — disabled 메모 (ai_enabled OFF 시기 캡처) 일괄 재투입.
// OFF→ON 전환 후 사용자가 "지금 모두 처리" 버튼 클릭 path. repo.requeueDisabled 가
// ai_status='pending' + pending_jobs row 보장, worker.enqueue 가 in-memory queue 갱신.
ipcMain.handle('inbox:enqueue-disabled', async () => {
// requeue 전 대상 id 수집 — UPDATE 가 status 바꾸므로 select 후 update 필요 없이
// requeueDisabled 가 처리한 다음 pending_jobs 에서 다시 가져와 enqueue.
const targets = deps.repo.getAllPendingJobs().map((j) => j.noteId);
const before = new Set(targets);
const count = deps.repo.requeueDisabled();
if (count > 0 && deps.enqueue) {
const after = deps.repo.getAllPendingJobs();
// requeue 직후 새로 들어온 pending_jobs row 만 enqueue (기존 row 는 이미 in-memory queue 에).
for (const j of after) {
if (!before.has(j.noteId)) {
await deps.enqueue(j.noteId);
}
}
}
return { count };
});
ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled'));
ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => {
// 검증: 새 인스턴스로 healthCheck
const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model });

View File

@@ -216,6 +216,50 @@ export class NoteRepository {
return { ids };
}
/**
* v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고
* pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리"
* 버튼을 누른 path. 단일 transaction. 호출자가 `now` 주입 가능 (테스트성).
*
* INSERT OR IGNORE — race 안전 (이미 pending_jobs row 존재 시 skip).
* 반환값 = 처리된 노트 수 (UI 가 "N건 처리됨" 토스트 등 표시용).
*/
requeueDisabled(now: Date = new Date()): number {
const tx = this.db.transaction(() => {
const ts = now.toISOString();
const targets = this.db
.prepare(`SELECT id FROM notes WHERE ai_status='disabled'`)
.all() as Array<{ id: string }>;
for (const { id } of targets) {
this.db
.prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`)
.run(ts, id);
this.db
.prepare(
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
)
.run(id, ts);
}
return targets.length;
});
return tx();
}
/**
* v0.2.9 Cut B Task 16 — ai_status 별 row count.
* 설정 페이지의 "원문만 저장된 메모 N건" 표기용 (status='disabled' 카운트).
* deleted_at 필터 없음 — disabled 메모도 trash 갈 수 있는데 사용자 의도는
* "AI 처리할 게 얼마나 남았나?" 라 trashed 까지 포함되면 안 됨. → deleted_at IS NULL 추가.
*/
countByAiStatus(status: AiStatus): number {
const row = this.db
.prepare(
`SELECT COUNT(*) AS c FROM notes WHERE ai_status=? AND deleted_at IS NULL`
)
.get(status) as { c: number };
return row.c;
}
/**
* pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음.
* v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로).

View File

@@ -78,6 +78,9 @@ const api: InklingApi = {
getSettings: () => ipcRenderer.invoke('settings:get'),
setAiEnabled: (enabled: boolean) => ipcRenderer.invoke('settings:set-ai-enabled', enabled),
setOnboardingCompleted: (completed: boolean) => ipcRenderer.invoke('settings:set-onboarding-completed', completed),
// v0.2.9 Cut B Task 16 — disabled 메모 재투입 + count.
enqueueDisabled: () => ipcRenderer.invoke('inbox:enqueue-disabled'),
getDisabledCount: () => ipcRenderer.invoke('inbox:get-disabled-count'),
}
};

View File

@@ -10,8 +10,9 @@ export function AiProviderSection(): React.ReactElement {
const [error, setError] = useState<string | null>(null);
const [saveResult, setSaveResult] = useState<string | null>(null);
const [recheckResult, setRecheckResult] = useState<string | null>(null);
// v0.2.9 Cut B Task 15: AI 자동 처리 토글.
// v0.2.9 Cut B Task 15-16: AI 자동 처리 토글 + disabled 메모 일괄 처리.
const [aiEnabled, setAiEnabledState] = useState<boolean | null>(null);
const [disabledCount, setDisabledCount] = useState(0);
useEffect(() => {
void (async () => {
@@ -21,13 +22,29 @@ export function AiProviderSection(): React.ReactElement {
setModel(s.model);
}
const settings = await inboxApi.getSettings();
setAiEnabledState(settings.ai_enabled ?? true);
const enabled = settings.ai_enabled ?? true;
setAiEnabledState(enabled);
if (enabled) {
const c = await inboxApi.getDisabledCount();
setDisabledCount(c);
}
})();
}, []);
async function onToggleAi(checked: boolean): Promise<void> {
await inboxApi.setAiEnabled(checked);
setAiEnabledState(checked);
if (checked) {
const c = await inboxApi.getDisabledCount();
setDisabledCount(c);
} else {
setDisabledCount(0);
}
}
async function onProcessDisabled(): Promise<void> {
await inboxApi.enqueueDisabled();
setDisabledCount(0);
}
async function onSave(): Promise<void> {
@@ -79,6 +96,27 @@ export function AiProviderSection(): React.ReactElement {
</a>
</p>
)}
{/* v0.2.9 Cut B Task 16 — ON 전환 후 disabled 메모 일괄 처리 prompt */}
{aiEnabled === true && disabledCount > 0 && (
<div style={{ padding: 8, background: '#fffbe5', borderRadius: 4, marginBottom: 12, fontSize: 13 }}>
{disabledCount} .
<button
onClick={() => void onProcessDisabled()}
style={{
marginLeft: 8,
background: '#0a4b80',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '4px 10px',
fontSize: 12,
cursor: 'pointer'
}}
>
</button>
</div>
)}
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
Endpoint
<input

View File

@@ -153,6 +153,9 @@ export interface InboxApi {
}>;
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
// v0.2.9 Cut B Task 16 — ai_status='disabled' 메모 재투입 (사용자가 ai_enabled OFF→ON 전환 시).
enqueueDisabled(): Promise<{ count: number }>;
getDisabledCount(): Promise<number>;
}
export interface InklingApi {

View File

@@ -73,4 +73,25 @@ describe('AiProviderSection', () => {
expect(screen.getByText(/원문만 저장 모드/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /ollama\.com|설치/ })).toBeInTheDocument();
});
// v0.2.9 Cut B Task 16 — ON 전환 후 disabled 메모 처리 prompt + 버튼.
it('shows disabled count + 처리 버튼 when ai_enabled=true and disabledCount > 0', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
vi.mocked(inboxApi.getDisabledCount).mockResolvedValue(5);
render(<AiProviderSection />);
await screen.findByText(/5건/);
expect(screen.getByRole('button', { name: /지금 모두 처리/ })).toBeInTheDocument();
});
it('clicking 처리 버튼 calls enqueueDisabled', async () => {
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
vi.mocked(inboxApi.getSettings).mockResolvedValue({ ai_enabled: true } as never);
vi.mocked(inboxApi.getDisabledCount).mockResolvedValue(3);
vi.mocked(inboxApi.enqueueDisabled).mockResolvedValue({ count: 3 } as never);
render(<AiProviderSection />);
await screen.findByText(/3건/);
fireEvent.click(screen.getByRole('button', { name: /지금 모두 처리/ }));
await waitFor(() => expect(inboxApi.enqueueDisabled).toHaveBeenCalled());
});
});

View File

@@ -53,7 +53,10 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
// v0.2.9 Cut B Task 12 — onboarding wizard 분기. default 는 onboarding_completed=true 라 wizard 미표시.
getSettings: vi.fn(async () => ({ onboarding_completed: true })),
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const }))
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
// v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출.
getDisabledCount: vi.fn(async () => 0),
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
}
}));

View File

@@ -998,3 +998,59 @@ describe('NoteRepository — setStatus + listByStatus', () => {
expect(after.deletedAt).toBeNull();
});
});
// v0.2.9 Cut B Task 16 — settings.ai_enabled OFF→ON 전환 시 disabled 메모 일괄 재투입.
describe('NoteRepository.requeueDisabled', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('changes ai_status="disabled" → "pending" + INSERT pending_jobs', () => {
const { id } = repo.create({ rawText: 't', aiStatus: 'disabled' });
const count = repo.requeueDisabled(new Date('2026-05-09T00:00:00Z'));
expect(count).toBe(1);
const note = repo.findById(id);
expect(note?.aiStatus).toBe('pending');
const job = db.prepare(`SELECT * FROM pending_jobs WHERE note_id=?`).get(id);
expect(job).toBeDefined();
});
it('does not affect non-disabled notes', () => {
const idP = repo.create({ rawText: 'p', aiStatus: 'pending' }).id;
const idC = repo.create({ rawText: 'c' }).id;
repo.updateAiResult(idC, { title: 't', summary: 'a\nb\nc', tags: [], provider: 'p' });
repo.requeueDisabled(new Date());
expect(repo.findById(idP)?.aiStatus).toBe('pending');
expect(repo.findById(idC)?.aiStatus).toBe('done');
});
it('returns 0 when no disabled notes', () => {
const count = repo.requeueDisabled(new Date());
expect(count).toBe(0);
});
});
describe('NoteRepository.countByAiStatus', () => {
let db: Database.Database;
let repo: NoteRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new NoteRepository(db);
});
it('returns count per ai_status', () => {
repo.create({ rawText: 'a', aiStatus: 'disabled' });
repo.create({ rawText: 'b', aiStatus: 'disabled' });
repo.create({ rawText: 'c', aiStatus: 'pending' });
expect(repo.countByAiStatus('disabled')).toBe(2);
expect(repo.countByAiStatus('pending')).toBe(1);
expect(repo.countByAiStatus('done')).toBe(0);
});
});

View File

@@ -40,7 +40,13 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
profileDir: '/tmp/Inkling'
})),
openProfileDir: vi.fn(async () => undefined),
copyAppInfo: vi.fn(async () => undefined)
copyAppInfo: vi.fn(async () => undefined),
// v0.2.9 Cut B Task 15-16 — AiProviderSection 의 토글 + disabled 메모 prompt.
getSettings: vi.fn(async () => ({ ai_enabled: true, onboarding_completed: true })),
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
getDisabledCount: vi.fn(async () => 0),
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
}
}));