SearchBox 에 scope dropdown 추가. 기본 'current' (현재 notebook ID 전달), 'all' 선택 시 notebookId=undefined 로 전체 검색. store.searchNotes opts 인자 추가.
525 lines
21 KiB
TypeScript
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) });
|
|
}
|
|
}));
|