From 59fc1d596209f42f3d6f7202d5de4a0531992882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dhji=28=EC=A7=80=EB=8C=80=ED=95=9C=29?= Date: Wed, 17 Jun 2026 11:01:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(notion):=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20diff=20=EC=95=8C=EB=A6=BC=20+=20=EB=8B=A4=EC=A4=91?= =?UTF-8?q?=20=EB=8B=B4=EB=8B=B9=EC=9E=90=20=EC=86=8D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 스냅샷 저장소(internal/notion/snapshot.go, data/notion-snapshots.json) 도입: 페이지당 최신 1개만 덮어쓰기(과거 이력 누적 안 함) - 속성은 이전→현재 diff([변경]/[추가]/[삭제] 태그 라벨형), 목록형(담당자/태그)은 항목별 추가·삭제 표시 - 본문은 블록 추가/수정/삭제 감지(archived), 댓글은 본문+멘션 표시 - page.deleted 시 마지막 상태를 동봉하고 스냅샷 제거 - 수신 People 속성 다중화: 담당자/처리자/참조인원(콤마 구분, dedup) - @멘션 대상을 수신자에 추가(작성자 본인 제외), 멘션 수신은 [멘션] 헤더 - config: NOTION_SNAPSHOT_FILE 추가 - 테스트: diffLines/listDiff/truncRunes/snapshot 라운드트립 Co-Authored-By: Claude Opus 4.8 --- .env.example | 2 + internal/config/config.go | 2 + internal/notion/notion.go | 515 +++++++++++++++++++++++---------- internal/notion/notion_test.go | 80 ++++- internal/notion/snapshot.go | 89 ++++++ main.go | 7 +- 6 files changed, 526 insertions(+), 169 deletions(-) create mode 100644 internal/notion/snapshot.go diff --git a/.env.example b/.env.example index de82ede..1cee3f6 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,8 @@ NOTION_VERIFICATION_TOKEN= NOTION_API_TOKEN= # 알림 대상이 들어있는 Notion People 속성 이름들 (콤마 구분, 모두 수신자에 포함) NOTION_ASSIGNEE_PROPERTY=담당자,처리자,참조인원 +# 페이지 스냅샷 저장 파일 (이전→현재 diff용). 비우면 메모리만 사용(재시작 시 diff 초기화) +NOTION_SNAPSHOT_FILE=data/notion-snapshots.json # --- App --- # 수동 유저 매핑 저장 파일 (관리 페이지에서 편집) diff --git a/internal/config/config.go b/internal/config/config.go index f1ad185..3abc6c1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,7 @@ type Config struct { NotionVerificationToken string NotionAPIToken string NotionAssigneeProperties []string // 알림 대상이 되는 People 속성들 (담당자/처리자/참조인원 등) + NotionSnapshotFile string // 페이지 스냅샷 저장 파일 (이전→현재 diff용). 비우면 메모리만 // App MappingFile string @@ -68,6 +69,7 @@ func Load(envOverride string) Config { NotionVerificationToken: os.Getenv("NOTION_VERIFICATION_TOKEN"), NotionAPIToken: os.Getenv("NOTION_API_TOKEN"), NotionAssigneeProperties: getenvList("NOTION_ASSIGNEE_PROPERTY", "담당자,처리자,참조인원"), + NotionSnapshotFile: getenv("NOTION_SNAPSHOT_FILE", "data/notion-snapshots.json"), MappingFile: getenv("MAPPING_FILE", "data/mappings.json"), Addr: getenv("ADDR", ":8000"), diff --git a/internal/notion/notion.go b/internal/notion/notion.go index f1df8a0..ab1b22f 100644 --- a/internal/notion/notion.go +++ b/internal/notion/notion.go @@ -1,4 +1,5 @@ -// Package notion maps Notion webhook events to DMs for a page's assignees. +// Package notion maps Notion webhook events to DMs for a page's assignees, +// summarizing what changed (이전→현재 diff) using a per-page snapshot store. package notion import ( @@ -8,6 +9,7 @@ import ( "io" "log/slog" "net/http" + "sort" "strconv" "strings" "time" @@ -19,21 +21,24 @@ const ( apiBase = "https://api.notion.com/v1" version = "2022-06-28" - // 본문 변경 시 DM에 표시할 블록 수 상한 (이벤트가 집계돼 다수일 수 있음). - maxChangedBlocks = 5 + maxChangedBlocks = 5 // 본문 변경 시 표시할 블록 수 상한 + valueRunes = 40 // 값 1개의 최대 표시 글자 수 + commentRunes = 120 // 댓글 본문 표시 최대 글자 수 ) -// Client calls the Notion API to resolve a page's assignees into emails. +// Client calls the Notion API and diffs against stored snapshots. type Client struct { token string - assigneeProperties []string // 알림 대상이 되는 People 속성들 (담당자/처리자/참조인원 등) + assigneeProperties []string // 알림 대상 People 속성들 (담당자/처리자/참조인원 등) + snap *SnapshotStore http *http.Client } -func New(token string, assigneeProperties []string) *Client { +func New(token string, assigneeProperties []string, snap *SnapshotStore) *Client { return &Client{ token: token, assigneeProperties: assigneeProperties, + snap: snap, http: &http.Client{Timeout: 10 * time.Second}, } } @@ -58,15 +63,13 @@ type event struct { type richText struct { PlainText string `json:"plain_text"` - Type string `json:"type"` // "text" | "mention" | ... + Type string `json:"type"` Mention *struct { - Type string `json:"type"` // "user" | "page" | "date" | ... + Type string `json:"type"` User ref `json:"user"` } `json:"mention"` } -// plain joins rich_text into a single string. plain_text already renders -// @user 멘션을 표시 이름으로 포함하므로 표시용으로는 별도 멘션 처리가 필요 없다. func plain(rt []richText) string { var b strings.Builder for _, t := range rt { @@ -86,12 +89,6 @@ func mentionUserIDs(rt []richText) []string { return ids } -// change summarizes one event: display lines plus any @-mentioned user IDs. -type change struct { - lines []string - mentionIDs []string -} - type selectOption struct { Name string `json:"name"` } @@ -122,15 +119,14 @@ type page struct { Properties map[string]property `json:"properties"` } -// BuildNotifications parses a Notion event and returns DMs for the page assignees, -// enriched with a human-readable summary of what changed. +// BuildNotifications parses a Notion event and returns DMs for the page's +// recipients (담당자 속성들 ∪ @멘션), with a compact summary of what changed. func (c *Client) BuildNotifications(ctx context.Context, body []byte) ([]notify.Notification, error) { var ev event if err := json.Unmarshal(body, &ev); err != nil { return nil, err } - // 대상 페이지 결정: page.* 는 entity, comment.* 는 data.page_id. pageID := "" switch { case ev.Entity.Type == "page" && ev.Entity.ID != "": @@ -143,116 +139,344 @@ func (c *Client) BuildNotifications(ctx context.Context, body []byte) ([]notify. return nil, nil } + // page.deleted: 마지막 상태를 알림에 동봉하고 스냅샷 제거. + if ev.Type == "page.deleted" { + return c.handleDeleted(ctx, &ev, pageID), nil + } + var pg page if err := c.get(ctx, "pages/"+pageID, &pg); err != nil { slog.Warn("notion get page failed", "err", err) return nil, nil } - title := pageTitle(&pg) - body2 := title - if pg.URL != "" { - body2 = fmt.Sprintf("<%s|%s>", pg.URL, title) + body2, mentionIDs := c.summarize(ctx, &ev, &pg, pageID, title) + + return c.deliver(ctx, &pg, &ev, eventLabel(ev.Type), titleLink(&pg, title), title, body2, mentionIDs), nil +} + +// summarize produces the compact body and any @-mentioned user IDs, and +// updates the page snapshot as a side effect. +func (c *Client) summarize(ctx context.Context, ev *event, pg *page, pageID, title string) (string, []string) { + switch ev.Type { + case "page.properties_updated": + return c.summarizeProps(ctx, ev, pg, pageID, title), nil + case "page.content_updated": + return c.summarizeContent(ctx, ev, pg, pageID, title) + case "comment.created", "comment.updated": + return c.summarizeComment(ctx, ev) + case "page.created": + cur := c.renderAllProps(ctx, pg) + c.snapPut(pageID, snapshot{Title: title, Props: cur}) + return currentLines(cur, propOrder(pg)), nil + case "page.moved": + return "페이지가 이동되었습니다.", nil + case "page.undeleted": + return "페이지가 복원되었습니다.", nil + case "comment.deleted": + return "댓글이 삭제되었습니다.", nil + } + return "", nil +} + +// summarizeProps diffs updated properties against the snapshot (이전 ➜ 현재). +func (c *Client) summarizeProps(ctx context.Context, ev *event, pg *page, pageID, title string) string { + prev, _ := c.snapGet(pageID) + cur := c.renderAllProps(ctx, pg) + + type meta struct{ name, ptype string } + byID := make(map[string]meta, len(pg.Properties)) + for name, p := range pg.Properties { + byID[p.ID] = meta{name, p.Type} + } + + var lines, changedNames []string + for _, id := range ev.Data.UpdatedProperties { + m := byID[id] + if m.name == "" { + continue + } + changedNames = append(changedNames, m.name) + lines = append(lines, diffLines(m.name, prev.Props[m.name], cur[m.name], m.ptype)...) + } + + c.snapPut(pageID, snapshot{Title: title, Props: cur, Blocks: prev.Blocks}) + + if len(lines) == 0 { + if len(changedNames) > 0 { + return "[변경] " + strings.Join(changedNames, ", ") + } + return "[변경] 속성이 변경되었습니다." + } + return strings.Join(lines, "\n") +} + +// summarizeContent diffs updated blocks against the snapshot (+추가/~수정/-삭제). +func (c *Client) summarizeContent(ctx context.Context, ev *event, pg *page, pageID, title string) (string, []string) { + prev, _ := c.snapGet(pageID) + blocks := make(map[string]string, len(prev.Blocks)) + for k, v := range prev.Blocks { + blocks[k] = v + } + + var lines, mentions []string + for i, b := range ev.Data.UpdatedBlocks { + if i >= maxChangedBlocks { + lines = append(lines, fmt.Sprintf("[그 외] 본문 %d건 더 변경", len(ev.Data.UpdatedBlocks)-maxChangedBlocks)) + break + } + text, ms, exists := c.blockInfo(ctx, b.ID) + mentions = append(mentions, ms...) + old := prev.Blocks[b.ID] + switch { + case !exists: // 삭제됨 + if old != "" { + lines = append(lines, "[삭제] 본문: \""+truncRunes(old, valueRunes)+"\"") + } else { + lines = append(lines, "[삭제] 본문") + } + delete(blocks, b.ID) + case old == "": // 신규 + if text != "" { + lines = append(lines, "[추가] 본문: \""+truncRunes(text, valueRunes)+"\"") + blocks[b.ID] = text + } + case old != text: // 수정 + lines = append(lines, "[변경] 본문: \""+truncRunes(old, valueRunes)+"\" → \""+truncRunes(text, valueRunes)+"\"") + blocks[b.ID] = text + default: // 변화 없음(서식 등) — 생략 + blocks[b.ID] = text + } + } + + c.snapPut(pageID, snapshot{Title: title, Props: prev.Props, Blocks: blocks}) + + if len(lines) == 0 { + return "[변경] 본문이 변경되었습니다.", mentions + } + return strings.Join(lines, "\n"), mentions +} + +// summarizeComment returns the matching comment's text + mentioned users. +func (c *Client) summarizeComment(ctx context.Context, ev *event) (string, []string) { + if ev.Data.PageID == "" { + return "", nil + } + var res struct { + Results []struct { + ID string `json:"id"` + RichText []richText `json:"rich_text"` + } `json:"results"` + } + if err := c.get(ctx, "comments?block_id="+ev.Data.PageID, &res); err != nil { + slog.Warn("notion get comments failed", "err", err) + return "", nil + } + for _, cm := range res.Results { + if cm.ID == ev.Entity.ID { + txt := strings.TrimSpace(plain(cm.RichText)) + if txt == "" { + return "", mentionUserIDs(cm.RichText) + } + return "[댓글] " + truncRunes(txt, commentRunes), mentionUserIDs(cm.RichText) + } + } + return "", nil +} + +// handleDeleted sends a "마지막 상태" notice from the snapshot, then drops it. +func (c *Client) handleDeleted(ctx context.Context, ev *event, pageID string) []notify.Notification { + prev, ok := c.snapGet(pageID) + + // archived 페이지도 조회 가능 — 수신자/제목 확보용. + var pg page + title := prev.Title + if err := c.get(ctx, "pages/"+pageID, &pg); err == nil { + if t := pageTitle(&pg); t != "(제목 없음)" { + title = t + } + } + if title == "" { + title = "(제목 없음)" } - label := eventLabel(ev.Type) - ch := c.describeChange(ctx, &ev, &pg) - if len(ch.lines) > 0 { - body2 += "\n\n*변경 내용*\n" + strings.Join(ch.lines, "\n") + body := "페이지가 삭제(휴지통 이동)되었습니다." + if ok && len(prev.Props) > 0 { + body = "마지막 상태:\n" + currentLines(prev.Props, sortedKeys(prev.Props)) } - // 작성자(이 변경/댓글을 일으킨 사람): 표시용 이름 + 자기 알림 제외용 이메일. - authorNames, authorEmails := c.resolveAuthors(ctx, &ev) + notes := c.deliver(ctx, &pg, ev, "페이지 삭제", titleLink(&pg, title), title, body, nil) + c.snapDelete(pageID) + return notes +} + +// deliver builds one DM per recipient (담당자 ∪ 멘션, 작성자 제외). +func (c *Client) deliver(ctx context.Context, pg *page, ev *event, label, headerTitle, title, body string, mentionIDs []string) []notify.Notification { + authorNames, authorEmails := c.resolveAuthors(ctx, ev) context := "" if len(authorNames) > 0 { - context = "작성자: " + strings.Join(authorNames, ", ") + context = "by " + strings.Join(authorNames, ", ") } - // 수신자: 담당자(People 속성) ∪ 멘션된 사람. 값 true = 멘션으로 받는 사람. - recipients := make(map[string]bool) - for _, id := range c.assigneeIDs(&pg) { + recipients := make(map[string]bool) // email → mentioned? + for _, id := range c.assigneeIDs(pg) { if email := c.resolveEmail(ctx, id); email != "" { if _, ok := recipients[email]; !ok { recipients[email] = false } } } - for _, id := range ch.mentionIDs { + for _, id := range mentionIDs { if email := c.resolveEmail(ctx, id); email != "" { - recipients[email] = true // 멘션이 더 구체적 — 라벨 우선 + recipients[email] = true } } var notes []notify.Notification for email, mentioned := range recipients { if authorEmails[email] { - continue // 작성자 본인에게는 보내지 않음 + continue } - header := "🗒️ Notion — " + label + tag := "[Notion]" text := fmt.Sprintf("[Notion] %s — %s", label, title) if mentioned { - header = "🔔 Notion 멘션 — " + label - text = fmt.Sprintf("[Notion] 멘션 — %s", title) + tag = "[멘션]" + text = fmt.Sprintf("[멘션] %s — %s", label, title) } + header := tag + " " + label + " — " + headerTitle notes = append(notes, notify.Notification{ Email: email, Text: text, - Blocks: notify.SimpleBlocks(header, body2, context), + Blocks: notify.SimpleBlocks(header, body, context), }) } - return notes, nil + return notes } -// describeChange summarizes what changed (display lines + mentioned users) by event type. -func (c *Client) describeChange(ctx context.Context, ev *event, pg *page) change { - switch ev.Type { - case "page.properties_updated": - return change{lines: c.changedProperties(ctx, ev, pg)} - case "page.content_updated": - return c.changedBlocks(ctx, ev) - case "comment.created", "comment.updated": - return c.commentText(ctx, ev) - case "page.created": - return change{lines: []string{"• 새 페이지가 생성되었습니다."}} - case "page.deleted": - return change{lines: []string{"• 페이지가 삭제(휴지통 이동)되었습니다."}} - case "page.undeleted": - return change{lines: []string{"• 페이지가 복원되었습니다."}} - case "page.moved": - return change{lines: []string{"• 페이지가 이동되었습니다."}} - case "comment.deleted": - return change{lines: []string{"• 댓글이 삭제되었습니다."}} +// --- diff helpers --- + +// diffLines formats one property change as labeled lines. List-type +// (people/multi_select)은 항목별 [추가]/[삭제], 그 외는 [변경] 이전 → 현재. +// 변화가 없으면 빈 슬라이스. +func diffLines(name, oldVal, newVal, ptype string) []string { + if ptype == "people" || ptype == "multi_select" { + add, rem := listDiff(oldVal, newVal) + var lines []string + for _, a := range add { + lines = append(lines, "[추가] "+name+": "+a) + } + for _, r := range rem { + lines = append(lines, "[삭제] "+name+": "+r) + } + return lines + } + if oldVal == newVal { + return nil + } + o, n := truncRunes(oldVal, valueRunes), truncRunes(newVal, valueRunes) + switch { + case oldVal == "": + return []string{"[변경] " + name + ": " + n + " (신규)"} + case newVal == "": + return []string{"[삭제] " + name + ": " + o} + default: + return []string{"[변경] " + name + ": " + o + " → " + n} + } +} + +func listDiff(oldVal, newVal string) (added, removed []string) { + o, n := splitList(oldVal), splitList(newVal) + oset, nset := toSet(o), toSet(n) + for _, x := range n { + if !oset[x] { + added = append(added, x) + } + } + for _, x := range o { + if !nset[x] { + removed = append(removed, x) + } } - return change{} + return added, removed } -// changedProperties maps updated property IDs to their name + new value. -func (c *Client) changedProperties(ctx context.Context, ev *event, pg *page) []string { - if len(ev.Data.UpdatedProperties) == 0 { +func splitList(s string) []string { + s = strings.TrimSpace(s) + if s == "" { return nil } - nameByID := make(map[string]string, len(pg.Properties)) - propByID := make(map[string]property, len(pg.Properties)) - for name, p := range pg.Properties { - nameByID[p.ID] = name - propByID[p.ID] = p + var out []string + for _, p := range strings.Split(s, ", ") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } } + return out +} + +func toSet(xs []string) map[string]bool { + m := make(map[string]bool, len(xs)) + for _, x := range xs { + m[x] = true + } + return m +} + +// currentLines renders "name: value" lines for the given key order. +func currentLines(props map[string]string, order []string) string { var lines []string - for _, id := range ev.Data.UpdatedProperties { - name := nameByID[id] - if name == "" { - name = "(알 수 없는 속성)" - } - val := c.renderValue(ctx, propByID[id]) - if val == "" { - val = "(비어 있음)" + for _, name := range order { + if v := props[name]; v != "" { + lines = append(lines, name+": "+truncRunes(v, valueRunes)) } - lines = append(lines, fmt.Sprintf("• *%s*: %s", name, val)) } - return lines + return strings.Join(lines, "\n") +} + +func truncRunes(s string, n int) string { + r := []rune(strings.TrimSpace(s)) + if len(r) <= n { + return string(r) + } + return string(r[:n]) + "…" +} + +// --- value rendering --- + +// renderAllProps renders every property's current value (속성명 → 값). +func (c *Client) renderAllProps(ctx context.Context, pg *page) map[string]string { + out := make(map[string]string, len(pg.Properties)) + for name, p := range pg.Properties { + out[name] = c.renderValue(ctx, p) + } + return out +} + +// propOrder returns property names ordered by their Notion property ID, +// for a stable display order across events. +func propOrder(pg *page) []string { + type kv struct{ name, id string } + kvs := make([]kv, 0, len(pg.Properties)) + for name, p := range pg.Properties { + kvs = append(kvs, kv{name, p.ID}) + } + sort.Slice(kvs, func(i, j int) bool { return kvs[i].id < kvs[j].id }) + out := make([]string, len(kvs)) + for i, k := range kvs { + out[i] = k.name + } + return out +} + +func sortedKeys(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out } -// renderValue formats a property's current value for display. func (c *Client) renderValue(ctx context.Context, p property) string { switch p.Type { case "title": @@ -315,92 +539,42 @@ func (c *Client) renderValue(ctx context.Context, p property) string { return "" } -// changedBlocks fetches updated blocks and renders text + mentioned users. -func (c *Client) changedBlocks(ctx context.Context, ev *event) change { - var ch change - for i, b := range ev.Data.UpdatedBlocks { - if i >= maxChangedBlocks { - ch.lines = append(ch.lines, fmt.Sprintf("• …외 %d개 블록 변경", len(ev.Data.UpdatedBlocks)-maxChangedBlocks)) - break - } - txt, ids := c.blockText(ctx, b.ID) - if txt != "" { - ch.lines = append(ch.lines, "• "+txt) - } - ch.mentionIDs = append(ch.mentionIDs, ids...) - } - return ch -} - -// blockText fetches a block and extracts the rich_text under its type key -// (paragraph/heading_*/bulleted_list_item/to_do/quote/callout/code/...), -// returning the rendered text and any @-mentioned user IDs. -func (c *Client) blockText(ctx context.Context, id string) (string, []string) { +// blockInfo fetches a block: its text, @-mentioned users, and whether it still +// exists (false = 삭제/휴지통 → 호출자가 마지막 텍스트를 스냅샷에서 가져감). +func (c *Client) blockInfo(ctx context.Context, id string) (text string, mentions []string, exists bool) { var raw json.RawMessage if err := c.get(ctx, "blocks/"+id, &raw); err != nil { - return "", nil + return "", nil, false } var head struct { - Type string `json:"type"` + Type string `json:"type"` + Archived bool `json:"archived"` + InTrash bool `json:"in_trash"` } if err := json.Unmarshal(raw, &head); err != nil || head.Type == "" { - return "", nil + return "", nil, false + } + if head.Archived || head.InTrash { + return "", nil, false } var m map[string]json.RawMessage if err := json.Unmarshal(raw, &m); err != nil { - return "", nil + return "", nil, true } var inner struct { RichText []richText `json:"rich_text"` } _ = json.Unmarshal(m[head.Type], &inner) // 타입별 키에만 rich_text 존재 - return strings.TrimSpace(plain(inner.RichText)), mentionUserIDs(inner.RichText) + return strings.TrimSpace(plain(inner.RichText)), mentionUserIDs(inner.RichText), true } -// commentText fetches the page's comments and returns the one matching the event, -// with its text and any @-mentioned user IDs. -func (c *Client) commentText(ctx context.Context, ev *event) change { - if ev.Data.PageID == "" { - return change{} - } - var res struct { - Results []struct { - ID string `json:"id"` - RichText []richText `json:"rich_text"` - } `json:"results"` - } - if err := c.get(ctx, "comments?block_id="+ev.Data.PageID, &res); err != nil { - slog.Warn("notion get comments failed", "err", err) - return change{} - } - for _, cm := range res.Results { - if cm.ID == ev.Entity.ID { - ch := change{mentionIDs: mentionUserIDs(cm.RichText)} - if txt := strings.TrimSpace(plain(cm.RichText)); txt != "" { - ch.lines = []string{"💬 " + txt} - } - return ch - } - } - return change{} -} +// --- page / user helpers --- -// resolveAuthors returns display names and a set of emails for the event's authors. -func (c *Client) resolveAuthors(ctx context.Context, ev *event) (names []string, emails map[string]bool) { - emails = make(map[string]bool) - for _, a := range ev.Authors { - if a.ID == "" { - continue - } - name, email := c.resolveUser(ctx, a.ID) - if name != "" { - names = append(names, name) - } - if email != "" { - emails[email] = true - } +func titleLink(pg *page, title string) string { + if pg.URL != "" { + return fmt.Sprintf("<%s|%s>", pg.URL, title) } - return names, emails + return title } func pageTitle(pg *page) string { @@ -414,15 +588,14 @@ func pageTitle(pg *page) string { return "(제목 없음)" } -// assigneeIDs collects user IDs from all configured People properties -// (담당자/처리자/참조인원 등), de-duplicated across properties. +// assigneeIDs collects user IDs from all configured People properties, +// de-duplicated across properties. func (c *Client) assigneeIDs(pg *page) []string { seen := make(map[string]bool) var ids []string for _, name := range c.assigneeProperties { prop, ok := pg.Properties[name] if !ok || prop.Type != "people" { - slog.Info("notion page has no people property", "property", name) continue } for _, p := range prop.People { @@ -435,7 +608,24 @@ func (c *Client) assigneeIDs(pg *page) []string { return ids } -// resolveUser returns a user's display name, and email when the user is a person. +// resolveAuthors returns display names and a set of emails for event authors. +func (c *Client) resolveAuthors(ctx context.Context, ev *event) (names []string, emails map[string]bool) { + emails = make(map[string]bool) + for _, a := range ev.Authors { + if a.ID == "" { + continue + } + name, email := c.resolveUser(ctx, a.ID) + if name != "" { + names = append(names, name) + } + if email != "" { + emails[email] = true + } + } + return names, emails +} + func (c *Client) resolveUser(ctx context.Context, userID string) (name, email string) { var u struct { Name string `json:"name"` @@ -463,6 +653,27 @@ func (c *Client) resolveName(ctx context.Context, userID string) string { return name } +// --- snapshot wrappers (nil-safe) --- + +func (c *Client) snapGet(pageID string) (snapshot, bool) { + if c.snap == nil { + return snapshot{}, false + } + return c.snap.Get(pageID) +} + +func (c *Client) snapPut(pageID string, s snapshot) { + if c.snap != nil { + c.snap.Put(pageID, s) + } +} + +func (c *Client) snapDelete(pageID string) { + if c.snap != nil { + c.snap.Delete(pageID) + } +} + // eventLabel maps a Notion event type to a short Korean label. func eventLabel(t string) string { switch t { diff --git a/internal/notion/notion_test.go b/internal/notion/notion_test.go index 1d2c1a2..b19a680 100644 --- a/internal/notion/notion_test.go +++ b/internal/notion/notion_test.go @@ -60,25 +60,73 @@ func TestRenderDate(t *testing.T) { } } -func TestChangedProperties(t *testing.T) { - c := &Client{} - pg := &page{Properties: map[string]property{ - "상태": {ID: "p1", Type: "status", Status: &selectOption{Name: "진행중"}}, - "우선순위": {ID: "p2", Type: "select", Select: &selectOption{Name: "높음"}}, - "제목": {ID: "title", Type: "title", Title: []richText{{PlainText: "T"}}}, - }} - ev := &event{} - ev.Data.UpdatedProperties = []string{"p1", "p2"} +func TestDiffLines(t *testing.T) { + cases := []struct { + name, old, new, ptype string + want []string + }{ + {"상태", "검토", "진행중", "status", []string{"[변경] 상태: 검토 → 진행중"}}, + {"마감일", "", "2026-06-20", "date", []string{"[변경] 마감일: 2026-06-20 (신규)"}}, + {"상태", "완료", "", "status", []string{"[삭제] 상태: 완료"}}, + {"상태", "완료", "완료", "status", nil}, // 변화 없음 + {"담당자", "김영운, 이철수", "김영운, 박지민", "people", []string{"[추가] 담당자: 박지민", "[삭제] 담당자: 이철수"}}, + {"태그", "", "긴급", "multi_select", []string{"[추가] 태그: 긴급"}}, + {"태그", "긴급, 배포", "긴급, 배포", "multi_select", nil}, // 동일 목록 + } + for _, tc := range cases { + got := diffLines(tc.name, tc.old, tc.new, tc.ptype) + if len(got) != len(tc.want) { + t.Errorf("diffLines(%q,%q,%q,%q) = %v, want %v", tc.name, tc.old, tc.new, tc.ptype, got, tc.want) + continue + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Errorf("diffLines[%d] = %q, want %q", i, got[i], tc.want[i]) + } + } + } +} - lines := c.changedProperties(context.Background(), ev, pg) - if len(lines) != 2 { - t.Fatalf("want 2 lines, got %d: %v", len(lines), lines) +func TestListDiff(t *testing.T) { + add, rem := listDiff("a, b, c", "b, c, d") + if len(add) != 1 || add[0] != "d" { + t.Errorf("added = %v, want [d]", add) } - if lines[0] != "• *상태*: 진행중" { - t.Errorf("line0 = %q", lines[0]) + if len(rem) != 1 || rem[0] != "a" { + t.Errorf("removed = %v, want [a]", rem) } - if lines[1] != "• *우선순위*: 높음" { - t.Errorf("line1 = %q", lines[1]) +} + +func TestTruncRunes(t *testing.T) { + if got := truncRunes("짧은글", 10); got != "짧은글" { + t.Errorf("no-trunc = %q", got) + } + if got := truncRunes("가나다라마바사", 3); got != "가나다…" { + t.Errorf("trunc = %q, want 가나다…", got) + } +} + +func TestSnapshotStoreRoundTrip(t *testing.T) { + path := t.TempDir() + "/snap.json" + s, err := NewSnapshotStore(path) + if err != nil { + t.Fatal(err) + } + s.Put("page1", snapshot{Title: "T", Props: map[string]string{"상태": "진행중"}}) + + // 다시 로드해도 유지되는지 (파일 영속화 확인) + s2, err := NewSnapshotStore(path) + if err != nil { + t.Fatal(err) + } + got, ok := s2.Get("page1") + if !ok || got.Props["상태"] != "진행중" { + t.Fatalf("reload = %+v ok=%v", got, ok) + } + + s2.Delete("page1") + if _, ok := s2.Get("page1"); ok { + t.Errorf("expected page1 deleted") } } diff --git a/internal/notion/snapshot.go b/internal/notion/snapshot.go new file mode 100644 index 0000000..d8ad7e6 --- /dev/null +++ b/internal/notion/snapshot.go @@ -0,0 +1,89 @@ +package notion + +import ( + "encoding/json" + "log/slog" + "os" + "path/filepath" + "sync" +) + +// snapshot is the last-known state of a page, used to diff the next event. +// 페이지당 1개만 유지(덮어쓰기) — 과거 이력을 누적하지 않는다. +type snapshot struct { + Title string `json:"title"` + Props map[string]string `json:"props"` // 속성명 → 렌더된 현재 값 + Blocks map[string]string `json:"blocks,omitempty"` // 블록 ID → 텍스트 (편집된 블록만 점진 저장) +} + +// SnapshotStore persists page snapshots to a JSON file (thread-safe). +// 비활성화하려면 path를 비우면 된다(메모리에만 유지, diff는 동작). +type SnapshotStore struct { + mu sync.Mutex + path string + data map[string]snapshot // pageID → snapshot +} + +// NewSnapshotStore loads snapshots from path (missing file is fine). +func NewSnapshotStore(path string) (*SnapshotStore, error) { + s := &SnapshotStore{path: path, data: make(map[string]snapshot)} + if path == "" { + return s, nil + } + b, err := os.ReadFile(path) + switch { + case err == nil: + if e := json.Unmarshal(b, &s.data); e != nil { + return nil, e + } + case os.IsNotExist(err): + // 최초 실행: 빈 스토어로 시작 + default: + return nil, err + } + return s, nil +} + +func (s *SnapshotStore) Get(pageID string) (snapshot, bool) { + s.mu.Lock() + defer s.mu.Unlock() + snap, ok := s.data[pageID] + return snap, ok +} + +func (s *SnapshotStore) Put(pageID string, snap snapshot) { + s.mu.Lock() + defer s.mu.Unlock() + s.data[pageID] = snap + s.persist() +} + +func (s *SnapshotStore) Delete(pageID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.data, pageID) + s.persist() +} + +// persist writes the whole map atomically (tmp + rename). 호출자가 lock 보유. +func (s *SnapshotStore) persist() { + if s.path == "" { + return + } + if dir := filepath.Dir(s.path); dir != "" && dir != "." { + _ = os.MkdirAll(dir, 0o755) + } + b, err := json.MarshalIndent(s.data, "", " ") + if err != nil { + slog.Warn("notion snapshot marshal failed", "err", err) + return + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + slog.Warn("notion snapshot write failed", "err", err) + return + } + if err := os.Rename(tmp, s.path); err != nil { + slog.Warn("notion snapshot rename failed", "err", err) + } +} diff --git a/main.go b/main.go index cbe058f..37c55da 100644 --- a/main.go +++ b/main.go @@ -31,7 +31,12 @@ func main() { os.Exit(1) } - notionClient := notion.New(cfg.NotionAPIToken, cfg.NotionAssigneeProperties) + notionSnapshots, err := notion.NewSnapshotStore(cfg.NotionSnapshotFile) + if err != nil { + slog.Warn("failed to load notion snapshot store — diff disabled", "file", cfg.NotionSnapshotFile, "err", err) + notionSnapshots = nil + } + notionClient := notion.New(cfg.NotionAPIToken, cfg.NotionAssigneeProperties, notionSnapshots) srv := server.New(cfg, slackClient, store, notionClient)