2025-11-09 18:02:15 +00:00

628 lines
17 KiB
JavaScript

import { processNotionIcon } from "./icon";
import { env } from "cloudflare:workers";
import { getNavigation } from "./navigation";
const NOTION_API_BASE = "https://api.notion.com/v1";
const NOTION_VERSION = "2025-09-03"; // or your preferred version
const iconColorSubstitutions = [
["#D44C47", "#FF453A"],
["#55534E", "#FFFFFF"],
["#448361", "#32D74B"],
["#337ea9", "#0A84FF"],
["#9065B0", "#BF5AF2"],
["#CB912F", "#FFD60A"],
["#C14C8A", "#FF375F"],
["#d9730d", "#FF9F0A"],
];
async function notionFetch(endpoint, options = {}) {
const res = await fetch(`${NOTION_API_BASE}${endpoint}`, {
headers: {
Authorization: `Bearer ${env.NOTION_AUTH}`,
"Notion-Version": NOTION_VERSION,
"Content-Type": "application/json",
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(
`Notion API error: ${res.status} ${res.statusText} - ${text}`
);
}
return res.json();
}
export async function addToNotionDataSource(
properties,
dataSourceId,
icon = null
) {
console.log("Adding to Notion data source...");
const pageParams = {};
const notionProperties = {};
if (icon != null) {
pageParams["icon"] = {
type: "external",
external: {
url: icon,
},
};
}
for (const [key, value] of Object.entries(properties)) {
if (key === "Email" && value != "") {
notionProperties[key] = { email: value };
} else if (key === "Name" && value != "") {
notionProperties[key] = { title: [{ text: { content: value } }] };
} else if (key === "Features") {
notionProperties[key] = {
multi_select: value.map((feature) => ({
name: feature,
})),
};
} else if (key === "Message" && value != "") {
pageParams["children"] = [
{
object: "block",
type: "paragraph",
paragraph: {
rich_text: [
{
type: "text",
text: {
content: value,
},
},
],
},
},
];
} else if (typeof value === "string" && value != "") {
notionProperties[key] = {
rich_text: [{ text: { content: value } }],
};
} else if (typeof value === "number" && value != undefined) {
notionProperties[key] = { number: value };
} else if (value && typeof value === "object" && !Array.isArray(value)) {
notionProperties[key] = value;
}
}
try {
const response = await notionFetch("/pages", {
method: "POST",
body: JSON.stringify({
parent: { data_source_id: dataSourceId },
...pageParams,
properties: notionProperties,
}),
});
console.log("Added to Notion DB!");
return response;
} catch (error) {
console.error("Failed to add to Notion:", error);
throw new Error("Failed to add to Notion");
}
}
export async function queryNotionDataSource(dataSourceId, queryParams = {}) {
console.log(`Fetching data source: ${dataSourceId} from Notion API...`);
try {
const body = JSON.stringify(queryParams);
const result = await notionFetch(`/data_sources/${dataSourceId}/query`, {
method: "POST",
body,
});
console.log("Fetched data source:", dataSourceId);
return result.results;
} catch (error) {
console.error(`Error fetching Notion data source ${dataSourceId}:`, error);
throw error;
}
}
export async function getNotionDatabase(databaseId) {
console.log(`Fetching database: ${databaseId} from Notion API...`);
try {
const result = await notionFetch(`/databases/${databaseId}`, {
method: "GET",
});
return result;
} catch (error) {
console.error(`Error fetching Notion database ${databaseId}:`, error);
throw error;
}
}
export async function getNotionDataSource(dataSourceId) {
console.log(`Fetching data source: ${dataSourceId} from Notion API...`);
try {
const result = await notionFetch(`/data_sources/${dataSourceId}`, {
method: "GET",
});
return result;
} catch (error) {
console.error(`Error fetching Notion data source ${dataSourceId}:`, error);
throw error;
}
}
export async function getNotionPage(pageId) {
console.log(`Fetching page: ${pageId} from Notion API...`);
try {
const result = await notionFetch(`/pages/${pageId}`, {
method: "GET",
});
return result;
} catch (error) {
console.error(`Error fetching Notion page ${pageId}:`, error);
throw error;
}
}
export async function updateNotionPage(
pageId,
properties,
archive = false,
trash = false
) {
console.log(`Updating page: ${pageId} in Notion API...`);
const notionProperties = {};
for (const [key, value] of Object.entries(properties)) {
if (key === "Email" && value != "") {
notionProperties[key] = { email: value };
} else if (key === "Name") {
notionProperties[key] = { title: [{ text: { content: value } }] };
} else if (key === "Features") {
notionProperties[key] = {
multi_select: value.map((feature) => ({
name: feature,
})),
};
} else if (typeof value === "string" && value != "") {
notionProperties[key] = {
rich_text: [{ text: { content: value } }],
};
} else if (typeof value === "number" && value != undefined) {
notionProperties[key] = { number: value };
} else if (value && typeof value === "object" && !Array.isArray(value)) {
notionProperties[key] = value;
}
}
try {
const result = await notionFetch(`/pages/${pageId}`, {
method: "PATCH",
body: JSON.stringify({
properties: notionProperties,
archived: archive,
in_trash: trash,
}),
});
return result;
} catch (error) {
console.error(`Error updating Notion page ${pageId}:`, error);
throw error;
}
}
export async function getNotionBlocks(pageId, queryParams = {}) {
console.log(`Fetching blocks for page: ${pageId} from Notion API...`);
try {
const result = await notionFetch(
`/blocks/${pageId}/children?${new URLSearchParams(queryParams)}`
);
return result.results;
} catch (error) {
console.error(`Error fetching Notion page ${pageId}:`, error);
throw error;
}
}
export async function getNotionSVGIcon(url) {
try {
console.log(`Fetching SVG icon from: ${url}`);
const response = await fetch(url);
if (!response.ok)
throw new Error(`Failed to fetch SVG: ${response.statusText}`);
let svgText = await response.text();
iconColorSubstitutions.forEach(([searchColor, replaceColor]) => {
svgText = svgText.replaceAll(searchColor, replaceColor);
});
return svgText;
} catch (error) {
console.error(error);
return null;
}
}
// Transform Notion blocks to content format
export async function transformContent(env, pageId, navigationItems) {
try {
console.log(`Transforming content for page: ${pageId}`);
// Fetch blocks from Notion
const blocks = await getNotionBlocks(pageId);
const content = [];
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
const blockType = block.type;
// Group consecutive list items
if (
blockType === "bulleted_list_item" ||
blockType === "numbered_list_item"
) {
const isNumbered = blockType === "numbered_list_item";
const children = [];
// Collect consecutive list items of the same type
while (
i < blocks.length &&
blocks[i].type ===
(isNumbered ? "numbered_list_item" : "bulleted_list_item")
) {
children.push({
type: "listItem",
text: await extractRichText(
isNumbered
? blocks[i].numbered_list_item.rich_text
: blocks[i].bulleted_list_item.rich_text,
navigationItems
),
});
i++;
}
// Step back one index because the for loop will increment it
i--;
content.push({
type: "list",
ordered: isNumbered,
children,
});
continue;
}
switch (blockType) {
case "heading_1":
content.push({
type: "title1",
text: await extractRichText(
block.heading_1.rich_text,
navigationItems
),
});
break;
case "heading_2":
content.push({
type: "title2",
text: await extractRichText(
block.heading_2.rich_text,
navigationItems
),
});
break;
case "heading_3":
content.push({
type: "title3",
text: await extractRichText(
block.heading_3.rich_text,
navigationItems
),
});
break;
case "paragraph":
content.push({
type: "paragraph",
text: await extractRichText(
block.paragraph.rich_text,
navigationItems
),
});
break;
case "divider":
content.push({
type: "divider",
});
break;
case "image":
content.push({
type: "image",
url: block.image.external?.url || block.image.file?.url,
caption: block.image.caption
? await extractRichText(block.image.caption, navigationItems)
: null,
});
break;
case "quote":
content.push({
type: "quote",
text: await extractRichText(block.quote.rich_text, navigationItems),
});
break;
case "code":
content.push({
type: "code",
text: await extractRichText(block.code.rich_text, navigationItems),
language: block.code.language,
});
break;
case "bookmark":
content.push({
type: "button",
text: await extractRichText(
block.bookmark.caption,
navigationItems
),
url: block.bookmark.url,
});
break;
case "callout":
console.log("Callout:", block);
var calloutChildren = [];
if (block.callout?.rich_text && block.callout.rich_text.length > 0) {
const text = await extractRichText(
block.callout.rich_text,
navigationItems
);
calloutChildren.push({
type: "paragraph",
text: text,
});
}
if (block.has_children == true) {
const children = await transformContent(
env,
block.id,
navigationItems
);
calloutChildren.push(...children);
}
content.push({
type: "callout",
children: calloutChildren,
icon: block.callout?.icon
? await processNotionIcon(block.callout.icon, "tb-callout-icon")
: undefined,
});
break;
case "video":
content.push({
type: "video",
url: block.video.file?.url || block.video?.external?.url,
});
break;
case "file":
content.push({
type: "file",
url: block.file.file?.url || block.file?.external?.url,
});
break;
case "link_to_page":
const linkedNavigationItem = getItemByNotionId(
navigationItems,
block.link_to_page.page_id
);
content.push({
type: "button",
url: `/${linkedNavigationItem.slug}`,
text: linkedNavigationItem.name,
icon: linkedNavigationItem.icon,
});
break;
case "column_list":
const childColumns = await getNotionBlocks(block.id);
const children = [];
for (const childColumn of childColumns) {
children.push({
type: "columnFlexItem",
width: childColumn.column.width_ratio * 100 + "%",
children: await transformContent(
env,
childColumn.id,
navigationItems
),
});
}
content.push({ type: "columnFlex", children: children });
break;
default:
// For unsupported block types, try to extract text if available
console.log(`Unsupported block type: ${blockType}`);
console.log(JSON.stringify(block));
break;
}
}
return content;
} catch (error) {
console.error(`Error transforming page content for ${pageId}:`, error);
// Return fallback content if transformation fails
return [
{
type: "paragraph",
text: "Content could not be loaded at this time.",
},
];
}
}
function processLink(href, navigationItems) {
var pageIdNoDashes = null;
if (href.startsWith("/")) {
pageIdNoDashes = href.split("/").pop().replaceAll("#", "");
}
if (href.includes("notion.so/")) {
if (href.includes("-")) {
pageIdNoDashes = href.split("-").pop().replaceAll("#", "");
} else {
pageIdNoDashes = href.split("/").pop().replaceAll("#", "");
}
}
if (pageIdNoDashes != null) {
const linkedNavigationItem = getItemByNotionId(
navigationItems,
pageIdNoDashes
);
if (!linkedNavigationItem.slug) {
return {
type: "externalLink",
url: href,
};
}
return {
type: "internalLink",
url: `/${linkedNavigationItem.slug}`,
};
} else {
return {
type: "externalLink",
url: href,
};
}
}
// Helper function to extract rich text content
export async function extractRichText(richTextArray, navigationItems) {
if (!richTextArray || !Array.isArray(richTextArray)) {
return [];
}
const textPieces = await Promise.all(
richTextArray.map(async (textObj) => {
let text = textObj?.plain_text || "";
let annotations = textObj?.annotations || {};
let link = undefined;
let emoji = undefined;
// Handle custom emoji mentions
if (
textObj.type === "mention" &&
textObj.mention?.type === "custom_emoji" &&
textObj.mention.custom_emoji?.url
) {
emoji = await processNotionIcon(
textObj.mention.custom_emoji,
"tb-inline-emoji"
);
}
// Handle links
if (textObj.href) {
link = processLink(textObj.href, navigationItems);
}
return {
text: text,
bold: annotations?.bold || false,
italic: annotations?.italic || false,
underline: annotations?.underline || false,
strikethrough: annotations?.strikethrough || false,
code: annotations?.code || false,
color: annotations?.color || undefined,
link: link,
};
})
);
return textPieces;
}
// Utility: safely extract plain text from a Notion title or rich_text field
export function getPlainText(field) {
if (!field) return "";
if (field.type === "title" || field.type === "rich_text") {
return field[field.type].map((t) => t.plain_text).join("") || "";
}
return "";
}
// Utility: convert a string to camelCase
export function toCamelCase(str = "") {
return str
.replace(/[^a-zA-Z0-9 ]/g, " ") // remove non-alphanumeric except spaces
.trim()
.split(/\s+/) // split on spaces
.map((word, index) => {
if (index === 0) return word.toLowerCase();
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
})
.join("");
}
// Moved from Blogs.js: buildNewCache, now buildListCache
export function buildListCache(
cachedList,
{ added = [], updated = [], deleted = [] }
) {
// Remove deleted and items that will be updated
const filtered = cachedList.filter(
(p) =>
!deleted.some((d) => d.notionId === p.notionId) &&
!updated.some((u) => u.notionId === p.notionId)
);
return [...filtered, ...updated, ...added];
}
export function getItemByNotionId(items, notionId) {
const itemWithDashes = items.find((item) => item.notionId === notionId);
if (itemWithDashes) {
return itemWithDashes;
}
const itemWithoutDashes = items.find(
(item) => item.notionId.replaceAll("-", "") === notionId.replaceAll("-", "")
);
if (itemWithoutDashes) {
return itemWithoutDashes;
}
return null;
}
// Sort function for objects with duration properties
// Sorts by end date (newest first), with ongoing positions (no end date) first
export function sortDuration(a, b) {
const aEnd = a.duration?.end;
const bEnd = b.duration?.end;
const aStart = a.duration?.start || "";
const bStart = b.duration?.start || "";
// Positions without end date (ongoing) should be first (newest)
if (!aEnd && !bEnd) {
// Both ongoing - sort by start date, newest first
return bStart.localeCompare(aStart);
}
if (!aEnd) return -1; // a is ongoing, should be first
if (!bEnd) return 1; // b is ongoing, should be first
// Both have start dates - sort by start date, newest first
const startComparison = bStart.localeCompare(aStart);
if (startComparison !== 0) return startComparison;
return bEnd.localeCompare(aEnd);
}