-
-
Save powmod/f248ab6147d0664a96da5e448e0622eb to your computer and use it in GitHub Desktop.
reMarkable to Notion Sync
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const GMAIL_LABEL_NAME = 'NotionToSync'; | |
| const SYNCED_LABEL = 'SyncedToNotion'; | |
| const GDRIVE_FOLDER_ID = ''; // optional; leave empty to upload directly to Notion | |
| const NOTION_DATABASE_ID = ''; | |
| const NOTION_SECRET = ''; | |
| const NOTION_VERSION = '2022-06-28'; | |
| // Direct-upload cap per Notion guide (multi-part required >20MB). :contentReference[oaicite:1]{index=1} | |
| const MAX_DIRECT_UPLOAD_BYTES = 20 * 1024 * 1024; | |
| const gmailToNotion = () => { | |
| const label = GmailApp.getUserLabelByName(GMAIL_LABEL_NAME); | |
| const successLabel = GmailApp.getUserLabelByName(SYNCED_LABEL); | |
| if (!label) return; | |
| label.getThreads(0, 20).forEach(thread => { | |
| const [message] = thread.getMessages().reverse(); | |
| try { | |
| postToNotion(message); | |
| thread.removeLabel(label); | |
| thread.addLabel(successLabel); | |
| } catch (e) { | |
| Logger.log(`Error: ${e}`); | |
| } | |
| }); | |
| }; | |
| function getRichTextChunks(s) { | |
| let t = s || ''; | |
| const out = []; | |
| while (t.length > 0) { | |
| if (t.length <= 2000) { | |
| out.push(richText(t)); | |
| t = ''; | |
| } else { | |
| const cut = t.substring(0, 2000); | |
| const br = cut.lastIndexOf('\n'); | |
| const chunk = t.substring(0, br > 0 ? br : 2000); | |
| out.push(richText(chunk)); | |
| t = t.substring(chunk.length + (br > 0 ? 1 : 0)); | |
| } | |
| } | |
| return out; | |
| } | |
| const richText = content => ({ type: 'text', text: { content } }); | |
| function getFileBlocksForAttachments(message) { | |
| const atts = message.getAttachments(); | |
| const blocks = []; | |
| if (!atts || !atts.length) return blocks; | |
| for (const a of atts) { | |
| const name = a.getName(); | |
| const type = a.getContentType() || ''; | |
| try { | |
| if (isPDF(name, type)) { | |
| blocks.push(...handlePDFAttachment(a)); | |
| } else if (isPNG(name, type) || isSVG(name, type)) { | |
| blocks.push(...handleImageAttachment(a)); // displays PNG or SVG | |
| } else if (isGenericImage(type)) { | |
| blocks.push(...handleImageAttachment(a)); // jpeg, webp, etc. | |
| } else { | |
| blocks.push(...handleGenericAttachment(a)); | |
| } | |
| } catch (e) { | |
| blocks.push(errorBlock(name, e.toString())); | |
| } | |
| } | |
| return blocks; | |
| } | |
| function isPDF(name, ctype) { | |
| return name.toLowerCase().endsWith('.pdf') || ctype === 'application/pdf'; | |
| } | |
| function isPNG(name, ctype) { | |
| const n = name.toLowerCase(); | |
| return n.endsWith('.png') || ctype === 'image/png'; | |
| } | |
| function isSVG(name, ctype) { | |
| const n = name.toLowerCase(); | |
| return n.endsWith('.svg') || ctype === 'image/svg+xml'; | |
| } | |
| function isGenericImage(ctype) { | |
| return typeof ctype === 'string' && ctype.startsWith('image/'); | |
| } | |
| function handlePDFAttachment(att) { | |
| const name = att.getName(); | |
| const size = att.getSize(); | |
| const header = heading(`π ${name}`); | |
| // Attempt Notion native upload first | |
| try { | |
| const block = uploadAsBlock(att, 'pdf', size); | |
| return [header, block, infoBlock(size)]; | |
| } catch (e) { | |
| // Fallback to Drive link if configured | |
| if (GDRIVE_FOLDER_ID) { | |
| const file = uploadToGoogleDrive(att); | |
| return [ | |
| header, | |
| paragraphLink(`View PDF: ${name}`, file.getUrl()), | |
| infoBlock(size), | |
| warnBlock('Uploaded to Drive due to Notion upload failure or size limit.') | |
| ]; | |
| } | |
| return [header, warnBlock(`PDF "${name}" not uploaded: ${e.toString()}`)]; | |
| } | |
| } | |
| function handleImageAttachment(att) { | |
| const name = att.getName(); | |
| const size = att.getSize(); | |
| const header = heading(`πΌοΈ ${name}`); | |
| try { | |
| const block = uploadAsBlock(att, 'image', size); // supports PNG, SVG, others | |
| return [header, block, infoBlock(size)]; | |
| } catch (e) { | |
| if (GDRIVE_FOLDER_ID) { | |
| const file = uploadToGoogleDrive(att); | |
| return [ | |
| header, | |
| paragraphLink(`View image: ${name}`, file.getUrl()), | |
| infoBlock(size), | |
| warnBlock('Uploaded to Drive due to Notion upload failure or size limit.') | |
| ]; | |
| } | |
| return [header, warnBlock(`Image "${name}" not uploaded: ${e.toString()}`)]; | |
| } | |
| } | |
| function handleGenericAttachment(att) { | |
| const name = att.getName(); | |
| const size = att.getSize(); | |
| // Try to attach as a generic file block via Notion upload | |
| try { | |
| const fu = uploadToNotionSmall(att); | |
| return [{ | |
| object: 'block', | |
| type: 'file', | |
| file: { type: 'file_upload', file_upload: { id: fu.id } } | |
| }, infoBlock(size)]; | |
| } catch (e) { | |
| if (GDRIVE_FOLDER_ID) { | |
| const file = uploadToGoogleDrive(att); | |
| return [paragraphLink(`π ${name}`, file.getUrl()), infoBlock(size)]; | |
| } | |
| return [paragraph(`π ${name} (${(size / 1024).toFixed(1)} KB)`), warnBlock(`File not uploaded: ${e.toString()}`)]; | |
| } | |
| } | |
| // Core upload helper -> returns a block with type 'image' or 'pdf' | |
| function uploadAsBlock(attachment, kind /* 'image' | 'pdf' */, size) { | |
| if (size > MAX_DIRECT_UPLOAD_BYTES) throw new Error('exceeds 20MB direct-upload limit'); | |
| const fu = uploadToNotionSmall(attachment); | |
| if (kind === 'image') { | |
| return { | |
| object: 'block', | |
| type: 'image', | |
| image: { type: 'file_upload', file_upload: { id: fu.id } } | |
| }; | |
| } else if (kind === 'pdf') { | |
| return { | |
| object: 'block', | |
| type: 'pdf', | |
| pdf: { type: 'file_upload', file_upload: { id: fu.id } } | |
| }; | |
| } | |
| throw new Error('unsupported kind'); | |
| } | |
| // Notion direct upload (single-part, β€20MB). :contentReference[oaicite:2]{index=2} | |
| function uploadToNotionSmall(attachment) { | |
| const blobSrc = attachment.copyBlob(); | |
| const blob = Utilities.newBlob(blobSrc.getBytes(), blobSrc.getContentType(), attachment.getName()); | |
| const createRes = UrlFetchApp.fetch('https://api.notion.com/v1/file_uploads', { | |
| method: 'post', | |
| contentType: 'application/json', | |
| headers: { Authorization: `Bearer ${NOTION_SECRET}`, 'Notion-Version': NOTION_VERSION }, | |
| payload: JSON.stringify({ filename: attachment.getName(), content_type: blob.getContentType() }) | |
| }); | |
| if (createRes.getResponseCode() !== 200) throw new Error(`create failed: ${createRes.getContentText()}`); | |
| const { id } = JSON.parse(createRes.getContentText()); | |
| // Important: do not set contentType; Apps Script sets multipart/form-data with boundary when payload has a Blob. | |
| const sendRes = UrlFetchApp.fetch(`https://api.notion.com/v1/file_uploads/${id}/send`, { | |
| method: 'post', | |
| headers: { Authorization: `Bearer ${NOTION_SECRET}`, 'Notion-Version': NOTION_VERSION }, | |
| payload: { file: blob }, | |
| muteHttpExceptions: true | |
| }); | |
| if (sendRes.getResponseCode() !== 200) throw new Error(`send failed: ${sendRes.getContentText()}`); | |
| return { id }; | |
| } | |
| function uploadToGoogleDrive(att) { | |
| const folder = DriveApp.getFolderById(GDRIVE_FOLDER_ID); | |
| const ts = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyyMMdd_HHmmss'); | |
| const fname = `${att.getName()}_${ts}`; | |
| const file = folder.createFile(att.copyBlob()).setName(fname); | |
| file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); | |
| return file; | |
| } | |
| // UI helpers | |
| const heading = text => ({ object: 'block', type: 'heading_3', heading_3: { rich_text: [{ type: 'text', text: { content: text } }] } }); | |
| const paragraph = text => ({ object: 'block', type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content: text } }] } }); | |
| const paragraphLink = (text, url) => ({ object: 'block', type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content: text, link: { url } } }] } }); | |
| const infoBlock = size => ({ object: 'block', type: 'callout', callout: { rich_text: [{ type: 'text', text: { content: `File size: ${(size / 1024).toFixed(1)} KB` } }], icon: { emoji: 'βΉοΈ' } } }); | |
| const warnBlock = msg => ({ object: 'block', type: 'callout', callout: { rich_text: [{ type: 'text', text: { content: msg } }], icon: { emoji: 'β οΈ' } } }); | |
| const errorBlock = (name, msg) => ({ object: 'block', type: 'callout', callout: { rich_text: [{ type: 'text', text: { content: `Failed to process attachment: ${name}. Error: ${msg}` } }], icon: { emoji: 'β' } } }); | |
| function postToNotion(message) { | |
| const body = message.getPlainBody() || ''; | |
| const subject = message.getSubject() || 'No Subject'; | |
| const date = Utilities.formatDate(message.getDate(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss'); | |
| const children = []; | |
| children.push({ object: 'block', type: 'callout', callout: { rich_text: [{ type: 'text', text: { content: `π§ Synced from email on ${date}` } }], icon: { emoji: 'π ' } } }); | |
| children.push(...getFileBlocksForAttachments(message)); | |
| if (body.trim()) { | |
| children.push(heading('π Notes')); | |
| children.push({ object: 'block', type: 'paragraph', paragraph: { rich_text: getRichTextChunks(body) } }); | |
| } | |
| const url = 'https://api.notion.com/v1/pages'; | |
| const payload = { | |
| parent: { type: 'database_id', database_id: NOTION_DATABASE_ID }, | |
| icon: { type: 'emoji', emoji: 'π' }, | |
| children: children, | |
| properties: { Name: { title: [{ text: { content: subject } }] } } | |
| }; | |
| const res = UrlFetchApp.fetch(url, { | |
| method: 'post', | |
| contentType: 'application/json', | |
| headers: { Authorization: `Bearer ${NOTION_SECRET}`, 'Notion-Version': NOTION_VERSION }, | |
| payload: JSON.stringify(payload), | |
| muteHttpExceptions: true | |
| }); | |
| if (res.getResponseCode() !== 200) throw new Error(`Notion API error: ${res.getResponseCode()} ${res.getContentText()}`); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment