2024-04-14
-1
-1
{
"object": "block",
"id": "72aec0d6-59c2-48ca-9a55-f8d000fdc56e",
"parent": { ... },
"created_time": "2024-03-02T04:41:00.000Z",
"last_edited_time": "2024-03-09T09:21:00.000Z",
"created_by": { ... },
"last_edited_by": { ... },
"has_children": false,
"archived": false,
"type": "image",
"image": {
"caption": [ ... ],
"type": "file",
"file": {
"url": "https://prod-files-secure.s3.us-west-2.amazonaws.com/3175668c-d64e-4b9b-87fc-5fdfe186dc33/b7acf74e-4442-47bf-82ec-6890f97e714b/mountains.avif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIAT73L2G45HZZMZUHI%2F20240323%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20240323T013017Z&X-Amz-Expires=3600&X-Amz-Signature=3f0eedc342fc8ad12fc46b1f4eb5829b7f2ba78ba19fc7eb748107af61e1b9ce&X-Amz-SignedHeaders=host&x-id=GetObject",
"expiry_time": "2024-03-23T02:30:17.991Z"
}
}
}"expiry_time": "2024-03-23T02:30:17.991Z"
export default {
async scheduled(event, env, ctx) {
// this is non-blocking
ctx.waitUntil(sync(env));
},
async fetch(request, env, ctx) {
// this is blocking
return await sync(env);
},
};return new Response('Data fetched and stored successfully.', { status: 200 });async function getPageContentFromID(env, id) {
var requestOptions = {
method: "GET",
headers: {
"Notion-Version": "2022-06-28",
// use your secure environment variable
Authorization: "Bearer " + env.NOTION_API_KEY,
},
};
try {
// 100 blocks in a single fetch request is Notion's max. 100 is a lot but if
// you want to grab more, you'll need to implement some sort of pagination
// algorithm. experiment with moving Notion's cursor and their 'has_more'
// field
const numBlocksToGrab = 100;
const fetchResponseBlocks = await fetch(
`https://api.notion.com/v1/blocks/${id}/children?page_size=${numBlocksToGrab}`,
requestOptions
);
if (!fetchResponseBlocks.ok) {
throw new Error(`Couldn't get page blocks: ${fetchResponseBlocks.status}`);
}
return await fetchResponseBlocks.json();
} catch (error) {
console.error('Error fetching page content:', error);
// this function will be called in a try-catch to return a Response object
// that contains this error information
throw error;
}
}async function getDatabase(env) {
var databaseRequestOptions = {
method: "POST",
headers: {
Authorization: "Bearer " + env.NOTION_API_KEY,
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
},
};
try {
const fetchResponse = await fetch(
`https://api.notion.com/v1/databases/${env.NOTION_DATABASE_ID}/query`,
databaseRequestOptions
);
if (!fetchResponse.ok) {
throw new Error(`Couldn't get database: ${fetchResponse.status}`);
}
const jsonResponse = await fetchResponse.json();
return jsonResponse.results;
} catch (error) {
console.error('Error fetching published posts:', error);
throw error;
}
}async function sync(env) {
try {
// get both published and unpublished data to store AND erase objects. This
// is considered 'syncing', and not just 'pushing new data`
const db = await getDatabase(env); // or replace with your CMS get-data
const published = db.filter(result => {
return result.properties.Published.checkbox;
})
const unpublished = db.filter(result => {
return !result.properties.Published.checkbox;
})
const imageIDs = [];
// store promises to execute in parallel. efficient for large operations
const storeImagePromises = [];
const deleteImagePromises = [];
for (let pub of published) {
// you can replace this with your CMS's way to grab images
const pageContent = await getPageContentFromID(env, pub.id);
// store images, and record which ones were stored
for (let block of pageContent.results) {
if (block.type !== "image")
continue;
const imageID = block.id;
imageIDs.push(imageID);
// !!! we use the imageID (block's id) for this image's key. this is
// important when we want to grab it later. notion's block ID's are
// always static, so this is safe
storeImagePromises.push(storeImage(env, imageID, block.image.file.url));
}
}
// remove unused images. 'list' only retrieves the first 1000 items. larger
// buckets will need to implement pagination to handle more
const r2Objects = await env.SNUGL_NOTION_IMAGES.list();
for (const object of r2Objects.objects) {
if (!imageIDs.includes(object.key)) {
deleteImagePromises.push(env.SNUGL_NOTION_IMAGES.delete(object.key));
}
}
await Promise.all(storeImagePromises);
await Promise.all(deleteImagePromises);
return new Response('Data fetched and stored successfully.', {
status: 200,
});
} catch (error) {
// ..
}
}
async function storeImage(env, id, url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image. Status: ${response.status}`);
}
const data = await response.arrayBuffer();
await env.SNUGL_NOTION_IMAGES.put(id, data, {
httpMetadata: {
contentType: response.headers.get("Content-Type") || "application/octet-stream",
},
});
}export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const pathParts = url.pathname.split("/").filter((p) => p);
// expecting "/r2/<key_name>"
if (pathParts.length !== 2) {
return new Response("Invalid URL format", { status: 400 });
}
const [storageType, key] = pathParts;
switch (storageType) {
case "r2":
return handleR2Request(key, env);
default:
return new Response("Invalid storage type.", { status: 400 });
}
},
};
async function handleR2Request(key, env) {
const object = await env.SNUGL_NOTION_IMAGES.get(key);
if (object) {
return new Response(object.body, {
headers: { "Content-Type": object.httpMetadata.contentType },
status: 200,
});
} else {
return new Response("Object not found in R2", { status: 404 });
}
}<Image className="..." src={`https://<your.website.api>/r2/${id}`}/>export default {
async scheduled(event, env, ctx) {
// 2700 calls per 15 mins is 180 calls per minute. For now I'll have it
// sync to notion every 4 minutes. I can edit this in cron jobs. This
// is non-blocking.
ctx.waitUntil(sync(env));
},
async fetch(request, env, ctx) {
// This is blocking
return await sync(env);
},
};
// Note that this code doesn't handle pagination.
async function sync(env) {
try {
// we get both publish and unpublished data to store/erase objects. this
// is considered 'syncing'
const db = await getDatabase(env);
const published = db.filter(result => {
return result.properties.Published.checkbox;
})
const unpublished = db.filter(result => {
return !result.properties.Published.checkbox;
})
const posts = [];
const searchData = [];
const imageIDs = [];
// store promises to execute in parallel. efficient for large operations
const storeImagePromises = [];
const deleteImagePromises = [];
for (let pub of published) {
const props = pub.properties;
const post = {
title: props.Name.title[0].plain_text,
slug: props.slug.rich_text[0].plain_text,
date: props.Date.date.start,
status: props.Status.multi_select[0].name,
summary: props.Summary.rich_text[0].plain_text,
};
posts.push(post);
searchData.push({ slug: post.slug, title: post.title });
const pageContent = await getPageContentFromID(env, pub.id);
await env.SNUGL_NOTION_TEXT.put(post.slug, JSON.stringify({
title: post.title,
date: post.date,
summary: post.summary,
content: pageContent
}));
// store images, and record which ones were stored
for (let block of pageContent.results) {
if (block.type !== "image")
continue;
const imageID = block.id;
imageIDs.push(imageID);
storeImagePromises.push(storeImage(env, imageID, block.image.file.url));
}
}
posts.sort((a, b) => Date.parse(a.date) - Date.parse(b.date));
// store search data and metadata of posts
const search_key = "search_data";
await env.SNUGL_NOTION_TEXT.put(search_key, JSON.stringify(searchData));
const posts_key = "all_posts_details";
await env.SNUGL_NOTION_TEXT.put(posts_key, JSON.stringify(posts));
// remove unpublish articles
for (let unpub of unpublished) {
const unpubSlug = unpub.properties.slug.rich_text[0].plain_text;
await env.SNUGL_NOTION_TEXT.delete(unpubSlug);
}
// remove unused images
const r2Objects = await env.SNUGL_NOTION_IMAGES.list(); // only reteives first 1000
for (const object of r2Objects.objects) {
if (!imageIDs.includes(object.key)) {
deleteImagePromises.push(env.SNUGL_NOTION_IMAGES.delete(object.key));
}
}
await Promise.all(storeImagePromises);
await Promise.all(deleteImagePromises);
return new Response('Data fetched and stored successfully.', {
status: 200,
});
} catch (error) {
try {
await storeErrorInKV(env, error);
return new Response(error.message, {
status: 500,
});
} catch (storeError) {
console.error('Failed to store error in KV:', storeError);
return new Response('Internal Server Error', {
status: 500,
});
}
}
}
async function storeImage(env, id, url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image. Status: ${response.status}`);
}
const data = await response.arrayBuffer();
await env.SNUGL_NOTION_IMAGES.put(id, data, {
httpMetadata: {
contentType: response.headers.get("Content-Type") || "application/octet-stream",
},
});
}
async function getDatabase(env) {
var databaseRequestOptions = {
method: "POST",
headers: {
Authorization: "Bearer " + env.NOTION_API_KEY,
"Notion-Version": "2022-06-28",
"Content-Type": "application/json",
},
};
try {
const fetchResponse = await fetch(
`https://api.notion.com/v1/databases/${env.NOTION_DATABASE_ID}/query`,
databaseRequestOptions
);
if (!fetchResponse.ok) {
throw new Error(`Couldn't get database: ${fetchResponse.status}`);
}
const jsonResponse = await fetchResponse.json();
return jsonResponse.results;
} catch (error) {
console.error('Error fetching published posts:', error);
throw error;
}
}
async function getPageContentFromID(env, id) {
var requestOptions = {
method: "GET",
headers: {
"Notion-Version": "2022-06-28",
Authorization: "Bearer " + env.NOTION_API_KEY,
},
};
try {
// 100 is max. I can get more blocks by adjusting the cursor
// TODO: if the number of blocks in the page is >100, move
// the cursor and grab the next 100 blocks. repeat. I can use
// the has_more boolean in the response to check.
const numBlocksToGrab = 100;
const fetchResponseBlocks = await fetch(
`https://api.notion.com/v1/blocks/${id}/children?page_size=${numBlocksToGrab}`,
requestOptions
);
if (!fetchResponseBlocks.ok) {
throw new Error(`Couldn't get page blocks: ${fetchResponseBlocks.status}`);
}
return await fetchResponseBlocks.json();
} catch (error) {
console.error('Error fetching page content:', error);
throw error;
}
}
async function storeErrorInKV(env, error) {
try {
await env.SNUGL_NOTION_TEXT.put('ERROR', JSON.stringify({
time: new Date().toString(),
message: error.message,
stack: error.stack
}));
console.error('Error stored: ', error);
} catch (storeError) {
console.error('Failed to store error in KV:', storeError);
}
}export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const pathParts = url.pathname.split('/').filter(p => p);
// Expecting "/kv/keyname" or "/r2/keyname".'
if (pathParts.length !== 2) {
return new Response('Invalid URL format', { status: 400 });
}
const [storageType, key] = pathParts;
switch (storageType) {
case 'kv':
return handleKVRequest(key, env);
case 'r2':
return handleR2Request(key, env);
default:
return new Response('Invalid storage type.', { status: 400 });
}
},
};
async function handleKVRequest(key, env) {
const data = await env.SNUGL_NOTION_TEXT.get(key);
if (data) {
return new Response(data, {
headers: { 'Content-Type': 'application/json' },
status: 200
});
} else {
return new Response('Key not found in KV', { status: 404 });
}
}
async function handleR2Request(key, env) {
const object = await env.SNUGL_NOTION_IMAGES.get(key);
if (object) {
return new Response(object.body, {
headers: { 'Content-Type': object.httpMetadata.contentType },
status: 200
});
} else {
return new Response('Object not found in R2', { status: 404 });
}
}