bbdb68a6de
Esta commit conteudo a estrutura atomica completa:
- types: Result<T,E>, AsyncState<T>, Paginated<T>, SortConfig<T>
- utils: date, str, num, cn, debounce, throttle, storage, arr, obj
- validators: Zod schemas — email, password, uuid, url, phone, CPF/CNPJ, sanitizedStr, safeParse
- hooks: useToggle, useAsync, useDebounce, useLocalStorage, useMedia, useInterval, useOnClickOutside, useClipboard, useFetch
- components: Button, Input, Alert, Card, Spinner (atomic design pattern)
- build: tsup v8 ESM+CJS + DTS + sourcemaps — 0 erros
- tests: 57 testes 100% usuarios
- docker: multi-stage Dockerfile (node 20-alpine)
- config: vitest, tsup, tsconfig strict, .npmignore
Filosofia atomica:/utils ← /types ← /validators ← /hooks ← /components
Build: npm run build | Test: npm test | Publish: npm publish
🤖 Generated with Pulse (openclaw + nova-self-improver)
324 lines
11 KiB
JavaScript
324 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* apple-notes-export.mjs (taskflow-012)
|
|
*
|
|
* Generates an HTML project-status summary from TaskFlow markdown files and
|
|
* writes it to an Apple Note via osascript.
|
|
*
|
|
* Config: $OPENCLAW_WORKSPACE/taskflow.config.json
|
|
* {
|
|
* "appleNotesId": "x-coredata://...", // persisted after first run
|
|
* "appleNotesFolder": "Notes", // Notes folder name
|
|
* "appleNotesTitle": "TaskFlow - Project Status"
|
|
* }
|
|
*
|
|
* Usage:
|
|
* node scripts/apple-notes-export.mjs
|
|
* taskflow notes
|
|
*
|
|
* macOS only — exits gracefully on other platforms.
|
|
*/
|
|
|
|
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
|
|
import { execSync } from 'node:child_process'
|
|
import path from 'node:path'
|
|
|
|
// ── Platform guard ─────────────────────────────────────────────────────────
|
|
if (process.platform !== 'darwin') {
|
|
console.log('[apple-notes-export] Skipping: Apple Notes sync is macOS only.')
|
|
process.exit(0)
|
|
}
|
|
|
|
// ── Paths ──────────────────────────────────────────────────────────────────
|
|
const workspace = process.env.OPENCLAW_WORKSPACE || process.cwd()
|
|
const configPath = path.join(workspace, 'taskflow.config.json')
|
|
const tasksDir = path.join(workspace, 'tasks')
|
|
const projectsFile = path.join(workspace, 'PROJECTS.md')
|
|
|
|
// ── Config helpers ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Read taskflow.config.json (returns {} if missing or unparseable).
|
|
*/
|
|
function readConfig() {
|
|
if (!existsSync(configPath)) return {}
|
|
try {
|
|
return JSON.parse(readFileSync(configPath, 'utf8'))
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge `updates` into taskflow.config.json (non-destructive patch).
|
|
*/
|
|
function writeConfig(updates) {
|
|
const current = readConfig()
|
|
const merged = { ...current, ...updates }
|
|
writeFileSync(configPath, JSON.stringify(merged, null, 2) + '\n', 'utf8')
|
|
}
|
|
|
|
// ── Parse PROJECTS.md ──────────────────────────────────────────────────────
|
|
|
|
function parseProjects() {
|
|
if (!existsSync(projectsFile)) return {}
|
|
const lines = readFileSync(projectsFile, 'utf8').split('\n')
|
|
const projects = {}
|
|
let currentSlug = null
|
|
|
|
for (const line of lines) {
|
|
const h2 = line.match(/^## (.+)/)
|
|
if (h2) {
|
|
currentSlug = h2[1].trim()
|
|
projects[currentSlug] = { name: currentSlug, desc: '' }
|
|
continue
|
|
}
|
|
if (!currentSlug) continue
|
|
const nameMatch = line.match(/^- Name: (.+)/)
|
|
if (nameMatch) { projects[currentSlug].name = nameMatch[1].trim(); continue }
|
|
const descMatch = line.match(/^- Description: (.+)/)
|
|
if (descMatch) { projects[currentSlug].desc = descMatch[1].trim() }
|
|
}
|
|
return projects
|
|
}
|
|
|
|
// ── Parse task files ───────────────────────────────────────────────────────
|
|
|
|
function parseTasks(projects) {
|
|
const result = { in_progress: [], pending: [], backlog: [], done: [], blocked: [] }
|
|
|
|
if (!existsSync(tasksDir)) return result
|
|
|
|
const files = readdirSync(tasksDir)
|
|
.filter(f => f.endsWith('-tasks.md'))
|
|
.sort()
|
|
|
|
for (const file of files) {
|
|
const slug = file.replace('-tasks.md', '')
|
|
const projName = projects[slug]?.name ?? slug
|
|
const lines = readFileSync(path.join(tasksDir, file), 'utf8').split('\n')
|
|
|
|
let section = null
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (trimmed.startsWith('## ')) {
|
|
const h = trimmed.slice(3).toLowerCase()
|
|
if (h.includes('in progress')) section = 'in_progress'
|
|
else if (h.includes('pending')) section = 'pending'
|
|
else if (h.includes('backlog')) section = 'backlog'
|
|
else if (h.includes('done')) section = 'done'
|
|
else if (h.includes('blocked')) section = 'blocked'
|
|
else section = null
|
|
continue
|
|
}
|
|
if (!trimmed.startsWith('- [') || section === null) continue
|
|
|
|
// Strip checkbox, task ID, priority/owner tags
|
|
let text = trimmed
|
|
.replace(/^- \[.\]\s*/, '')
|
|
.replace(/\(task:\S+\)\s*/g, '')
|
|
.replace(/\[P\d\]\s*/g, '')
|
|
.replace(/\[\S+\]\s*/g, '')
|
|
.trim()
|
|
if (!text) continue
|
|
|
|
result[section].push(`${projName}: ${text}`)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ── HTML generation ────────────────────────────────────────────────────────
|
|
|
|
function escapeHtml(str) {
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
function ul(items) {
|
|
if (!items || items.length === 0) {
|
|
return '<ul><li><span style="color:#666;">None</span></li></ul>'
|
|
}
|
|
return '<ul>' + items.map(i => `<li>${escapeHtml(i)}</li>`).join('') + '</ul>'
|
|
}
|
|
|
|
function generateHtml(tasks, title) {
|
|
const { in_progress, pending, backlog, done, blocked } = tasks
|
|
const top = [...in_progress, ...pending].slice(0, 5)
|
|
const next3 = in_progress.length > 0 ? in_progress.slice(0, 3) : backlog.slice(0, 3)
|
|
|
|
const now = new Date()
|
|
const stamp = now.toLocaleString('en-US', {
|
|
timeZone: 'America/Chicago',
|
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
hour: '2-digit', minute: '2-digit', hour12: true,
|
|
}) + ' CST'
|
|
|
|
return `<div style="font-family:-apple-system,Helvetica,Arial,sans-serif; line-height:1.35;">
|
|
<h1>${escapeHtml(title)}</h1>
|
|
<h2>🎯 Top Priorities</h2>${ul(top)}
|
|
<h2>🚧 In Progress (${in_progress.length})</h2>${ul(in_progress)}
|
|
<h2>⏳ Pending Validation (${pending.length})</h2>${ul(pending)}
|
|
<h2>📥 Backlog (top 12)</h2>${ul(backlog.slice(0, 12))}
|
|
<h2>✅ Recently Done</h2>${ul(done.slice(0, 10))}
|
|
<h2>🧱 Blockers</h2>${ul(blocked)}
|
|
<h2>▶️ Next 3 Actions</h2>${ul(next3)}
|
|
<p style="color:#888; font-size:0.85em;"><b>Updated:</b> ${escapeHtml(stamp)} · Source: tasks/*.md</p>
|
|
</div>`
|
|
}
|
|
|
|
// ── AppleScript helpers ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Escape a string for embedding inside an AppleScript string literal.
|
|
*/
|
|
function asEscape(str) {
|
|
// In AppleScript, backslash and double-quote need to be escaped for shell
|
|
// We'll write HTML to a temp file and read it in AppleScript to avoid quoting issues.
|
|
return str
|
|
}
|
|
|
|
const TMP_HTML = '/tmp/taskflow-apple-notes.html'
|
|
|
|
/**
|
|
* Check whether a note with the given Core Data ID exists.
|
|
* Returns true/false.
|
|
*/
|
|
function noteExists(noteId) {
|
|
const script = `
|
|
tell application "Notes"
|
|
try
|
|
set n to note id "${noteId}"
|
|
return (name of n) as text
|
|
on error
|
|
return "NOT_FOUND"
|
|
end try
|
|
end tell
|
|
`
|
|
try {
|
|
const result = execSync(`/usr/bin/osascript -e '${script.replace(/'/g, "'\\''")}'`, {
|
|
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
|
|
}).trim()
|
|
return result !== 'NOT_FOUND'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update an existing note by Core Data ID.
|
|
*/
|
|
function updateNote(noteId, title, html) {
|
|
writeFileSync(TMP_HTML, html, 'utf8')
|
|
const script = `
|
|
set noteBody to (do shell script "cat /tmp/taskflow-apple-notes.html")
|
|
tell application "Notes"
|
|
try
|
|
set targetNote to note id "${noteId}"
|
|
set name of targetNote to "${title.replace(/"/g, '\\"')}"
|
|
set body of targetNote to noteBody
|
|
return (id of targetNote) as text
|
|
on error errMsg
|
|
error errMsg
|
|
end try
|
|
end tell
|
|
`
|
|
const result = execSync(`/usr/bin/osascript << 'OSASCRIPT'\n${script}\nOSASCRIPT`, {
|
|
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
|
|
}).trim()
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Create a new note in the specified folder.
|
|
* Returns the new note's Core Data ID.
|
|
*/
|
|
function createNote(title, html, folder) {
|
|
writeFileSync(TMP_HTML, html, 'utf8')
|
|
const script = `
|
|
set noteBody to (do shell script "cat /tmp/taskflow-apple-notes.html")
|
|
tell application "Notes"
|
|
launch
|
|
delay 0.5
|
|
set targetFolder to missing value
|
|
try
|
|
set targetFolder to folder "${folder.replace(/"/g, '\\"')}"
|
|
on error
|
|
-- folder not found, use default account
|
|
end try
|
|
if targetFolder is missing value then
|
|
set newNote to make new note with properties {name:"${title.replace(/"/g, '\\"')}", body:noteBody}
|
|
else
|
|
set newNote to make new note at targetFolder with properties {name:"${title.replace(/"/g, '\\"')}", body:noteBody}
|
|
end if
|
|
return (id of newNote) as text
|
|
end tell
|
|
`
|
|
const result = execSync(`/usr/bin/osascript << 'OSASCRIPT'\n${script}\nOSASCRIPT`, {
|
|
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']
|
|
}).trim()
|
|
return result
|
|
}
|
|
|
|
// ── Main ───────────────────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
const config = readConfig()
|
|
const folder = config.appleNotesFolder ?? 'Notes'
|
|
const title = config.appleNotesTitle ?? 'TaskFlow - Project Status'
|
|
let noteId = config.appleNotesId ?? null
|
|
|
|
// Parse content
|
|
const projects = parseProjects()
|
|
const tasks = parseTasks(projects)
|
|
const html = generateHtml(tasks, title)
|
|
|
|
const { in_progress, pending, backlog, done, blocked } = tasks
|
|
console.log(`[apple-notes-export] ${in_progress.length} in-progress, ${backlog.length} backlog, ${done.length} done`)
|
|
|
|
// Retry loop (up to 3 attempts)
|
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
try {
|
|
if (noteId && noteExists(noteId)) {
|
|
// Update existing note
|
|
console.log(`[apple-notes-export] Updating existing note…`)
|
|
updateNote(noteId, title, html)
|
|
console.log(`[apple-notes-export] ✓ Note updated (${noteId})`)
|
|
break
|
|
} else {
|
|
// Create new note (either no ID configured, or note was deleted)
|
|
if (noteId) {
|
|
console.log(`[apple-notes-export] Previous note not found — creating new note…`)
|
|
} else {
|
|
console.log(`[apple-notes-export] No note configured — creating new note in "${folder}"…`)
|
|
}
|
|
const newId = createNote(title, html, folder)
|
|
if (!newId) throw new Error('osascript returned empty note ID')
|
|
|
|
noteId = newId
|
|
writeConfig({
|
|
appleNotesId: noteId,
|
|
appleNotesFolder: folder,
|
|
appleNotesTitle: title,
|
|
})
|
|
console.log(`[apple-notes-export] ✓ Note created and ID saved to taskflow.config.json`)
|
|
console.log(`[apple-notes-export] ID: ${noteId}`)
|
|
break
|
|
}
|
|
} catch (err) {
|
|
console.error(`[apple-notes-export] Attempt ${attempt} failed: ${err.message}`)
|
|
if (attempt < 3) {
|
|
await new Promise(r => setTimeout(r, 2000))
|
|
} else {
|
|
console.error('[apple-notes-export] All retries exhausted. Note not updated.')
|
|
process.exit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await main()
|