628 lines
17 KiB
JavaScript
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);
|
|
}
|