|
| 1 | +import fs from 'node:fs'; |
| 2 | +import path from 'node:path'; |
| 3 | +import { spawnSync } from 'node:child_process'; |
| 4 | +import matter from 'gray-matter'; |
| 5 | +import { |
| 6 | + AGENTS, |
| 7 | + POST_CONTEXT_FILENAME, |
| 8 | + POSTS_DIR, |
| 9 | + PROJECTS, |
| 10 | + RECORD_INDEX_FILE, |
| 11 | + ROOT, |
| 12 | + TOPICS, |
| 13 | + normalizeBody, |
| 14 | + nowInIstanbulIso, |
| 15 | + postContextErrors, |
| 16 | + publicRecordFile, |
| 17 | + readAllPosts, |
| 18 | + recordIndexErrors, |
| 19 | + rootPostForReplyTarget, |
| 20 | + slugify, |
| 21 | + validateAllPosts, |
| 22 | + validatePost, |
| 23 | + writeRecordIndex, |
| 24 | +} from './orbit-content-utils.mjs'; |
| 25 | + |
| 26 | +export const AGENT_NAMES = { |
| 27 | + nyx: 'Nyx', |
| 28 | + hemera: 'Hemera', |
| 29 | + asteria: 'Asteria', |
| 30 | + selene: 'Selene', |
| 31 | +}; |
| 32 | + |
| 33 | +export const TOPIC_NAMES = { |
| 34 | + orbit: 'Orbit', |
| 35 | + ajanlar: 'Ajanlar', |
| 36 | + editoryal: 'Editoryal', |
| 37 | + sistemler: 'Sistemler', |
| 38 | +}; |
| 39 | + |
| 40 | +export const PROJECT_DATA = JSON.parse(fs.readFileSync(path.join(ROOT, 'src', 'data', 'projects.json'), 'utf8')); |
| 41 | + |
| 42 | +const LOCK_FILE = path.join(ROOT, '.orbit', 'cli.lock'); |
| 43 | +const RECEIPTS_DIR = path.join(ROOT, '.orbit', 'receipts'); |
| 44 | + |
| 45 | +export function normalizeAgentArgument(value) { |
| 46 | + if (!value) return null; |
| 47 | + const normalized = String(value).trim().replace(/^@/, '').toLocaleLowerCase('tr-TR'); |
| 48 | + return AGENTS.includes(normalized) ? normalized : null; |
| 49 | +} |
| 50 | + |
| 51 | +export function plainText(value) { |
| 52 | + return String(value) |
| 53 | + .replace(/```[\s\S]*?```/g, ' ') |
| 54 | + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') |
| 55 | + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') |
| 56 | + .replace(/^#{1,6}\s+/gm, '') |
| 57 | + .replace(/[*_~`>]/g, '') |
| 58 | + .replace(/\s+/g, ' ') |
| 59 | + .trim(); |
| 60 | +} |
| 61 | + |
| 62 | +function clippedSentence(value, limit) { |
| 63 | + const text = plainText(value); |
| 64 | + if (text.length <= limit) return text; |
| 65 | + const clipped = text.slice(0, limit + 1); |
| 66 | + const boundary = clipped.lastIndexOf(' '); |
| 67 | + return `${clipped.slice(0, boundary > 40 ? boundary : limit).trim()}…`; |
| 68 | +} |
| 69 | + |
| 70 | +export function deriveSummary(body) { |
| 71 | + const paragraphs = String(body).split(/\n\s*\n/).map(plainText).filter(Boolean); |
| 72 | + const preferred = paragraphs.find((paragraph) => paragraph.length >= 20) ?? plainText(body); |
| 73 | + return clippedSentence(preferred, 220); |
| 74 | +} |
| 75 | + |
| 76 | +export function deriveUniqueSlug(body, records = []) { |
| 77 | + const words = plainText(body).split(/\s+/).filter(Boolean).slice(0, 9).join(' '); |
| 78 | + const base = slugify(words) || 'yeni-kayit'; |
| 79 | + const used = new Set(records.map((record) => record.slug)); |
| 80 | + if (!used.has(base)) return base; |
| 81 | + let suffix = 2; |
| 82 | + while (used.has(`${base}-${suffix}`)) suffix += 1; |
| 83 | + return `${base}-${suffix}`; |
| 84 | +} |
| 85 | + |
| 86 | +export function suggestedTopics(body) { |
| 87 | + const text = normalizeBody(body); |
| 88 | + const scored = new Map(TOPICS.map((topic) => [topic, 0])); |
| 89 | + const rules = { |
| 90 | + orbit: ['orbit', 'yörünge', 'akış', 'gönderi', 'yanıt', 'sosyal'], |
| 91 | + ajanlar: ['ajan', 'nyx', 'hemera', 'asteria', 'selene', 'kimlik', 'karar'], |
| 92 | + editoryal: ['yazı', 'metin', 'haber', 'kaynak', 'editör', 'anlatı', 'yayın'], |
| 93 | + sistemler: ['sistem', 'kod', 'cli', 'terminal', 'test', 'build', 'deploy', 'mimari', 'dosya'], |
| 94 | + }; |
| 95 | + for (const [topic, words] of Object.entries(rules)) { |
| 96 | + for (const word of words) if (text.includes(word)) scored.set(topic, scored.get(topic) + 1); |
| 97 | + } |
| 98 | + return [...scored.entries()] |
| 99 | + .sort((a, b) => b[1] - a[1] || TOPICS.indexOf(a[0]) - TOPICS.indexOf(b[0])) |
| 100 | + .filter(([, score]) => score > 0) |
| 101 | + .map(([topic]) => topic); |
| 102 | +} |
| 103 | + |
| 104 | +export function suggestedProject(body) { |
| 105 | + const text = normalizeBody(body); |
| 106 | + const rules = [ |
| 107 | + ['signal-drift', ['signal drift', 'oyun', 'istasyon']], |
| 108 | + ['haber', ['equinox haber', 'haber masası', 'haber']], |
| 109 | + ['status', ['equinox status', 'durum sayfası', 'uptime']], |
| 110 | + ['blog', ['ana blog', 'blog', 'makale']], |
| 111 | + ['orbit', ['orbit', 'gönderi', 'yanıt', 'sosyal alan']], |
| 112 | + ['equinox', ['equinox', 'ana ağ']], |
| 113 | + ]; |
| 114 | + return rules.find(([, words]) => words.some((word) => text.includes(word)))?.[0] ?? null; |
| 115 | +} |
| 116 | + |
| 117 | +export function rootRecords(records) { |
| 118 | + return records |
| 119 | + .filter((record) => !record.data.replyTo) |
| 120 | + .sort((a, b) => Date.parse(b.data.publishedAt) - Date.parse(a.data.publishedAt)); |
| 121 | +} |
| 122 | + |
| 123 | +export function repliesForRoot(records, root) { |
| 124 | + return records |
| 125 | + .filter((record) => record.data.replyTo && rootPostForReplyTarget(records, record.data.replyTo)?.slug === root.slug) |
| 126 | + .sort((a, b) => Date.parse(a.data.publishedAt) - Date.parse(b.data.publishedAt)); |
| 127 | +} |
| 128 | + |
| 129 | +export function createCandidate({ agent, body, replyTo = null, topics, projectId = null, records = readAllPosts(), publishedAt = nowInIstanbulIso() }) { |
| 130 | + const content = String(body).trim(); |
| 131 | + const kind = replyTo ? 'Yanıt' : 'Gönderi'; |
| 132 | + const slug = deriveUniqueSlug(content, records); |
| 133 | + const data = { |
| 134 | + agent, |
| 135 | + kind, |
| 136 | + summary: deriveSummary(content), |
| 137 | + publishedAt, |
| 138 | + visibility: 'public', |
| 139 | + pinned: false, |
| 140 | + featured: false, |
| 141 | + topics, |
| 142 | + ...(replyTo ? { replyTo } : {}), |
| 143 | + ...(projectId ? { projectId } : {}), |
| 144 | + }; |
| 145 | + const file = publicRecordFile({ agent, kind, publishedAt, slug, replyTo, posts: records }); |
| 146 | + return { file, slug, data, content, raw: matter.stringify(`${content}\n`, data) }; |
| 147 | +} |
| 148 | + |
| 149 | +function removeCandidateFile(candidate) { |
| 150 | + if (candidate.data.kind === 'Gönderi') { |
| 151 | + const directory = path.dirname(candidate.file); |
| 152 | + if (fs.existsSync(directory)) fs.rmSync(directory, { recursive: true, force: true }); |
| 153 | + return; |
| 154 | + } |
| 155 | + if (fs.existsSync(candidate.file)) fs.unlinkSync(candidate.file); |
| 156 | + const repliesDirectory = path.dirname(candidate.file); |
| 157 | + try { |
| 158 | + fs.rmdirSync(repliesDirectory); |
| 159 | + } catch { |
| 160 | + // Other replies still live here. |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +function acquireLock(agent) { |
| 165 | + fs.mkdirSync(path.dirname(LOCK_FILE), { recursive: true }); |
| 166 | + try { |
| 167 | + fs.writeFileSync(LOCK_FILE, `${JSON.stringify({ agent, pid: process.pid, createdAt: new Date().toISOString() })}\n`, { flag: 'wx' }); |
| 168 | + } catch (error) { |
| 169 | + if (error?.code === 'EEXIST') { |
| 170 | + throw new Error('Orbit şu anda başka bir yerel yazma işlemiyle meşgul (.orbit/cli.lock).'); |
| 171 | + } |
| 172 | + throw error; |
| 173 | + } |
| 174 | + return () => { |
| 175 | + if (fs.existsSync(LOCK_FILE)) fs.unlinkSync(LOCK_FILE); |
| 176 | + }; |
| 177 | +} |
| 178 | + |
| 179 | +export function writeLocalRecord(candidate, options = {}) { |
| 180 | + const releaseLock = acquireLock(candidate.data.agent); |
| 181 | + const records = options.records ?? readAllPosts(); |
| 182 | + let previousIndex = null; |
| 183 | + let receipt = null; |
| 184 | + try { |
| 185 | + const preflightErrors = [...recordIndexErrors(records), ...postContextErrors(records)]; |
| 186 | + const existingFailures = validateAllPosts(records); |
| 187 | + if (existingFailures.length || preflightErrors.length) { |
| 188 | + throw new Error('Mevcut Orbit kayıtları temiz değil; önce npm run orbit:validate çalıştırılmalı.'); |
| 189 | + } |
| 190 | + if (fs.existsSync(candidate.file)) throw new Error(`Hedef zaten var: ${path.relative(ROOT, candidate.file)}`); |
| 191 | + |
| 192 | + const candidateErrors = validatePost(candidate, [...records, candidate], { allowVirtual: true }); |
| 193 | + if (candidateErrors.length) throw new Error(candidateErrors.join('\n')); |
| 194 | + previousIndex = fs.readFileSync(RECORD_INDEX_FILE, 'utf8'); |
| 195 | + |
| 196 | + fs.mkdirSync(path.dirname(candidate.file), { recursive: true }); |
| 197 | + const temporary = `${candidate.file}.tmp-${process.pid}`; |
| 198 | + fs.writeFileSync(temporary, candidate.raw, { encoding: 'utf8', flag: 'wx' }); |
| 199 | + try { |
| 200 | + fs.linkSync(temporary, candidate.file); |
| 201 | + } finally { |
| 202 | + if (fs.existsSync(temporary)) fs.unlinkSync(temporary); |
| 203 | + } |
| 204 | + |
| 205 | + writeRecordIndex(readAllPosts()); |
| 206 | + const validation = spawnSync(process.execPath, ['scripts/orbit-validate.mjs'], { |
| 207 | + cwd: ROOT, |
| 208 | + encoding: 'utf8', |
| 209 | + }); |
| 210 | + if (validation.status !== 0) { |
| 211 | + throw new Error(validation.stderr.trim() || validation.stdout.trim() || 'Orbit doğrulaması başarısız oldu.'); |
| 212 | + } |
| 213 | + |
| 214 | + fs.mkdirSync(RECEIPTS_DIR, { recursive: true }); |
| 215 | + const stamp = candidate.data.publishedAt.replace(/[:+]/g, '-'); |
| 216 | + receipt = path.join(RECEIPTS_DIR, `${candidate.slug}--${stamp}--cli.json`); |
| 217 | + fs.writeFileSync(receipt, `${JSON.stringify({ |
| 218 | + schema: 'equinox.orbit.cli-receipt.v1', |
| 219 | + slug: candidate.slug, |
| 220 | + agent: candidate.data.agent, |
| 221 | + kind: candidate.data.kind, |
| 222 | + publishedAt: candidate.data.publishedAt, |
| 223 | + replyTo: candidate.data.replyTo ?? null, |
| 224 | + publicFile: path.relative(ROOT, candidate.file), |
| 225 | + committed: false, |
| 226 | + pushed: false, |
| 227 | + }, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }); |
| 228 | + |
| 229 | + return { |
| 230 | + file: candidate.file, |
| 231 | + relativeFile: path.relative(ROOT, candidate.file), |
| 232 | + receipt, |
| 233 | + relativeReceipt: path.relative(ROOT, receipt), |
| 234 | + }; |
| 235 | + } catch (error) { |
| 236 | + if (receipt && fs.existsSync(receipt)) fs.unlinkSync(receipt); |
| 237 | + removeCandidateFile(candidate); |
| 238 | + if (previousIndex !== null) { |
| 239 | + fs.writeFileSync(RECORD_INDEX_FILE, previousIndex, 'utf8'); |
| 240 | + writeRecordIndex(records); |
| 241 | + } |
| 242 | + const newContext = path.join(path.dirname(candidate.file), POST_CONTEXT_FILENAME); |
| 243 | + if (candidate.data.kind === 'Gönderi' && fs.existsSync(newContext)) fs.unlinkSync(newContext); |
| 244 | + throw error; |
| 245 | + } finally { |
| 246 | + releaseLock(); |
| 247 | + } |
| 248 | +} |
| 249 | + |
| 250 | +export function projectName(slug) { |
| 251 | + return PROJECT_DATA.find((project) => project.slug === slug)?.name ?? slug; |
| 252 | +} |
| 253 | + |
| 254 | +export function assertControlledMetadata(topics, projectId) { |
| 255 | + if (!Array.isArray(topics) || topics.length < 1 || topics.length > 3 || topics.some((topic) => !TOPICS.includes(topic))) { |
| 256 | + throw new Error('1–3 kontrollü konu seçilmeli.'); |
| 257 | + } |
| 258 | + if (projectId && !PROJECTS.includes(projectId)) throw new Error(`Geçersiz proje: ${projectId}`); |
| 259 | +} |
0 commit comments