/* ── Notion integration ── */
// Task-to-Notion uses Notion REST API directly via /api/notion-add-task (no AI needed).
// callClaudeWithNotion is kept for the URL-bookmarking form via /api/notion-ai proxy.
// Notion token lives in config.local.ps1 (server-side, never exposed to the browser).
// Anthropic key lives in config.local.ps1 (server-side); the /api/ai and /api/notion-ai
// proxies inject it — the browser never holds or reads the key.
// One-time migration: clear any key previously stored in localStorage.
localStorage.removeItem('wl_anthropic_key');
/**
* Calls the Claude API with a Notion MCP server attached, via the local proxy
* at `/api/notion-ai` (API keys never exposed to the browser).
* Used for the URL-bookmarking form — task imports use {@link addTaskToNotion} instead.
* @param {string} prompt - User prompt text.
* @param {Object} [opts] - Optional overrides (`model` string, `maxTokens` number).
* @returns {Promise<string>} The concatenated text content of the response.
* @throws {Error} If the API returns a non-OK status.
*/
async function callClaudeWithNotion(prompt, opts = {}) {
const res = await fetch('/api/notion-ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: opts.model || 'claude-sonnet-4-6',
max_tokens: opts.maxTokens || 1000,
mcp_servers: [{ type: 'url', url: 'https://mcp.notion.com/mcp', name: 'notion' }],
messages: [{ role: 'user', content: prompt }],
}),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`API ${res.status}${body ? ': ' + body.slice(0, 200) : ''}`);
}
const data = await res.json();
return (data.content || [])
.filter((b) => b.type === 'text')
.map((b) => b.text)
.join('')
.trim();
}
/**
* Adds a work-log task to Notion as a child page under the matching project.
* The project is looked up server-side by matching the task's category label to a
* Notion project's Epic field. Uses `/api/notion-add-task` (Notion REST API, no AI).
* @param {Object} task - Plan task object with at least `text` and `tag`.
* @returns {Promise<string>} The URL of the newly created Notion page.
* @throws {Error} If the API call fails or no URL is returned.
*/
async function addTaskToNotion(task) {
const cat = getCat(task.tag || 'other');
const epic = (cat.label || 'other').toLowerCase();
const res = await fetch('/api/notion-add-task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: task.text, epic }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = (data.detail || data.error || `API ${res.status}`).slice(0, 300);
throw new Error(msg);
}
if (!data.url) throw new Error('No URL returned from Notion');
return data.url;
}
/**
* Persists the Notion page URL on a plan task so the per-task button changes
* to an "open in Notion" link on next render.
* @param {string} taskId - Plan task ID.
* @param {string} url - Notion page URL returned by the API.
*/
function saveTaskNotionUrl(taskId, url) {
const t = planTasks.find((t) => t.id === taskId);
if (!t) return;
t.notionUrl = url;
savePlan();
renderPlan();
}
// Delegated click handler for the per-task Notion button
document.addEventListener(
'click',
(e) => {
const btn = e.target.closest('.notion-task-btn');
if (!btn || !btn.dataset.pid) return;
e.stopPropagation();
const t = planTasks.find((x) => x.id === btn.dataset.pid);
if (!t) return;
// If already sent, open the Notion page
if (t.notionUrl) {
window.open(t.notionUrl, '_blank', 'noopener');
return;
}
btn.disabled = true;
btn.textContent = '…';
addTaskToNotion(t)
.then((url) => {
if (url && url.startsWith('http')) {
saveTaskNotionUrl(t.id, url);
} else {
btn.textContent = '📋';
btn.disabled = false;
alert('Notion responded but no URL: ' + url);
}
})
.catch((err) => {
btn.textContent = '📋';
btn.disabled = false;
alert('Failed to add to Notion: ' + err.message);
});
},
true
);
// Expose for the URL-bookmarking form so it shares the same auth path
window._wlNotion = { callClaudeWithNotion };