Files
inkling/src/renderer/inbox/store.ts
th-kim0823 343624dceb feat(search): scope 토글 — 이 노트북 / 모든 노트북
SearchBox 에 scope dropdown 추가. 기본 'current' (현재 notebook ID 전달),
'all' 선택 시 notebookId=undefined 로 전체 검색. store.searchNotes opts 인자 추가.
2026-05-15 11:07:37 +09:00

525 lines
21 KiB
TypeScript

import { create } from 'zustand';
import type { Note, NoteStatus, Notebook, PromotionCandidate, ReviewAggregate, WeeklyContinuity } from '@shared/types';
import { inboxApi, notebookApi } from './api.js';
import { nextKstMidnightMs } from '@shared/util/kstDate.js';
function toTitleCase(s: string): string {
return s.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
export { selectFilteredNotes } from './selectFilteredNotes.js';
// v0.2.9 Cut B Task 4 — 3탭 view enum + settings.
// v0.4 Task 16 — 'archived' view 제거 (NoteStatus 에서 archived 삭제됨).
// 'inbox' = active, 'completed' = NoteStatus 그대로, 'trash' = trashed (mirror), 'settings' = SettingsPage.
export type InboxView =
| 'inbox' | 'completed' | 'trash' | 'settings'
| 'review-daily' | 'review-weekly' | 'review-monthly';
export interface InboxCounts {
active: number;
completed: number;
trashed: number;
}
interface InboxState {
notes: Note[];
trashNotes: Note[];
trashCount: number;
showTrash: boolean;
showSettings: boolean;
// v0.2.9 Cut B Task 4 — view enum + counts. showTrash/showSettings 는 mirror 로 잠시 잔류.
view: InboxView;
counts: InboxCounts;
continuity: WeeklyContinuity;
pendingCount: number;
ollamaStatus: { ok: boolean; reason?: string };
todayCount: number;
loading: boolean;
tagFilter: string | null;
expiredCandidates: Note[];
expiredSnoozeUntilMs: number | null;
failedCount: number;
recallCandidate: Note | null;
recallSnoozeUntilMs: number | null;
// v0.2.9 Cut B Task 14 — AI 비활성 모드에서는 OllamaBanner/FailedBanner render skip.
// 기본 true (기존 사용자 무영향). loadInitial / refreshMeta 가 settings 로드.
ai_enabled: boolean;
// v0.2.11 Cut D — FTS5 search + review aggregate state.
searchQuery: string;
searchResults: Note[] | null; // null = 검색 안 한 상태
reviewData: ReviewAggregate | null;
// v0.4 — Notebook sidebar state.
notebooks: Notebook[];
selectedNotebookId: string | null;
sidebarVisible: boolean;
sidebarWidth: number;
// v0.4 Task 11 — promotion candidates (dismissed/snoozed 필터 적용 후 목록).
promotionCandidates: PromotionCandidate[];
loadInitial: () => Promise<void>;
refreshMeta: () => Promise<void>;
upsertNote: (note: Note) => void;
removeNote: (id: string) => void;
setTagFilter: (tag: string | null) => void;
setShowSettings: (open: boolean) => void;
setView: (view: InboxView) => void;
loadByView: (view: 'inbox' | 'completed' | 'trash') => Promise<void>;
toggleShowTrash: () => Promise<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
emptyTrash: () => Promise<void>;
trashExpiredBatch: (ids: string[]) => Promise<void>;
snoozeExpired: () => void;
recheckOllama: () => Promise<void>;
retryAllFailed: () => Promise<void>;
loadRecallCandidate: () => Promise<void>;
openRecall: (id: string) => Promise<void>;
dismissRecallNote: (id: string) => Promise<void>;
snoozeRecall: () => Promise<void>;
// v0.2.11 Cut D — search + review actions.
setSearchQuery: (q: string) => void;
// v0.4 Task 18 — scope 토글. notebookId 전달 시 해당 notebook 안 검색, undefined 시 전체 검색.
searchNotes: (q: string, opts?: { notebookId?: string }) => Promise<void>;
clearSearch: () => void;
loadReview: (period: 'daily' | 'weekly' | 'monthly') => Promise<void>;
// v0.4 — Notebook actions.
loadNotebooks: () => Promise<void>;
selectNotebook: (id: string) => void;
createNotebook: (name: string, color?: string) => Promise<{ ok: boolean; reason?: string }>;
renameNotebook: (id: string, name: string) => Promise<{ ok: boolean; reason?: string }>;
setNotebookColor: (id: string, color: string | null) => Promise<void>;
deleteNotebook: (id: string) => Promise<{ ok: boolean; reason?: string }>;
moveNoteToNotebook: (noteId: string, notebookId: string) => Promise<void>;
toggleSidebar: () => void;
// v0.4 Task 11 — promotion candidate actions.
loadPromotionCandidates: () => Promise<void>;
acceptPromotion: (tag: string, customName: string, color: string | undefined) => Promise<void>;
snoozePromotion: () => Promise<void>;
dismissPromotion: (tag: string) => Promise<void>;
}
const emptyContinuity: WeeklyContinuity = {
weekStart: '', weekCount: 0, weekTarget: 7,
consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
};
export const useInbox = create<InboxState>((set, get) => ({
notes: [],
trashNotes: [],
trashCount: 0,
showTrash: false,
showSettings: false,
view: 'inbox',
counts: { active: 0, completed: 0, trashed: 0 },
continuity: emptyContinuity,
pendingCount: 0,
ollamaStatus: { ok: true },
todayCount: 0,
loading: false,
tagFilter: null,
expiredCandidates: [],
expiredSnoozeUntilMs: null,
failedCount: 0,
recallCandidate: null,
recallSnoozeUntilMs: null,
ai_enabled: true,
searchQuery: '',
searchResults: null,
reviewData: null,
notebooks: [],
selectedNotebookId: null,
sidebarVisible: false,
sidebarWidth: 240,
promotionCandidates: [],
async loadInitial() {
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
set({ loading: true });
try {
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
// inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
// listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
inboxApi.listByStatus('active', { limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
} catch (e) {
// 첫 launch 의 IPC 실패 (DB migration 실패 / main process 비정상) 시 무한 loading 회피.
// 빈 데이터로 진입하면 사용자가 캡처 시도 → 실제 fail 이 표면화 → 재시도 가능.
console.error('[inbox] loadInitial failed', e);
set({ loading: false });
}
},
async refreshMeta() {
try {
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount(),
inboxApi.listRecallCandidate(),
inboxApi.countsByStatus(),
inboxApi.getSettings()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
} catch (e) {
// refreshMeta 는 background poll/event 에서 자주 호출 → fail 무시 (다음 호출에 회복).
console.error('[inbox] refreshMeta failed', e);
}
},
upsertNote(note) {
// v0.3.8 — status 가 current view 와 매칭될 때만 notes 에 유지. 그 외엔 제거.
// 이전 구현은 trashed 외 모든 status 를 notes 에 누적 → 사용자가 inbox view 에서
// 완료/보관 으로 옮긴 노트가 list 에 잔류하는 버그. push-based (setStatus 도 emit) 로
// 모든 status 전이가 upsertNote 를 거치므로 view-aware filter 가 필수.
//
// trashCount/trashNotes 는 server-authoritative. trashNotes 가 cache-loaded
// (view='trash') 일 때만 trashCount 를 local recompute. 그 외엔 server 값
// (refreshMeta) 보존. searchResults 도 별도로 갱신 (status 변경 시 list 에서 제거).
const state = get();
const view = state.view;
const showTrash = state.showTrash;
const viewStatus: 'active' | 'completed' | 'trashed' | null =
view === 'inbox' ? 'active' :
view === 'completed' ? 'completed' :
view === 'trash' ? 'trashed' : null;
// trashNotes — note.status='trashed' 면 upsert, 아니면 제거.
const cleanTrash = state.trashNotes.filter((n) => n.id !== note.id);
let nextTrash = cleanTrash;
if (note.status === 'trashed') {
const ti = state.trashNotes.findIndex((n) => n.id === note.id);
nextTrash = cleanTrash.slice();
if (ti >= 0) nextTrash.splice(ti, 0, note);
else nextTrash.unshift(note);
}
// notes — current view 의 status 와 매칭되는 경우만 유지/upsert.
// viewStatus=null (review/settings/검색) 이면 notes 직접 렌더 안 함 → 갱신 skip.
const cleanNotes = state.notes.filter((n) => n.id !== note.id);
let nextNotes = state.notes;
if (viewStatus !== null) {
if (note.status === viewStatus) {
const i = state.notes.findIndex((n) => n.id === note.id);
nextNotes = cleanNotes.slice();
if (i >= 0) nextNotes.splice(i, 0, note);
else nextNotes.unshift(note);
} else {
nextNotes = cleanNotes;
}
}
// searchResults — null 아니면 동일 패턴으로 갱신 (status 가 current search status 와
// 안 맞으면 제거, 맞으면 upsert).
let nextSearch = state.searchResults;
if (state.searchResults !== null) {
const cleanSearch = state.searchResults.filter((n) => n.id !== note.id);
if (viewStatus === null || note.status === viewStatus) {
// search 가 active 한 view 가 review/settings 면 status filter 없음 → 모두 keep.
const i = state.searchResults.findIndex((n) => n.id === note.id);
nextSearch = cleanSearch.slice();
if (i >= 0) nextSearch.splice(i, 0, note);
else nextSearch.unshift(note);
} else {
nextSearch = cleanSearch;
}
}
set({
notes: nextNotes,
trashNotes: nextTrash,
searchResults: nextSearch,
...(showTrash ? { trashCount: nextTrash.length } : {})
});
},
removeNote(id) {
const cleanNotes = get().notes.filter((n) => n.id !== id);
const cleanTrash = get().trashNotes.filter((n) => n.id !== id);
const showTrash = get().showTrash;
set({
notes: cleanNotes,
trashNotes: cleanTrash,
...(showTrash ? { trashCount: cleanTrash.length } : {})
});
},
setTagFilter(tag) {
set({ tagFilter: tag });
},
setShowSettings(open) {
// backward-compat — setView 로 위임. mirror state (view, showTrash, showSettings) 동기 갱신.
if (open) get().setView('settings');
else get().setView('inbox');
},
setView(view) {
// view 전환 시 검색/태그 필터 reset — 이전 view 의 필터가 새 view 에 잘못 적용되는 것 방지.
set({
view,
showTrash: view === 'trash',
showSettings: view === 'settings',
searchResults: null,
searchQuery: '',
tagFilter: null
});
// status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
// 이전 status 로 stale 한 상태이므로 재로드 필요.
if (view === 'inbox' || view === 'completed' || view === 'trash') {
void get().loadByView(view);
}
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
if (view === 'review-daily') void get().loadReview('daily');
if (view === 'review-weekly') void get().loadReview('weekly');
if (view === 'review-monthly') void get().loadReview('monthly');
},
async loadByView(view) {
// v0.3.8 — IPC 실패 시 stale 한 이전 view 의 notes 가 계속 노출되는 사고 방지.
// fail 시 빈 배열로 reset 해서 사용자에게 "비어있음" 으로 표시 (혼동 < stale).
const status =
view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view;
try {
const notes = await inboxApi.listByStatus(status, { limit: 200 });
if (view === 'trash') {
set({ trashNotes: notes, trashCount: notes.length });
} else {
set({ notes });
}
} catch (e) {
console.error('[inbox] loadByView failed', view, e);
if (view === 'trash') set({ trashNotes: [] });
else set({ notes: [] });
}
},
async toggleShowTrash() {
const next = !get().showTrash;
set({ showTrash: next });
if (next) await get().loadTrash();
},
async loadTrash() {
const trashNotes = await inboxApi.listTrash({ limit: 200 });
set({ trashNotes, trashCount: trashNotes.length });
},
async restoreNote(id) {
await inboxApi.restoreNote(id);
// 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음
// (현재 AiWorker.onUpdate + setStatus 만 push). 자가 반영이 primary 메커니즘.
// 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출).
// v0.3.8 — status 도 'active' 로 함께 갱신. upsertNote 가 status='trashed' 만 trash 로 라우팅.
const note = get().trashNotes.find((n) => n.id === id);
if (note) {
get().upsertNote({ ...note, deletedAt: null, status: 'active' });
}
},
async permanentDeleteNote(id) {
const r = await inboxApi.permanentDeleteNote(id);
if (r.confirmed) get().removeNote(id);
},
async emptyTrash() {
const r = await inboxApi.emptyTrash();
if (r.confirmed) {
set({ trashNotes: [], trashCount: 0 });
}
},
async trashExpiredBatch(ids: string[]) {
const r = await inboxApi.trashExpiredBatch(ids);
if (!r.confirmed) return;
const idSet = new Set(ids);
set({
expiredCandidates: get().expiredCandidates.filter((n) => !idSet.has(n.id)),
notes: get().notes.filter((n) => !idSet.has(n.id)),
trashCount: get().trashCount + r.trashedCount
});
},
snoozeExpired() {
set({ expiredSnoozeUntilMs: nextKstMidnightMs(Date.now()) });
},
async recheckOllama() {
const status = await inboxApi.ollamaRecheck();
set({ ollamaStatus: status });
},
async retryAllFailed() {
await inboxApi.retryAllFailed();
// 낙관적 갱신: failedCount = 0. AiWorker 처리 진행 중에 PendingBanner 가 N건 노출.
// refreshMeta 가 트리거되면 자연 동기 (worker.onUpdate → main → renderer).
// 반환된 r.count 는 의도적으로 무시 — 단일 process 환경 (Electron) 이라 race 무관,
// 모든 ai_status='failed' 가 retry 대상이므로 사용자 시점 카운트는 0 으로 reset 가 정확.
set({ failedCount: 0 });
},
async loadRecallCandidate() {
const recallCandidate = await inboxApi.listRecallCandidate();
set({ recallCandidate });
},
async openRecall(id) {
await inboxApi.markRecallOpened(id);
const recallCandidate = await inboxApi.listRecallCandidate();
set({ recallCandidate });
},
async dismissRecallNote(id) {
await inboxApi.dismissRecall(id);
const recallCandidate = await inboxApi.listRecallCandidate();
// m2 fix — dismiss 후 새 candidate 가 들어와도 이전 snooze 가 적용되지 않도록 clear
set({ recallCandidate, recallSnoozeUntilMs: null });
},
async snoozeRecall() {
set({ recallSnoozeUntilMs: nextKstMidnightMs(Date.now()) });
// m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
const candidate = get().recallCandidate;
if (candidate) {
inboxApi.emitRecallSnoozed(candidate.id);
}
},
// v0.2.11 Cut D — FTS5 search + review aggregate actions.
setSearchQuery(q) {
set({ searchQuery: q });
if (q.trim().length === 0) set({ searchResults: null });
},
async searchNotes(q, opts) {
if (q.trim().length === 0) {
set({ searchResults: null });
return;
}
const view = get().view;
// 회고/설정 view 일 때는 status filter 무의미 → 그대로 전체 검색
const status = view === 'completed' || view === 'trash'
? (view === 'trash' ? 'trashed' : view)
: view === 'inbox' ? 'active' : undefined;
try {
// v0.4 Task 18 — opts.notebookId 전달 시 해당 notebook 안 검색, undefined 시 전체 검색.
const searchOpts: { status?: NoteStatus; notebookId?: string } = status ? { status: status as NoteStatus } : {};
if (opts?.notebookId !== undefined) searchOpts.notebookId = opts.notebookId;
const r = await inboxApi.search(q, searchOpts);
set({ searchResults: r });
} catch (e) {
// FTS5 query parse error (special char 미escape) / IPC fail → 빈 결과로.
console.error('[inbox] searchNotes failed', e);
set({ searchResults: [] });
}
},
clearSearch() {
set({ searchQuery: '', searchResults: null });
},
async loadReview(period) {
try {
const data = await inboxApi.reviewAggregate(period);
set({ reviewData: data });
} catch (e) {
// review IPC fail 시 reviewData=null → ReviewView 의 "불러오는 중…" 영구 표시 회피.
// 빈 aggregate 로 set 해서 사용자에게 "0건" 표기.
console.error('[inbox] loadReview failed', period, e);
set({ reviewData: { totalCount: 0, tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 }, recentNotes: [] } });
}
},
// v0.4 — Notebook actions.
async loadNotebooks() {
const notebooks = await notebookApi.list();
const current = get().selectedNotebookId;
// selectedNotebookId 가 null 이면 첫 notebook (가장 오래된 = 기본) 으로 설정.
const selectedNotebookId = current === null && notebooks.length > 0
? notebooks[0]!.id
: current;
set({ notebooks, selectedNotebookId });
},
selectNotebook(id) {
set({ selectedNotebookId: id });
},
async createNotebook(name, color) {
const r = await notebookApi.create({ name, color });
if (r.ok) {
set({ notebooks: [...get().notebooks, r.notebook] });
return { ok: true };
}
return { ok: false, reason: r.reason };
},
async renameNotebook(id, name) {
const r = await notebookApi.rename(id, name);
if (r.ok) {
set({ notebooks: get().notebooks.map((n) => n.id === id ? { ...n, name } : n) });
return { ok: true };
}
return { ok: false, reason: r.reason };
},
async setNotebookColor(id, color) {
await notebookApi.setColor(id, color);
set({ notebooks: get().notebooks.map((n) => n.id === id ? { ...n, color } : n) });
},
async deleteNotebook(id) {
const r = await notebookApi.delete(id);
if (r.ok) {
const remaining = get().notebooks.filter((n) => n.id !== id);
const wasSelected = get().selectedNotebookId === id;
set({
notebooks: remaining,
selectedNotebookId: wasSelected ? (remaining[0]?.id ?? null) : get().selectedNotebookId
});
return { ok: true };
}
return { ok: false, reason: r.reason };
},
async moveNoteToNotebook(noteId, notebookId) {
await notebookApi.moveNote(noteId, notebookId);
await get().refreshMeta();
},
toggleSidebar() {
set({ sidebarVisible: !get().sidebarVisible });
},
// v0.4 Task 11 — promotion candidate actions.
async loadPromotionCandidates() {
try {
const [dismissed, snoozeUntil, raw] = await Promise.all([
inboxApi.getPromotionDismissedTags(),
inboxApi.getPromotionSnoozeUntil(),
inboxApi.listPromotionCandidates()
]);
// snoozed_until > now → 빈 배열 (전체 스누즈).
if (snoozeUntil > Date.now()) {
set({ promotionCandidates: [] });
return;
}
const dismissedSet = new Set(dismissed);
const candidates: PromotionCandidate[] = raw
.filter((c) => !dismissedSet.has(c.tag))
.map((c) => ({ ...c, suggestedName: toTitleCase(c.tag) }));
set({ promotionCandidates: candidates });
} catch (e) {
console.error('[inbox] loadPromotionCandidates failed', e);
set({ promotionCandidates: [] });
}
},
async acceptPromotion(tag, customName, color) {
const candidate = get().promotionCandidates.find((c) => c.tag === tag);
if (!candidate) return;
const r = await notebookApi.create({ name: customName, color });
if (!r.ok) return;
const notebookId = r.notebook.id;
await Promise.all(candidate.noteIds.map((noteId) => notebookApi.moveNote(noteId, notebookId)));
// state: candidate 제거 + notebooks 갱신 + sidebar 열기 + 새 notebook 선택.
set({
promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag),
notebooks: [...get().notebooks, r.notebook],
sidebarVisible: true,
selectedNotebookId: notebookId
});
await get().refreshMeta();
},
async snoozePromotion() {
const snoozeUntil = Date.now() + 24 * 60 * 60 * 1000;
await inboxApi.setPromotionSnoozeUntil(snoozeUntil);
set({ promotionCandidates: [] });
},
async dismissPromotion(tag) {
await inboxApi.addPromotionDismissedTag(tag);
set({ promotionCandidates: get().promotionCandidates.filter((c) => c.tag !== tag) });
}
}));