feat(frontend): 주소록 가져오기 · 내보내기 · 여러 서명과 서명 바꾸기 · 부재중 서식 · 휴대폰 바꾸기

메일 16단계(개인 설정)의 화면이다(보드 Contacts16 · Signature16 · Settings16).
- 주소록: 브라우저가 vCard · CSV · 엑셀(.xlsx)을 읽어 미리 보기(파일은 서버로 가지 않는다 — 새 의존성 없이
  DecompressionStream · DOMParser), 네이버 · Google · Outlook 머리를 알아보고 칸 없는 정보는 메모 끝에,
  묶음(100명 · 값 8,000 · 700KB)으로 넣고 ✕까지 남는 결과 알림의 [되돌리기], 내보내기 창.
- 서명: 왼쪽 목록 · 오른쪽 편집 · 메일에 넣을 서명 둘, 쓰기 화면 [서명 ▾]로 서명 자리만 바꾸기
  (앱과 같은 경계 — 앞 빈 줄을 지켜 임시 저장 · 이어 쓰기에서도 찾는다).
- 부재중: 내용 편집기(서식이 있을 때만 서식 본문) · 회사 밖에서 온 메일 응답 셋 · 미리 보기 전환.
- 2단계 인증: [휴대폰 바꾸기] 두 걸음 창(비밀은 창 안에만).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 11:44:42 +09:00
parent 0c3238936a
commit 907287021f
102 changed files with 14738 additions and 924 deletions
+35
View File
@@ -0,0 +1,35 @@
// 단위 테스트 전용 — 글을 EUC-KR(CP949) 바이트로 바꾼다 (16단계 주소록 가져오기 — 한국어 엑셀의 "CSV(쉼표로 분리)" 흉내).
// 브라우저 · Node 의 TextEncoder 는 UTF-8 만 만든다 — TextDecoder('euc-kr')로 두 바이트 글자를 모두 풀어 거꾸로 찾는 표를 만든다.
// 앱 코드에서 import 하지 않는다
let table: Map<string, [number, number]> | null = null
function reverseTable(): Map<string, [number, number]> {
if (table) return table
const decoder = new TextDecoder('euc-kr')
const map = new Map<string, [number, number]>()
for (let lead = 0x81; lead <= 0xfe; lead += 1) {
for (let trail = 0x41; trail <= 0xfe; trail += 1) {
const char = decoder.decode(Uint8Array.from([lead, trail]))
if (char.length === 1 && char !== '�' && !map.has(char)) map.set(char, [lead, trail])
}
}
table = map
return map
}
export function encodeEucKr(text: string): Uint8Array {
const map = reverseTable()
const bytes: number[] = []
for (const char of text) {
const code = char.charCodeAt(0)
if (code < 0x80) {
bytes.push(code)
continue
}
const pair = map.get(char)
if (!pair) throw new Error(`EUC-KR 로 쓸 수 없는 글자: ${char}`)
bytes.push(...pair)
}
return Uint8Array.from(bytes)
}
+173
View File
@@ -0,0 +1,173 @@
// 단위 테스트 전용 — 작은 zip(.xlsx)을 만든다 (16단계 주소록 가져오기 시험). 앱 코드에서 import 하지 않는다.
// 항목마다 그대로(store) 또는 deflate(CompressionStream('deflate-raw')). CRC 는 0 — 읽는 쪽(zip.ts)은 CRC 를 보지 않는다
export interface ZipInput {
name: string
data: string | Uint8Array
/** 처음 값 deflate */
method?: 'store' | 'deflate'
/** 암호를 건 항목 표시(일반 목적 비트 0) — 읽는 쪽이 거절하는지 본다 */
encrypted?: boolean
}
async function deflateRaw(data: Uint8Array): Promise<Uint8Array> {
const stream = new CompressionStream('deflate-raw')
const writer = stream.writable.getWriter()
void writer.write(data.slice()).then(() => writer.close())
const chunks: Uint8Array[] = []
const reader = stream.readable.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
}
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
const result = new Uint8Array(total)
let at = 0
for (const chunk of chunks) {
result.set(chunk, at)
at += chunk.byteLength
}
return result
}
function u16(value: number): number[] {
return [value & 0xff, (value >>> 8) & 0xff]
}
function u32(value: number): number[] {
return [value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff]
}
export async function buildZip(inputs: readonly ZipInput[]): Promise<Uint8Array> {
const encoder = new TextEncoder()
const local: number[] = []
const central: number[] = []
for (const input of inputs) {
const name = encoder.encode(input.name)
const raw = typeof input.data === 'string' ? encoder.encode(input.data) : input.data
const method = input.method === 'store' ? 0 : 8
const data = method === 8 ? await deflateRaw(raw) : raw
const flags = input.encrypted ? 1 : 0
const offset = local.length
local.push(
...u32(0x04034b50),
...u16(20),
...u16(flags),
...u16(method),
...u16(0),
...u16(0),
...u32(0),
...u32(data.length),
...u32(raw.length),
...u16(name.length),
...u16(0),
...name,
...data,
)
central.push(
...u32(0x02014b50),
...u16(20),
...u16(20),
...u16(flags),
...u16(method),
...u16(0),
...u16(0),
...u32(0),
...u32(data.length),
...u32(raw.length),
...u16(name.length),
...u16(0),
...u16(0),
...u16(0),
...u16(0),
...u32(0),
...u32(offset),
...name,
)
}
const end = [
...u32(0x06054b50),
...u16(0),
...u16(0),
...u16(inputs.length),
...u16(inputs.length),
...u32(central.length),
...u32(local.length),
...u16(0),
]
return Uint8Array.from([...local, ...central, ...end])
}
const SPREADSHEET_NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
const RELATIONSHIPS_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
const PACKAGE_RELS_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'
function escapeXml(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function columnName(index: number): string {
let name = ''
let rest = index + 1
while (rest > 0) {
const remainder = (rest - 1) % 26
name = String.fromCharCode(65 + remainder) + name
rest = Math.floor((rest - 1) / 26)
}
return name
}
/**
* 시트 하나짜리 .xlsx — 칸은 칸 안 문자열(inlineStr — 네이버 내보내기) 또는 공유 문자열(sharedStrings — 엑셀이 저장한 파일).
* 빈 칸은 쓰지 않는다(엑셀처럼 — 칸 자리는 r 로 맞춘다). sheetFile 로 시트 파일 이름을 바꿀 수 있다(workbook.xml.rels 로 찾는지)
*/
export async function buildXlsx(
rows: readonly (readonly string[])[],
options: { strings?: 'inline' | 'shared'; sheetFile?: string; sheetName?: string } = {},
): Promise<Uint8Array> {
const strings = options.strings ?? 'inline'
const sheetFile = options.sheetFile ?? 'sheet1.xml'
const shared: string[] = []
const sharedIndex = new Map<string, number>()
const rowXml = rows
.map((cells, rowIndex) => {
const cellXml = cells
.map((value, columnIndex) => {
if (value === '') return ''
const ref = `${columnName(columnIndex)}${rowIndex + 1}`
if (strings === 'inline') return `<c r="${ref}" t="inlineStr"><is><t>${escapeXml(value)}</t></is></c>`
let index = sharedIndex.get(value)
if (index === undefined) {
index = shared.length
shared.push(value)
sharedIndex.set(value, index)
}
return `<c r="${ref}" t="s"><v>${index}</v></c>`
})
.join('')
return `<row r="${rowIndex + 1}">${cellXml}</row>`
})
.join('')
const entries: { name: string; data: string }[] = [
{
name: 'xl/workbook.xml',
data: `<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="${SPREADSHEET_NS}" xmlns:r="${RELATIONSHIPS_NS}"><sheets><sheet name="${options.sheetName ?? 'Sheet0'}" sheetId="1" r:id="rId1"/></sheets></workbook>`,
},
{
name: 'xl/_rels/workbook.xml.rels',
data: `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="${PACKAGE_RELS_NS}"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/${sheetFile}"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>`,
},
{
name: `xl/worksheets/${sheetFile}`,
data: `<?xml version="1.0" encoding="UTF-8"?><worksheet xmlns="${SPREADSHEET_NS}"><sheetData>${rowXml}</sheetData></worksheet>`,
},
]
if (strings === 'shared') {
entries.push({
name: 'xl/sharedStrings.xml',
data: `<?xml version="1.0" encoding="UTF-8"?><sst xmlns="${SPREADSHEET_NS}" count="${shared.length}" uniqueCount="${shared.length}">${shared.map((value) => `<si><t>${escapeXml(value)}</t></si>`).join('')}</sst>`,
})
}
return buildZip([{ name: '[Content_Types].xml', data: '<Types/>' }, ...entries])
}