feat(v029): NoteRepository.requeueDisabled + countByAiStatus + AiProviderSection 처리 버튼
This commit is contained in:
@@ -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/... 초기화 직후로 이동.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 와 별도 경로).
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user