master
chwan1 4 years ago
commit 526254885c
  1. 9
      .env_example
  2. 119
      .gitignore
  3. 42
      @types/miro.d.ts
  4. 109
      MiroHelper.ts
  5. 95
      Utility.ts
  6. 231
      index.ts
  7. 38
      package.json
  8. 74
      tsconfig.json
  9. 2781
      yarn.lock

@ -0,0 +1,9 @@
MIRO_APP_ID=
MIRO_TOKEN=
MIRO_BOARD=
NOTION_PAGE=
NOTION_TOKEN=
VERBOSE=0
DATABASE_SYNC_PAGE_COUNT=0
SYNCED_PROPERTY=Miro Name,Equipment,Scenario,No.
FILTER={"filter":{"or":[{"property": "UC scope", "select": {"equals": "A"}},{"property": "UC scope","select": {"equals": "B"}}]}}

119
.gitignore vendored

@ -0,0 +1,119 @@
exe
db.json
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

42
@types/miro.d.ts vendored

@ -0,0 +1,42 @@
// https://developers.miro.com/reference#board-object
///////////////////
type WidgetType = 'sticker' | 'shape' | 'text' | 'image' | 'webscreenshot' | 'document' | 'paint' | 'preview' | 'embed' | 'mockup' | 'line' | 'frame' | 'card' | 'kanban' | 'usm';
type ShapeType = string;
interface Style {
backgroundColor: string;
borderColor: string;
shapeType: string;
}
interface MetaData {
[key: string]: any;
}
// https://developers.miro.com/reference#widget-object
interface MiroWidget {
id: string;
style: Style;
type: WidgetType;
x: number;
y: number;
height: number;
width: number;
metadata?: MetaData;
}
interface MiroWidgetText extends MiroWidget {
text: string;
}
interface MiroWidgetCard extends MiroWidget {
title: string;
}
interface Collection {
type: "collection";
data: MiroWidget[];
size: number;
}

@ -0,0 +1,109 @@
import got from 'got';
import dotenv from 'dotenv';
dotenv.config();
let miro_app_id = process.env.MIRO_APP_ID || "";
let miro_token = process.env.MIRO_TOKEN || "";
let miro_board = process.env.MIRO_BOARD || "";
let miroCreateWidgetUrl = `https://api.miro.com/v1/boards/${miro_board}/widgets`;
export async function CreateCard(title: string, metaData?: MetaData, x?: number, y?: number): Promise<MiroWidgetCard> {
let metadata: undefined | MetaData = undefined;
if (metaData) {
metadata = {};
metadata[miro_app_id] = metaData;
};
return await got.post<MiroWidgetCard>(miroCreateWidgetUrl, {
headers: { Authorization: `Bearer ${miro_token}` },
json: { "type": "card", "title": title, metadata, x, y },
responseType: 'json',
resolveBodyOnly: true
});
// console.log(JSON.stringify({ "type": "card", "title": title, metadata }));
// console.log(response.body);
}
export async function CreateText(title: string, metaData?: MetaData, x?: number, y?: number, callback?: MiroRateLimitingCallbackType): Promise<MiroWidgetText> {
let metadata: undefined | MetaData = undefined;
if (metaData) {
metadata = {};
metadata[miro_app_id] = metaData;
};
let result = await got.post<MiroWidgetText>(miroCreateWidgetUrl, {
headers: { Authorization: `Bearer ${miro_token}` },
json: { "type": "text", "text": title, metadata, x, y },
responseType: 'json'
});
callback && callback(Number(result.headers["x-ratelimit-reset"]), Number(result.headers["x-ratelimit-remaining"]));
return result.body;
// console.log(response.body);
}
// (async () => {
// let card = await CreateCard('simple card 3', { notionId: "2" });
// console.log(JSON.stringify(card));
// card = await CreateCard('simple card 3');
// console.log(JSON.stringify(card));
// })();
type MiroRateLimitingCallbackType = (rateLimitReset: number, rateLimitRemaining: number) => void;
let miroUpdateWidgetUrl = (widget_id: string) => `https://api.miro.com/v1/boards/${miro_board}/widgets/${widget_id}`;
export async function UpdateCard(widget_id: string, title: string, metaData?: MetaData, callback?: MiroRateLimitingCallbackType): Promise<MiroWidgetCard> {
let metadata: undefined | MetaData = undefined;
if (metaData) {
metadata = {};
metadata[miro_app_id] = metaData;
};
let result = await got.patch<MiroWidgetCard>(miroUpdateWidgetUrl(widget_id), {
headers: { Authorization: `Bearer ${miro_token}` },
json: { "title": title, metadata },
responseType: 'json'
});
callback && callback(Number(result.headers["x-ratelimit-reset"]), Number(result.headers["x-ratelimit-remaining"]));
return result.body;
}
export async function UpdateText(widget_id: string, title: string, metaData?: MetaData, callback?: MiroRateLimitingCallbackType): Promise<MiroWidgetText> {
let metadata: undefined | MetaData = undefined;
if (metaData) {
metadata = {};
metadata[miro_app_id] = metaData;
};
let result = await got.patch<MiroWidgetText>(miroUpdateWidgetUrl(widget_id), {
headers: { Authorization: `Bearer ${miro_token}` },
json: { "text": title, metadata },
responseType: 'json'
});
// clg
// if (callback) {
// console.log('rate limit callback: ', Number(result.headers["x-ratelimit-reset"]), Number(result.headers["x-ratelimit-remaining"]));
callback && callback(Number(result.headers["x-ratelimit-reset"]), Number(result.headers["x-ratelimit-remaining"]));
// }
return result.body;
}
let miroListWidgetUrl = `https://api.miro.com/v1/boards/${miro_board}/widgets/`
export async function GetAllWidgets(): Promise<Collection> {
return await got<Collection>(miroListWidgetUrl, {
headers: { Authorization: `Bearer ${miro_token}` },
responseType: 'json',
resolveBodyOnly: true
});
}
export async function GetAllTexts(): Promise<Collection> {
return await got<Collection>(miroListWidgetUrl + "?widgetType=text", {
headers: { Authorization: `Bearer ${miro_token}` },
responseType: 'json',
resolveBodyOnly: true
});
}
// (async () => {
// let collection = await GetAllWidgets();
// collection && collection.data
// .map((widget => ({ id: widget.id, type: widget.type, metadata: widget.metadata })))
// .forEach(c => console.log(c.id, c.type, c.metadata));
// })();

@ -0,0 +1,95 @@
import { Block, PropertyValue, StringFormulaValue } from "@notionhq/client/build/src/api-types";
export function isEmptyOrSpaces(value: string) {
return (!value || value == undefined || value == "" || value.length == 0);
}
export function ProcessNotionBlock(blocks: Block[]) {
return blocks.map(block => {
switch (block.type) {
case "paragraph": return { id: block.id, richText: block[block.type].text };
case 'heading_1': return { id: block.id, richText: block[block.type].text };
case 'heading_2': return { id: block.id, richText: block[block.type].text };
case 'heading_3': return { id: block.id, richText: block[block.type].text };
case 'bulleted_list_item': return { id: block.id, richText: block[block.type].text };
case 'numbered_list_item': return { id: block.id, richText: block[block.type].text };
case 'to_do': return { id: block.id, richText: block[block.type].text };
case 'toggle': return { id: block.id, richText: block[block.type].text };
case 'child_page': {
let title = block[block.type].title;
title && console.warn(`not handling child page: ${title}`);
return null;
// SyncInfo(result.id, `# ${prettyText}`);
}
case 'unsupported': {
return { id: block.id, richText: null };
// await SyncNotionDatabase(block.id, miroWidgetInfo);
}
}
})
}
export function ProcessNotionProperty(property: [key: string, value: PropertyValue][]) {
return property.map(([key, value]) => {
switch (value.type) {
case "title": return { id: value.id, richText: value[value.type], name: key };
case 'rich_text': return { id: value.id, richText: value[value.type], name: key };
case 'select': return { id: value.id, text: value[value.type].name, name: key };
case 'formula': return {
id: value.id,
text: (value.formula as StringFormulaValue).string,
name: key
};
default: return null;
}
})
}
export type MiroWidgetInfo = {
id: string,
type: string
};
export interface MiroSyncInfo {
[notionPageId: string]: {
[notionPropertyId: string]: MiroWidgetInfo[]
}
}
export function ProcessMiroWidget(data: MiroWidget[], miro_app_id: string) {
let info: MiroSyncInfo = {};
data.forEach(w => {
if (w.metadata == null) return;
if (w.metadata[miro_app_id] == null) return;
// console.log(w.metadata[miro_app_id]);
let notionPageId = w.metadata[miro_app_id].notionPageId as string;
let notionPropertyId = w.metadata[miro_app_id].notionPropertyId as string;
let obj = { id: w.id, type: w.type };
if (notionPageId === undefined) return;
if (info[notionPageId] === undefined)
info[notionPageId] = {};
if (notionPropertyId !== undefined) {
if (info[notionPageId][notionPropertyId] !== undefined)
info[notionPageId][notionPropertyId] = [...info[notionPageId][notionPropertyId], obj];
else
info[notionPageId][notionPropertyId] = [obj];
}
else {
if (info[notionPageId]["_"] !== undefined)
info[notionPageId]["_"] = [...info[notionPageId]["_"], obj];
else
info[notionPageId]["_"] = [obj];
}
});
return info;
}
export function sleep(ms: number) {
return new Promise(resolve => (setTimeout(resolve, ms)));
}

@ -0,0 +1,231 @@
// import { Low, JSONFile } from 'lowdb';
import dotenv from 'dotenv';
dotenv.config();
import { Client as NotionClient } from '@notionhq/client';
import { Filter, RichText } from '@notionhq/client/build/src/api-types';
import { CreateText, GetAllTexts, UpdateCard, UpdateText } from './MiroHelper.js';
import { isEmptyOrSpaces, MiroSyncInfo, MiroWidgetInfo, ProcessMiroWidget, ProcessNotionBlock, ProcessNotionProperty, sleep } from './Utility.js';
// const adapter = new JSONFile<DBDataType>('db.json')
// const db = new Low<DBDataType>(adapter);
// Read data from JSON file, this will set db.data content
// (async () => await db.read())();
// db.data = db.data || { syncInfo: [] };
// (async () => await db.write())();//if db.json doesn't exist, write file will silent fail at program end
let miro_app_id = process.env.MIRO_APP_ID || "";
let notion_page = process.env.NOTION_PAGE || "";
let notion_token = process.env.NOTION_TOKEN || "";
let database_sync_page_count: Number = Number(process.env.DATABASE_SYNC_PAGE_COUNT) || 0;
let synced_property_arr = (process.env.SYNCED_PROPERTY || "").split(',');
let filter = (process.env.FILTER || "");
let verbose: Number = Number(process.env.VERBOSE) || 0;
console.log(synced_property_arr);
const notion = new NotionClient({ auth: notion_token })
const PrettyText = (textArr: RichText[]) => {
let str = textArr.reduce((all, cur) => `${all}${cur.plain_text}`, '');
return `${str}`;
}
let counter = 0;
let timestamp = 0;
let remaining = 0;
function MiroRateLimitCallback(rateLimitReset: number, rateLimitRemaining: number) {
timestamp = rateLimitReset;
remaining = rateLimitRemaining;
}
function GetTimer() {
return Date.now() / 1000 - timestamp;
}
function ShouldIgnore(str: string) {
return isEmptyOrSpaces(str) || str == "<p></p>";
}
let x = 0;
let y = 0;
const COLUME_WIDTH = 200;
const PART_OFFSET = 1000;
function NewRow() {
y += 120;
}
function ResetRow() {
y = 0;
}
function NewColume() {
x += COLUME_WIDTH;
}
function ResetColume() {
x = 0;
}
async function SyncNotion2Miro(notionInfo: NotionInfoType, miroInfo?: MiroSyncInfo, onCreated?: () => void) {
if (ShouldIgnore(notionInfo.text)) {
console.log(`\tskipping empty string block\n`);
return;
}
counter++;
await sleep(100);
if (counter % 10 == 0) {
console.log('sleep to prevent miro blocking');
await sleep(2000);
}
while (remaining <= 1000 && GetTimer() < 0) {
console.log(`sleep to prevent miro blocking, timer[${GetTimer().toFixed()}]`);
await sleep(1000);
}
console.log(`miro sync counter[${counter}] timer[${GetTimer().toFixed()}] remaining points[${remaining}]`);
if (miroInfo == undefined) return;
let notionPageId = notionInfo.pageId;
let notionPropertyId = notionInfo.propertyId;
let info = undefined;
if (notionPropertyId === undefined)
info = miroInfo[notionPageId] && miroInfo[notionPageId]["_"];
else
info = miroInfo[notionPageId] && miroInfo[notionPageId][notionPropertyId];
async function CreateOrUpdateInfo(info?: MiroWidgetInfo) {
if (info === undefined) {
console.log(`\tcreate text: ${notionInfo.text}\n`);
// CreateCard(notionInfo.text, { notionId: notionInfo.id });
await CreateText(notionInfo.text, { notionPageId, notionPropertyId }, x, y, MiroRateLimitCallback);
// db.data?.syncInfo.push({ notionId: id });
}
else {
switch (info.type) {//conversion?
case 'card':
console.log(`\tupdate miro card[${info.id}] ${notionInfo.text}\n`);
await UpdateCard(info.id, notionInfo.text, undefined, MiroRateLimitCallback);
break;
case 'text':
console.log(`\tupdate miro text[${info.id}] ${notionInfo.text}\n`);
await UpdateText(info.id, notionInfo.text, undefined, MiroRateLimitCallback);
}
}
}
for (let index = 0; index < 2; index++) {
if (info === undefined) {
await CreateOrUpdateInfo();
onCreated && onCreated();
if (index == 0) x += PART_OFFSET;
else x -= PART_OFFSET + COLUME_WIDTH;
}
else
await CreateOrUpdateInfo(info[index]);
}
}
// async function SyncNotionPage(pageId: string, miroWidgetInfo: MiroSyncInfo) {
// let blocks = await notion.blocks.children.list({ block_id: pageId });
// console.log(blocks);
// //TODO deal with pagination
// if (blocks == undefined) return;
// if (miroWidgetInfo == undefined) return;
// if (blocks.has_more)
// console.warn("Need to deal with paging results!!");
// if (verbose)
// console.log(JSON.stringify(blocks.results, null, "\t"));
// let processedBlocks = ProcessNotionBlock(blocks.results);
// for (var block of processedBlocks) {
// if (block == null) continue;
// if (block.richText == null) {
// if (block.id == null) continue;
// console.warn(`try to handle unsupported block[${block.id}]`);
// await SyncNotionDatabase(block.id, miroWidgetInfo);
// continue;
// }
// let text = PrettyText(block.richText);
// console.log(`block[${block.id}] notionInfo.text`);
// await SyncNotion2Miro({ pageId: block.id, text: `<p>${text}</p>` }, miroWidgetInfo);
// }
// }
async function SyncNotionDatabase(databaseId: string, miroWidgetInfo?: MiroSyncInfo) {
try {
let db = await notion.databases.retrieve({ database_id: databaseId });
console.log(`Processing Database: ${PrettyText(db.title)}`);
let _filter = JSON.parse(filter) as Filter;
console.log(filter);
let pages = await notion.databases.query({ database_id: databaseId, filter: _filter });
//TODO deal with pagination
if (pages == undefined) return;
if (miroWidgetInfo == undefined) return;
let count = 0;
for (let page of pages.results) {
let processedProperties = ProcessNotionProperty(Object.entries(page.properties));
for (let property of processedProperties) {
if (property == null) continue;
if (synced_property_arr.includes(property.name) == false) continue;
if (database_sync_page_count != 0 && count >= database_sync_page_count) continue;
count++;
let text = property.richText ? PrettyText(property.richText) : property.text;
text = text || "";
console.log(`property[${property.id}] ${text}`);
let notionInfo = { pageId: page.id, propertyId: property.id, text: `<p>${text}</p>` };
if (ShouldIgnore(text)) NewColume();
await SyncNotion2Miro(notionInfo, miroWidgetInfo, NewColume);
}
ResetColume();
NewRow();
}
} catch (err) {
} finally {
return [];
}
}
(async () => {
try {
let collection = await GetAllTexts();
let info = ProcessMiroWidget(collection.data, miro_app_id);
// console.log(JSON.stringify(collection));
await SyncNotionDatabase(notion_page, info);
// await SyncNotionPage(notion_page, info);
// await db.write();
} catch (error) {
console.log(error);
}
})();
type NotionInfoType = {
pageId: string,
propertyId?: string,
text: string
}

@ -0,0 +1,38 @@
{
"name": "node-notion2miro",
"version": "3.0.0",
"main": "index.js",
"license": "MIT",
"bin": "./dist/index.js",
"type": "module",
"scripts": {
"dev": "yarn dev:compile && yarn dev:run",
"dev:compile": "tsc",
"dev:run": "node dist/index.js",
"build": "./node_modules/.bin/esbuild index.ts --bundle --outfile=./dist/index.js --platform=node && pkg ."
},
"pkg": {
"targets": [
"node14-win-x64"
],
"options": [
"experimental-modules"
],
"outputPath": "exe"
},
"devDependencies": {
"@types/node": "^15.12.1",
"@vercel/ncc": "^0.28.6",
"concurrently": "^6.0.2",
"nodemon": "^2.0.7",
"pkg": "^5.2.1",
"typescript": "^4.2.4"
},
"dependencies": {
"@notionhq/client": "^0.2.1",
"dotenv": "^9.0.2",
"esbuild": "^0.12.6",
"got": "^11.8.2",
"lowdb": "^2.1.0"
}
}

@ -0,0 +1,74 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "ESNext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./dist", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
"types": [
"node"
], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
},
"exclude": [
"dist",
"node_modules",
"db.json"
],
"include": [
"./"
]
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save