= [Interpreter]Kanban Kanban 인터프리터는 위키 텍스트를 단일 원본으로 사용한다. 보기 화면에서는 보드 UI로 편집하고, 저장 시 Kanban 블록을 다시 위키 텍스트로 직렬화한다. == Example [[[#!Kanban === Info ==== 작업 제목 ==== #cardid123 ===== Description 카드에 대한 설명을 여러 줄로 작성할 수 있다. 새 댓글이 추가되어도 밀리지 않는다. ===== Property * DueDate: [2027-01-01] * Assignee: [User:Aha00a] * Attachment: [[Attachment(path/to/file.png)]] ===== Activity * [User:Aha00a] [2026-05-04]T10:36:17 * ["#cardid123" #작업 제목] moved - '''Done''' to '''Info''' === ToDo ==== 점심 설거지 ==== #cmq4akx08r020y2 ===== Property * Creator: [User:Aha00a] * dateCreated: [2026-06-07]T21:26:27Z * ParentCard: [#cmq4ak09d89cnyp 점심에 할일] ===== Activity * [User:Aha00a] [2026-06-07]T21:29:15Z * ["#cmq4akx08r020y2" #점심 설거지] updated - ParentCard - [#cmq4ak09d89cnyp 점심에 할일] * [User:Aha00a] [2026-06-07]T21:26:27Z * ["#cmq4akx08r020y2" #점심 설거지] added === Pending ==== 점심 식사 ==== #cmq4aksurw4h9b6 ===== Property * Creator: [User:Aha00a] * dateCreated: [2026-06-07]T21:26:22Z * ParentCard: [#cmq4ak09d89cnyp 점심에 할일] ===== Activity * [User:Aha00a] [2026-06-07]T21:30:17Z * ["#cmq4aksurw4h9b6" #점심 식사] moved - '''ToDo''' to '''Pending''' * [User:Aha00a] [2026-06-07]T21:29:10Z * ["#cmq4aksurw4h9b6" #점심 식사] updated - ParentCard - [#cmq4ak09d89cnyp 점심에 할일] * [User:Aha00a] [2026-06-07]T21:26:22Z * ["#cmq4aksurw4h9b6" #점심 식사] added === Doing ==== 점심에 할일 ==== #cmq4ak09d89cnyp ===== Property * Creator: [User:Aha00a] * dateCreated: [2026-06-07]T21:25:45Z * BlockedBy: [#cmq4aiz5lnvvyen 일어나기] ===== Activity * [User:Aha00a] [2026-06-07]T21:29:36Z * ["#cmq4ak09d89cnyp" #점심에 할일] moved - '''ToDo''' to '''Doing''' * [User:Aha00a] [2026-06-07]T21:29:28Z * ["#cmq4ak09d89cnyp" #점심에 할일] updated - BlockedBy - [#cmq4aiz5lnvvyen 일어나기] * [User:Aha00a] [2026-06-07]T21:25:54Z * ["#cmq4ak09d89cnyp" #점심에 할일] renamed - '''점심에할일''' * [User:Aha00a] [2026-06-07]T21:25:45Z * ["#cmq4ak09d89cnyp" #점심에할일] added ==== 점심밥 차리기 ==== #cmq4akmkpy6chrx ===== Property * Creator: [User:Aha00a] * dateCreated: [2026-06-07]T21:26:14Z * ParentCard: [#cmq4ak09d89cnyp 점심에 할일] ===== Activity * [User:Aha00a] [2026-06-07]T21:29:57Z * ["#cmq4akmkpy6chrx" #점심밥 차리기] moved - '''ToDo''' to '''Doing''' * [User:Aha00a] [2026-06-07]T21:29:04Z * ["#cmq4akmkpy6chrx" #점심밥 차리기] updated - ParentCard - [#cmq4ak09d89cnyp 점심에 할일] * [User:Aha00a] [2026-06-07]T21:26:14Z * ["#cmq4akmkpy6chrx" #점심밥 차리기] added === Done ==== 일어나기 ==== #cmq4aiz5lnvvyen ===== Property * Creator: [User:Aha00a] * dateCreated: [2026-06-07]T21:24:57Z ===== Activity * [User:Aha00a] [2026-06-07]T21:28:07Z * ["#cmq4aiz5lnvvyen" #일어나기] moved - '''ToDo''' to '''Done''' * [User:Aha00a] [2026-06-07]T21:24:57Z * ["#cmq4aiz5lnvvyen" #일어나기] added ==== 아침에 할일 ==== #cmq4ajpa7tig43b ===== Property * Creator: [User:Aha00a] * dateCreated: [2026-06-07]T21:25:30Z * BlockedBy: [#cmq4aiz5lnvvyen 일어나기] ===== Activity * [User:Aha00a] [2026-06-07]T21:28:38Z * ["#cmq4ajpa7tig43b" #아침에 할일] moved - '''Doing''' to '''Done''' * [User:Aha00a] [2026-06-07]T21:28:15Z * ["#cmq4ajpa7tig43b" #아침에 할일] updated - BlockedBy - [#cmq4aiz5lnvvyen 일어나기] * [User:Aha00a] [2026-06-07]T21:27:31Z * ["#cmq4ajpa7tig43b" #아침에 할일] moved - '''ToDo''' to '''Doing''' * [User:Aha00a] [2026-06-07]T21:25:50Z * ["#cmq4ajpa7tig43b" #아침에 할일] renamed - '''아침에할일''' * [User:Aha00a] [2026-06-07]T21:25:30Z * ["#cmq4ajpa7tig43b" #아침에할일] added ==== 아침밥 차리기 ==== #cmq4aj3iyvga6ec ===== Property * Creator: [User:Aha00a] * dateCreated: [2026-06-07]T21:25:02Z * ParentCard: [#cmq4ajpa7tig43b 아침에 할일] ===== Activity * [User:Aha00a] [2026-06-07]T21:28:43Z * ["#cmq4aj3iyvga6ec" #아침밥 차리기] moved - '''Doing''' to '''Done''' * [User:Aha00a] [2026-06-07]T21:27:39Z * ["#cmq4aj3iyvga6ec" #아침밥 차리기] moved - '''ToDo''' to '''Doing''' * [User:Aha00a] [2026-06-07]T21:26:56Z * ["#cmq4aj3iyvga6ec" #아침밥 차리기] updated - ParentCard - [#cmq4ajpa7tig43b 아침에 할일] * [User:Aha00a] [2026-06-07]T21:26:34Z * ["#cmq4aj3iyvga6ec" #아침밥 차리기] renamed - '''아침밥차리기''' * [User:Aha00a] [2026-06-07]T21:25:08Z * ["#cmq4aj3iyvga6ec" #아침밥차리기] renamed - '''밥차리기''' * [User:Aha00a] [2026-06-07]T21:25:02Z * ["#cmq4aj3iyvga6ec" #밥차리기] added ==== 아침 식사 ==== #cmq4ajesg43bp1g ===== Property * Creator: [User:Aha00a] * dateCreated: [2026-06-07]T21:25:17Z * ParentCard: [#cmq4ajpa7tig43b 아침에 할일] ===== Activity * [User:Aha00a] [2026-06-07]T21:28:45Z * ["#cmq4ajesg43bp1g" #아침 식사] moved - '''Doing''' to '''Done''' * [User:Aha00a] [2026-06-07]T21:27:42Z * ["#cmq4ajesg43bp1g" #아침 식사] moved - '''ToDo''' to '''Doing''' * [User:Aha00a] [2026-06-07]T21:27:13Z * ["#cmq4ajesg43bp1g" #아침 식사] updated - ParentCard - [#cmq4ajpa7tig43b 아침에 할일] * [User:Aha00a] [2026-06-07]T21:26:45Z * ["#cmq4ajesg43bp1g" #아침 식사] renamed - '''아침식사''' * [User:Aha00a] [2026-06-07]T21:25:17Z * ["#cmq4ajesg43bp1g" #아침식사] added ==== 아침 설거지 ==== #cmq4ajlewxrxne2 ===== Property * Creator: [User:Aha00a] * dateCreated: [2026-06-07]T21:25:25Z * ParentCard: [#cmq4ajpa7tig43b 아침에 할일] ===== Activity * [User:Aha00a] [2026-06-07]T21:28:48Z * ["#cmq4ajlewxrxne2" #아침 설거지] moved - '''Doing''' to '''Done''' * [User:Aha00a] [2026-06-07]T21:27:44Z * ["#cmq4ajlewxrxne2" #아침 설거지] moved - '''ToDo''' to '''Doing''' * [User:Aha00a] [2026-06-07]T21:27:24Z * ["#cmq4ajlewxrxne2" #아침 설거지] updated - ParentCard - [#cmq4ajpa7tig43b 아침에 할일] * [User:Aha00a] [2026-06-07]T21:26:39Z * ["#cmq4ajlewxrxne2" #아침 설거지] renamed - '''아침설거지''' * [User:Aha00a] [2026-06-07]T21:25:25Z * ["#cmq4ajlewxrxne2" #아침설거지] added ]]] == Syntax * `=== 리스트명`: 컬럼 * `==== 카드 제목 ==== #cardId`: 카드 * `===== Description`: 카드 설명 (선택, 여러 줄, Activity에 밀리지 않음). 다음 `=====` 줄이나 `===` 리스트 줄까지가 설명이다. 설명 바로 뒤에 `====` 카드 줄을 쓰면 그 카드도 설명으로 읽히므로, 손으로 쓸 때는 설명 뒤에 `===== Property`를 둔다. UI로 저장하면 늘 그렇게 쓴다. * `===== Property`: 카드 속성 * `===== Activity`: 활동 로그 == 규칙 * 카드 ID(`#...`)는 생성 후 변경하지 않는다. * 카드/리스트 순서는 입력 순서를 그대로 유지한다. * Kanban 블록 외부 텍스트는 저장 시 변경하지 않는다. 저장은 줄 번호로 자리를 찾으므로, 페이지를 연 뒤 첫 저장은 먼저 페이지 원문을 읽어 블록의 자리를 원문에서 잡는다. 화면이 그려진 줄 번호는 맨 위 `#!` 줄을 뺀 번호라서, 2026-09-15 전에는 `#!var` 같은 줄이 있는 페이지에서 원격 업데이트를 한 번 받기 전까지 저장이 그 줄 수만큼 위에 들어갔다. * 저장 전 현재 리비전이 확인되지 않으면 최신 리비전을 1회 조회한 뒤 저장한다. * 저장마다 reCAPTCHA token 을 새로 받아 함께 보낸다(409 재시도 포함). 받지 못하면 저장하지 않고 알린다. 규칙은 [Dev Editor] '''저장'''. 2026-09-13 전에는 늘 빈 token 을 보냈다. * `409 Conflict` 가 오면 최신 원문을 다시 읽고, 이 보드가 아직 저장하지 못한 변경을 서버의 보드 위에 다시 얹어(목록의 추가·삭제·이름·순서, 카드의 추가·삭제·내용·이동) 원문 기준 줄 범위로 다시 보낸다. 최대 3회. 양쪽이 같은 것을 바꿨으면 원격 업데이트처럼 서버 쪽이 남고, 그랬다는 것을 토스트로 알린다. 다시 얹을 것이 남지 않으면 보내지 않는다. 모두 실패하면 화면 하단에 non-blocking 토스트를 표시하며 페이지는 새로고침하지 않는다. 2026-09-15 전에는 리비전 번호만 다시 받아 같은 텍스트를 같은 줄 범위에 보냈고, 그 사이 다른 세션이 보드에 한 변경이 사라졌다. * 다른 탭/사용자의 저장을 받아 보드를 자동 갱신한다 — 조건과 한계는 아래 '''실시간 동기화'''. * 카드 글(제목·Description·댓글)은 AhaMark 로 그린다. 백틱으로 감싼 코드도 보드 안에서 코드로 보인다. 2026-09-25 전에는 백틱이 페이지를 그릴 때 이미 코드 태그로 바뀌어 버려서, 보드가 그것을 태그 없이 읽고 '''다음 저장에 백틱을 지운 채''' 문서를 다시 썼다 — 쓴 사람이 지우지 않은 글자가 사라지는 셈이었다. 까닭은 [Interpreter] '''구간 안의 백틱'''. == 실시간 동기화 다른 탭 또는 다른 사용자가 같은 Kanban 페이지를 저장하면 현재 탭의 보드가 자동으로 갱신된다. 2026-09-15 부터는 저장이 어느 인스턴스를 거쳤든 온다 — `page.updated` 를 인스턴스 사이로 전달한다([Dev WebSocket]). 그 전에는 같은 인스턴스에 붙은 사람에게만 갔다. * 다른 세션의 저장을 WebSocket `page.updated` 이벤트로 감지한다. * 최신 원문을 다시 읽고 Kanban 블록을 파싱한 뒤 현재 보드에 병합한다. * 저장 중인 변경이 있으면 저장 완료 후 원격 업데이트를 다시 적용한다. * 병합 상세와 구현 노트는 [Dev Kanban Realtime]에 둔다. ===> 동작 조건 * WebSocket이 연결된 상태에서 다른 세션의 저장이 완료되면 `page.updated` 이벤트를 수신한다. * 이벤트 수신 후 500ms debounce 처리 후 `GET /w/{page}?action=raw`로 최신 원문을 가져온다. * 가져온 원문에서 칸반 블록 위치를 재계산하여 파싱한다. ===> 병합 전략 (3-way merge) * `base` — 이 탭과 서버가 마지막으로 같았던 칸반 상태. 처음 읽을 때, 저장에 성공할 때, 원격 업데이트를 적용할 때(방금 받은 서버 상태로) 다시 잡는다. * `local` — 현재 로컬 in-memory 상태 (저장 중인 변경 포함) * `server` — 방금 가져온 서버 상태 [[[ 로컬만 변경된 카드 → 로컬 버전 유지 서버만 변경된 카드 → 서버 버전 적용 양쪽 모두 변경된 카드 → 서버 우선 + 카드 표면에 주황 conflict 배지 로컬에만 추가된 카드 → 서버 상태에 병합하여 유지 모달에서 열린 카드 → 편집 중이 아닌 칸(제목·설명·속성)은 서버 값으로 바로 바꾸고, 편집 중인 칸을 서버가 바꿨으면 그 칸은 두고 모달 상단 배너 표시 ]]] ===> pending 뮤테이션 처리 * 저장 중인 뮤테이션이 있으면 업데이트를 즉시 적용하지 않고 뮤테이션 완료 후 재적용한다. ===> 부수 효과 * 원격 업데이트를 적용하면 `interpreterStartLine`과 줄 범위를 서버 원문 기준으로, 현재 리비전을 받은 리비전으로, `base`를 방금 받은 서버 상태로 다시 잡는다. ** 리비전이 맞으니 다음 저장이 409를 받지 않고, 줄 번호가 맞으니 저장이 보드 밖 줄을 덮지 않는다. == 속성 UI가 쓰는 속성: * `Creator` (1) — 카드를 만들 때 쓴다. * `dateCreated` (1) — 카드를 만들 때 쓴다. * `DueDate` (1) * `Assignee` (n) * `BlockedBy` (n) * `ParentCard` (n) * `RelatedCard` (n) * `Attachment` (n) (1)·(n)은 UI가 쓰는 개수일 뿐 강제하지 않는다. 목록에 없는 키도 그대로 읽고 저장하며 모달에 표시한다. === 카드 관계 같은 `InterpreterKanban` 블록 안의 카드 관계를 Property로 표현한다. 관계 대상은 페이지명을 저장하지 않고 카드 ID hash만 저장한다. [[[ ===== Property * BlockedBy * [#card-api-spec API spec 확정] * ParentCard: [#card-login-epic Login Epic] * RelatedCard * [#card-design-review Design review] ]]] * 저장값은 `[#cardId]` 또는 `[#cardId label]` 형식을 사용한다. * 실제 식별자는 `cardId`이고, label은 원문 가독성을 위한 보조 정보로만 사용한다. * 관계 해석 범위는 같은 `InterpreterKanban` 블록 내부로 제한한다. * `BlockedBy`, `ParentCard`, `RelatedCard`를 직접 저장한다. * `Blocks`, `SubCards`는 저장하지 않고 `BlockedBy`, `ParentCard`를 역방향으로 스캔해서 계산 표시한다. === 카드 표면 관계 배지 보드 뷰의 카드 표면에 관계가 있는 경우 관계 배지를 표시한다. * 직접 저장 관계: `BlockedBy`, `ParentCard`, `RelatedCard` * 파생 계산 관계: `Blocks`, `SubCards` * 관계가 없는 카드는 배지 영역을 표시하지 않는다. * 관계가 2개 이상인 경우 배지에 개수를 함께 표시한다. * 배지와 카드 하단 메타(담당자·통계)는 `kanban-card-footer` 안에 묶여, 내용이 있을 때만 구분선과 함께 표시된다. * 내용(관계·담당자·Activity·첨부·기한)이 없으면 footer에 여백과 구분선이 붙지 않는다. Activity는 시스템 기록(added·moved 등)도 세므로, UI에서 만든 카드는 처음부터 댓글 수 1과 함께 footer가 보인다. === 관계 색상 체계 카드 표면 배지와 카드 상세 모달의 관계 Property 행에 동일한 색상 체계를 적용한다. [[[#!Table tsv 1 관계 타입 색상 BlockedBy 빨간색 (error) Blocks (파생) 주황색 (warning) ParentCard 파란색 (primary) SubCards (파생) 파란색 (primary) RelatedCard 회색 (muted) ]]] 카드 상세 모달에서는 관계 Property 행의 라벨과 카드 칩 테두리·배경에 위 색상을 적용한다. === 카드 상세 모달 관계 표시 * 관계 Property별로 현재 연결된 카드를 카드 칩으로 표시한다. * 칩에는 최신 카드 제목과 카드 ID(`#` 포함 전체)를 표시한다. * 칩 클릭 시 `openCardDetailById(cardId)`로 대상 카드 모달을 연다. * 연결 추가는 카드 선택 목록으로 한다. 리스트별로 묶여 있고, 자기 자신과 이미 연결한 카드는 빠진다. ID를 직접 입력하는 칸은 없다(2026-06-10에 없앴다). * 원문에 있는 값이 보드에 없는 카드를 가리키면, 누를 수 없는 칩으로 표시한다. * 현재 카드 자신을 관계 대상으로 선택하지 못하게 한다. * 관계 삭제 버튼을 제공하고, 삭제 시 `card:property:update`로 저장한다. === Assignee `Assignee`는 기존 Property 형식을 유지하되 카드 상세 모달에서 직접 편집할 수 있게 한다. [[[ ===== Property * Assignee * [User:Aha00a] * [User:Alice] ]]] * Assignee는 복수 값을 허용한다. * 입력값은 `User:Aha00a`, `[User:Aha00a]`, `Aha00a`를 받아 `[User:Aha00a]`로 정규화한다. * 현재 Assignee는 사용자 칩으로 표시한다. * Assignee는 선택 목록(현재 사용자와 같은 보드에 등장한 Assignee)으로만 고른다. 목록에 없는 이름은 위키 텍스트를 직접 고친다. * Assignee 변경은 `card:property:update`로 저장하고 Activity에 기록한다. * 카드 목록에는 Assignee 프로필 이미지 아바타를 표시하고, 이름은 mouseover tooltip으로 제공한다. == 모바일 UX 뷰포트 너비가 767px 이하이면 자동으로 모바일 모드로 전환된다. `window.matchMedia('(max-width: 767px)')` 이벤트를 구독하여, 리사이즈 시에도 탭 바·단일 컬럼 보기·카드 드래그는 바로 바뀐다. 스와이프와 리스트 드래그는 페이지를 열 때의 너비로 정해진다. === 탭 바 + 단일 컬럼 뷰 * 보드 상단에 컬럼 이름을 탭으로 나열한다. * 활성 탭만 해당 컬럼을 표시하고, 나머지 컬럼은 숨긴다. * 탭에는 해당 컬럼의 카드 수를 배지로 표시한다. * 탭 바 우측 끝의 `+` 버튼으로 리스트를 추가할 수 있다. === 컬럼 이동 * 탭을 탭하여 컬럼을 전환한다. * 컬럼 영역을 좌우로 스와이프(50px 이상)하여 인접 컬럼으로 이동한다. === 드래그 비활성화 * 모바일에서는 카드/리스트 드래그가 비활성화된다. * 카드 이동은 카드 상세 모달의 "List" 셀렉트로 대신한다. === List 이동 (카드 상세 모달) * 카드 상세 모달 Properties 영역 최상단에 "List" 셀렉트를 표시한다. * 현재 카드가 속한 컬럼이 기본 선택된다. * 다른 컬럼을 선택하면 즉시 이동하고 모달을 닫는다. * Activity에 이동 기록이 남으며 `card:move`로 저장된다. * 컬럼이 하나뿐이거나 read-only인 경우 표시하지 않는다. * 데스크톱에서도 동일하게 표시된다. === 바텀시트 모달 * 카드 상세 모달이 화면 하단에서 올라오는 바텀시트 형태로 표시된다. * 높이 최대 90dvh, 상단 모서리 border-radius 16px. == 카드 모달 * 카드 열기 시 URL hash를 `#`로 설정한다. * 카드 닫기 시 해당 hash를 제거한다. * `#`로 직접 진입하면 해당 카드 모달을 자동으로 열고 보드 위치로 이동한다. * 존재하지 않는 `#`는 오류 없이 일반 보드 화면을 유지한다. * Property 영역에서 `DueDate`, `Assignee`, 카드 관계를 직접 편집할 수 있다. * 카드 관계 칩 클릭 시 대상 카드 모달을 연다. * 카드 목록에는 Assignee 프로필 이미지 아바타가 표시된다. == 리비전 코멘트 형식: `Kanban - [주체] 동사과거형 - Detail` * 카드 이벤트: 카드링크가 주체. ex) `Kanban - ["Kanban#cardId" Kanban#할일] moved - '''ToDo''' to '''Done'''` * 리스트 이벤트: 리스트명(bold)이 주체. ex) `Kanban - '''ToDo''' added` * 지원 액션: * `list:add`, `list:rename`, `list:move`, `list:delete` * `card:add`, `card:rename`, `card:move`, `card:delete` * `card:comment:add`, `card:property:update`, `card:description:update` * `card:comment:add`는 코멘트 첫 줄 기준 최대 80자만 기록한다. * `card:rename`의 Detail은 이전 제목(fromTitle). 카드링크 자체가 새 제목을 담고 있으므로 redundancy 없이 변경 내역을 표현한다. * `card:delete`는 카드가 삭제되므로 Activity에는 별도로 기록하지 않는다. * `list:move`와, 같은 리스트 안의 `card:move`는 minor edit로 저장한다([MacroRecentChanges]). == Read Only InterpreterKanban은 일반 위키 페이지와 같은 쓰기 권한 정책을 따른다. 페이지 렌더링 시 계산된 `isWritable` 값은 `.wikiContent`의 `data-wiki-writable` 속성으로 전달되고, Kanban UI는 이 값을 기준으로 root별 read-only 모드를 결정한다. 옛 리비전을 보는 화면(`?revision=N` 이 최신이 아닐 때)은 쓰기 권한이 있어도 read-only 이고, 실시간 동기화도 하지 않는다 — [Dev Page] '''옛 리비전 화면'''. 쓰기 권한이 없는 사용자는 Kanban 보드를 조회할 수 있지만, 내용을 변경하는 UI는 노출되지 않는다. * 보드, 컬럼, 카드, 속성, Activity, 첨부 이미지는 조회할 수 있다. * 카드 상세 모달, 이미지 라이트박스, `#` hash navigation은 동작한다. * 보드 배경 드래그를 이용한 가로 스크롤은 read-only에서도 동작한다. * list/card 추가, rename, delete, drag sort는 비활성화된다. * 댓글 입력, DueDate 수정, 첨부 삭제, 파일 drop 업로드는 비활성화된다. * 보드에는 read-only 상태가 표시된다. 클라이언트의 read-only 처리는 UX를 위한 방어선이다. 실제 보안 경계는 서버 저장 로직의 `/w/{page}` POST 쓰기 권한 검사이며, 쓰기 권한이 없으면 `403 Permission denied`로 차단된다. 한 페이지에 여러 Kanban interpreter가 있을 수 있으므로 read-only 상태와 mutation 방어는 interpreter root별로 처리한다. signed read URL로 페이지를 조회하는 경우에도 쓰기 권한이 없으면 read-only로 동작해야 한다.