vcf.mjs
150 lines 4.5 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * vCard (.vcf) — one Markdown note per BEGIN:VCARD block under contacts/…/vcf/
3 */
4
5 import fs from 'fs';
6 import path from 'path';
7 import crypto from 'crypto';
8 import { writeNote } from '../write.mjs';
9 import { normalizeSlug } from '../vault.mjs';
10
11 const MAX_VCF_BYTES = 20 * 1024 * 1024;
12 const MAX_CARDS = 20_000;
13
14 /**
15 * @param {string} raw
16 * @returns {string[]}
17 */
18 function unfoldVcfLines(raw) {
19 const lines = raw.split(/\r?\n/);
20 const out = [];
21 for (const line of lines) {
22 if (line.length > 0 && (line[0] === ' ' || line[0] === '\t')) {
23 if (out.length) out[out.length - 1] += line.slice(1);
24 } else {
25 out.push(line);
26 }
27 }
28 return out;
29 }
30
31 /**
32 * @param {string} block
33 * @returns {Record<string, string>}
34 */
35 function vcardKeyValues(block) {
36 /** @type {Record<string, string>} */
37 const m = {};
38 for (const line of block.split(/\r?\n/)) {
39 const t = line.trim();
40 if (!t) continue;
41 const idx = t.indexOf(':');
42 if (idx < 0) continue;
43 const keyPart = t.slice(0, idx);
44 const val = t.slice(idx + 1);
45 const key = keyPart.split(/[;:]/)[0].toUpperCase();
46 if (!key) continue;
47 m[key] = m[key] ? m[key] + '\n' + val : val;
48 }
49 return m;
50 }
51
52 /**
53 * @param {string} input
54 * @param {{ vaultPath: string, outputBase: string, project?: string, tags: string[], dryRun: boolean }} ctx
55 */
56 export async function importVcf(input, ctx) {
57 const { vaultPath, outputBase, project, tags, dryRun } = ctx;
58 const absInput = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input);
59 if (!fs.existsSync(absInput) || !fs.statSync(absInput).isFile()) {
60 throw new Error('vcf import expects a path to a .vcf file.');
61 }
62 const low = absInput.toLowerCase();
63 if (!low.endsWith('.vcf') && !low.endsWith('.vcard')) {
64 throw new Error('vcf import requires a .vcf or .vcard file.');
65 }
66 if (fs.statSync(absInput).size > MAX_VCF_BYTES) {
67 throw new Error(`VCF file too large (max ${MAX_VCF_BYTES} bytes).`);
68 }
69
70 let raw = fs.readFileSync(absInput, 'utf8');
71 if (raw.charCodeAt(0) === 0xfeff) {
72 raw = raw.slice(1);
73 }
74 const text = unfoldVcfLines(raw).join('\n');
75 const re = /BEGIN:VCARD\b([\s\S]*?)END:VCARD/gi;
76 const blocks = [];
77 let mm;
78 while ((mm = re.exec(text)) !== null) {
79 blocks.push(mm[1].trim());
80 }
81 if (blocks.length === 0) {
82 throw new Error('No vCard blocks (BEGIN:VCARD … END:VCARD) found in this file.');
83 }
84 if (blocks.length > MAX_CARDS) {
85 throw new Error(`Too many vCards in one file (max ${MAX_CARDS}).`);
86 }
87
88 const baseName = path.basename(absInput);
89 const outDir = path.join(outputBase, 'contacts', 'vcf').replace(/\\/g, '/');
90 const now = new Date().toISOString().slice(0, 10);
91 const imported = [];
92
93 for (let i = 0; i < blocks.length; i++) {
94 const f = vcardKeyValues(blocks[i]);
95 const fnRaw = (f.FN || f['X-ABSHOWAS'] || '').split('\n')[0].trim() || 'Contact';
96 const fn = fnRaw.length > 200 ? fnRaw.slice(0, 200) : fnRaw;
97 const uid = (f.UID || '').split('\n')[0].trim();
98 const sourceId = uid
99 ? uid.slice(0, 200)
100 : crypto
101 .createHash('sha256')
102 .update(blocks[i] + baseName + String(i))
103 .digest('hex')
104 .slice(0, 32);
105
106 const fileSlug = crypto
107 .createHash('sha256')
108 .update(blocks[i] + String(i))
109 .digest('hex')
110 .slice(0, 8);
111 const safe = normalizeSlug(fn.replace(/[<>:"/\\|?*]+/g, ' ')) || 'contact';
112 const nameFile = `${safe}`.slice(0, 60) + `-${fileSlug}.md`;
113 const outputRel = path.join(outDir, nameFile).replace(/\\/g, '/');
114
115 const lines = ['# ' + fn, ''];
116 const add = (label, key) => {
117 const v = f[key];
118 if (v && String(v).trim()) lines.push(`- **${label}:** ${String(v).split('\n').join(' · ')}`);
119 };
120 add('Name', 'N');
121 add('Full name', 'FN');
122 add('Organization', 'ORG');
123 add('Title', 'TITLE');
124 add('Phone', 'TEL');
125 add('Email', 'EMAIL');
126 add('URL', 'URL');
127 add('Address', 'ADR');
128 add('Note', 'NOTE');
129 lines.push('', '## Raw vCard', '', '```', blocks[i], '```');
130
131 const body = lines.join('\n');
132 const frontmatter = {
133 source: 'vcf-import',
134 source_id: sourceId,
135 date: now,
136 vcf_file: baseName,
137 vcf_index: i,
138 title: fn,
139 ...(project && { project: normalizeSlug(project) }),
140 ...(tags.length && { tags }),
141 };
142
143 if (!dryRun) {
144 writeNote(vaultPath, outputRel, { body, frontmatter });
145 }
146 imported.push({ path: outputRel, source_id: sourceId });
147 }
148
149 return { imported, count: imported.length };
150 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago