Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7418eb9363 | ||
|
|
906e9b6f7d | ||
|
|
2b5ba8a50e | ||
|
|
3c731cc754 | ||
|
|
352457189e | ||
|
|
a68feae20e | ||
|
|
64935d943c | ||
|
|
9cf6cafab2 | ||
|
|
d3bc972783 | ||
|
|
30b14d2b74 | ||
|
|
431b35a72a | ||
|
|
e34f036f20 | ||
|
|
c616555d7d | ||
|
|
218868206b | ||
|
|
b2be29bd33 | ||
|
|
4266376b23 |
48
CHANGELOG.md
48
CHANGELOG.md
@@ -3,6 +3,54 @@
|
|||||||
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
|
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
|
||||||
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
|
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
|
||||||
|
|
||||||
|
## [0.3.14] — 2026-05-12 (force re-tag: 2026-05-14, 추가 dogfood fixes 7건)
|
||||||
|
|
||||||
|
### 추가 dogfood fixes (2026-05-14 force re-tag)
|
||||||
|
|
||||||
|
force re-tag 로 같은 v0.3.14 안에 묶인 후속 변경. 새 minor 안 늘리고 동일 release notes 확장.
|
||||||
|
|
||||||
|
- **fix(capture): QuickCapture blur-on-hide 제거.** 다른 창 클릭해도 ESC / Cmd+Enter 까지 창 유지. alwaysOnTop + screen-saver level 로 다른 앱 위에 떠 있음.
|
||||||
|
- **chore(ux): macOS 사용자 위해 Cmd 키 hint 안내.** Inbox empty state 와 QuickCapture 하단 hint 가 platform-aware 로 분기 — Mac 에선 `Cmd+Shift+J` / `Cmd+Enter`.
|
||||||
|
- **fix(macos): hidden autostart dock indicator + autostart mismatch false positive.** ① LoginItems `--hidden` spawn 시 NSPanel 만 떠 있어 dock 점 안 보이던 문제 — inboxWindow 를 hidden 으로 미리 create + `showInactive → hide` trick 으로 NSApp register. ② SettingsPage 의 자동실행 mismatch 경고가 macOS 13+ SMAppService 한계로 false positive 떠 있던 문제 — willLaunch 신호는 win32 에서만 mismatch 판정에 사용.
|
||||||
|
- **feat(notes): 원문 편집/이력 복원 시 AI 재처리.** `NoteRepository.markAiPendingForReprocess` 신설 — done/failed/pending 노트를 pending reset + pending_jobs 재투입. disabled 는 사용자 의도 존중 no-op. NoteCard.saveRaw 가 optimistic 으로 `aiStatus='pending'` 표시. updateAiResult 의 user-edit 가드로 사용자가 직접 편집한 필드는 보존.
|
||||||
|
- **feat(expiry): 마감 알림 inbox 제한 + 오늘 당일 포함 + 헤딩/라벨/메모 바로가기.** ① `findExpiredCandidates` 가 `due_date <= today` + `status='active'` 로 변경, 완료/보관 노트는 제외. 정렬도 `due_date DESC` 로 오늘 → 어제 순. ② ExpiryBanner 헤딩 분리 카운트 "오늘 마감 X · 지난 Y", 노트 라벨 [오늘] / [N일 지남]. ③ 노트 제목 클릭 → `note-{id}` smooth scroll.
|
||||||
|
- **fix(sync): manifest.exported_at 제거 — no-op push 회피.** 노트 변경 0건이어도 매 sync 마다 timestamp 1줄 commit + push 가 쌓이던 문제. `composeManifest` 에서 cosmetic 필드 제거. 이제 진짜 변경 있을 때만 commit.
|
||||||
|
- **feat(settings): SectionIntro + SyncHelpModal 풀어쓰기.** 설정 페이지 6 section 상단에 1-2 문장 안내. SyncHelpModal 의 기술 용어 (rebase, fast-forward, NTP) 를 사용자 언어로 풀어쓰기.
|
||||||
|
|
||||||
|
### 게이트 (추가 fix 후)
|
||||||
|
|
||||||
|
- 단위 763 PASS
|
||||||
|
- typecheck 0 errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 원본 release (2026-05-12)
|
||||||
|
|
||||||
|
AI 처리 fail 원인 가시화. 이전엔 ai_error 가 NoteCard tooltip (title attribute) 에만 있어 사용자가 마우스 오버해야 보이는 데다 raw 메시지만 노출 → 무엇이 fail 했는지 불명.
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
|
||||||
|
- **NoteCard failed 노트에 "원인 보기" 접힘 섹션 (P1).** `<details>` summary 클릭하면 `<pre>` 로 `ai_error` 전체 노출. wrap + word-break 적용. 사용자가 직접 메시지를 보고 모델/네트워크/JSON 등 fail 카테고리 진단 가능.
|
||||||
|
- **`ai_error` 에 reason + provider name prefix 추가.** AiWorker 의 markAiFailed 시 `[schema|other] local-ollama/gemma4:26b\n<원본 message>` 형식. 사용자가 어느 카테고리에서, 어느 모델로 실패했는지 즉시 식별. log 의 ai.failed 에도 reason/provider 필드 함께 출력.
|
||||||
|
|
||||||
|
### 게이트
|
||||||
|
|
||||||
|
- 단위 752 PASS (ai_error 포맷 변경은 test 영향 없음 — 기존 test 가 정확한 prefix 매칭 안 함)
|
||||||
|
- typecheck 0 errors
|
||||||
|
- 신규 npm dependency 0
|
||||||
|
|
||||||
|
### 사용자 안내
|
||||||
|
|
||||||
|
이미지 AI 처리가 fail 한다면 NoteCard 의 "정리 보류" 옆 "원인 보기" 클릭 → 표시되는 메시지로:
|
||||||
|
- `[timeout] ...` → vision 모델 cold-start 가 5분 초과. `ollama run gemma4:26b` 으로 한번 warm-up 후 재시도
|
||||||
|
- `[schema] title must contain Korean characters` → vision 모델이 영어 title 반환. prompt 가 한국어 강조했지만 일부 모델은 여전히 영어. `gemma3:27b` 등 다른 vision 모델로 대체 고려
|
||||||
|
- `[schema] unparseable response: ...` → vision 모델 JSON 출력 안 따름. v0.3.12 의 loose parse 가 실패한 경우
|
||||||
|
- `[other] missing response field` → Ollama 가 빈 응답 반환. 모델 자체 문제
|
||||||
|
|
||||||
|
### 업그레이드
|
||||||
|
|
||||||
|
v0.3.13 인스톨러 위에 v0.3.14 인스톨러를 같은 위치에 실행하면 in-place 업그레이드.
|
||||||
|
|
||||||
## [0.3.13] — 2026-05-12
|
## [0.3.13] — 2026-05-12
|
||||||
|
|
||||||
대형 vision 모델 (gemma4:26b 등) 의 cold-start timeout 으로 인한 AI 처리 실패 fix.
|
대형 vision 모델 (gemma4:26b 등) 의 cold-start timeout 으로 인한 AI 처리 실패 fix.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "inkling",
|
"name": "inkling",
|
||||||
"version": "0.3.13",
|
"version": "0.3.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "inkling",
|
"name": "inkling",
|
||||||
"version": "0.3.13",
|
"version": "0.3.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "12.9.0",
|
"better-sqlite3": "12.9.0",
|
||||||
"electron-log": "5.2.0",
|
"electron-log": "5.2.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "inkling",
|
"name": "inkling",
|
||||||
"version": "0.3.13",
|
"version": "0.3.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||||
|
|||||||
@@ -137,6 +137,23 @@ export class AiWorker {
|
|||||||
const nowDate = this.now();
|
const nowDate = this.now();
|
||||||
const todayDate = kstTodayAsDate(nowDate);
|
const todayDate = kstTodayAsDate(nowDate);
|
||||||
const todayIso = kstTodayIso(nowDate);
|
const todayIso = kstTodayIso(nowDate);
|
||||||
|
|
||||||
|
// v0.3.14 — 본문 빈 + 이미지만 첨부 케이스는 모델이 의미 있는 응답 못 함
|
||||||
|
// (gemma4:26b 등 vision 모델의 한계 확인). AI 호출 skip, 자동 placeholder 적용 후
|
||||||
|
// 즉시 done. 사용자가 후에 NoteCard 의 EditableField 로 제목/요약 편집 가능.
|
||||||
|
const rawEmpty = note.rawText.trim().length === 0;
|
||||||
|
if (rawEmpty && note.media.length > 0) {
|
||||||
|
const n = note.media.length;
|
||||||
|
const title = n === 1 ? '첨부 이미지' : `첨부 이미지 ${n}장`;
|
||||||
|
const summary = `이미지 ${n}장이 첨부된 메모입니다.\n원문 영역에서 이미지 확인할 수 있습니다.\n제목과 요약을 클릭해 직접 편집할 수 있습니다.`;
|
||||||
|
this.repo.updateAiResult(job.noteId, {
|
||||||
|
title, summary, tags: [], provider: 'image-only-skip', dueDate: null
|
||||||
|
});
|
||||||
|
this.logger.info('ai.skip.image-only', { noteId: job.noteId, mediaCount: n });
|
||||||
|
this.emit(job.noteId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = parseAllCandidates(note.rawText, todayDate);
|
const candidates = parseAllCandidates(note.rawText, todayDate);
|
||||||
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
|
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
|
||||||
// v0.3.1 Cut F — vision path: visionModel + note.media → base64 images
|
// v0.3.1 Cut F — vision path: visionModel + note.media → base64 images
|
||||||
@@ -144,7 +161,17 @@ export class AiWorker {
|
|||||||
// 5MB cap 초과 시 throw → AiWorker 의 'other' 분기 → markAiFailed 도달.
|
// 5MB cap 초과 시 throw → AiWorker 의 'other' 분기 → markAiFailed 도달.
|
||||||
const visionModel = this.settings ? await this.settings.getVisionModel() : null;
|
const visionModel = this.settings ? await this.settings.getVisionModel() : null;
|
||||||
let images: Array<{ base64: string; mime: string }> | undefined;
|
let images: Array<{ base64: string; mime: string }> | undefined;
|
||||||
if (visionModel && note.media.length > 0 && this.mediaStore) {
|
const visionActive = !!(visionModel && note.media.length > 0 && this.mediaStore);
|
||||||
|
// v0.3.14 — vision 활성 여부 진단 로그. 사용자가 vision_model 미설정으로 text-only
|
||||||
|
// path 가 도는지 / 이미지가 모델에 전달되는지 확인 가능 (logs/main.log).
|
||||||
|
this.logger.info('ai.vision.decide', {
|
||||||
|
noteId: job.noteId,
|
||||||
|
visionActive,
|
||||||
|
visionModelConfigured: !!visionModel,
|
||||||
|
mediaCount: note.media.length,
|
||||||
|
mediaStorePresent: !!this.mediaStore
|
||||||
|
});
|
||||||
|
if (visionActive) {
|
||||||
const oversize = note.media.find((m) => m.bytes > 5 * 1024 * 1024);
|
const oversize = note.media.find((m) => m.bytes > 5 * 1024 * 1024);
|
||||||
if (oversize) {
|
if (oversize) {
|
||||||
throw new Error(`image ${oversize.relPath} exceeds 5MB cap (${oversize.bytes} bytes)`);
|
throw new Error(`image ${oversize.relPath} exceeds 5MB cap (${oversize.bytes} bytes)`);
|
||||||
@@ -233,8 +260,13 @@ export class AiWorker {
|
|||||||
const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString();
|
const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString();
|
||||||
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
|
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
|
||||||
if (isLast) {
|
if (isLast) {
|
||||||
this.repo.markAiFailed(job.noteId, msg);
|
// v0.3.14 — ai_error 에 reason + provider name prefix 추가. NoteCard 의 "원인 보기"
|
||||||
this.logger.error('ai.failed', { noteId: job.noteId, err: msg });
|
// 가 사용자에게 보여주는 raw 메시지에 context (timeout/unreachable/schema/other +
|
||||||
|
// 어느 모델이 fail 했는지) 가 포함되어 진단성 향상.
|
||||||
|
const provider = this.holder.get().name;
|
||||||
|
const annotated = `[${reason}] ${provider}\n${msg}`;
|
||||||
|
this.repo.markAiFailed(job.noteId, annotated);
|
||||||
|
this.logger.error('ai.failed', { noteId: job.noteId, err: msg, reason, provider });
|
||||||
if (this.telemetry) {
|
if (this.telemetry) {
|
||||||
await this.telemetry.emit({
|
await this.telemetry.emit({
|
||||||
kind: 'ai_failed',
|
kind: 'ai_failed',
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other'
|
|||||||
/**
|
/**
|
||||||
* v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드
|
* v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로 따라 markdown 코드
|
||||||
* 펜스 / prose 가 섞인 응답을 반환할 때 fallback. 첫 '{' ~ 마지막 '}' substring 만
|
* 펜스 / prose 가 섞인 응답을 반환할 때 fallback. 첫 '{' ~ 마지막 '}' substring 만
|
||||||
* 추출해서 JSON.parse 재시도. 실패하면 raw response 일부 포함한 에러 throw (디버깅용).
|
* 추출해서 JSON.parse 재시도.
|
||||||
|
*
|
||||||
|
* v0.3.14 — fail 시 throw 대신 `{}` 반환. schema 의 graceful coerce 가 빈 객체를
|
||||||
|
* placeholder title/summary 로 채움 → 사용자 데이터 손실 없이 노트 보관 (raw_text 그대로).
|
||||||
|
* 모델이 repetition loop 로 num_predict cap 도달해 JSON truncate 된 케이스에 robust.
|
||||||
|
* 원본 응답 snippet 은 console.warn 으로 로그 (디버그성).
|
||||||
*/
|
*/
|
||||||
function parseJsonLoose(raw: string): unknown {
|
function parseJsonLoose(raw: string): unknown {
|
||||||
try { return JSON.parse(raw); } catch { /* fallback below */ }
|
try { return JSON.parse(raw); } catch { /* fallback below */ }
|
||||||
@@ -26,7 +31,9 @@ function parseJsonLoose(raw: string): unknown {
|
|||||||
const slice = raw.slice(first, last + 1);
|
const slice = raw.slice(first, last + 1);
|
||||||
try { return JSON.parse(slice); } catch { /* fall through */ }
|
try { return JSON.parse(slice); } catch { /* fall through */ }
|
||||||
}
|
}
|
||||||
throw new Error(`unparseable response: ${raw.slice(0, 200).replace(/\s+/g, ' ')}`);
|
// 빈 객체 반환 → schema coerce 로 placeholder 적용. 원본 일부는 stderr 에 남김.
|
||||||
|
console.warn(`[LocalOllamaProvider] unparseable response, falling back to {}: ${raw.slice(0, 200).replace(/\s+/g, ' ')}`);
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalOllamaOptions {
|
export interface LocalOllamaOptions {
|
||||||
@@ -74,7 +81,10 @@ export class LocalOllamaProvider implements InferenceProvider {
|
|||||||
prompt,
|
prompt,
|
||||||
format: 'json',
|
format: 'json',
|
||||||
stream: false,
|
stream: false,
|
||||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
// v0.3.14 — repeat_penalty 추가. vision 모델 (특히 gemma 시리즈) 이 "기기기기..."
|
||||||
|
// 같은 repetition loop 에 빠져 num_predict cap 도달 → JSON truncate → unparseable.
|
||||||
|
// 1.15 는 Ollama 권장 범위 (1.0-1.3) 안쪽 conservative 값.
|
||||||
|
options: { temperature: this.temperature, num_predict: this.numPredict, repeat_penalty: 1.15 }
|
||||||
};
|
};
|
||||||
if (useVision) {
|
if (useVision) {
|
||||||
body.images = input.images!.map((i) => i.base64);
|
body.images = input.images!.map((i) => i.base64);
|
||||||
|
|||||||
@@ -39,13 +39,34 @@ function validateDueDate(d: string | null | undefined): string | null {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAiResponse(raw: unknown): AiResponse {
|
/**
|
||||||
const parsed = RawResponseSchema.parse(raw);
|
* vision 모델 (gemma4:26b 등) 이 본문 빈 케이스에 title/summary null 반환하는 케이스
|
||||||
if (!KOREAN_REGEX.test(parsed.title)) {
|
* 대응. null → placeholder 한국어 문자열로 coerce 후 schema 통과. 빈 string / empty regex
|
||||||
throw new Error('title must contain Korean characters');
|
* dueDate 도 null 로 normalize. raw_text 는 호출자가 보존하므로 사용자 데이터 손실 없음.
|
||||||
|
*/
|
||||||
|
function coerceNullable(raw: unknown): unknown {
|
||||||
|
if (typeof raw !== 'object' || raw === null) return raw;
|
||||||
|
const obj = { ...(raw as Record<string, unknown>) };
|
||||||
|
if (obj.title === null || obj.title === undefined || obj.title === '') obj.title = '(첨부 메모)';
|
||||||
|
if (obj.summary === null || obj.summary === undefined || obj.summary === '') obj.summary = '내용을 자동으로 정리하지 못했습니다.';
|
||||||
|
// due_date 의 빈 string / regex mismatch 도 null 로 강제 (schema 가 거부하지 않게).
|
||||||
|
if (obj.due_date === '' || (typeof obj.due_date === 'string' && !ISO_DATE_REGEX.test(obj.due_date))) {
|
||||||
|
obj.due_date = null;
|
||||||
}
|
}
|
||||||
|
// tags 가 null 이면 빈 배열로.
|
||||||
|
if (obj.tags === null || obj.tags === undefined) obj.tags = [];
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAiResponse(raw: unknown): AiResponse {
|
||||||
|
const coerced = coerceNullable(raw);
|
||||||
|
const parsed = RawResponseSchema.parse(coerced);
|
||||||
|
// title 이 한국어 0 자면 fallback placeholder 적용 (영어 title 도 fail 안 함).
|
||||||
|
// placeholder 는 한국어 포함이라 자기 자신 통과.
|
||||||
|
const titleHasKorean = KOREAN_REGEX.test(parsed.title);
|
||||||
|
const finalTitle = titleHasKorean ? parsed.title : '(첨부 메모)';
|
||||||
return {
|
return {
|
||||||
title: parsed.title.slice(0, 60),
|
title: finalTitle.slice(0, 60),
|
||||||
summary: normalizeSummary(parsed.summary),
|
summary: normalizeSummary(parsed.summary),
|
||||||
tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3),
|
tags: parsed.tags.filter((t) => KEBAB_REGEX.test(t)).slice(0, 3),
|
||||||
dueDate: validateDueDate(parsed.due_date)
|
dueDate: validateDueDate(parsed.due_date)
|
||||||
|
|||||||
@@ -4,19 +4,29 @@ export function buildVisionPrompt(
|
|||||||
dueCandidates: string[],
|
dueCandidates: string[],
|
||||||
vocab: string[]
|
vocab: string[]
|
||||||
): string {
|
): string {
|
||||||
// v0.3.11 — vision model 이 'format:json' constraint 를 부분적으로만 따르는 경우가
|
// v0.3.14 — 본문 빈 케이스에 one-shot 예시 추가. gemma4:26b 등이 본문 없이
|
||||||
// 잦음 (특히 gemma3 vision). title 한국어 + JSON only 를 prompt 에서 명시 강조,
|
// 이미지만 받으면 null 반환하는 한계 우회. 예시 입력/출력 구조 따라가도록 유도.
|
||||||
// markdown fence 금지 표기로 schema parse 통과율 개선.
|
const bodySection = text
|
||||||
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
|
? `메모 본문:\n${text}\n\n첨부 이미지도 함께 분석해 요약에 반영하세요.`
|
||||||
|
: `본문이 없습니다. 첨부 이미지의 내용 (텍스트/사람/장면/문서 등) 만으로 한국어 title 과 summary 를 작성하세요. null 반환 절대 금지.
|
||||||
|
|
||||||
메모 본문 (비어 있을 수 있음):
|
예시 (이미지: 갈색 강아지가 잔디 위에 앉은 사진):
|
||||||
${text || '(이미지만 있음)'}
|
{"title":"잔디 위 강아지","summary":"갈색 강아지가 잔디 위에 앉아 있다.\\n배경에 나무가 보인다.\\n날씨가 맑다.","tags":["pet"],"due_date":null}
|
||||||
|
|
||||||
규칙:
|
예시 (이미지: 회의실 화이트보드 사진):
|
||||||
- "title" 은 반드시 한국어로 (영어 금지). 60자 이내.
|
{"title":"회의실 화이트보드","summary":"화이트보드에 일정과 안건이 적혀 있다.\\n프로젝트 이름이 보인다.\\n다이어그램이 그려져 있다.","tags":["meeting"],"due_date":null}
|
||||||
- "summary" 는 3줄. 이미지 시각 정보 (텍스트/사람/장면) 포함.
|
|
||||||
- "tags" 는 영문 kebab-case (예: meeting-notes), 최대 3개. 한국어 태그 금지.
|
이제 첨부된 실제 이미지를 보고 같은 형식으로 작성하세요.`;
|
||||||
- "due_date" 는 ISO 형식 YYYY-MM-DD 또는 null.
|
|
||||||
|
return `다음 메모를 한국어로 분석해 JSON 으로 정리하세요.
|
||||||
|
|
||||||
|
${bodySection}
|
||||||
|
|
||||||
|
규칙 (위반 시 재시도):
|
||||||
|
- "title": 한국어 문자열 필수, null 금지. 60자 이내. 영어 단독 금지.
|
||||||
|
- "summary": 한국어 문자열 필수, null 금지. 3줄. 이미지 시각 정보 (텍스트/사람/장면) 포함.
|
||||||
|
- "tags": 영문 kebab-case 배열 (예: ["meeting-notes"]), 최대 3개. 한국어 태그 금지. 없으면 [].
|
||||||
|
- "due_date": ISO YYYY-MM-DD 또는 null. 빈 문자열 금지.
|
||||||
|
|
||||||
오직 JSON 객체 하나만 출력. markdown 코드 펜스 (\`\`\`) / 설명 prose 금지.
|
오직 JSON 객체 하나만 출력. markdown 코드 펜스 (\`\`\`) / 설명 prose 금지.
|
||||||
출력 형식: {"title":"...","summary":"...","tags":[],"due_date":null}
|
출력 형식: {"title":"...","summary":"...","tags":[],"due_date":null}
|
||||||
|
|||||||
@@ -193,9 +193,10 @@ app.whenReady().then(async () => {
|
|||||||
});
|
});
|
||||||
if (!reg.ok) logger.warn('hotkey.register.failed', { reason: reg.reason });
|
if (!reg.ok) logger.warn('hotkey.register.failed', { reason: reg.reason });
|
||||||
|
|
||||||
if (!startedHidden) {
|
// macOS LoginItems autostart 시 startedHidden=true 로 spawn — 그대로 두면 quickCapture
|
||||||
createInboxWindow();
|
// (NSPanel) 만 떠 있어 dock running indicator 미표출. inboxWindow 를 hidden 상태로
|
||||||
}
|
// 미리 create 하면 NSApp register → 점 표출 + 사용자가 dock 아이콘 확인으로 앱 살아있음 인지.
|
||||||
|
createInboxWindow({ visible: !startedHidden });
|
||||||
createQuickCaptureWindow();
|
createQuickCaptureWindow();
|
||||||
await worker.loadFromDb();
|
await worker.loadFromDb();
|
||||||
|
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
|||||||
return { ok: false as const, reason: 'empty' as const };
|
return { ok: false as const, reason: 'empty' as const };
|
||||||
}
|
}
|
||||||
deps.repo.updateRawText(id, newText);
|
deps.repo.updateRawText(id, newText);
|
||||||
|
await reprocessAi(deps, id);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -311,6 +312,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
|||||||
ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => {
|
ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => {
|
||||||
try {
|
try {
|
||||||
deps.repo.restoreRevision(id, revId);
|
deps.repo.restoreRevision(id, revId);
|
||||||
|
await reprocessAi(deps, id);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false as const, reason: (e as Error).message };
|
return { ok: false as const, reason: (e as Error).message };
|
||||||
@@ -344,6 +346,19 @@ export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note):
|
|||||||
w.webContents.send('note:updated', note);
|
w.webContents.send('note:updated', note);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원문 변경 후 AI 재처리 트리거. ai_status='pending' 으로 reset + pending_jobs 재투입 +
|
||||||
|
* worker enqueue + renderer push. disabled 노트는 사용자 명시 비활성화 의도 존중하여 skip.
|
||||||
|
*/
|
||||||
|
async function reprocessAi(deps: InboxIpcDeps, id: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const r = deps.repo.markAiPendingForReprocess(id, now);
|
||||||
|
if (!r.ok) return;
|
||||||
|
if (deps.enqueue) await deps.enqueue(id);
|
||||||
|
const updated = deps.repo.findById(id);
|
||||||
|
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
|
||||||
|
}
|
||||||
|
|
||||||
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
|
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
|
||||||
const w = getWin();
|
const w = getWin();
|
||||||
if (!w || w.isDestroyed()) return;
|
if (!w || w.isDestroyed()) return;
|
||||||
|
|||||||
@@ -269,6 +269,29 @@ export class NoteRepository {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* raw_text 편집/복원 직후 AI 재처리 트리거. done/failed/pending 노트를 pending 으로
|
||||||
|
* reset + pending_jobs row 보장 (attempts=0). disabled 는 사용자의 명시적 비활성화
|
||||||
|
* 의도 존중 — no-op. updateAiResult 의 user-edit 가드 (title_edited_by_user 등) 가
|
||||||
|
* 사용자가 직접 편집한 필드는 새 AI 결과로 덮어쓰지 않음.
|
||||||
|
*/
|
||||||
|
markAiPendingForReprocess(id: string, now: string): { ok: boolean } {
|
||||||
|
const row = this.db
|
||||||
|
.prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`)
|
||||||
|
.get(id) as { ai_status: string } | undefined;
|
||||||
|
if (!row || row.ai_status === 'disabled') return { ok: false };
|
||||||
|
const tx = this.db.transaction(() => {
|
||||||
|
this.db
|
||||||
|
.prepare(`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`)
|
||||||
|
.run(now, id);
|
||||||
|
this.db
|
||||||
|
.prepare(`INSERT OR REPLACE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`)
|
||||||
|
.run(id, now);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v0.3.9 — pending 노트의 AI 처리 cancel. ai_status='disabled' 로 전환 + pending_jobs 삭제.
|
* v0.3.9 — pending 노트의 AI 처리 cancel. ai_status='disabled' 로 전환 + pending_jobs 삭제.
|
||||||
* raw_text 는 보존. 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path.
|
* raw_text 는 보존. 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path.
|
||||||
@@ -1098,9 +1121,12 @@ export class NoteRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notes whose due_date is strictly before today (KST calendar) and that are
|
* Notes whose due_date is today (KST calendar) or already past, that are still
|
||||||
* still active (not trashed) and AI-processed. Includes both AI-extracted and
|
* active (inbox only — completed/archived/trashed 제외), and AI-processed.
|
||||||
* user-edited due_date (v0.2.3 #5 spec §1 Q1=B).
|
* Includes both AI-extracted and user-edited due_date.
|
||||||
|
*
|
||||||
|
* 정렬: due_date DESC → 오늘 당일 먼저, 그 다음 어제, 그 전... 같은 due_date 내에선
|
||||||
|
* created_at DESC, id DESC tiebreak.
|
||||||
*
|
*
|
||||||
* Caller may inject `now` for testability; defaults to `new Date()`.
|
* Caller may inject `now` for testability; defaults to `new Date()`.
|
||||||
*/
|
*/
|
||||||
@@ -1110,10 +1136,11 @@ export class NoteRepository {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM notes
|
`SELECT * FROM notes
|
||||||
WHERE due_date IS NOT NULL
|
WHERE due_date IS NOT NULL
|
||||||
AND due_date < ?
|
AND due_date <= ?
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
|
AND status = 'active'
|
||||||
AND ai_status = 'done'
|
AND ai_status = 'done'
|
||||||
ORDER BY created_at DESC, id DESC`
|
ORDER BY due_date DESC, created_at DESC, id DESC`
|
||||||
)
|
)
|
||||||
.all(today) as Record<string, unknown>[];
|
.all(today) as Record<string, unknown>[];
|
||||||
return rows.map((r) => this.hydrate(r));
|
return rows.map((r) => this.hydrate(r));
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export interface AutostartState {
|
|||||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||||
execPath: string;
|
execPath: string;
|
||||||
|
/**
|
||||||
|
* 플랫폼 분기용. macOS 13+ 의 SMAppService API 는 args 옵션 무시 + unsigned/Electron
|
||||||
|
* 앱에 대해 executableWillLaunchAtLogin 이 false 를 반환할 수 있어, mismatch 판정에서
|
||||||
|
* 해당 신호를 제외해야 false positive 방지 가능.
|
||||||
|
*/
|
||||||
|
platform: NodeJS.Platform;
|
||||||
registryPath?: string;
|
registryPath?: string;
|
||||||
registryValue?: string | null;
|
registryValue?: string | null;
|
||||||
}
|
}
|
||||||
@@ -26,7 +32,8 @@ export async function collectAutostartState(): Promise<AutostartState> {
|
|||||||
const state: AutostartState = {
|
const state: AutostartState = {
|
||||||
withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin },
|
withArgs: { openAtLogin: w.openAtLogin, executableWillLaunchAtLogin: w.executableWillLaunchAtLogin },
|
||||||
noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin },
|
noArgs: { openAtLogin: n.openAtLogin, executableWillLaunchAtLogin: n.executableWillLaunchAtLogin },
|
||||||
execPath: process.execPath
|
execPath: process.execPath,
|
||||||
|
platform: process.platform
|
||||||
};
|
};
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
state.registryPath = `${WIN_REGISTRY_PATH}\\${WIN_REGISTRY_KEY}`;
|
state.registryPath = `${WIN_REGISTRY_PATH}\\${WIN_REGISTRY_KEY}`;
|
||||||
|
|||||||
@@ -146,9 +146,9 @@ export class CaptureService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 만료 후보 (due_date < today KST, active, ai_status=done) 조회.
|
* 마감 임박 후보 (due_date ≤ today KST, status=active inbox, ai_status=done) 조회.
|
||||||
|
* 오늘 당일 마감 메모도 포함하여 사용자에게 미리 인지시킨다 (정렬은 due_date DESC).
|
||||||
* candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit.
|
* candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit.
|
||||||
* v0.2.3 #5 spec §6.2 — dedup 위치 main 통합.
|
|
||||||
*/
|
*/
|
||||||
async listExpired(now: Date = new Date()): Promise<Note[]> {
|
async listExpired(now: Date = new Date()): Promise<Note[]> {
|
||||||
const candidates = this.repo.findExpiredCandidates(now);
|
const candidates = this.repo.findExpiredCandidates(now);
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ export class ExportService {
|
|||||||
totalBytes += Buffer.byteLength(indexJsonl, 'utf8');
|
totalBytes += Buffer.byteLength(indexJsonl, 'utf8');
|
||||||
|
|
||||||
const manifest = composeManifest({
|
const manifest = composeManifest({
|
||||||
exportedAt: this.now().toISOString(),
|
|
||||||
noteCount: notes.length,
|
noteCount: notes.length,
|
||||||
mediaCount
|
mediaCount
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -253,14 +253,15 @@ export function composeIndexJsonl(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function composeManifest(input: {
|
export function composeManifest(input: {
|
||||||
exportedAt: string;
|
|
||||||
noteCount: number;
|
noteCount: number;
|
||||||
mediaCount: number;
|
mediaCount: number;
|
||||||
}): string {
|
}): string {
|
||||||
|
// exported_at 필드 의도적 제외 — note 변경 없이도 git sync 가 매 호출마다
|
||||||
|
// timestamp 갱신 1줄 commit 을 만들어 history 노이즈와 불필요한 push 유발.
|
||||||
|
// import path 는 inkling_export_version 만 read 하므로 안전.
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
{
|
{
|
||||||
inkling_export_version: 1,
|
inkling_export_version: 1,
|
||||||
exported_at: input.exportedAt,
|
|
||||||
note_count: input.noteCount,
|
note_count: input.noteCount,
|
||||||
media_count: input.mediaCount
|
media_count: input.mediaCount
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ export function getInboxWindow(): BrowserWindowType | null {
|
|||||||
return inboxWindow;
|
return inboxWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInboxWindow(): BrowserWindowType {
|
export function createInboxWindow(opts: { visible?: boolean } = {}): BrowserWindowType {
|
||||||
|
const visible = opts.visible ?? true;
|
||||||
if (inboxWindow && !inboxWindow.isDestroyed()) {
|
if (inboxWindow && !inboxWindow.isDestroyed()) {
|
||||||
inboxWindow.show();
|
if (visible) {
|
||||||
inboxWindow.focus();
|
inboxWindow.show();
|
||||||
|
inboxWindow.focus();
|
||||||
|
}
|
||||||
return inboxWindow;
|
return inboxWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +46,19 @@ export function createInboxWindow(): BrowserWindowType {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
inboxWindow.once('ready-to-show', () => inboxWindow?.show());
|
inboxWindow.once('ready-to-show', () => {
|
||||||
|
if (visible) {
|
||||||
|
inboxWindow?.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// macOS hidden autostart: regular NSWindow 를 NSApp 에 register 해야 dock running
|
||||||
|
// indicator (점) 가 표출된다. panel type 의 quickCapture 만 있으면 NSPanel 미인지 →
|
||||||
|
// dock 점이 안 보여 "앱이 안 떠 있는 것처럼" 보이는 버그. showInactive 로 focus 점유
|
||||||
|
// 없이 짧게 표출 후 즉시 hide — 사용자 화면 깜빡임 최소화.
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
inboxWindow?.showInactive();
|
||||||
|
inboxWindow?.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
return inboxWindow;
|
return inboxWindow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export function createQuickCaptureWindow(): BrowserWindowType {
|
|||||||
win.loadFile(join(__dirname, '../renderer/quickcapture/index.html'));
|
win.loadFile(join(__dirname, '../renderer/quickcapture/index.html'));
|
||||||
}
|
}
|
||||||
|
|
||||||
win.on('blur', () => { if (win?.isVisible()) win.hide(); });
|
|
||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { SearchBox } from './components/SearchBox.js';
|
|||||||
import { ReviewView } from './components/ReviewView.js';
|
import { ReviewView } from './components/ReviewView.js';
|
||||||
import type { InboxView } from './store.js';
|
import type { InboxView } from './store.js';
|
||||||
|
|
||||||
|
// QuickCapture 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl.
|
||||||
|
const MOD_KEY = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl';
|
||||||
|
|
||||||
export function App(): React.ReactElement {
|
export function App(): React.ReactElement {
|
||||||
const {
|
const {
|
||||||
notes, trashNotes, trashCount, showTrash,
|
notes, trashNotes, trashCount, showTrash,
|
||||||
@@ -190,7 +193,7 @@ export function App(): React.ReactElement {
|
|||||||
) : searchResults !== null && displayed.length === 0 ? (
|
) : searchResults !== null && displayed.length === 0 ? (
|
||||||
<div className="empty">검색 결과가 없습니다.</div>
|
<div className="empty">검색 결과가 없습니다.</div>
|
||||||
) : notes.length === 0 ? (
|
) : notes.length === 0 ? (
|
||||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
|
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>{MOD_KEY}+Shift+J</code></div>
|
||||||
) : displayed.length === 0 ? (
|
) : displayed.length === 0 ? (
|
||||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import type { Note } from '@shared/types';
|
import type { Note } from '@shared/types';
|
||||||
import { useInbox } from '../store.js';
|
import { useInbox } from '../store.js';
|
||||||
import { Banner } from './Banner.js';
|
import { Banner } from './Banner.js';
|
||||||
|
import { DAY_MS, kstTodayIso } from '@shared/util/kstDate.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* due_date 대비 오늘 (KST) 의 상대 라벨. 오늘 = "오늘", 지난 = "N일 지남".
|
||||||
|
* findExpiredCandidates 가 미래 due 는 반환하지 않으므로 음수 케이스 미고려.
|
||||||
|
*/
|
||||||
|
function dueRelativeLabel(due: string, todayKst: string): string {
|
||||||
|
if (due === todayKst) return '오늘';
|
||||||
|
const dueUtc = Date.UTC(
|
||||||
|
Number(due.slice(0, 4)), Number(due.slice(5, 7)) - 1, Number(due.slice(8, 10))
|
||||||
|
);
|
||||||
|
const todayUtc = Date.UTC(
|
||||||
|
Number(todayKst.slice(0, 4)), Number(todayKst.slice(5, 7)) - 1, Number(todayKst.slice(8, 10))
|
||||||
|
);
|
||||||
|
const days = Math.round((todayUtc - dueUtc) / DAY_MS);
|
||||||
|
return `${days}일 지남`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headingText(todayCount: number, overdueCount: number): string {
|
||||||
|
if (todayCount > 0 && overdueCount > 0) return `오늘 마감 ${todayCount} · 지난 ${overdueCount}`;
|
||||||
|
if (todayCount > 0) return `오늘 마감 ${todayCount}개`;
|
||||||
|
return `지난 ${overdueCount}개`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RecallBanner 와 동일 패턴 — NoteCard 의 `note-{id}` element 로 smooth scroll. */
|
||||||
|
function scrollToNote(id: string): void {
|
||||||
|
const el = document.getElementById(`note-${id}`);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
export function ExpiryBanner(): React.ReactElement | null {
|
export function ExpiryBanner(): React.ReactElement | null {
|
||||||
const candidates = useInbox((s) => s.expiredCandidates);
|
const candidates = useInbox((s) => s.expiredCandidates);
|
||||||
@@ -58,6 +87,10 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
|||||||
const allSelected = candidates.length > 0 && candidates.every((c) => selected.has(c.id));
|
const allSelected = candidates.length > 0 && candidates.every((c) => selected.has(c.id));
|
||||||
const someSelected = selected.size > 0 && !allSelected;
|
const someSelected = selected.size > 0 && !allSelected;
|
||||||
|
|
||||||
|
const todayKst = kstTodayIso();
|
||||||
|
const todayCount = candidates.filter((c) => c.dueDate === todayKst).length;
|
||||||
|
const overdueCount = candidates.length - todayCount;
|
||||||
|
|
||||||
function toggleAll() {
|
function toggleAll() {
|
||||||
if (allSelected) setSelected(new Set());
|
if (allSelected) setSelected(new Set());
|
||||||
else setSelected(new Set(candidates.map((c) => c.id)));
|
else setSelected(new Set(candidates.map((c) => c.id)));
|
||||||
@@ -75,7 +108,7 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
|||||||
return (
|
return (
|
||||||
<Banner severity="warning">
|
<Banner severity="warning">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span>⏰ <b>오늘 기준 만료 {candidates.length}개</b></span>
|
<span>⏰ <b>{headingText(todayCount, overdueCount)}</b></span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded((e) => !e)}
|
onClick={() => setExpanded((e) => !e)}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#946100' }}
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#946100' }}
|
||||||
@@ -107,33 +140,51 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
|||||||
<span style={{ color: '#666' }}>전체 선택 ({selected.size}/{candidates.length})</span>
|
<span style={{ color: '#666' }}>전체 선택 ({selected.size}/{candidates.length})</span>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div>
|
||||||
{candidates.map((n) => (
|
{candidates.map((n) => {
|
||||||
<label
|
const title = n.aiTitle ?? n.rawText.slice(0, 60);
|
||||||
key={n.id}
|
return (
|
||||||
style={{
|
<div
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
key={n.id}
|
||||||
padding: '4px 0', cursor: 'pointer'
|
style={{
|
||||||
}}
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
>
|
padding: '4px 0'
|
||||||
<input
|
}}
|
||||||
type="checkbox"
|
>
|
||||||
checked={selected.has(n.id)}
|
<input
|
||||||
onChange={() => toggle(n.id)}
|
type="checkbox"
|
||||||
/>
|
checked={selected.has(n.id)}
|
||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
onChange={() => toggle(n.id)}
|
||||||
{n.aiTitle ?? n.rawText.slice(0, 60)}
|
aria-label={`${title} 선택`}
|
||||||
</span>
|
/>
|
||||||
<span style={{ color: '#946100', fontSize: 12 }}>due {n.dueDate}</span>
|
<button
|
||||||
{n.tags[0] && (
|
type="button"
|
||||||
<span style={{
|
onClick={() => scrollToNote(n.id)}
|
||||||
background: '#fce8b2', color: '#946100', padding: '0 6px',
|
title="해당 메모로 이동"
|
||||||
borderRadius: 10, fontSize: 11
|
style={{
|
||||||
}}>
|
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
#{n.tags[0].name}
|
background: 'none', border: 'none', padding: 0,
|
||||||
|
cursor: 'pointer', color: 'inherit', font: 'inherit', textAlign: 'left'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
style={{ color: '#946100', fontSize: 12 }}
|
||||||
|
title={`due ${n.dueDate}`}
|
||||||
|
>
|
||||||
|
{dueRelativeLabel(n.dueDate ?? todayKst, todayKst)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
{n.tags[0] && (
|
||||||
</label>
|
<span style={{
|
||||||
))}
|
background: '#fce8b2', color: '#946100', padding: '0 6px',
|
||||||
|
borderRadius: 10, fontSize: 11
|
||||||
|
}}>
|
||||||
|
#{n.tags[0].name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onTrash(Array.from(selected))}
|
onClick={() => onTrash(Array.from(selected))}
|
||||||
|
|||||||
@@ -154,7 +154,15 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
|||||||
if (next.trim().length === 0) return;
|
if (next.trim().length === 0) return;
|
||||||
const r = await inboxApi.updateRawText(note.id, next);
|
const r = await inboxApi.updateRawText(note.id, next);
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const updated = { ...local, rawText: next, updatedAt: new Date().toISOString() };
|
// disabled 노트는 AI 재처리 안 됨 (서버에서 skip) — aiStatus 유지.
|
||||||
|
// 그 외는 optimistic 으로 pending 표시 → AiWorker 완료 시 push 로 자동 sync.
|
||||||
|
const willReprocess = local.aiStatus !== 'disabled';
|
||||||
|
const updated = {
|
||||||
|
...local,
|
||||||
|
rawText: next,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
...(willReprocess ? { aiStatus: 'pending' as const, aiError: null } : {})
|
||||||
|
};
|
||||||
setLocal(updated);
|
setLocal(updated);
|
||||||
onUpdated(updated);
|
onUpdated(updated);
|
||||||
setEditingRaw(false);
|
setEditingRaw(false);
|
||||||
@@ -236,23 +244,40 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{local.aiStatus === 'failed' && (
|
{local.aiStatus === 'failed' && (
|
||||||
<div style={{ marginTop: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ marginTop: 4 }}>
|
||||||
<div title={local.aiError ?? ''} style={{ fontSize: 16, fontWeight: 600, color: '#a55' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
정리 보류 — 원문은 안전합니다
|
<div style={{ fontSize: 16, fontWeight: 600, color: '#a55' }}>
|
||||||
|
정리 보류 — 원문은 안전합니다
|
||||||
|
</div>
|
||||||
|
{/* v0.3.9 — per-note 재시도 UI. FailedBanner 의 일괄 재시도와 별개. */}
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await inboxApi.retryOneFailed(local.id);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: '1px solid #a55', color: '#a55',
|
||||||
|
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
|
||||||
|
}}
|
||||||
|
title="이 노트만 AI 처리 재시도"
|
||||||
|
>
|
||||||
|
재시도
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* v0.3.9 — per-note 재시도 UI. FailedBanner 의 일괄 재시도와 별개. */}
|
{/* v0.3.14 — fail 원인 inline 표시. ai_error 의 raw message 가 그대로 사용자에게
|
||||||
<button
|
보여서 디버깅 + 모델/네트워크 이슈 진단 가능. 너무 길면 <details> 로 접힘. */}
|
||||||
onClick={async () => {
|
{local.aiError !== null && local.aiError.length > 0 && (
|
||||||
await inboxApi.retryOneFailed(local.id);
|
<details style={{ marginTop: 4 }}>
|
||||||
}}
|
<summary style={{ fontSize: 12, color: '#a55', cursor: 'pointer' }}>
|
||||||
style={{
|
원인 보기
|
||||||
background: 'none', border: '1px solid #a55', color: '#a55',
|
</summary>
|
||||||
cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 4
|
<pre style={{
|
||||||
}}
|
fontSize: 11, color: '#666', background: '#fff0f0', padding: 6,
|
||||||
title="이 노트만 AI 처리 재시도"
|
borderRadius: 4, marginTop: 4, whiteSpace: 'pre-wrap', wordBreak: 'break-word'
|
||||||
>
|
}}>
|
||||||
재시도
|
{local.aiError}
|
||||||
</button>
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
|
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ export function SyncHelpModal({ onClose, initialAnchor }: Props): React.ReactEle
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section id="main-conflict" style={sectionStyle}>
|
<section id="main-conflict" style={sectionStyle}>
|
||||||
<h4 style={h4Style}>1. 충돌 해결 (메인 시나리오)</h4>
|
<h4 style={h4Style}>1. 충돌 해결 (직접 결정해야 하는 일)</h4>
|
||||||
<p style={pStyle}>같은 노트를 두 기기에서 동시에 수정하면 충돌이 발생한다. "충돌 해결…" 버튼이 활성화되면 ConflictModal 이 열려 path 별 결정 (내 것 사용 / 원격 사용) 을 받는다.</p>
|
<p style={pStyle}>같은 노트를 두 기기에서 동시에 수정하면 어느 쪽을 남길지 Inkling 이 자동으로 결정할 수 없습니다. 이때 "충돌 해결…" 버튼이 활성화되고, 노트별로 "내 것 사용" 또는 "원격 사용" 을 골라주시면 됩니다.</p>
|
||||||
|
|
||||||
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>편집/편집 — 가장 흔한 경우</p>
|
<p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>편집/편집 — 가장 흔한 경우</p>
|
||||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||||
@@ -79,21 +79,23 @@ export function SyncHelpModal({ onClose, initialAnchor }: Props): React.ReactEle
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="auto" style={sectionStyle}>
|
<section id="auto" style={sectionStyle}>
|
||||||
<h4 style={h4Style}>2. 자동 처리 (내가 안 해도 되는 일)</h4>
|
<h4 style={h4Style}>2. 자동으로 처리되는 일</h4>
|
||||||
|
<p style={pStyle}>아래 동작은 Inkling 이 알아서 처리합니다. 충돌이 없으면 사용자가 신경 쓸 일은 없습니다.</p>
|
||||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||||
<li style={liStyle}><b>fetch + rebase</b>: sync 시작 시 원격 변경을 가져와 내 변경 위에 다시 쌓음 (linear history). conflict 없으면 자동 진행</li>
|
<li style={liStyle}><b>원격 변경 먼저 받아오기</b>: 동기화를 시작하면 다른 기기가 올린 변경을 먼저 받아와 내 변경 위에 차곡차곡 올려놓습니다. 양쪽이 같은 줄을 건드리지 않으면 자동으로 진행됩니다.</li>
|
||||||
<li style={liStyle}><b>첫 sync 순서</b>: 빈 원격에는 어느 기기든 먼저 push 가능. 두 번째 기기는 fetch 후 자동 rebase</li>
|
<li style={liStyle}><b>첫 동기화 순서</b>: 비어있는 원격 저장소에는 어느 기기든 먼저 올릴 수 있습니다. 두 번째 기기는 받아온 뒤 자동으로 합쳐집니다.</li>
|
||||||
<li style={liStyle}><b>push 거부 (non-fast-forward)</b>: 다른 기기가 먼저 push 했어도 자동 fetch + rebase + 재시도. 사용자 개입은 rebase conflict 발생 시에만</li>
|
<li style={liStyle}><b>업로드 거부 시 자동 재시도</b>: 다른 기기가 이미 변경을 올려둔 상태라 내 업로드가 막혀도, 받아오기 + 합치기 + 재시도가 자동으로 진행됩니다. 사용자가 개입할 일은 같은 위치를 양쪽이 동시에 수정한 경우에만 생깁니다.</li>
|
||||||
<li style={liStyle}><b>자동 sync 주기</b>: 기본 30분 (설정에서 변경). 앱 종료 시 자동 1회 추가</li>
|
<li style={liStyle}><b>자동 동기화 주기</b>: 기본 30분 (설정에서 변경). 앱 종료 시에도 한 번 추가로 실행됩니다.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="silent" style={sectionStyle}>
|
<section id="silent" style={sectionStyle}>
|
||||||
<h4 style={h4Style}>3. 조용히 잘못될 수 있는 케이스 (silent risk)</h4>
|
<h4 style={h4Style}>3. 모르고 넘어가기 쉬운 함정</h4>
|
||||||
|
<p style={pStyle}>아래 상황은 에러처럼 보이지 않지만 결과가 잘못 나올 수 있으니 미리 알아두는 것이 좋습니다.</p>
|
||||||
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
<ul style={{ paddingLeft: 18, margin: '4px 0' }}>
|
||||||
<li style={liStyle}><b>시계 어긋남 (NTP)</b>: 양 기기 시계가 다르면 timestamp 기반 merge 가 잘못된 결과를 낼 수 있음. macOS / Windows 모두 기본 NTP 동기화 켜져 있음 — 수동으로 끄지 말 것</li>
|
<li style={liStyle}><b>두 기기의 시각 어긋남</b>: 시계가 어긋나면 변경 순서가 뒤바뀌어 한쪽 수정이 묻힐 수 있습니다. macOS / Windows 모두 기본적으로 시각이 자동 동기화되니, 일부러 끄지 마세요.</li>
|
||||||
<li style={liStyle}><b>두 기기 동시 수정 회피</b>: 같은 노트를 동시에 수정하면 conflict 가 더 자주 발생. 한 기기에서 작업 중이면 다른 기기에서 같은 노트 수정 자제</li>
|
<li style={liStyle}><b>같은 노트를 두 기기에서 동시에 수정</b>: 충돌이 더 자주 발생합니다. 한 기기에서 작업 중이라면 다른 기기에서 같은 노트는 만지지 않는 편이 안전합니다.</li>
|
||||||
<li style={liStyle}><b>자동 sync 실패 silent</b>: 주기적 sync 실패 시 토스트 안 뜸. 마지막 sync 시각 / 결과는 설정 페이지에서 확인 — 주 1회 점검 권장</li>
|
<li style={liStyle}><b>자동 동기화 실패는 조용히 지나갑니다</b>: 주기적 동기화가 실패해도 알림 토스트는 뜨지 않습니다. 마지막 동기화 시각과 결과는 설정 페이지에서 확인할 수 있으니, 주 1회 정도 점검을 권장합니다.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { inboxApi } from '../../api.js';
|
import { inboxApi } from '../../api.js';
|
||||||
import { VisionSection } from './VisionSection.js';
|
import { VisionSection } from './VisionSection.js';
|
||||||
|
import { SectionIntro } from './SectionIntro.js';
|
||||||
|
|
||||||
const endpointSchema = z.string().url();
|
const endpointSchema = z.string().url();
|
||||||
|
|
||||||
@@ -78,6 +79,10 @@ export function AiProviderSection(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<SectionIntro>
|
||||||
|
메모를 자동으로 정리하는 AI 제공자입니다. Inkling 은 기본적으로 로컬 Ollama 를 사용해
|
||||||
|
데이터가 기기 밖으로 나가지 않게 합니다. 사용할 모델과 접속 주소를 여기서 지정합니다.
|
||||||
|
</SectionIntro>
|
||||||
{/* v0.2.9 Cut B Task 15 — AI 자동 처리 토글 (가장 위, 스위치 의미가 가장 큰 결정) */}
|
{/* v0.2.9 Cut B Task 15 — AI 자동 처리 토글 (가장 위, 스위치 의미가 가장 큰 결정) */}
|
||||||
{aiEnabled !== null && (
|
{aiEnabled !== null && (
|
||||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: 13 }}>
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: 13 }}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { AutostartResponse } from '@shared/types';
|
import type { AutostartResponse } from '@shared/types';
|
||||||
import { inboxApi } from '../../api.js';
|
import { inboxApi } from '../../api.js';
|
||||||
|
import { SectionIntro } from './SectionIntro.js';
|
||||||
|
|
||||||
export function AutostartSection(): React.ReactElement {
|
export function AutostartSection(): React.ReactElement {
|
||||||
const [data, setData] = useState<AutostartResponse | null>(null);
|
const [data, setData] = useState<AutostartResponse | null>(null);
|
||||||
@@ -31,14 +32,19 @@ export function AutostartSection(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const d = data.diagnostic;
|
const d = data.diagnostic;
|
||||||
// v0.2.7 F12 deeper fix — withArgs vs noArgs 의 openAtLogin 불일치, 또는
|
// withArgs vs noArgs 의 openAtLogin 불일치는 양 플랫폼에서 진짜 mismatch 시그널.
|
||||||
// executableWillLaunchAtLogin = false 면 mismatch 로 간주 (등록은 됐지만 실제론
|
// executableWillLaunchAtLogin 은 Win 에서만 신뢰 — macOS 13+ SMAppService API 는
|
||||||
// 로그인 시 실행되지 않을 수 있는 상태).
|
// LoginItems 에 등록되어 있어도 unsigned/Electron 앱에 대해 false 를 자주 반환해
|
||||||
const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin
|
// false positive 가 발생함. macOS 는 이 신호를 mismatch 판정에서 제외.
|
||||||
|| (data.openAtLogin && !d.withArgs.executableWillLaunchAtLogin);
|
const willLaunchSignal = d.platform === 'win32' && data.openAtLogin && !d.withArgs.executableWillLaunchAtLogin;
|
||||||
|
const mismatch = d.withArgs.openAtLogin !== d.noArgs.openAtLogin || willLaunchSignal;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<SectionIntro>
|
||||||
|
시스템에 로그인하면 Inkling 이 백그라운드로 함께 시작합니다. 메인 창은 뜨지 않고,
|
||||||
|
Cmd+Shift+J (macOS) / Ctrl+Shift+J (Windows) 로 필요할 때 불러와 쓰시면 됩니다.
|
||||||
|
</SectionIntro>
|
||||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13 }}>
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 13 }}>
|
||||||
<input type="checkbox" checked={data.openAtLogin} onChange={onToggle} />
|
<input type="checkbox" checked={data.openAtLogin} onChange={onToggle} />
|
||||||
앱 시작 시 자동으로 실행
|
앱 시작 시 자동으로 실행
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { inboxApi } from '../../api.js';
|
import { inboxApi } from '../../api.js';
|
||||||
|
import { SectionIntro } from './SectionIntro.js';
|
||||||
|
|
||||||
export function BackupSection(): React.ReactElement {
|
export function BackupSection(): React.ReactElement {
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
@@ -14,6 +15,10 @@ export function BackupSection(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<SectionIntro>
|
||||||
|
메모와 첨부 이미지를 안전하게 백업하거나 다른 기기로 옮길 때 사용합니다. 자동 백업은 매일
|
||||||
|
앱 종료 시 1회 실행되고, 여기서는 필요할 때 수동으로 실행할 수 있습니다.
|
||||||
|
</SectionIntro>
|
||||||
<button onClick={() => run('지금 백업', () => inboxApi.runBackup())}>지금 백업</button>
|
<button onClick={() => run('지금 백업', () => inboxApi.runBackup())}>지금 백업</button>
|
||||||
<button onClick={() => run('내보내기', () => inboxApi.runExport())}>내보내기...</button>
|
<button onClick={() => run('내보내기', () => inboxApi.runExport())}>내보내기...</button>
|
||||||
<button onClick={() => run('백업에서 복원', () => inboxApi.runImport())}>백업에서 복원...</button>
|
<button onClick={() => run('백업에서 복원', () => inboxApi.runImport())}>백업에서 복원...</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { inboxApi } from '../../api.js';
|
import { inboxApi } from '../../api.js';
|
||||||
|
import { SectionIntro } from './SectionIntro.js';
|
||||||
|
|
||||||
interface AppInfo {
|
interface AppInfo {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -20,6 +21,9 @@ export function InfoSection(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<SectionIntro>
|
||||||
|
문제 보고나 호환성 확인이 필요할 때 참고하실 정보입니다.
|
||||||
|
</SectionIntro>
|
||||||
<dl style={{ fontSize: 12, lineHeight: 1.6 }}>
|
<dl style={{ fontSize: 12, lineHeight: 1.6 }}>
|
||||||
<dt style={{ fontWeight: 600 }}>버전</dt>
|
<dt style={{ fontWeight: 600 }}>버전</dt>
|
||||||
<dd>{info.version}</dd>
|
<dd>{info.version}</dd>
|
||||||
|
|||||||
12
src/renderer/inbox/components/settings/SectionIntro.tsx
Normal file
12
src/renderer/inbox/components/settings/SectionIntro.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Props { children: React.ReactNode; }
|
||||||
|
|
||||||
|
/** Settings page 각 section 상단에 표시되는 간단한 설명 paragraph. */
|
||||||
|
export function SectionIntro({ children }: Props): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<p style={{ fontSize: 12, color: '#666', lineHeight: 1.6, margin: '0 0 12px 0' }}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { inboxApi } from '../../api.js';
|
|||||||
import type { SyncStatusSnapshot } from '@shared/types';
|
import type { SyncStatusSnapshot } from '@shared/types';
|
||||||
import { ConflictModal } from '../ConflictModal.js';
|
import { ConflictModal } from '../ConflictModal.js';
|
||||||
import { SyncHelpModal, type SyncHelpAnchor } from '../SyncHelpModal.js';
|
import { SyncHelpModal, type SyncHelpAnchor } from '../SyncHelpModal.js';
|
||||||
|
import { SectionIntro } from './SectionIntro.js';
|
||||||
|
|
||||||
export function SyncSection(): React.ReactElement {
|
export function SyncSection(): React.ReactElement {
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
@@ -64,6 +65,10 @@ export function SyncSection(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<section style={{ marginTop: 24 }}>
|
<section style={{ marginTop: 24 }}>
|
||||||
<h3 style={{ fontSize: 14, marginBottom: 8 }}>동기화 저장소</h3>
|
<h3 style={{ fontSize: 14, marginBottom: 8 }}>동기화 저장소</h3>
|
||||||
|
<SectionIntro>
|
||||||
|
Git 저장소를 통해 여러 기기 간 메모를 동기화합니다. 단일 기기에서만 사용하시면 URL 을
|
||||||
|
비워두셔도 됩니다. 자동 동기화 주기와 충돌 처리는 아래에서 설정합니다.
|
||||||
|
</SectionIntro>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { inboxApi } from '../../api.js';
|
import { inboxApi } from '../../api.js';
|
||||||
|
import { SectionIntro } from './SectionIntro.js';
|
||||||
|
|
||||||
export function VisionSection(): React.ReactElement {
|
export function VisionSection(): React.ReactElement {
|
||||||
const [models, setModels] = useState<string[]>([]);
|
const [models, setModels] = useState<string[]>([]);
|
||||||
@@ -44,6 +45,10 @@ export function VisionSection(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<section style={{ marginTop: 16 }}>
|
<section style={{ marginTop: 16 }}>
|
||||||
<h4 style={{ fontSize: 13, marginBottom: 6 }}>이미지 분석 모델 (선택사항)</h4>
|
<h4 style={{ fontSize: 13, marginBottom: 6 }}>이미지 분석 모델 (선택사항)</h4>
|
||||||
|
<SectionIntro>
|
||||||
|
첨부 이미지를 함께 분석할 vision 지원 모델입니다. 텍스트용 모델과 별도로 지정할 수 있고,
|
||||||
|
미지정 시 이미지 첨부 메모는 텍스트만 정리됩니다.
|
||||||
|
</SectionIntro>
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||||||
<select
|
<select
|
||||||
aria-label="이미지 분석 모델"
|
aria-label="이미지 분석 모델"
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { captureApi } from './api.js';
|
|||||||
|
|
||||||
interface PastedImage { url: string; buffer: ArrayBuffer; }
|
interface PastedImage { url: string; buffer: ArrayBuffer; }
|
||||||
|
|
||||||
|
// 저장 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl.
|
||||||
|
const MOD_KEY = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl';
|
||||||
|
|
||||||
export function App(): React.ReactElement {
|
export function App(): React.ReactElement {
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [images, setImages] = useState<PastedImage[]>([]);
|
const [images, setImages] = useState<PastedImage[]>([]);
|
||||||
@@ -65,7 +68,7 @@ export function App(): React.ReactElement {
|
|||||||
{images.map((i, idx) => (<img key={idx} src={i.url} alt="" />))}
|
{images.map((i, idx) => (<img key={idx} src={i.url} alt="" />))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="hint">Ctrl+Enter 저장 · Esc 취소 · 이미지 붙여넣기</div>
|
<div className="hint">{MOD_KEY}+Enter 저장 · Esc 취소 · 이미지 붙여넣기</div>
|
||||||
{err && <div className="err">{err}</div>}
|
{err && <div className="err">{err}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ export interface AutostartDiagnostic {
|
|||||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||||
execPath: string;
|
execPath: string;
|
||||||
|
/** mismatch 판정 플랫폼 분기용 (macOS 의 SMAppService API 한계 우회). */
|
||||||
|
platform: NodeJS.Platform;
|
||||||
registryPath?: string;
|
registryPath?: string;
|
||||||
registryValue?: string | null;
|
registryValue?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,53 @@ describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
|||||||
expect(calls[0]![0].images).toBeUndefined();
|
expect(calls[0]![0].images).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('v0.3.14 — 본문 빈 + 이미지만 첨부 → generate 호출 skip + 자동 placeholder', async () => {
|
||||||
|
const { id } = repo.create({ rawText: '' }); // 빈 본문
|
||||||
|
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||||
|
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||||
|
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]);
|
||||||
|
|
||||||
|
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
|
||||||
|
const generate = vi.fn(async (
|
||||||
|
input: Parameters<InferenceProvider['generate']>[0],
|
||||||
|
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||||
|
): Promise<AiResponse> => {
|
||||||
|
calls.push([input, opts]);
|
||||||
|
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||||
|
});
|
||||||
|
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma4:26b');
|
||||||
|
const worker = makeWorker(generate, getVisionModel);
|
||||||
|
await worker.enqueue(id);
|
||||||
|
await worker.drain();
|
||||||
|
|
||||||
|
// vision 호출 자체 skip
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
// 노트가 자동 placeholder 로 done
|
||||||
|
const note = repo.findById(id);
|
||||||
|
expect(note?.aiStatus).toBe('done');
|
||||||
|
expect(note?.aiTitle).toContain('첨부 이미지');
|
||||||
|
expect(note?.aiSummary).toContain('이미지');
|
||||||
|
expect(note?.aiProvider).toBe('image-only-skip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('v0.3.14 — 이미지 다수 첨부 시 placeholder 가 개수 포함', async () => {
|
||||||
|
const { id } = repo.create({ rawText: '' });
|
||||||
|
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||||
|
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89]));
|
||||||
|
await writeFile(join(workDir, 'media', id, '2.png'), Buffer.from([0x89]));
|
||||||
|
repo.insertMedia([
|
||||||
|
{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 1 },
|
||||||
|
{ noteId: id, kind: 'image', relPath: `media/${id}/2.png`, mime: 'image/png', bytes: 1 }
|
||||||
|
]);
|
||||||
|
const generate = vi.fn(async (): Promise<AiResponse> => ({ title: 't', summary: 'a\nb\nc', tags: [], dueDate: null }));
|
||||||
|
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma4:26b');
|
||||||
|
const worker = makeWorker(generate, getVisionModel);
|
||||||
|
await worker.enqueue(id);
|
||||||
|
await worker.drain();
|
||||||
|
const note = repo.findById(id);
|
||||||
|
expect(note?.aiTitle).toContain('2장');
|
||||||
|
});
|
||||||
|
|
||||||
it('5MB 초과 이미지 → throw → AiWorker 의 fail 분기 (generate 미호출)', async () => {
|
it('5MB 초과 이미지 → throw → AiWorker 의 fail 분기 (generate 미호출)', async () => {
|
||||||
const { id } = repo.create({ rawText: 'big image' });
|
const { id } = repo.create({ rawText: 'big image' });
|
||||||
await mkdir(join(workDir, 'media', id), { recursive: true });
|
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ describe('AutostartDiagnostic — collectAutostartState', () => {
|
|||||||
expect(state.withArgs).toEqual({ openAtLogin: true, executableWillLaunchAtLogin: true });
|
expect(state.withArgs).toEqual({ openAtLogin: true, executableWillLaunchAtLogin: true });
|
||||||
expect(state.noArgs).toEqual({ openAtLogin: false, executableWillLaunchAtLogin: true });
|
expect(state.noArgs).toEqual({ openAtLogin: false, executableWillLaunchAtLogin: true });
|
||||||
expect(state.execPath).toBe(process.execPath);
|
expect(state.execPath).toBe(process.execPath);
|
||||||
|
expect(state.platform).toBe('darwin');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes args=["--hidden"] for the first call, no args for the second', async () => {
|
it('passes args=["--hidden"] for the first call, no args for the second', async () => {
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||||
|
|
||||||
function makeDiag(open: boolean): {
|
function makeDiag(open: boolean, platform: NodeJS.Platform = 'win32'): {
|
||||||
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
withArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||||
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
noArgs: { openAtLogin: boolean; executableWillLaunchAtLogin: boolean };
|
||||||
execPath: string;
|
execPath: string;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
|
withArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
|
||||||
noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
|
noArgs: { openAtLogin: open, executableWillLaunchAtLogin: open },
|
||||||
execPath: '/path/to/exe'
|
execPath: '/path/to/exe',
|
||||||
|
platform
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +53,8 @@ describe('AutostartSection', () => {
|
|||||||
diagnostic: {
|
diagnostic: {
|
||||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||||
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true },
|
noArgs: { openAtLogin: false, executableWillLaunchAtLogin: true },
|
||||||
execPath: '/path/to/Inkling.exe'
|
execPath: '/path/to/Inkling.exe',
|
||||||
|
platform: 'win32'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
render(<AutostartSection />);
|
render(<AutostartSection />);
|
||||||
@@ -71,6 +74,7 @@ describe('AutostartSection', () => {
|
|||||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||||
execPath: 'C:\\app.exe',
|
execPath: 'C:\\app.exe',
|
||||||
|
platform: 'win32',
|
||||||
registryPath: 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling',
|
registryPath: 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\Inkling',
|
||||||
registryValue: '"C:\\app.exe" --hidden'
|
registryValue: '"C:\\app.exe" --hidden'
|
||||||
}
|
}
|
||||||
@@ -89,7 +93,8 @@ describe('AutostartSection', () => {
|
|||||||
diagnostic: {
|
diagnostic: {
|
||||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||||
execPath: '/p'
|
execPath: '/p',
|
||||||
|
platform: 'win32'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
render(<AutostartSection />);
|
render(<AutostartSection />);
|
||||||
@@ -97,6 +102,38 @@ describe('AutostartSection', () => {
|
|||||||
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('macOS: no false-positive mismatch when willLaunch=false (SMAppService 한계)', async () => {
|
||||||
|
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||||
|
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
||||||
|
openAtLogin: true,
|
||||||
|
diagnostic: {
|
||||||
|
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
|
||||||
|
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
|
||||||
|
execPath: '/Applications/Inkling.app',
|
||||||
|
platform: 'darwin'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
render(<AutostartSection />);
|
||||||
|
await screen.findByRole('checkbox');
|
||||||
|
expect(screen.queryByText(/⚠️/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Win: mismatch warning when openAtLogin=true but willLaunch=false', async () => {
|
||||||
|
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||||
|
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
||||||
|
openAtLogin: true,
|
||||||
|
diagnostic: {
|
||||||
|
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
|
||||||
|
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: false },
|
||||||
|
execPath: 'C:\\app.exe',
|
||||||
|
platform: 'win32'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
render(<AutostartSection />);
|
||||||
|
await screen.findByRole('checkbox');
|
||||||
|
expect(await screen.findByText(/⚠️/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('"재등록" button calls setAutostart with current openAtLogin value', async () => {
|
it('"재등록" button calls setAutostart with current openAtLogin value', async () => {
|
||||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||||
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
vi.mocked(inboxApi.getAutostart).mockResolvedValueOnce({
|
||||||
@@ -104,7 +141,8 @@ describe('AutostartSection', () => {
|
|||||||
diagnostic: {
|
diagnostic: {
|
||||||
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
withArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||||
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
noArgs: { openAtLogin: true, executableWillLaunchAtLogin: true },
|
||||||
execPath: '/p'
|
execPath: '/p',
|
||||||
|
platform: 'win32'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
render(<AutostartSection />);
|
render(<AutostartSection />);
|
||||||
|
|||||||
@@ -45,13 +45,30 @@ describe('LocalOllamaProvider', () => {
|
|||||||
expect(parsed.prompt).toContain('Prefer reusing');
|
expect(parsed.prompt).toContain('Prefer reusing');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generate throws on non-JSON', async () => {
|
it('v0.3.14 — generate falls back to placeholder when JSON unparseable', async () => {
|
||||||
|
// 이전엔 throw 했지만 schema graceful coerce 추가 후 placeholder 채워서 통과.
|
||||||
|
// truncated / repetition-loop 응답에서 사용자 데이터 (raw_text) 무손실 보존.
|
||||||
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
||||||
response: 'not json'
|
response: 'not json'
|
||||||
});
|
});
|
||||||
await expect(
|
const r = await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
|
||||||
new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] })
|
expect(r.title).toBe('(첨부 메모)');
|
||||||
).rejects.toThrow(/unparseable/i);
|
expect(r.summary.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('v0.3.14 — body 에 repeat_penalty 포함 (repetition loop 방지)', async () => {
|
||||||
|
let capturedBody: string = '';
|
||||||
|
mock.get('http://localhost:11434').intercept({
|
||||||
|
path: '/api/generate', method: 'POST'
|
||||||
|
}).reply((opts) => {
|
||||||
|
capturedBody = opts.body as string;
|
||||||
|
return { statusCode: 200, data: JSON.stringify({
|
||||||
|
response: JSON.stringify({ title: '회의', summary: 'a\nb\nc', tags: [] })
|
||||||
|
}) };
|
||||||
|
});
|
||||||
|
await new LocalOllamaProvider().generate({ text: 'x', todayKst: '2026-04-26', dueDateCandidates: [] });
|
||||||
|
const parsed = JSON.parse(capturedBody) as { options: { repeat_penalty: number } };
|
||||||
|
expect(parsed.options.repeat_penalty).toBe(1.15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('v0.3.11 — generate extracts JSON from markdown fence', async () => {
|
it('v0.3.11 — generate extracts JSON from markdown fence', async () => {
|
||||||
|
|||||||
@@ -592,6 +592,7 @@ describe('NoteRepository.findExpiredCandidates', () => {
|
|||||||
edited?: boolean;
|
edited?: boolean;
|
||||||
deletedAt?: string | null;
|
deletedAt?: string | null;
|
||||||
aiStatus?: 'pending' | 'done' | 'failed';
|
aiStatus?: 'pending' | 'done' | 'failed';
|
||||||
|
status?: 'active' | 'completed' | 'archived' | 'trashed';
|
||||||
}): string {
|
}): string {
|
||||||
const { id } = repo.create({ rawText: opts.rawText });
|
const { id } = repo.create({ rawText: opts.rawText });
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -599,19 +600,21 @@ describe('NoteRepository.findExpiredCandidates', () => {
|
|||||||
SET due_date = ?,
|
SET due_date = ?,
|
||||||
due_date_edited_by_user = ?,
|
due_date_edited_by_user = ?,
|
||||||
ai_status = ?,
|
ai_status = ?,
|
||||||
deleted_at = ?
|
deleted_at = ?,
|
||||||
|
status = ?
|
||||||
WHERE id = ?`
|
WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
opts.dueDate,
|
opts.dueDate,
|
||||||
opts.edited ? 1 : 0,
|
opts.edited ? 1 : 0,
|
||||||
opts.aiStatus ?? 'done',
|
opts.aiStatus ?? 'done',
|
||||||
opts.deletedAt ?? null,
|
opts.deletedAt ?? null,
|
||||||
|
opts.status ?? 'active',
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
it('returns notes with due_date < today (KST), ORDER BY created_at DESC', () => {
|
it('returns notes with due_date <= today (KST), ORDER BY due_date DESC then created_at DESC', () => {
|
||||||
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
||||||
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a);
|
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a);
|
||||||
const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' });
|
const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' });
|
||||||
@@ -620,6 +623,14 @@ describe('NoteRepository.findExpiredCandidates', () => {
|
|||||||
expect(r.map((n) => n.id)).toEqual([b, a]);
|
expect(r.map((n) => n.id)).toEqual([b, a]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes notes with due_date == today (오늘 당일 우선 표시)', () => {
|
||||||
|
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
|
||||||
|
const todayNote = makeDone({ rawText: 'b', dueDate: '2026-05-01' });
|
||||||
|
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||||
|
// 오늘 당일이 먼저, 그 다음 지난 메모.
|
||||||
|
expect(r.map((n) => n.id)).toEqual([todayNote, past]);
|
||||||
|
});
|
||||||
|
|
||||||
it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => {
|
it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => {
|
||||||
const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false });
|
const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false });
|
||||||
const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true });
|
const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true });
|
||||||
@@ -629,7 +640,7 @@ describe('NoteRepository.findExpiredCandidates', () => {
|
|||||||
|
|
||||||
it('excludes trashed notes (deleted_at IS NOT NULL)', () => {
|
it('excludes trashed notes (deleted_at IS NOT NULL)', () => {
|
||||||
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
||||||
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z' });
|
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z', status: 'trashed' });
|
||||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||||
expect(r.map((n) => n.id)).toEqual([a]);
|
expect(r.map((n) => n.id)).toEqual([a]);
|
||||||
});
|
});
|
||||||
@@ -649,11 +660,12 @@ describe('NoteRepository.findExpiredCandidates', () => {
|
|||||||
expect(r.map((n) => n.id)).toEqual([dated]);
|
expect(r.map((n) => n.id)).toEqual([dated]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes notes with due_date == today (boundary, not expired)', () => {
|
it('excludes completed / archived notes (inbox 만 — 사용자 의도: 완료/보관은 알림 제외)', () => {
|
||||||
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
|
const active = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
||||||
makeDone({ rawText: 'b', dueDate: '2026-05-01' });
|
makeDone({ rawText: 'b', dueDate: '2026-04-20', status: 'completed' });
|
||||||
|
makeDone({ rawText: 'c', dueDate: '2026-04-20', status: 'archived' });
|
||||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||||
expect(r.map((n) => n.id)).toEqual([past]);
|
expect(r.map((n) => n.id)).toEqual([active]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ describe('SyncHelpModal', () => {
|
|||||||
it('4 섹션 헤더 렌더링', () => {
|
it('4 섹션 헤더 렌더링', () => {
|
||||||
render(<SyncHelpModal onClose={() => {}} />);
|
render(<SyncHelpModal onClose={() => {}} />);
|
||||||
expect(screen.getByRole('heading', { name: /충돌 해결/ })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /충돌 해결/ })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: /자동 처리/ })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /자동으로 처리되는 일/ })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: /조용히 잘못될 수 있는/ })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /모르고 넘어가기 쉬운 함정/ })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: /Setup/ })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /Setup/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,21 @@ describe('parseAiResponse', () => {
|
|||||||
expect(r.tags).toEqual(['api-timeout', 'meeting']);
|
expect(r.tags).toEqual(['api-timeout', 'meeting']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects title without Korean', () => {
|
it('영어 title → (첨부 메모) placeholder fallback (vision graceful 처리)', () => {
|
||||||
expect(() =>
|
const r = parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] });
|
||||||
parseAiResponse({ title: 'English only', summary: 'a\nb\nc', tags: [] })
|
expect(r.title).toBe('(첨부 메모)');
|
||||||
).toThrow(/korean/i);
|
});
|
||||||
|
|
||||||
|
it('null title/summary → placeholder coerce (vision 본문 빈 케이스)', () => {
|
||||||
|
const r = parseAiResponse({ title: null, summary: null, tags: [], due_date: null });
|
||||||
|
expect(r.title).toBe('(첨부 메모)');
|
||||||
|
expect(r.summary.startsWith('내용을 자동으로 정리하지 못했습니다')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty string title/summary → placeholder coerce', () => {
|
||||||
|
const r = parseAiResponse({ title: '', summary: '', tags: [] });
|
||||||
|
expect(r.title).toBe('(첨부 메모)');
|
||||||
|
expect(r.summary.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pads short summary to 3 lines', () => {
|
it('pads short summary to 3 lines', () => {
|
||||||
@@ -82,10 +93,14 @@ describe('parseAiResponse', () => {
|
|||||||
expect(r.dueDate).toBeNull();
|
expect(r.dueDate).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects malformed due_date string', () => {
|
it('malformed due_date string → null coerce (vision graceful 처리)', () => {
|
||||||
expect(() =>
|
const r = parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: 'tomorrow' });
|
||||||
parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: 'tomorrow' })
|
expect(r.dueDate).toBeNull();
|
||||||
).toThrow();
|
});
|
||||||
|
|
||||||
|
it('empty string due_date → null coerce', () => {
|
||||||
|
const r = parseAiResponse({ title: '내일', summary: 'a\nb\nc', tags: [], due_date: '' });
|
||||||
|
expect(r.dueDate).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('coerces invalid date that passes regex (e.g. 2026-13-99) to null', () => {
|
it('coerces invalid date that passes regex (e.g. 2026-13-99) to null', () => {
|
||||||
|
|||||||
@@ -230,16 +230,22 @@ describe('composeIndexJsonl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('composeManifest', () => {
|
describe('composeManifest', () => {
|
||||||
it('emits pretty JSON with required fields', () => {
|
it('emits pretty JSON with required fields (timestamp-free)', () => {
|
||||||
const m = composeManifest({
|
const m = composeManifest({
|
||||||
exportedAt: '2026-04-26T00:00:00.000Z',
|
|
||||||
noteCount: 42,
|
noteCount: 42,
|
||||||
mediaCount: 17
|
mediaCount: 17
|
||||||
});
|
});
|
||||||
const obj = JSON.parse(m);
|
const obj = JSON.parse(m);
|
||||||
expect(obj.inkling_export_version).toBe(1);
|
expect(obj.inkling_export_version).toBe(1);
|
||||||
expect(obj.exported_at).toBe('2026-04-26T00:00:00.000Z');
|
|
||||||
expect(obj.note_count).toBe(42);
|
expect(obj.note_count).toBe(42);
|
||||||
expect(obj.media_count).toBe(17);
|
expect(obj.media_count).toBe(17);
|
||||||
|
// exported_at 필드 제거 — sync git history noise 방지.
|
||||||
|
expect(obj.exported_at).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('두 번 호출 결과 stable (sync no-op invariant — 같은 input 이면 git diff 0)', () => {
|
||||||
|
const a = composeManifest({ noteCount: 5, mediaCount: 2 });
|
||||||
|
const b = composeManifest({ noteCount: 5, mediaCount: 2 });
|
||||||
|
expect(a).toBe(b);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function makeDeps(overrides: Partial<InboxIpcDeps> = {}): InboxIpcDeps {
|
|||||||
updateRawText: vi.fn(),
|
updateRawText: vi.fn(),
|
||||||
listRevisions: vi.fn(() => []),
|
listRevisions: vi.fn(() => []),
|
||||||
restoreRevision: vi.fn(),
|
restoreRevision: vi.fn(),
|
||||||
|
markAiPendingForReprocess: vi.fn(() => ({ ok: false })),
|
||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
listByStatus: vi.fn(),
|
listByStatus: vi.fn(),
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ describe('buildVisionPrompt', () => {
|
|||||||
expect(result).toContain('work, meeting, project, todo');
|
expect(result).toContain('work, meeting, project, todo');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses (이미지만 있음) placeholder when text is empty', () => {
|
it('본문 빈 경우 이미지 묘사 + null 금지 명시 + one-shot 예시 (v0.3.14+)', () => {
|
||||||
const result = buildVisionPrompt('', '2026-05-09', [], []);
|
const result = buildVisionPrompt('', '2026-05-09', [], []);
|
||||||
expect(result).toContain('(이미지만 있음)');
|
expect(result).toContain('본문이 없습니다');
|
||||||
expect(result).not.toContain('\n\n\n'); // no double-blank from empty text
|
expect(result).toContain('null 반환 절대 금지');
|
||||||
|
expect(result).toContain('예시'); // one-shot 예시 포함
|
||||||
|
expect(result).toContain('잔디 위 강아지'); // 예시 1
|
||||||
|
expect(result).not.toContain('메모 본문:\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('본문 있는 경우 본문 우선 + 이미지 함께 분석 명시', () => {
|
||||||
|
const result = buildVisionPrompt('회의 메모', '2026-05-09', [], []);
|
||||||
|
expect(result).toContain('메모 본문:\n회의 메모');
|
||||||
|
expect(result).toContain('첨부 이미지도 함께 분석');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user