diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2559846..09fdc2c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -33,6 +33,7 @@ | 2026-11-05 | v3.98 | **사전: 고친 낱말 제안(PR6, 소유자 선택: 자동 바꾸기 없이 제안만).** Rust `meetings::corrections`가 녹음 노트(메모·예시 노트 제외)에서 `corrected_text`가 원문과 다른 줄을 [원문, 고친 문장]으로 모아(5000쌍까지) `glossary_corrections`로 넘기고, 화면은 기존 `vocab.termFromEdit`(낱말 1~2개·소리가 비슷함·용어처럼 보임)로 낱말을 골라 `dictionary.suggestFromCorrections`가 세고 거른다(사전에 있음·무시함 제외, 많이 고친 순 8개). 사전 화면에 `고쳐 쓴 낱말` 칩: 누르면 추가, ×는 무시(localStorage). 글자를 저절로 바꾸지 않아 측정이 필요 없다. 규칙이 화면 한 곳(vocab.ts)에만 있도록 코어 명령 대신 Rust가 원문만 읽어 넘긴다. | | 2026-11-05 | v3.99 | **의존성 줄이기: onnxruntime을 뺐다(소유자 선택: 지금 모델을 MLX로).** 목소리 프로필의 CAM++ ONNX를 `onnx_mlx`가 그대로 실행한다: protobuf에서 그래프·가중치·메타데이터를 직접 읽고(필드 몇 개뿐이라 `onnx` 패키지 없이), 이 모델이 쓰는 26개 연산만 구현한다(모양 계산은 numpy, 텐서는 MLX, 모르는 연산은 실행 전 오류). 받는 파일·sha256·`MODEL_ID`는 그대로라 **등록한 목소리를 다시 등록하지 않는다**. onnxruntime 1.30과 비교: 무작위 입력 5가지 길이에서 cosine 1.0·최대 차이 약 1e-5, `bench/voice_eval.py`(AI Hub 4세션 40명 1427발화, 지표만) 결과가 RTF(0.0085 → 0.0082) 말고 모두 같다. 검사: `tests/test_onnx_mlx.py`(테스트 안에서 만든 작은 ONNX로 protobuf·연산을 numpy와 비교, slow는 실제 모델을 onnxruntime으로 미리 뽑아 둔 임베딩과 비교). 후보였던 mlx-community CAM++는 출력 512차원·가중치 6MB로 우리 모델(192차원·28MB)과 달라 쓰지 않았다. 번들 감소량은 다음 번들 빌드에서 잰다(개발 환경의 onnxruntime 폴더는 75MB). | | 2026-11-06 | v3.100 | **⌘K 넓히기: 이 노트의 동작과 폴더·태그로 가기(브랜치 `feat/palette-note-actions`, 앱만).** 노트를 열었을 때 맨 앞 `이 노트` 묶음: 즐겨찾기(`org.setFavorite`)·태그 편집(`org.requestTagFocus`, 사이드바 줄 메뉴와 같은 통로)·형식별 내보내기·Obsidian. 내보내기는 `useNoteExport`가 MeetingView 안에 있어 App이 `exportReq {id, format, nonce}`를 내려 주고 MeetingView가 노트를 다 읽은 뒤 한 번 `exp.exportAs`/`toObsidian`을 부른다(노트 머리 메뉴와 같은 함수: Markdown·Word는 넣을 내용 고르기 창). 목록이 길어지지 않게 `PaletteAction.typed`(빈 입력에서는 숨기고 찾을 때만)를 두어 빈 입력에서는 Markdown·Word만 보인다. 입력이 있으면 `폴더·태그` 묶음: 고르면 사이드바 거르기를 그 폴더(태그) 하나로 바꾸고(`clearFilters` → `selectFolder`/`toggleTag`) 노트 목록을 펼친다. 폴더로 옮기기·인쇄는 고르는 창·쓰던 메모 저장이 끼어 넣지 않았다. WORDS §4.30. | +| 2026-11-06 | v3.101 | **일정과 노트 잇기(브랜치 `feat/event-note-link`).** v3.96의 이번 주 띠는 일정과 그 일정으로 녹음한 노트를 따로 보였다(노트에 일정 ID가 없었다). `event.json`에 선택 키 `id`(EventKit 일정 ID, 한 줄 256자 이하)를 더했다: 앱 `calendar::clean_event`와 코어 `calendar_event.clean_event`가 둘 다 남긴다(코어가 녹음 폴더 → 노트 폴더로 옮길 때 다시 검사하므로). 노트 목록 `MeetingInfo.event`에 `{id?, title, start}`만 싣는다(`calendar::note_event`, 목록 캐시 안: `event.json`은 두 저장 경로 모두 transcript.json보다 먼저 쓰인다). 화면 `week.linked`: 같은 순간에 시작하고 ID가 같으면(반복 일정은 회차마다 ID가 같아 시각으로 가른다), ID가 없는 옛 노트는 제목이 같으면 잇는다. `groupWeek`가 이어진 노트를 일정(`DayEvent.notes`) 아래로 옮기고 그날 노트에서는 뺀다(`dayNotes`는 둘 다 센다). 미리보기는 세 번째 노트를 화요일 스탠드업에 이었다. 실제 EventKit ID로 녹음해 잇는 것은 이 터미널에 캘린더 권한이 없어 확인하지 않았다(릴리스 번들 확인 때 함께). | | 2026-10-30 | v3.78 | **한국어 파인튜닝 엔진을 8bit 변환본으로 교체(브랜치 `feat/univa-8bit`).** 0.5.2에 올린 `asr-ko-univa`가 원본 bf16(4.7GB)이었다(변환·업로드 위치를 정하지 않고 허브 원본을 그대로 등록했다). `mlx_audio.convert -q --q-bits 8 --q-group-size 64 --q-mode affine`로 변환해(표준 엔진과 같은 설정, `model.safetensors` 2,463,307,541바이트로 공식 Qwen3-ASR 1.7B 8bit와 같다) 소유자 계정에 `voyagar/Qwen3-ASR-1.7B-ko-mlx-8bit`(공개, Apache-2.0, 커밋 `8fcc32c4`, 모델 카드에 원 제작자·원본 revision·변환 명령)로 올리고 등록 목록을 바꿨다. ID `asr-ko-univa`·`level_note`는 그대로라 사용자가 고른 엔진 설정은 유지되고, 새 폴더(`qwen3-asr-1.7b-ko-8bit`)를 받을 때까지는 받아 둔 다른 엔진으로 변환한다. 0.5.2로 bf16을 받은 폴더(`qwen3-asr-1.7b-ko-bf16`)는 등록 밖이 되어 `models prune`으로 지운다. 간이 점검: 원본과 출력 차이 CER 2.5, 처리 시간 약 30% 단축(docs/design/asr-engines.md). | | 2026-10-30 | v3.77 | **0.5.2 릴리스 준비(브랜치 `fix/mic-config-change-crash`).** 버전 0.5.2를 다섯 매니페스트와 잠금 파일에 맞췄다. 0.5.1 이후: 한국어 파인튜닝 엔진 `asr-ko-univa` 등록과 모델 화면 변환 엔진 라디오 목록(v3.75), 장치 전환 중 녹음 프로그램 크래시 수정(v3.76, 이슈 #57). 릴리스 노트 `docs/releases/v0.5.2.md`, 로드맵 항목. | | 2026-10-30 | v3.76 | **녹음 중 입력 장치가 바뀌면 `tractus-rec`가 죽던 것(이슈 #57, 브랜치 `fix/mic-config-change-crash`).** `AVAudioEngineConfigurationChange` → `Recorder.restart` → `MicSource.open`의 `installTap`이 Objective-C 예외(NSException)를 던져 Swift `do/catch`를 지나 프로세스가 abort됐다(66분 녹음 중 크래시, `recording.json`의 `duration`이 null로 남음). ① `RecorderKit.micFormatProblem`: tap 전에 채널·표본 속도 0과 하드웨어 포맷 불일치를 오류로 던진다(`restart`의 경고와 watchdog 2초 재시도로 넘어감). 음성 처리(에코 제거) 중에는 두 포맷이 다를 수 있어 표본 속도 일치는 보지 않는다. ② `MicSource.stop()`마다 새 `AVAudioEngine`으로 바꾸고 관찰자를 다시 단다(장치가 바뀐 뒤 같은 엔진의 입력 노드가 낡은 포맷을 쥐는 것을 피함). ③ 새 SwiftPM 타깃 `ObjCCatch`(Objective-C, `TractusObjC.catching { }`)로 `installTap`을 감싸 NSException을 Swift 오류로 바꾼다(`recorder/`의 tools-version 5.10·플랫폼은 그대로). 이 Mac의 실제 마이크로 일반·에코 제거 녹음이 정상임은 확인했고, **실제 장치 전환(블루투스 연결·해제)은 재현하지 못했다.** 비정상 종료로 `duration`이 null인 녹음은 앱의 `변환 대기 중인 녹음`에 보이고 코어가 그대로 변환 전 노트로 저장한다는 것을 테스트로 고정했다. | diff --git a/WORDS.md b/WORDS.md index 9af9922..f963aeb 100644 --- a/WORDS.md +++ b/WORDS.md @@ -897,7 +897,7 @@ LLM 프롬프트(요약·AI 질문)도 이름 없는 참석자를 `참석자 N` | 제목 | `이번 주`. 다른 주를 보면 `{month} {range}일`(예: `2026년 10월 5–11일`) + 단추 `이번 주` | | 옮기기 단추 (`aria-label`·`title`) | `지난주` / `다음 주` | | 요일 칸 묶음 / 칸 (`aria-label`) | `요일` / `{day}일 {weekday}요일 · 일정 {e} · 노트 {n}` | -| 고른 날 | `{day}일 {weekday}요일`. 일정 줄은 `09:30–10:00` 또는 `종일` + 제목 + 참석자 줄(§4.24와 같은 `외 n명`), 오늘 시작해 끝나지 않은 일정만 `이 일정으로 녹음`. 노트 줄은 홈 `최근 노트`와 같다 | +| 고른 날 | `{day}일 {weekday}요일`. 일정 줄은 `09:30–10:00` 또는 `종일` + 제목 + 참석자 줄(§4.24와 같은 `외 n명`), 오늘 시작해 끝나지 않은 일정만 `이 일정으로 녹음`. 노트 줄은 홈 `최근 노트`와 같다. 그 일정으로 녹음한 노트는 일정 줄 바로 아래에 들여 쓰고(새 문구 없음) 그날의 노트 줄에서는 빠진다. 칸의 노트 점·개수는 둘 다 센다 | | 빈 날 | `일정과 노트가 없습니다` | ## 5. 금지어 검사 diff --git a/app/src-tauri/src/calendar.rs b/app/src-tauri/src/calendar.rs index d85895d..ce1ded3 100644 --- a/app/src-tauri/src/calendar.rs +++ b/app/src-tauri/src/calendar.rs @@ -14,6 +14,7 @@ const EVENT_FILE: &str = "event.json"; const MAX_TITLE: usize = 200; const MAX_NAME: usize = 80; const MAX_ATTENDEES: usize = 50; +const MAX_ID: usize = 256; /// `tractus-rec calendar ` → stdout 한 줄 JSON fn run(s: &Settings, args: &[String]) -> Result { @@ -134,7 +135,22 @@ pub fn clean_event(v: &Value) -> Result { names.push(n); } } - Ok(json!({"title": title, "start": start, "end": end, "calendar": text("calendar", MAX_TITLE).unwrap_or_default(), "attendees": names})) + let mut ev = json!({"title": title, "start": start, "end": end, "calendar": text("calendar", MAX_TITLE).unwrap_or_default(), "attendees": names}); + // 캘린더의 일정 ID: 이번 주 띠가 일정과 노트를 잇는다. 모양이 이상하면 버린다 (잇기만 못 할 뿐) + if let Some(id) = v["id"].as_str().filter(|x| !x.is_empty() && x.len() <= MAX_ID && !x.chars().any(char::is_control)) { + ev["id"] = json!(id); + } + Ok(ev) +} + +/// 노트 폴더 event.json에서 일정과 노트를 잇는 데 쓰는 것만 (노트 목록 MeetingInfo.event). 없거나 깨졌으면 None +pub fn note_event(note_dir: &Path) -> Option { + let v = clean_event(&serde_json::from_str::(&std::fs::read_to_string(note_dir.join(EVENT_FILE)).ok()?).ok()?).ok()?; + let mut link = json!({"title": v["title"], "start": v["start"]}); + if v["id"].is_string() { + link["id"] = v["id"].clone(); + } + Some(link) } pub fn write_event(rec_dir: &Path, event: &Value) -> Result<(), String> { @@ -170,6 +186,18 @@ mod tests { assert!(clean_event(&json!({"title": "", "start": "a", "end": "b"})).is_err()); assert_eq!(clean_event(&json!({"title": "x".repeat(300), "start": "a", "end": "b"})).unwrap()["title"].as_str().unwrap().len(), 200); // 긴 제목은 잘라 쓴다 assert_eq!(clean_event(&json!({"title": "t", "start": "a", "end": "b", "attendees": ["guest@example.com", "김민수"]})).unwrap()["attendees"], json!(["김민수"])); // 이메일은 남기지 않는다 + assert_eq!(clean_event(&json!({"id": "E1:2", "title": "t", "start": "a", "end": "b"})).unwrap()["id"], "E1:2"); + assert!(clean_event(&json!({"id": "a\nb", "title": "t", "start": "a", "end": "b"})).unwrap().get("id").is_none()); + } + + #[test] + fn note_event_keeps_only_what_links_a_note() { + let d = std::env::temp_dir().join(format!("tractus-note-event-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + assert!(note_event(&d).is_none()); + std::fs::write(d.join(EVENT_FILE), r#"{"id": "E1", "title": "주간", "start": "2026-11-05T09:30:00+09:00", "end": "2026-11-05T10:00:00+09:00", "attendees": ["김민수"]}"#).unwrap(); + assert_eq!(note_event(&d), Some(json!({"id": "E1", "title": "주간", "start": "2026-11-05T09:30:00+09:00"}))); + std::fs::remove_dir_all(&d).unwrap(); } #[test] diff --git a/app/src-tauri/src/meetings.rs b/app/src-tauri/src/meetings.rs index 4b24286..8ffc2dd 100644 --- a/app/src-tauri/src/meetings.rs +++ b/app/src-tauri/src/meetings.rs @@ -242,6 +242,9 @@ pub struct MeetingInfo { /// 예시 노트(온보딩)일 때만 싣는다 #[serde(skip_serializing_if = "std::ops::Not::not")] pub sample: bool, + /// 캘린더 일정으로 시작한 노트: {"id"?, "title", "start"} (event.json, 이번 주 띠가 일정과 잇는다) + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option, } #[derive(Serialize, Debug)] @@ -314,6 +317,7 @@ fn info(dir: &Path, t: &Transcript) -> MeetingInfo { state: recorded_state(t), tracks_bytes: recorded_state(t).map(|_| dir_bytes(&dir.join("tracks"))), sample: t.sample == Some(true), + event: crate::calendar::note_event(dir), // event.json은 transcript.json보다 먼저 쓰여 목록 캐시가 놓치지 않는다 } } diff --git a/app/src/api.ts b/app/src/api.ts index aec25ee..5f907c59 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -39,6 +39,8 @@ export interface MeetingInfo { tracks_bytes?: number; /** 예시 노트 (온보딩, `tractus note sample`). 목록에 `예시` 표시 */ sample?: boolean; + /** 캘린더 일정으로 시작한 노트의 일정 (event.json). 이번 주 띠가 일정과 잇는다 */ + event?: { id?: string; title: string; start: string }; } export interface Summary { diff --git a/app/src/components/home/WeekStrip.tsx b/app/src/components/home/WeekStrip.tsx index fe20db2..24cbb3c 100644 --- a/app/src/components/home/WeekStrip.tsx +++ b/app/src/components/home/WeekStrip.tsx @@ -1,5 +1,5 @@ // 홈의 `이번 주` 띠 (WORDS.md §4.31): 월~일 7칸에 날마다 캘린더 일정·노트 개수, 고른 날의 목록(일정 → 녹음, 노트 → 열기). 규칙은 week.ts -import { useEffect, useMemo, useState } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { AudioLines, CalendarDays, ChevronLeft, ChevronRight, FileText, Mic } from "lucide-react"; import type { CalendarEvent, MeetingInfo } from "../../api"; import { attendeeLine, CARD_RECORD } from "../../calendar"; @@ -8,7 +8,7 @@ import { GatedButton } from "../../GatedButton"; import { clock, t } from "../../i18n"; import { dayParts } from "../../lib"; import { isMemo, noteTime } from "../../memo"; -import { addDays, groupWeek, recordable, weekDays, weekStart, ymd, useWeekEvents, type WeekEvent } from "../../week"; +import { addDays, dayNotes, groupWeek, recordable, weekDays, weekStart, ymd, useWeekEvents, type WeekEvent } from "../../week"; import { Button, buttonVariants } from "@/components/ui/button"; import { cn } from "@/components/ui/utils"; @@ -70,7 +70,7 @@ export function WeekStrip({ notes, onOpen, onRecordEvent, recordGate, recording key={d} type="button" aria-pressed={d === day} - aria-label={t("{day}일 {weekday}요일 · 일정 {e} · 노트 {n}", { day: dp.day, weekday: dp.weekday, e: g.events.length, n: g.notes.length })} + aria-label={t("{day}일 {weekday}요일 · 일정 {e} · 노트 {n}", { day: dp.day, weekday: dp.weekday, e: g.events.length, n: dayNotes(g) })} className={cn("flex flex-col items-center gap-1 py-2.5 outline-none transition-colors hover:bg-accent/60 focus-visible:bg-accent", d === day && "bg-accent")} onClick={() => setPicked(d)} > @@ -78,7 +78,7 @@ export function WeekStrip({ notes, onOpen, onRecordEvent, recordGate, recording {dp.day} - + ); @@ -89,16 +89,15 @@ export function WeekStrip({ notes, onOpen, onRecordEvent, recordGate, recording {!cur.events.length && !cur.notes.length &&

{t("일정과 노트가 없습니다")}

}
    {cur.events.map((ev) => ( - onRecordEvent(ev)} /> + + onRecordEvent(ev)} /> + {ev.notes.map((m) => ( + + ))} + ))} {cur.notes.map((m) => ( -
  • - -
  • + ))}
@@ -107,6 +106,19 @@ export function WeekStrip({ notes, onOpen, onRecordEvent, recordGate, recording ); } +/** 노트 줄. inEvent: 위 일정으로 녹음한 노트 (일정 줄 아래에 들여 쓴다) */ +function NoteLine({ m, onOpen, inEvent = false }: { m: MeetingInfo; onOpen: (id: string) => void; inEvent?: boolean }) { + return ( +
  • + +
  • + ); +} + /** 칸의 점: 3개까지는 점, 넘으면 `+n` */ function Dots({ n, className }: { n: number; className: string }) { if (!n) return null; diff --git a/app/src/preview/main.tsx b/app/src/preview/main.tsx index b556dc8..919c469 100644 --- a/app/src/preview/main.tsx +++ b/app/src/preview/main.tsx @@ -3,6 +3,7 @@ // 빌드(index.html)에는 들어가지 않는다. `?lang=en`이면 화면도 가짜 데이터도 영어다(README 영어 스크린샷·데모). import { emit } from "@tauri-apps/api/event"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; +import { addDays, weekStart } from "../week"; import dataKo from "./data/meetings.json"; import doctorKo from "./data/doctor.json"; import modelsKo from "./data/models.json"; @@ -301,6 +302,13 @@ const glossaryAdd = (terms: string[]) => { return { added: fresh.length, skipped: terms.filter((x) => x.trim().length > 40).length }; }; +/** 이번 주 화요일 9:30 (calendar_week의 데일리 스탠드업 s1) */ +const standupAt = () => { + const d = addDays(weekStart(new Date()), 1); + d.setHours(9, 30); + return d.toISOString(); +}; + const handlers: Record unknown> = { glossary_list: () => ({ terms: glossary, max: 50 }), // 사전 제안: 사람이 고친 문장 쌍 (온톨로지는 두 번, Grafana 한 번, 말투 고침은 제안되지 않는다) @@ -323,7 +331,8 @@ const handlers: Record unknown> = { rec_send: fakeRecSend, marks_save: (a) => ((noteMarks = { ...noteMarks, [a.id]: a.marks }), { marks: a.marks }), summary_start: fakeSummary, - list_meetings: () => notes.map(info), + // 세 번째 노트는 이번 주 화요일 데일리 스탠드업(calendar_week의 s1)으로 녹음한 노트다: 이번 주 띠에서 일정 아래에 보인다 + list_meetings: () => notes.map(info).map((m, i) => (i === 2 ? { ...m, event: { id: "s1", title: "", start: standupAt() } } : m)), note_sample: () => (sampleId = notes.find((n) => n.t.title.includes(Q3))?.t.meeting_id ?? notes[0].t.meeting_id), list_recordings: () => [], list_folders: () => [{ id: "f1", name: EN ? "Customers" : "고객사", parent: null, order: 0, created_at: "2026-10-01T09:00:00+09:00" }], diff --git a/app/src/week.test.ts b/app/src/week.test.ts index 6eedc98..567e316 100644 --- a/app/src/week.test.ts +++ b/app/src/week.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import type { MeetingInfo } from "./api"; -import { addDays, groupWeek, recordable, weekDays, weekStart, ymd, type WeekEvent } from "./week"; +import { addDays, dayNotes, groupWeek, linked, recordable, weekDays, weekStart, ymd, type WeekEvent } from "./week"; const ev = (title: string, start: string, end: string, all_day = false): WeekEvent => ({ id: title, title, start, end, calendar: "회사", attendees: [], all_day }); const note = (id: string, created_at: string) => ({ id, title: id, created_at }) as MeetingInfo; @@ -32,6 +32,27 @@ describe("날마다 묶기", () => { }); }); +describe("일정과 노트 잇기", () => { + const days = weekDays(new Date(2026, 10, 2)); + const standup = { ...ev("스탠드업", "2026-11-03T09:30:00+09:00", "2026-11-03T10:00:00+09:00"), id: "E1" }; + const withEvent = (id: string, event: MeetingInfo["event"]) => ({ ...note(id, "2026-11-03T09:31:00"), event }) as MeetingInfo; + + test("시작 시각이 같고 일정 ID가 같으면 (ID가 없는 옛 노트는 제목이 같으면) 이어진다", () => { + expect(linked(standup, withEvent("a", { id: "E1", title: "이름을 바꿈", start: "2026-11-03T00:30:00Z" }))).toBe(true); // 같은 순간이면 표기가 달라도 + expect(linked(standup, withEvent("b", { title: "스탠드업", start: "2026-11-03T09:30:00+09:00" }))).toBe(true); + expect(linked(standup, withEvent("c", { id: "E2", title: "스탠드업", start: "2026-11-03T09:30:00+09:00" }))).toBe(false); // 다른 일정 + expect(linked(standup, withEvent("d", { id: "E1", title: "스탠드업", start: "2026-11-10T09:30:00+09:00" }))).toBe(false); // 반복 일정의 다른 회차 + expect(linked(standup, note("e", "2026-11-03T09:31:00"))).toBe(false); + }); + + test("이어진 노트는 일정 아래로 가고 그날 노트 목록에서는 빠진다 (개수는 그대로)", () => { + const g = groupWeek(days, [standup], [withEvent("a", { id: "E1", title: "스탠드업", start: "2026-11-03T09:30:00+09:00" }), note("b", "2026-11-03T15:00:00")]).get("2026-11-03")!; + expect(g.events[0].notes.map((n) => n.id)).toEqual(["a"]); + expect(g.notes.map((n) => n.id)).toEqual(["b"]); + expect(dayNotes(g)).toBe(2); + }); +}); + test("녹음 단추는 오늘 시작해 아직 끝나지 않은 일정에만 (종일 제외)", () => { const now = new Date(2026, 10, 5, 14, 30); expect(recordable(ev("진행 중", "2026-11-05T14:00:00", "2026-11-05T15:00:00"), now)).toBe(true); diff --git a/app/src/week.ts b/app/src/week.ts index 5389c82..a97db81 100644 --- a/app/src/week.ts +++ b/app/src/week.ts @@ -23,7 +23,17 @@ export const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMo export const weekDays = (start: Date): string[] => Array.from({ length: 7 }, (_, i) => ymd(addDays(start, i))); export type WeekEvent = CalendarEvent & { all_day?: boolean }; -export type Day = { events: WeekEvent[]; notes: MeetingInfo[] }; +/** 일정 + 그 일정으로 녹음한 노트 (이른 것부터) */ +export type DayEvent = WeekEvent & { notes: MeetingInfo[] }; +/** notes: 일정에 이어지지 않은 그날의 노트 */ +export type Day = { events: DayEvent[]; notes: MeetingInfo[] }; + +/** 이 일정으로 시작한 노트인가: 시작 시각이 같고, 일정 ID가 같다 (ID를 남기기 전의 노트는 제목이 같다) */ +export function linked(ev: WeekEvent, n: Pick): boolean { + const e = n.event; + if (!e || new Date(e.start).getTime() !== new Date(ev.start).getTime()) return false; + return e.id ? e.id === ev.id : e.title === ev.title; +} /** 일정이 걸친 날들 (시작 날부터 끝나기 직전까지. 종일 일정의 끝은 다음 날 0시라 그날은 빼진다) */ function eventDays(ev: WeekEvent): string[] { @@ -34,14 +44,24 @@ function eventDays(ev: WeekEvent): string[] { return out; } -/** 날마다 일정(시작 순, 받은 순서)과 그날 만든 노트(created_at의 날짜 = 노트의 현지 날짜, 이른 것부터) */ +/** 날마다 일정(시작 순, 받은 순서)과 그 일정의 노트, 일정에 이어지지 않은 그날 만든 노트(created_at의 날짜 = 노트의 현지 날짜, 이른 것부터) */ export function groupWeek(days: readonly string[], events: readonly WeekEvent[], notes: readonly MeetingInfo[]): Map { const map = new Map(days.map((d) => [d, { events: [], notes: [] } as Day])); - for (const ev of events) for (const d of eventDays(ev)) map.get(d)?.events.push(ev); - for (const n of [...notes].sort((a, b) => a.created_at.localeCompare(b.created_at))) map.get(n.created_at.slice(0, 10))?.notes.push(n); + const sorted = [...notes].sort((a, b) => a.created_at.localeCompare(b.created_at)); + const withEvent = sorted.filter((n) => n.event); + const taken = new Set(); + for (const ev of events) { + const mine = withEvent.filter((n) => linked(ev, n)); + for (const n of mine) taken.add(n.id); + for (const d of eventDays(ev)) map.get(d)?.events.push({ ...ev, notes: mine }); + } + for (const n of sorted) if (!taken.has(n.id)) map.get(n.created_at.slice(0, 10))?.notes.push(n); return map; } +/** 그날의 노트 수 (일정에 이어진 것 포함) */ +export const dayNotes = (d: Day) => d.notes.length + d.events.reduce((k, e) => k + e.notes.length, 0); + /** 목록에서 `이 일정으로 녹음`을 보일 일정: 오늘 시작하고 아직 끝나지 않은, 종일이 아닌 일정 */ export const recordable = (ev: WeekEvent, now: Date) => !ev.all_day && ymd(new Date(ev.start)) === ymd(now) && new Date(ev.end) > now; diff --git a/docs/design/calendar.md b/docs/design/calendar.md index 724758a..887fdb4 100644 --- a/docs/design/calendar.md +++ b/docs/design/calendar.md @@ -12,7 +12,7 @@ 2. **참석자 이름은 세 곳에:** 그 녹음의 음성 인식 힌트(자주 쓰는 단어 목록에는 넣지 않는다), 대화 내용의 참석자 이름 고르기, 노트의 일정 정보(`event.json`, Obsidian 속성 `attendees`). 3. **알림은 하지 않는다.** 알림 권한이 필요 없다. 4. **캘린더는 설정에서 고른다.** 처음에는 모두 읽고, 끈 캘린더 ID만 저장한다. -5. **홈의 `이번 주` 띠 (2026-11-05, 소유자 선택).** 월~일 7칸에 날마다 일정·그날 만든 노트를 점으로 보이고, 고른 날의 목록에서 일정(오늘 시작하고 끝나지 않은 것은 `이 일정으로 녹음`)과 노트(열기)를 보인다. 지난주·다음 주로 옮길 수 있다. 캘린더를 연결하지 않았으면 노트만 보인다. 일정과 그 일정으로 녹음한 노트를 잇지는 않는다(노트에 일정 ID가 없다). 화면 문구는 WORDS.md §4.31. +5. **홈의 `이번 주` 띠 (2026-11-05, 소유자 선택).** 월~일 7칸에 날마다 일정·그날 만든 노트를 점으로 보이고, 고른 날의 목록에서 일정(오늘 시작하고 끝나지 않은 것은 `이 일정으로 녹음`)과 노트(열기)를 보인다. 지난주·다음 주로 옮길 수 있다. 캘린더를 연결하지 않았으면 노트만 보인다. 그 일정으로 녹음한 노트는 일정 줄 아래에 들여 써서 보이고 그날의 노트 목록에서는 빠진다(2026-11-06, 아래 `event.json`의 `id`). 화면 문구는 WORDS.md §4.31. ## 읽는 쪽: `tractus-rec calendar` (EventKit, Swift) @@ -34,10 +34,11 @@ Google·Exchange 계정도 macOS 캘린더에 추가돼 있으면 읽힌다(OAut 앱이 녹음을 시작할 때 녹음 폴더에 쓰고, 코어가 노트 폴더로 옮긴다(`memos.json`과 같은 방식). transcript.json은 바꾸지 않는다. ```json -{"title": "주간 개발", "start": "2026-10-16T10:00:00+09:00", "end": "2026-10-16T11:00:00+09:00", "calendar": "회사", "attendees": ["김민수", "이지은"]} +{"id": "", "title": "주간 개발", "start": "2026-10-16T10:00:00+09:00", "end": "2026-10-16T11:00:00+09:00", "calendar": "회사", "attendees": ["김민수", "이지은"]} ``` -- 코어는 읽을 때 검사한다(`calendar_event.load_event`): 제목 1~200자, 시각은 ISO 8601, 참석자는 한 줄 80자 이하·중복 제거·50명까지. 깨졌으면 없는 것으로 본다. +- 코어는 읽을 때 검사한다(`calendar_event.load_event`): 제목 1~200자, 시각은 ISO 8601, 참석자는 한 줄 80자 이하·중복 제거·50명까지. 깨졌으면 없는 것으로 본다. `id`(2026-11-06)는 선택 키다: 한 줄 256자 이하가 아니면 버린다(잇기만 못 한다). +- **일정과 노트 잇기(2026-11-06):** 앱 노트 목록(`MeetingInfo.event` = `{id?, title, start}`, Rust `calendar::note_event`)이 이것만 싣고, 이번 주 띠(`week.linked`)가 **시작 시각이 같은 순간이고 일정 ID가 같을 때** 잇는다. 반복 일정은 회차마다 ID가 같아 시작 시각으로 가른다. `id`가 없는 옛 노트는 제목이 같으면 잇는다(캘린더에서 제목을 바꾸면 끊긴다). - **제목:** 녹음 노트의 기본 제목이 `녹음 HH:MM` 대신 일정 제목이 된다(바로 변환·나중에 변환 모두). - **음성 인식 힌트:** 참석자 이름을 그 노트의 변환에만 `hotwords`로 더한다(`--term`과 같다). 나중에 변환(`--into`)도 노트 폴더의 `event.json`을 읽는다. - **참석자 이름 고르기:** 대화 내용의 참석자 메뉴에 일정의 참석자 이름을 먼저 보인다. 고르면 기존 `이름 정하기`와 같다(목소리 제안이 아니라 사용자 확정). diff --git a/src/tractus/calendar_event.py b/src/tractus/calendar_event.py index 36dc0d2..590e83d 100644 --- a/src/tractus/calendar_event.py +++ b/src/tractus/calendar_event.py @@ -14,6 +14,7 @@ MAX_TITLE = 200 MAX_NAME = 80 MAX_ATTENDEES = 50 +MAX_ID = 256 def _iso(v) -> str | None: @@ -24,7 +25,7 @@ def _iso(v) -> str | None: def clean_event(raw) -> dict | None: - """검사한 일정 {"title", "start", "end", "calendar", "attendees"}. 제목이 비었거나 시각이 맞지 않으면 None. 긴 제목은 자른다.""" + """검사한 일정 {"title", "start", "end", "calendar", "attendees"} (+ 캘린더의 일정 ID "id"가 있으면: 앱이 일정과 노트를 잇는다). 제목이 비었거나 시각이 맞지 않으면 None. 긴 제목은 자른다.""" if not isinstance(raw, dict): return None title = (one_line(raw["title"]) if isinstance(raw.get("title"), str) else "")[:MAX_TITLE] @@ -37,7 +38,11 @@ def clean_event(raw) -> dict | None: if n and len(n) <= MAX_NAME and "@" not in n and n not in names: # 이메일은 남기지 않는다 names.append(n) calendar = one_line(raw["calendar"])[:MAX_TITLE] if isinstance(raw.get("calendar"), str) else "" - return {"title": title, "start": start, "end": end, "calendar": calendar, "attendees": names[:MAX_ATTENDEES]} + ev = {"title": title, "start": start, "end": end, "calendar": calendar, "attendees": names[:MAX_ATTENDEES]} + eid = raw.get("id") + if isinstance(eid, str) and 0 < len(eid) <= MAX_ID and eid == one_line(eid): # 모양이 이상한 ID는 버린다 (잇기만 못 할 뿐) + ev["id"] = eid + return ev def load_event(d: Path | None) -> dict | None: diff --git a/tests/test_calendar_event.py b/tests/test_calendar_event.py index 3cedb43..44386de 100644 --- a/tests/test_calendar_event.py +++ b/tests/test_calendar_event.py @@ -28,6 +28,8 @@ def test_clean_event_checks_title_times_and_attendees(): ev = clean_event({**EVENT, "title": "주간\n개발", "calendar": 3, "attendees": ["김민수", "김민수", "", 7, "가" * 81, " 이지은 ", "guest@example.com"]}) assert ev["title"] == "주간 개발" and ev["calendar"] == "" and ev["attendees"] == ["김민수", "이지은"] assert len(clean_event({**EVENT, "attendees": [f"사람{i}" for i in range(60)]})["attendees"]) == 50 + assert clean_event({**EVENT, "id": "ABC-1:2"})["id"] == "ABC-1:2" # 일정 ID는 노트와 잇는 데 쓴다 + assert "id" not in clean_event({**EVENT, "id": "a\nb"}) and "id" not in clean_event({**EVENT, "id": "x" * 300}) and "id" not in clean_event({**EVENT, "id": 3}) assert len(clean_event({**EVENT, "title": "가" * 300})["title"]) == 200 # 긴 제목은 잘라서 쓴다 (녹음을 막지 않는다)