Files
2026-09-16 17:22:14 +09:00

951 lines
40 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
'use strict';
// zkanban.js — zkanban.md 를 칸반 보드로 비추는 단일 파일 도구.
// 의존성 0, package.json 없이 `node zkanban.js` 로 바로 돈다.
//
// 구역:
// ① 파싱 — 파일을 줄 배열로 읽고, 어느 줄이 어느 열·카드인지 꼬리표만 붙인다
// ② 이동 — 줄 묶음을 잘라 다른 열로 옮기고 파일에 쓴다
// ③ HTTP — 로컬 서버, SSE, 화면
// ④ CLI — 인자 파싱, 보드 표 출력, move
//
// 절대 규칙: 카드를 문자열로 다시 만들어내지 않는다. 원본 줄을 옮기기만 한다.
// (그래야 주석·빈 줄·들여쓰기·줄바꿈이 한 글자도 안 상한다)
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const http = require('node:http');
// ────────────────────────────────────────────────────────────────
// ① 파싱
// ────────────────────────────────────────────────────────────────
const BOM = '';
/**
* 파일 내용을 "자기 줄바꿈을 품은 줄" 배열로 쪼갠다.
* 각 줄이 자기 \r\n / \n 을 들고 있어서, 이어붙이기만 하면 원문이 그대로 돌아온다.
* → EOL 을 추론할 필요가 없고, CRLF·LF 가 섞인 파일도 안 깨진다.
*/
function splitLines(text) {
const bom = text.startsWith(BOM) ? BOM : '';
const body = bom ? text.slice(BOM.length) : text;
// 빈 문자열은 줄이 하나도 없는 것으로 본다 (split 은 [''] 을 주므로 걸러냄)
const lines = body === '' ? [] : body.split(/(?<=\n)/);
return { bom, lines };
}
/** splitLines 의 역. 줄을 새로 만들지 않으므로 바이트가 그대로 복원된다. */
function joinLines(bom, lines) {
return bom + lines.join('');
}
/** 줄 끝 줄바꿈을 뗀 사본. 비교·정규식은 항상 이걸로 한다 (원본 줄은 안 고침). */
function bare(line) {
return line.replace(/\r?\n$/, '');
}
/**
* 파일 내용의 지문. 브라우저·CLI 가 "내가 본 그 파일이 맞나"를 확인할 때 쓴다.
* mtime 대신 내용 해시를 쓰는 이유는 research.md R-003.
*/
function revOf(text) {
return crypto.createHash('sha1').update(text, 'utf8').digest('hex').slice(0, 12);
}
const COLUMN_HEADER = /^##\s*\[(.+)\]\s*$/;
/**
* `## [이름]` 줄을 찾아 열 목록을 만든다.
* 열 이름·개수를 코드에 안 박으므로, 파일에 섹션을 추가하면 열이 늘어난다 (FR-002).
*/
function parseColumns(lines) {
const columns = [];
for (let i = 0; i < lines.length; i++) {
const matched = bare(lines[i]).match(COLUMN_HEADER);
if (!matched) continue;
if (columns.length > 0) columns.at(-1).endLine = i - 1;
columns.push({ name: matched[1].trim(), headerLine: i, endLine: lines.length - 1 });
}
return columns;
}
const CARD_START = /^-\s+/;
// 날짜는 있으면 활용, 없어도 됨 (FR-004). 강제하지 않는다.
const DATE_HEAD = /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s*,\s*/;
/**
* 한 열 안의 카드들을 뽑는다.
* 카드 = `- ` 줄 하나 + 거기 딸린 들여쓴 줄들.
* 빈 줄은 그 뒤에 또 들여쓴 줄이 올 때만 카드에 딸린다 (안 그러면 이동할 때마다
* 빈 줄이 따라다니며 파일이 조금씩 변형된다).
*/
function parseCards(lines, column) {
if (!column) return [];
const cards = [];
let inComment = false;
for (let i = column.headerLine + 1; i <= column.endLine; i++) {
const line = bare(lines[i]);
// `<!-- 사용법 -->` 같은 주석 블록 안의 `- ` 줄은 카드가 아니다
if (inComment) {
if (line.includes('-->')) inComment = false;
continue;
}
if (line.trimStart().startsWith('<!--')) {
if (!line.includes('-->')) inComment = true;
continue;
}
if (!CARD_START.test(line)) continue;
let end = i;
for (let j = i + 1; j <= column.endLine; j++) {
const next = bare(lines[j]);
if (next.trim() === '') continue; // 보류 — 뒤에 들여쓴 줄이 오면 그때 포함된다
if (!/^[ \t]/.test(next)) break;
end = j; // 사이에 낀 빈 줄까지 이 카드 것이 된다
}
cards.push(makeCard(lines.slice(i, end + 1), column.name, i, end));
i = end;
}
return cards;
}
/** `- ` 줄을 날짜 / 제목 / 상세로 가른다. 원본 줄(raw)은 그대로 들고 있는다. */
function makeCard(raw, columnName, startLine, endLine) {
const body = bare(raw[0]).replace(CARD_START, '');
const dated = body.match(DATE_HEAD);
const date = dated ? dated[1] : null;
const rest = dated ? body.slice(dated[0].length) : body;
const comma = rest.indexOf(',');
const title = (comma === -1 ? rest : rest.slice(0, comma)).trim();
let detail = (comma === -1 ? '' : rest.slice(comma + 1)).trim();
const subLines = raw.slice(1).map((l) => bare(l).trim()).filter((l) => l !== '');
if (subLines.length > 0) {
detail = detail === '' ? subLines.join('\n') : `${detail}\n${subLines.join('\n')}`;
}
// 편집창에 띄울 원문. detail 은 첫 줄 뒷부분과 하위 줄을 합쳐놔서 되돌릴 수 없다.
// 이걸 그대로 editCard 에 되먹이면 카드가 한 글자도 안 바뀐다
const text = [rest, ...subLines].join('\n');
return { column: columnName, startLine, endLine, date, title, detail, text, raw };
}
/**
* 카드 식별자. 줄 번호가 아니라 **내용**으로 만든다 (FR-005).
* 그래야 파일이 그사이 바뀌었을 때 엉뚱한 카드를 옮기는 대신 깔끔히 실패한다.
* 해시 입력에서 줄바꿈을 떼므로 CRLF·LF 차이로 id 가 흔들리지 않는다.
*/
function cardId(columnName, bareRaw, dupIndex) {
return crypto
.createHash('sha1')
.update(`${columnName}\n${bareRaw}\n${dupIndex}`, 'utf8')
.digest('hex')
.slice(0, 6);
}
/** 파일 내용 하나를 보드로. 이 함수 결과가 화면·CLI 가 보는 전부다. */
function parseBoard(text, filePath = null) {
const { bom, lines } = splitLines(text);
const columns = parseColumns(lines).map((column) => ({
...column,
cards: parseCards(lines, column),
}));
for (const column of columns) {
const seenCount = new Map();
for (const card of column.cards) {
const key = card.raw.map(bare).join('\n');
const dupIndex = seenCount.get(key) ?? 0;
seenCount.set(key, dupIndex + 1);
card.id = cardId(column.name, key, dupIndex);
}
}
return { bom, lines, columns, rev: revOf(text), path: filePath };
}
/** 사람이 치기 편하게 앞자리 일부만 받되, 딱 하나에 걸릴 때만 성공시킨다. */
function resolveCardId(board, input) {
const cards = board.columns.flatMap((column) => column.cards);
const exact = cards.filter((card) => card.id === input);
if (exact.length === 1) return { card: exact[0] };
const matched = input && input.length >= 2
? cards.filter((card) => card.id.startsWith(input))
: [];
if (matched.length === 1) return { card: matched[0] };
if (matched.length > 1) {
return { error: 'ambiguous', candidates: matched.map((card) => card.id) };
}
return { error: 'no-card' };
}
/** 열 이름도 앞글자만 — `in` → `In Progress`. 대소문자·공백 무시. */
function resolveColumn(board, input) {
const normalize = (s) => String(s).toLowerCase().replace(/\s+/g, '');
const wanted = normalize(input);
const names = board.columns.map((column) => column.name);
const exact = board.columns.filter((column) => normalize(column.name) === wanted);
if (exact.length === 1) return { column: exact[0] };
const matched = wanted === ''
? []
: board.columns.filter((column) => normalize(column.name).startsWith(wanted));
if (matched.length === 1) return { column: matched[0] };
if (matched.length > 1) {
return { error: 'bad-column', candidates: matched.map((column) => column.name) };
}
return { error: 'bad-column', candidates: names };
}
// ────────────────────────────────────────────────────────────────
// ② 이동
// ────────────────────────────────────────────────────────────────
/** 파일에서 제일 많이 쓰인 줄바꿈. 새 줄바꿈이 필요한 딱 한 경우에만 쓴다. */
function guessEol(lines) {
const crlf = lines.filter((line) => line.endsWith('\r\n')).length;
const lf = lines.filter((line) => line.endsWith('\n')).length - crlf;
return lf > crlf ? '\n' : '\r\n';
}
/**
* 카드를 다른 열로 옮긴다. 줄을 **새로 만들지 않고** 잘라서 다른 자리에 끼워넣는다.
* 그래서 주석·빈 줄·들여쓰기·줄바꿈이 그대로 남는다 (FR-006, SC-002).
*/
function moveCard(board, cardIdInput, columnInput) {
const foundCard = resolveCardId(board, cardIdInput);
if (foundCard.error) return foundCard;
const foundColumn = resolveColumn(board, columnInput);
if (foundColumn.error) return foundColumn;
const card = foundCard.card;
const target = foundColumn.column;
if (card.column === target.name) return { moved: false, lines: board.lines, card, column: target };
const lines = board.lines.slice();
const cut = lines.splice(card.startLine, card.raw.length);
// 잘라낸 뒤로 줄 번호가 밀리므로, 넣을 자리는 새 배열에서 다시 찾는다
lines.splice(insertPos(lines, target.name), 0, ...cut);
// 원래 마지막 줄에 줄바꿈이 없었는데 그 뒤로 뭔가 붙었으면, 줄바꿈 하나를 보탠다
const eol = guessEol(board.lines);
for (let i = 0; i < lines.length - 1; i++) {
if (!lines[i].endsWith('\n')) lines[i] += eol;
}
return { moved: true, lines, card, column: target };
}
/** 그 열에서 새 카드가 들어갈 자리 — 마지막 카드 다음, 카드가 없으면 헤더 다음 줄. */
function insertPos(lines, columnName) {
const column = parseColumns(lines).find((c) => c.name === columnName);
const cards = parseCards(lines, column);
return cards.length > 0 ? cards.at(-1).endLine + 1 : column.headerLine + 1;
}
function stamp(now) {
const two = (n) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} ${two(now.getHours())}:${two(now.getMinutes())}`;
}
/**
* 카드를 하나 만들어 그 열 맨 아래에 붙인다. **사용자가 브라우저에서 적는 경로.**
* (에이전트는 이걸 안 쓴다 — zkanban.md 에 항목을 적는 건 사용자다. CLAUDE.md 규칙)
*/
function addCard(board, columnInput, text, now = new Date()) {
const found = resolveColumn(board, columnInput);
if (found.error) return found;
const eol = guessEol(board.lines);
const fresh = toCardLines(text, `${stamp(now)}, `, eol);
if (!fresh) return { error: 'empty' };
const lines = board.lines.slice();
lines.splice(insertPos(lines, found.column.name), 0, ...fresh);
// 원래 마지막 줄에 줄바꿈이 없었는데 그 뒤로 뭔가 붙었으면 줄바꿈 하나를 보탠다
for (let i = 0; i < lines.length - 1; i++) {
if (!lines[i].endsWith('\n')) lines[i] += eol;
}
return { added: true, lines, column: found.column };
}
/**
* 사람이 친 글을 카드 줄들로 바꾼다. 첫 줄은 `- ` 카드 줄, 나머지는 들여쓴 하위 줄.
* 그게 파서가 이미 아는 구조라 새 포맷을 만들 필요가 없다 (data-model.md 줄 소유 규칙).
*
* 가운데 빈 줄은 버린다 — 빈 줄이 그대로 들어가면 카드가 거기서 끊겨 둘로 쪼개진다.
* 빈 글이면 null.
*/
function toCardLines(text, head, eol) {
const parts = String(text ?? '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (parts.length === 0) return null;
return [`- ${head}${parts[0]}${eol}`, ...parts.slice(1).map((line) => ` ${line}${eol}`)];
}
/**
* 카드의 **첫 줄만** 새로 쓴다. 날짜와 줄바꿈은 원래 것을 그대로 물려받고,
* 딸린 들여쓴 하위 줄은 손대지 않는다.
*
* 이 함수는 이 파일에서 유일하게 **줄을 새로 만든다.** 그래서 범위를 첫 줄로 좁혔다 —
* 나머지 줄은 여전히 원본 문자열 그대로라 서식이 안 흘러나간다.
*/
function editCard(board, cardIdInput, text) {
const found = resolveCardId(board, cardIdInput);
if (found.error) return found;
const card = found.card;
const first = card.raw[0];
const eol = first.endsWith('\r\n') ? '\r\n' : first.endsWith('\n') ? '\n' : guessEol(board.lines);
const fresh = toCardLines(text, card.date ? `${card.date}, ` : '', eol);
if (!fresh) return { error: 'empty' };
const lines = board.lines.slice();
lines.splice(card.startLine, card.raw.length, ...fresh);
return { edited: true, lines, card };
}
/** 카드를 지운다. 딸린 하위 줄까지 통째로. 줄을 빼기만 하므로 나머지 서식은 안 다친다. */
function removeCard(board, cardIdInput) {
const found = resolveCardId(board, cardIdInput);
if (found.error) return found;
const card = found.card;
const lines = board.lines.slice();
lines.splice(card.startLine, card.raw.length);
return { removed: true, lines, card };
}
/** 파일을 읽어 보드로. 파일이 없으면 빈 보드 (죽지 않는다). */
function readBoard(filePath) {
let text = '';
try {
text = fs.readFileSync(filePath, 'utf8');
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
return parseBoard(text, filePath);
}
/**
* 보드를 파일에 쓴다. 임시 파일에 쓰고 rename 으로 갈아끼운다 —
* 쓰는 도중 죽어도 원본(사용자의 유일한 할 일 목록)이 안 날아가게.
*/
function writeBoard(board, lines = board.lines) {
const text = joinLines(board.bom, lines);
const tmpPath = `${board.path}.${process.pid}.tmp`;
fs.writeFileSync(tmpPath, text, 'utf8');
fs.renameSync(tmpPath, board.path);
return revOf(text);
}
// ────────────────────────────────────────────────────────────────
// ③ HTTP
// ────────────────────────────────────────────────────────────────
const HOST = '127.0.0.1'; // 로컬 전용. 밖에서 못 들어온다 (FR-012)
const PORT_BASE = 39000;
const PORT_SPAN = 1000; // 39000~39999
const PORT_TRIES = 10;
/**
* 그 보드의 주소를 파일 경로에서 정한다. **같은 프로젝트는 항상 같은 포트** —
* 여러 프로젝트에서 띄워도 주소가 안 섞이고 북마크가 먹는다.
*
* 39000 대인 이유: 49152 위는 Windows 가 임시 포트로 자동 할당하는 대역이라
* 고정으로 잡으면 남이 이미 쓰고 있을 수 있다. 1024 아래는 권한이 필요하다.
*
* 해시라 다른 프로젝트와 부딪힐 수는 있다. 그땐 listen 이 하나씩 올려 잡는다.
*/
function portFor(filePath) {
// Windows 에선 D:\x 와 d:/x 가 같은 파일이다. 주소가 갈리지 않게 맞춰준다
const key = String(filePath).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
const hash = crypto.createHash('sha1').update(key).digest();
return PORT_BASE + (hash.readUInt32BE(0) % PORT_SPAN);
}
/** 그 포트에 이미 뜬 게 **내 보드**인가? (남의 보드거나 빈 자리면 false) */
async function boardAlreadyServing(port, filePath) {
try {
const res = await fetch(`http://${HOST}:${port}/api/board`, { signal: AbortSignal.timeout(500) });
if (!res.ok) return false;
return (await res.json()).path === filePath;
} catch {
return false; // 아무도 없거나, 칸반이 아닌 뭔가가 있거나
}
}
/** 화면·CLI 에 내보낼 모양으로. 줄 번호 같은 내부 사정은 안 내보낸다. */
function boardToJson(board) {
return {
rev: board.rev,
path: board.path,
columns: board.columns.map((column) => ({
name: column.name,
cards: column.cards.map((card) => ({
id: card.id,
date: card.date,
title: card.title,
detail: card.detail,
text: card.text, // 편집창이 띄울 원문
lineCount: card.raw.length,
})),
})),
};
}
function sendJson(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(payload),
});
res.end(payload);
}
/**
* 파일을 감시하다가 바뀌면 알려준다.
* 파일이 아니라 **그 폴더**를 본다 — 에디터가 저장할 때 파일을 지웠다 새로 만드는
* 경우가 흔해서, 파일을 직접 감시하면 그 순간 감시가 끊긴다 (research.md R-001).
*/
function watchFile(filePath, onChange) {
const dir = path.dirname(path.resolve(filePath));
const name = path.basename(filePath);
let timer = null;
// 저장 한 번에 이벤트가 여러 번 오는 게 흔해서 뭉쳐서 한 번만 알린다
const fire = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(onChange, 120);
};
try {
const watcher = fs.watch(dir, (_event, changed) => {
if (changed === null || changed === name) fire();
});
watcher.on('error', () => {}); // 감시가 죽어도 서버는 계속 산다
return () => {
if (timer) clearTimeout(timer);
watcher.close();
};
} catch {
// 폴더 감시가 안 되는 환경(네트워크 드라이브 등)에서는 폴링으로 물러선다
fs.watchFile(filePath, { interval: 500 }, fire);
return () => {
if (timer) clearTimeout(timer);
fs.unwatchFile(filePath, fire);
};
}
}
function readJsonBody(req, done) {
let raw = '';
req.on('data', (chunk) => {
raw += chunk;
});
req.on('end', () => {
try {
done(JSON.parse(raw));
} catch {
done({});
}
});
}
/** 요청 하나를 처리해 [상태코드, 본문] 을 돌려준다 (contracts/http-api.md) */
function handleMove(filePath, { rev, id, to } = {}) {
if (!id || !to) return [400, { ok: false, error: 'bad-request' }];
return handleWrite(filePath, rev, (board) => moveCard(board, id, to));
}
function handleAdd(filePath, { rev, column, text } = {}) {
if (!column) return [400, { ok: false, error: 'bad-request' }];
return handleWrite(filePath, rev, (board) => addCard(board, column, text));
}
function handleEdit(filePath, { rev, id, text } = {}) {
if (!id) return [400, { ok: false, error: 'bad-request' }];
return handleWrite(filePath, rev, (board) => editCard(board, id, text));
}
function handleRemove(filePath, { rev, id } = {}) {
if (!id) return [400, { ok: false, error: 'bad-request' }];
return handleWrite(filePath, rev, (board) => removeCard(board, id));
}
/**
* 파일을 바꾸는 요청들의 공통 뼈대: rev 확인 → 시킨 일 → 저장.
* 셋 다 "내가 본 그 파일이 맞나"부터 확인하고, 아니면 아무것도 안 하고 거절한다 (FR-010).
*/
function handleWrite(filePath, rev, work) {
const board = readBoard(filePath);
if (!rev) return [400, { ok: false, error: 'bad-request' }];
if (rev !== board.rev) {
return [409, { ok: false, error: 'stale', message: '파일이 바뀌어서 취소됨', rev: board.rev }];
}
const result = work(board);
if (result.error === 'no-card' || result.error === 'ambiguous') {
return [404, { ok: false, error: 'no-card' }];
}
if (result.error === 'bad-column') {
return [400, { ok: false, error: 'bad-column', candidates: result.candidates }];
}
if (result.error) return [400, { ok: false, error: result.error }];
if (result.moved === false) return [200, { ok: true, rev: board.rev, moved: false }];
try {
return [200, { ok: true, rev: writeBoard(board, result.lines), moved: true }];
} catch (err) {
return [500, { ok: false, error: 'write-failed', message: err.message }];
}
}
// 화면 전부. 빌드 단계도 외부 파일도 없다 — zkanban.js 하나만 복사하면 끝 (SC-004).
const PAGE = `<!doctype html>
<html lang="ko"><meta charset="utf-8"><title>칸반</title>
<style>
:root{color-scheme:light}
body{margin:0;font:14px/1.55 system-ui,"Malgun Gothic",sans-serif;background:#f4f5f7;color:#1c1f26}
header{display:flex;align-items:center;gap:9px;padding:11px 16px;border-bottom:1px solid #e3e5ea;background:#fff}
#dot{width:9px;height:9px;border-radius:50%;background:#d9534f}
#dot.on{background:#1aa06d}
#file{color:#8a90a0;font-size:12px}
nav{display:none}
main{display:flex;gap:12px;padding:16px;align-items:flex-start;overflow-x:auto}
section{flex:1 0 250px;background:#eceef2;border:1px solid #e0e3ea;border-radius:10px;padding:10px}
h2{margin:0 0 9px;font-size:12px;letter-spacing:.04em;color:#6f7688;display:flex;justify-content:space-between}
article{background:#fff;border:1px solid #e0e3ea;border-radius:8px;padding:8px 10px;margin-bottom:7px;box-shadow:0 1px 2px rgba(16,20,32,.05)}
time{display:block;font-size:11px;color:#8a90a0;margin-bottom:2px}
.t{font-weight:600}
.d{margin:4px 0 0;font-size:12.5px;color:#5d6472;white-space:pre-wrap}
article{cursor:grab}
article.dragging{opacity:.4}
section.over{border-color:#1aa06d}
article{position:relative}
.x,.mv{position:absolute;top:5px;width:19px;height:19px;line-height:17px;text-align:center;border-radius:5px;color:#9aa1b0;cursor:pointer;opacity:0;user-select:none}
.x{right:6px}
.mv{right:28px}
article:hover .x,article:hover .mv{opacity:1}
.x:hover{background:#fde8e8;color:#b3261e}
.mv:hover{background:#e4ecfb;color:#1f5fd0}
/* 카드를 다른 열로 보내는 목록. 좁아서 드래그가 안 될 때의 길 */
.menu{position:absolute;top:26px;right:6px;z-index:5;min-width:118px;background:#fff;border:1px solid #d7dae3;border-radius:8px;box-shadow:0 6px 20px rgba(16,20,32,.16);overflow:hidden}
.menu div{padding:7px 12px;cursor:pointer;white-space:nowrap}
.menu div:hover{background:#eef1f7}
.edit,.add{width:100%;box-sizing:border-box;background:#fff;color:#1c1f26;font:inherit;resize:none;overflow:hidden;display:block}
.edit{border:1px solid #1aa06d;border-radius:6px;padding:5px 7px}
.hint{font-size:11px;color:#8a90a0;margin-top:4px}
.add{border:1px dashed #c8cdd8;border-radius:8px;padding:7px 10px;background:transparent}
.add::placeholder{color:#9aa1b0}
.add:focus{outline:none;border-color:#1aa06d;border-style:solid;background:#fff}
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#fde8e8;border:1px solid #f0b4b4;color:#8c1d18;padding:8px 14px;border-radius:8px;display:none}
/* 좁으면 열을 나란히 못 놓는다. 탭으로 하나만 보여주고, 이동은 카드의 → 로 한다 */
@media (max-width:820px){
nav{display:flex;gap:4px;padding:8px 10px;overflow-x:auto;background:#fff;border-bottom:1px solid #e3e5ea;position:sticky;top:0;z-index:4}
nav button{flex:0 0 auto;font:inherit;font-size:12.5px;padding:6px 11px;border:1px solid transparent;border-radius:999px;background:transparent;color:#6f7688;cursor:pointer;white-space:nowrap}
nav button.on{background:#e8f4ee;border-color:#bfe3d2;color:#14684a;font-weight:600}
nav b{font-weight:600;margin-left:5px;opacity:.6}
/* 경로는 길어서 헤더를 세 줄로 터뜨린다. 좁을 땐 어느 프로젝트인지 대개 아니까 숨긴다 */
#file{display:none}
main{display:block;padding:10px;overflow-x:visible}
section{background:transparent;border:0;border-radius:0;padding:0}
section:not(.on){display:none}
h2{display:none}
}
</style>
<header><span id="dot"></span><strong>칸반</strong><span id="file"></span></header>
<nav></nav><main></main><div id="toast"></div>
<script>
const main=document.querySelector('main'),nav=document.querySelector('nav'),dot=document.getElementById('dot'),toast=document.getElementById('toast');
let rev=null,dragging=null,pending=null,editing=null;
// 좁은 화면에서 지금 보고 있는 열. 넓으면 CSS 가 무시하므로 폭에 상관없이 늘 들고 있는다
let active=sessionStorage.getItem('col');
let last=null;
function say(msg){toast.textContent=msg;toast.style.display='block';setTimeout(()=>toast.style.display='none',3000)}
function el(tag,cls,text){const n=document.createElement(tag);if(cls)n.className=cls;if(text!=null)n.textContent=text;return n}
// 줄이 늘면 칸도 같이 늘어나게 (스크롤바 대신)
function grow(t){const fit=()=>{t.style.height='auto';t.style.height=t.scrollHeight+'px'};t.addEventListener('input',fit);setTimeout(fit)}
// Enter 는 저장, Shift+Enter 는 줄바꿈. 한글은 조합 중에도 Enter 가 오므로 그때는 넘긴다
function enterToSubmit(e){
if(e.key!=='Enter'||e.shiftKey||e.isComposing)return false;
e.preventDefault();return true;
}
// 카드를 다른 열로 보내는 목록. 좁아서 드래그할 자리가 없을 때의 길이고, 넓어도 그대로 쓴다
function closeMenu(){const m=document.querySelector('.menu');if(m)m.remove()}
function openMenu(box,card,here){
closeMenu();
const m=el('div','menu');
for(const col of last.columns){
if(col.name===here)continue;
const it=el('div',null,col.name);
it.addEventListener('click',e=>{e.stopPropagation();closeMenu();send('/api/move',{id:card.id,to:col.name})});
m.append(it);
}
box.append(m);
}
document.addEventListener('click',closeMenu);
function render(board){
rev=board.rev;last=board;
document.getElementById('file').textContent=board.path||'';
closeMenu();
// 보던 열이 사라졌으면(이름을 고쳤거나) 첫 열로 돌아간다
const names=board.columns.map(c=>c.name);
if(!names.includes(active))active=names[0]||null;
nav.textContent='';
for(const col of board.columns){
const tab=el('button',col.name===active?'on':null,col.name);
tab.append(el('b',null,col.cards.length));
tab.addEventListener('click',()=>{active=col.name;sessionStorage.setItem('col',active);render(last)});
nav.append(tab);
}
// 다시 그리면 입력칸이 통째로 새로 만들어진다. 치던 글이 날아가지 않게 담아뒀다 되돌린다
const at=document.activeElement;
const typing=at&&at.classList.contains('add')?{name:at.closest('section').dataset.name,value:at.value}:null;
main.textContent='';
for(const col of board.columns){
const sec=el('section',col.name===active?'on':null);sec.dataset.name=col.name;
const head=el('h2');head.append(el('span',null,col.name),el('span',null,col.cards.length));
sec.append(head);
sec.addEventListener('dragover',e=>{e.preventDefault();sec.classList.add('over')});
sec.addEventListener('dragleave',()=>sec.classList.remove('over'));
sec.addEventListener('drop',e=>{e.preventDefault();sec.classList.remove('over');move(sec.dataset.name)});
for(const card of col.cards){
const box=el('article');box.dataset.id=card.id;box.draggable=true;
box.addEventListener('dragstart',()=>{dragging=card.id;box.classList.add('dragging')});
box.addEventListener('dragend',()=>{dragging=null;box.classList.remove('dragging');if(pending){const p=pending;pending=null;render(p)}});
if(card.date)box.append(el('time',null,card.date));
box.append(el('div','t',card.title));
if(card.detail)box.append(el('p','d',card.detail));
const x=el('span','x','×');x.title='지우기';
x.addEventListener('click',()=>remove(card));
const mv=el('span','mv','→');mv.title='다른 열로';
mv.addEventListener('click',e=>{e.stopPropagation();openMenu(box,card,col.name)});
box.append(mv,x);
box.addEventListener('dblclick',()=>startEdit(box,card));
sec.append(box);
}
const input=el('textarea','add');input.rows=1;input.placeholder='+ 적고 엔터 (Shift+Enter 줄바꿈)';
grow(input);
input.addEventListener('keydown',e=>{if(enterToSubmit(e))add(sec,input)});
sec.append(input);
main.append(sec);
}
if(typing){
const back=main.querySelector('section[data-name="'+CSS.escape(typing.name)+'"] .add');
if(back){back.value=typing.value;back.focus()}
}
}
const refresh=()=>fetch('/api/board').then(r=>r.json()).then(render);
// 파일을 바꾸는 요청은 전부 여기를 지난다. rev 를 실어 보내고, 성공하면 새 rev 를 받아둔다
// (SSE 방송을 안 기다리고 바로 다음 요청을 보낼 수 있게)
async function send(path,body,retryStale){
const res=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rev,...body})});
const b=await res.json();
if(res.status===409){
rev=b.rev;
// 추가처럼 남과 부딪힐 게 없는 일은 새 rev 로 한 번만 조용히 다시 (재귀는 안 함)
if(retryStale)return send(path,body);
say(b.message||'파일이 바뀌어서 취소됨');pending=null;refresh();return null;
}
if(!res.ok){say('안 됨: '+(b.error||res.status));return null}
rev=b.rev;return b;
}
async function add(sec,input){
const text=input.value.trim();
if(text&&await send('/api/add',{column:sec.dataset.name,text},true))input.value='';
}
async function move(to){
if(dragging)await send('/api/move',{id:dragging,to});
}
async function remove(card){
if(confirm('지울까? '+card.title))await send('/api/remove',{id:card.id});
}
// 카드를 그 자리에서 고친다. 첫 줄(제목·상세)만 바뀌고 날짜·하위 줄은 그대로다
function startEdit(box,card){
if(editing)return;
editing=card.id;
const input=el('textarea','edit');input.rows=1;
// 파일에 적힌 원문 그대로 띄운다. 안 고치고 저장하면 파일도 그대로다
input.value=card.text;
box.textContent='';box.draggable=false;
box.append(input,el('div','hint','Enter 저장 · Shift+Enter 줄바꿈 · Esc 취소'));
grow(input);input.focus();input.select();
const stop=()=>{if(editing){editing=null;refresh()}};
input.addEventListener('blur',stop);
input.addEventListener('keydown',async e=>{
if(e.key==='Escape')return stop();
if(!enterToSubmit(e))return;
const text=input.value.trim();
if(text)await send('/api/edit',{id:card.id,text});
stop();
});
}
const es=new EventSource('/events');
// 카드를 손에 들었거나 고치는 중에 화면이 갈아엎어지면 하던 일이 날아간다. 끝날 때까지 미룬다
es.addEventListener('board',e=>{const board=JSON.parse(e.data);if(dragging||editing)pending=board;else render(board)});
es.onopen=()=>dot.classList.add('on');
es.onerror=()=>dot.classList.remove('on');
</script>
`;
function createServer(filePath) {
const clients = new Set();
const broadcast = () => {
let payload;
try {
payload = `event: board\ndata: ${JSON.stringify(boardToJson(readBoard(filePath)))}\n\n`;
} catch {
return; // 저장 직후 잠깐 못 읽는 일이 있다. 다음 변경 때 다시 보내면 된다
}
for (const client of clients) client.write(payload);
};
const server = http.createServer((req, res) => {
try {
return route(req, res);
} catch (err) {
// 파일을 못 읽는 등 무슨 일이 나도 서버는 살아 있어야 한다
return sendJson(res, 500, { ok: false, error: 'server-error', message: err.message });
}
});
function route(req, res) {
const url = new URL(req.url, `http://${HOST}`);
// 127.0.0.1 에 묶는 것만으로는 DNS 리바인딩을 못 막는다. 이름도 확인한다
const host = (req.headers.host ?? '').split(':')[0];
if (host !== HOST && host !== 'localhost' && host !== '[::1]') {
return sendJson(res, 403, { ok: false, error: 'bad-host' });
}
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': Buffer.byteLength(PAGE),
});
return res.end(PAGE);
}
if (req.method === 'GET' && url.pathname === '/api/board') {
return sendJson(res, 200, boardToJson(readBoard(filePath)));
}
if (req.method === 'GET' && url.pathname === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
clients.add(res);
res.write(`event: board\ndata: ${JSON.stringify(boardToJson(readBoard(filePath)))}\n\n`);
req.on('close', () => clients.delete(res));
return undefined;
}
if (req.method === 'POST' && url.pathname === '/api/move') {
return readJsonBody(req, (body) => sendJson(res, ...handleMove(filePath, body)));
}
if (req.method === 'POST' && url.pathname === '/api/add') {
return readJsonBody(req, (body) => sendJson(res, ...handleAdd(filePath, body)));
}
if (req.method === 'POST' && url.pathname === '/api/edit') {
return readJsonBody(req, (body) => sendJson(res, ...handleEdit(filePath, body)));
}
if (req.method === 'POST' && url.pathname === '/api/remove') {
return readJsonBody(req, (body) => sendJson(res, ...handleRemove(filePath, body)));
}
return sendJson(res, 404, { ok: false, error: 'not-found' });
}
const unwatch = watchFile(filePath, broadcast);
// 죽은 연결을 빨리 정리하려고 가끔 신호를 보낸다 (SSE 주석 줄)
const ping = setInterval(() => {
for (const client of clients) client.write(':ping\n\n');
}, 15000);
ping.unref();
server.kanbanFile = filePath;
server.kanbanClients = clients;
server.kanbanBroadcast = broadcast;
server.on('close', () => {
unwatch();
clearInterval(ping);
});
return server;
}
/**
* 포트가 이미 쓰이면 하나씩 올려가며 최대 10번 시도한다.
* 실제로 잡은 포트를 돌려준다 (0 을 주면 OS 가 비어 있는 걸 골라줌).
*/
function listen(server, port, host = HOST) {
return new Promise((resolve, reject) => {
let attempt = 0;
const tryPort = (candidate) => {
const onError = (err) => {
if (err.code !== 'EADDRINUSE' || candidate === 0 || ++attempt >= PORT_TRIES) {
return reject(err);
}
tryPort(candidate + 1);
};
server.once('error', onError);
server.listen(candidate, host, () => {
server.removeListener('error', onError);
resolve(server.address().port);
});
};
tryPort(port);
});
}
function closeServer(server) {
// SSE 연결은 끝나지 않는 응답이라, 먼저 끊어주지 않으면 close 가 안 끝난다
for (const client of server.kanbanClients ?? []) client.end();
return new Promise((resolve) => server.close(resolve));
}
// ────────────────────────────────────────────────────────────────
// ④ CLI
// ────────────────────────────────────────────────────────────────
const USAGE = `사용법: node zkanban.js [명령]
(없음) 보드를 표로 출력
move <id> <열> 카드를 다른 열로 옮김
serve 보드 서버를 띄움 (이미 떠 있으면 주소만 알려줌)
help 이 도움말
옵션:
--file <경로> 대상 파일 (기본: ./zkanban.md)
--port <번호> serve 포트 (기본: 파일 경로로 정함 — 39000~39999,
프로젝트마다 항상 같은 자리라 북마크가 먹음)
`;
/** `--file 경로` `--port 번호` 같은 옵션을 떼어내고 나머지를 남긴다 */
function parseArgs(argv) {
const options = { file: 'zkanban.md', port: null }; // port 는 안 주면 파일 경로에서 정한다
const rest = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--file' && argv[i + 1] !== undefined) options.file = argv[++i];
else if (argv[i] === '--port' && argv[i + 1] !== undefined) options.port = Number(argv[++i]);
else rest.push(argv[i]);
}
return { options, rest };
}
async function runServe(options) {
if (options.port !== null && (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535)) {
return fail(`포트가 이상함: ${options.port}`);
}
const filePath = path.resolve(options.file);
const wanted = options.port ?? portFor(filePath);
// 이 보드가 이미 떠 있으면 또 띄우지 않는다. 주소만 알려주고 끝
if (await boardAlreadyServing(wanted, filePath)) {
process.stdout.write(`이미 떠 있음: http://${HOST}:${wanted}\n`);
process.stdout.write(`파일: ${filePath}\n`);
return 0;
}
const server = createServer(filePath);
const port = await listen(server, wanted);
process.stdout.write(`칸반 보드: http://${HOST}:${port}\n`);
process.stdout.write(`파일: ${filePath}\n`);
// 해시로 정한 자리를 남이 쓰고 있으면 옆으로 밀린다. 그럼 다음에 주소가 또 달라지니 알려준다
if (port !== wanted) process.stdout.write(`(${wanted} 은 남이 쓰는 중이라 ${port} 으로 잡음)\n`);
process.stdout.write('(Ctrl+C 로 종료)\n');
return 0;
}
function fail(message) {
process.stderr.write(`${message}\n`);
return 1;
}
/** 인자 없이 실행했을 때 — 보드를 표로 */
function printBoard(board) {
const width = Math.max(40, (process.stdout.columns ?? 100) - 30);
process.stdout.write(`${board.path} rev ${board.rev}\n\n`);
for (const column of board.columns) {
process.stdout.write(`[${column.name}] (${column.cards.length})\n`);
for (const card of column.cards) {
const title = card.title.length > width ? `${card.title.slice(0, width - 1)}…` : card.title;
process.stdout.write(` ${card.id} ${card.date ?? '—'} ${title}\n`);
}
}
return 0;
}
function runMove(board, idInput, columnInput) {
if (idInput === undefined || columnInput === undefined) return fail('사용법: move <id> <열>');
const result = moveCard(board, idInput, columnInput);
if (result.error === 'no-card') return fail(`그런 카드 없음: ${idInput}`);
if (result.error === 'ambiguous') return fail(`id 가 여러 개에 걸림: ${result.candidates.join(', ')}`);
if (result.error === 'bad-column') return fail(`그런 열 없음: ${columnInput} (있는 열: ${result.candidates.join(', ')})`);
if (!result.moved) {
process.stdout.write(`이미 [${result.column.name}] 에 있음\n`);
return 0;
}
writeBoard(board, result.lines);
process.stdout.write(`${result.card.id} → [${result.column.name}]\n`);
return 0;
}
async function main(argv) {
const { options, rest } = parseArgs(argv);
const [command, ...args] = rest;
if (command === 'serve') return runServe(options);
if (command === undefined || command === 'move') {
const filePath = path.resolve(options.file);
// 보기만 할 땐 파일이 없어도 빈 보드를 보여준다. 옮기려면 당연히 파일이 있어야 한다
if (command === 'move' && !fs.existsSync(filePath)) {
return fail(`${path.basename(filePath)} 없음: ${filePath}`);
}
const board = readBoard(filePath);
return command === undefined ? printBoard(board) : runMove(board, args[0], args[1]);
}
process.stdout.write(USAGE);
return 0;
}
module.exports = {
splitLines, joinLines, revOf, parseColumns, parseCards, parseBoard,
resolveCardId, resolveColumn, moveCard, addCard, editCard, removeCard, readBoard, writeBoard,
boardToJson, createServer, listen, closeServer, portFor, boardAlreadyServing,
};
// import 만 했을 땐 아무 일도 안 일어나야 한다 (테스트가 이걸 기대함)
if (require.main === module) {
main(process.argv.slice(2))
.then((code) => {
process.exitCode = code;
})
.catch((err) => {
process.exitCode = fail(err.message);
});
}