generated from ttipo/project-base
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:
@@ -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)
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
Reference in New Issue
Block a user