diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..4171f3f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +dist/* +test/* \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 77d6f8d..3cb4894 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,17 +5,35 @@ "es6": true, "node": true }, - "extends": "eslint:recommended", + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "plugin:@typescript-eslint/recommended" + ], "globals": { "Atomics": "readonly", "SharedArrayBuffer": "readonly" }, "parserOptions": { - "ecmaVersion": 2018 + "ecmaVersion": 2018, + "sourceType": "module" }, "rules": { + "camelcase": "off", + "@typescript-eslint/camelcase": [ + "off" + ], "indent": [ - "error", + "warn", + 2, + { + "SwitchCase": 1 + } + ], + "@typescript-eslint/indent": [ + "warn", 2, { "SwitchCase": 1 @@ -33,6 +51,37 @@ "error", "never" ], - "no-console": "off" - } + "no-console": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-use-before-define": [ + "error", + { + "functions": false, + "classes": true + } + ], + "@typescript-eslint/member-delimiter-style": [ + "error", + { + "multiline": { + "delimiter": "none", + "requireLast": false + }, + "singleline": { + "delimiter": "semi", + "requireLast": false + } + } + ] + }, + "overrides": [ + { + "files": [ + "*.js" + ], + "rules": { + "@typescript-eslint/no-var-requires": "off" + } + } + ] } \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index a4b5bd3..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Launch Program", - "program": "${workspaceFolder}/index.js" - } - ] -} \ No newline at end of file diff --git a/LICENSE b/LICENSE index 5b8ba49..fda9c66 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-present Wen-Zhi Wang +Copyright (c) 2019-present Wen-Zhi Wang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2379ec9..cb2b9d5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -# notablog +# Notablog ![version](https://img.shields.io/npm/v/notablog.svg?style=flat-square&color=007acc&label=version) ![license](https://img.shields.io/github/license/dragonman225/notablog.svg?style=flat-square&label=license&color=08CE5D) Generate a minimalistic blog from a Notion.so table. -Here are some images of [my blog](https://dragonman225.js.org/), using [`notablog-starter`'s default theme](https://github.com/dragonman225/notablog-starter/tree/master/themes/pure). 🙂 +Below are some screenshots of [my blog](https://dragonman225.js.org/). 🙂 | Mobile | Desktop | | :---------------------------: | :----------------------------: | @@ -55,22 +55,28 @@ Here are some images of [my blog](https://dragonman225.js.org/), using [`notablo notablog generate . ``` -7. After it finishes, go to `notablog-starter/public/` directory, open `index.html` with a browser to preview your site. Or, you can change `previewBrowser` field in `config.json` to the path of a browser you use and run command: +7. After it finishes, go to `notablog-starter/public/` directory, open `index.html` with a browser to preview your site. + +* Optionally, you could change the `previewBrowser` field in `config.json` to the path of a browser executable you have on your computer and issue the following command anywhere to preview. ```bash - notablog preview . + notablog preview ``` -### Your site is ready now! +### Congratulations! Your website is ready now! + +* You can copy files in `notablog-starter/public/` directory to a server or upload them to any static hosting service to share your content with the world. -* You can copy files in `notablog-starter/public/` directory to your server or upload them to any static hosting service to share your content with the world. * Whenever you want to update your site, go into `notablog-starter/` directory and issue command `notablog generate .`, or issue the command from outside `notablog-starter/` with the pattern `notablog generate `. * Some options for static hosting services: + * [Github Pages](https://pages.github.com/) * [Netlify](https://www.netlify.com/) * [surge.sh](https://surge.sh) + * Some options for self-hosting: + * [nginx](https://www.nginx.com/) * [lighttpd](https://www.lighttpd.net/) * [Apache httpd](https://httpd.apache.org/) @@ -211,6 +217,10 @@ Generated by `dependency-cruiser` NPM package. ![](assets/deps_graph.svg) -### EJS +### Project Status + +See https://dragonman225.js.org/notablog-stat.html + +### Git -There is an experimental version at `ejs` branch that uses [EJS](https://ejs.co/) as template engine. Main advantage of EJS is its `include` feature, which enable us to make repetitive parts of template into components that can be reused. I also made an EJS version of `notablog-theme-pure` [here](https://github.com/dragonman225/notablog-theme-pure-ejs). +`master` is the working branch, latest release is `v0.4.1`, which is at branch `v0.4.1`. \ No newline at end of file diff --git a/assets/deps_graph.svg b/assets/deps_graph.svg index 2f7dde0..a28f1d6 100644 --- a/assets/deps_graph.svg +++ b/assets/deps_graph.svg @@ -4,270 +4,270 @@ - - + + dependency-cruiser output - + cluster_src - -src + +src child_process - -child_process + +child_process fs - -fs + +fs path - -path + +path - + -src/generate.js - - -generate.js +src/generate.ts + + +generate.ts - + -src/generate.js->fs - - +src/generate.ts->fs + + - + -src/generate.js->path - - +src/generate.ts->path + + - + -src/parse-table.js - - -parse-table.js +src/parse-table.ts + + +parse-table.ts - + -src/generate.js->src/parse-table.js - - +src/generate.ts->src/parse-table.ts + + - + -src/render-index.js - - -render-index.js +src/render-index.ts + + +render-index.ts - + -src/generate.js->src/render-index.js - - +src/generate.ts->src/render-index.ts + + - + -src/render-post.js - - -render-post.js +src/render-post.ts + + +render-post.ts - + -src/generate.js->src/render-post.js - - +src/generate.ts->src/render-post.ts + + - + -src/template-provider.js - - -template-provider.js +src/template-provider.ts + + +template-provider.ts - + -src/generate.js->src/template-provider.js - - +src/generate.ts->src/template-provider.ts + + - + -src/util.js - - -util.js +src/util.ts + + +util.ts - + -src/generate.js->src/util.js - - +src/generate.ts->src/util.ts + + - + -src/parse-table.js->src/util.js - - +src/parse-table.ts->src/util.ts + + - + -src/notion-utils.js - - -notion-utils.js +src/notion-utils.ts + + +notion-utils.ts - + -src/parse-table.js->src/notion-utils.js - - +src/parse-table.ts->src/notion-utils.ts + + - + -src/render-index.js->fs - - +src/render-index.ts->fs + + - + -src/render-index.js->path - - +src/render-index.ts->path + + - + -src/render-index.js->src/util.js - - +src/render-index.ts->src/util.ts + + - + -src/render-post.js->fs - - +src/render-post.ts->fs + + - + -src/render-post.js->path - - +src/render-post.ts->path + + - + -src/render-post.js->src/util.js - - +src/render-post.ts->src/util.ts + + - + -src/render-post.js->src/notion-utils.js - - +src/render-post.ts->src/notion-utils.ts + + - + -src/template-provider.js->fs - - +src/template-provider.ts->fs + + - + -src/template-provider.js->path - - +src/template-provider.ts->path + + - + -src/template-provider.js->src/util.js - - +src/template-provider.ts->src/util.ts + + - + -src/util.js->fs - - +src/util.ts->fs + + - + -src/util.js->path - - +src/util.ts->path + + - + -src/index.js - - -index.js +src/index.ts + + +index.ts - + -src/index.js->src/generate.js - - +src/index.ts->src/generate.ts + + - + -src/preview.js - - -preview.js +src/preview.ts + + +preview.ts - + -src/index.js->src/preview.js - - +src/index.ts->src/preview.ts + + - + -src/preview.js->child_process - - +src/preview.ts->child_process + + - + -src/preview.js->path - - +src/preview.ts->path + + - + -src/preview.js->src/util.js - - +src/preview.ts->src/util.ts + + diff --git a/bin/cli.js b/bin/cli.js index 6487b2c..881eb0f 100644 --- a/bin/cli.js +++ b/bin/cli.js @@ -25,7 +25,7 @@ async function cmdGenerate(opts, logger) { try { const startTime = Date.now() - await generate(opts) + await generate(opts.workDir, opts) const endTime = Date.now() const timeElapsed = (endTime - startTime) / 1000 logger.info(`\ diff --git a/package.json b/package.json index f999f8c..17380af 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,19 @@ "description": "Generate a minimalistic blog from a Notion.so table.", "author": "dragonman225", "license": "MIT", - "main": "src/index.js", + "main": "dist/index.js", + "types": "dist/index.d.ts", "bin": { "notablog": "bin/cli.js" }, "scripts": { - "build": "npm run build:dependency-graph", - "build:dependency-graph": "npx depcruise --exclude '^node_modules' --output-type dot --prefix 'https://github.com/dragonman225/notablog/tree/master/' src/index.js | dot -T svg > assets/deps_graph.svg", + "build": "npm run build:module && npm run build:doc", + "build:module": "rm -rf dist && rollup -c && tsc --emitDeclarationOnly", + "build:doc": "npm run build:dependency-graph", + "build:dependency-graph": "npx depcruise --exclude '^node_modules' --output-type dot --prefix 'https://github.com/dragonman225/notablog/tree/master/' src/index.ts | dot -T svg > assets/deps_graph.svg", + "build:tsviz": "tsviz src/ntable.ts assets/ntable.png", + "dev": "rollup -cw", + "test": "ts-node test/ntable.spec.ts", "release": "npm run build && npm publish", "release:beta": "npm run build && npm publish --tag beta", "upgrade": "node tools/upgrade-deps.js" @@ -26,11 +32,21 @@ "squirrelly": "^7.9.2" }, "devDependencies": { + "@rollup/plugin-typescript": "^2.1.0", + "@types/node": "^13.9.0", + "@typescript-eslint/eslint-plugin": "^2.23.0", + "@typescript-eslint/parser": "^2.23.0", "dependency-cruiser": "^8.0.1", - "eslint": "^6.8.0" + "eslint": "^6.8.0", + "nast-types": "^1.1.1", + "rollup": "^2.0.2", + "ts-node": "^8.6.2", + "tsviz": "^1.0.11", + "typescript": "^3.8.3", + "zora": "^3.1.8" }, "files": [ - "src/", + "dist/", "assets/" ], "keywords": [ diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..2c344b7 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,18 @@ +import typescript from "@rollup/plugin-typescript" + +const pkg = require("./package.json") + +export default { + input: "src/index.ts", + output: [ + { + file: "dist/index.js", + format: "cjs" + }, { + file: "dist/index.esm.js", + format: "es" + } + ], + plugins: [typescript()], + external: [...Object.keys(pkg.dependencies), "crypto", "fs", "path", "child_process"] +} \ No newline at end of file diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..b4a41fc --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,76 @@ +import crypto from 'crypto' +import fs from 'fs' +import path from 'path' +import { log } from './util' + +export class Cache { + private cacheDir: string + + constructor(cacheDir: string) { + this.cacheDir = cacheDir + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }) + } + } + + get(namespace: string, id: string): object | undefined { + const fPath = this.fPath(namespace, id) + /** Read file. */ + if (!fs.existsSync(fPath)) { + log.debug(`Failed to get cache "${id}" of namespace "${namespace}".`) + return undefined + } + const data = fs.readFileSync(fPath, { encoding: 'utf-8' }) + /** Parse file. */ + try { + const obj = JSON.parse(data) + return obj + } catch (error) { + log.debug(`Cache object "${id}" of namespace "${namespace}" is corrupted.`) + log.debug(error) + return undefined + } + } + + set(namespace: string, id: string, obj: object) { + const fPath = this.fPath(namespace, id) + fs.writeFileSync(fPath, JSON.stringify(obj, getCircularReplacer())) + } + + shouldUpdate(namespace: string, id: string, lastModifiedTime: number) { + const fPath = this.fPath(namespace, id) + if (fs.existsSync(fPath)) { + const lastModifiedTimeOfCache = fs.statSync(fPath).mtimeMs + return lastModifiedTime > lastModifiedTimeOfCache + } else { + return true + } + } + + fPath(namespace: string, id: string) { + return path.join(this.cacheDir, this._hash(namespace + id)) + } + + private _hash(payload: string) { + return crypto.createHash('sha256').update(payload).digest('hex') + } +} + +/** + * Filter circular object for JSON.stringify() + * @function getCircularReplacer + * @returns {object} Filtered object. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value + */ +function getCircularReplacer() { + const seen = new WeakSet() + return (_key, value) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) { + return + } + seen.add(value) + } + return value + } +} \ No newline at end of file diff --git a/src/generate.js b/src/generate.ts similarity index 83% rename from src/generate.js rename to src/generate.ts index eea5919..e040afd 100644 --- a/src/generate.js +++ b/src/generate.ts @@ -1,14 +1,18 @@ -const fs = require('fs') -const path = require('path') -const { createAgent } = require('notionapi-agent') -const { TaskManager2 } = require('@dnpr/task-manager') -const { copyDirSync } = require('@dnpr/fsutil') - -const TemplateProvider = require('./template-provider') -const { parseTable } = require('./parse-table') -const { renderIndex } = require('./render-index') -const { renderPost } = require('./render-post') -const { log, getConfig } = require('./util') +import fs from 'fs' +import path from 'path' +import { createAgent } from 'notionapi-agent' +import { TaskManager2 } from '@dnpr/task-manager' +import { copyDirSync } from '@dnpr/fsutil' +import { TemplateProvider } from './template-provider' +import { parseTable } from './parse-table' +import { renderIndex } from './render-index' +import { renderPost } from './render-post' +import { log, getConfig } from './util' + +type GenerateOptions = { + concurrency?: number + verbose?: boolean +} /** * Check if a page is newer than its cached version. @@ -38,11 +42,8 @@ function isPageUpdated(pageId, lastEditedTime, cacheDir) { /** * Generate a blog. - * @param {GenerateOptions} opts */ -async function generate(opts = {}) { - - const workDir = opts.workDir +export async function generate(workDir: string, opts: GenerateOptions = {}) { const concurrency = opts.concurrency const verbose = opts.verbose const notion = createAgent({ debug: verbose }) @@ -111,6 +112,9 @@ async function generate(opts = {}) { return data }, { pagesUpdated: [], pagesNotUpdated: [] + } as { + pagesUpdated: typeof siteMeta.pages, + pagesNotUpdated: typeof siteMeta.pages }) const pageTotalCount = siteMeta.pages.length @@ -134,7 +138,7 @@ async function generate(opts = {}) { ...dirs, doFetchPage: true } - }])) + }]) as never) }) pagesNotUpdated.forEach(page => { tasks.push(tm2.queue(renderPost, [{ @@ -148,10 +152,8 @@ async function generate(opts = {}) { ...dirs, doFetchPage: false } - }])) + }]) as never) }) await Promise.all(tasks) return 0 -} - -module.exports = { generate } \ No newline at end of file +} \ No newline at end of file diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 1d77454..0000000 --- a/src/index.js +++ /dev/null @@ -1,4 +0,0 @@ -const { generate } = require('./generate') -const { preview } = require('./preview') - -module.exports = { generate, preview } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..43a3af2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +import { generate } from './generate' +import { preview } from './preview' + +export { generate, preview } \ No newline at end of file diff --git a/src/notion-utils.js b/src/notion-utils.js deleted file mode 100644 index e9dda8d..0000000 --- a/src/notion-utils.js +++ /dev/null @@ -1,87 +0,0 @@ -module.exports = { - getPageIDFromNotionDatabaseURL, - getBookmarkLinkFromNotionPageURL, - getPageIDFromNotionPageURL, - toDashID, - isValidDashID, - convertNotionURLToLocalLink, // Deprecated - getPageIDfromNotionURL // Deprecated -} - -const dashIDLen = '0eeee000-cccc-bbbb-aaaa-123450000000'.length -const noDashIDLen = '0eeee000ccccbbbbaaaa123450000000'.length - -function getPageIDFromNotionDatabaseURL(str) { - let splitArr = str.split('/') - splitArr = splitArr.pop().split('-') - splitArr = splitArr.pop().split('?') - - let pageID = splitArr[0] - if (pageID && pageID.length === noDashIDLen) { - return toDashID(pageID) - } else { - throw new Error(`Cannot get pageID from ${str}`) - } -} - -function getBookmarkLinkFromNotionPageURL(str) { - let splitArr = str.split('/') - splitArr = splitArr.pop().split('-') - splitArr = splitArr.pop().split('#') - - let blockID = splitArr[1] - if (blockID && blockID.length === noDashIDLen) { - return `#${toDashID(blockID)}` - } else { - return str - } -} - -function getPageIDFromNotionPageURL(str) { - let splitArr = str.split('/') - splitArr = splitArr.pop().split('-') - - let pageID = splitArr.pop() - if (pageID && pageID.length === noDashIDLen) { - return toDashID(pageID) - } else { - throw new Error(`Cannot get pageID from ${str}`) - } -} - -function toDashID(str) { - if (isValidDashID(str)) { - return str - } - - let s = str.replace(/-/g, '') - - if (s.length !== noDashIDLen) { - return str - } - - let res = str.substring(0, 8) + '-' + str.substring(8, 12) + '-' + str.substring(12, 16) + '-' + str.substring(16, 20) + '-' + str.substring(20) - return res -} - -function isValidDashID(str) { - if (str.length !== dashIDLen) { - return false - } - - if (str.indexOf('-') === -1) { - return false - } - - return true -} - -/** Deprecated. Please use getBookmarkLinkfromNotionPageURL() instead. */ -function convertNotionURLToLocalLink(str) { - return getBookmarkLinkFromNotionPageURL(str) -} - -/** Deprecated. Please use getPageIDFromNotionPageURL() instead. */ -function getPageIDfromNotionURL(str) { - return getPageIDFromNotionPageURL(str) -} \ No newline at end of file diff --git a/src/notion-utils.ts b/src/notion-utils.ts new file mode 100644 index 0000000..8c8dea4 --- /dev/null +++ b/src/notion-utils.ts @@ -0,0 +1,67 @@ +const dashIDLen = '0eeee000-cccc-bbbb-aaaa-123450000000'.length +const noDashIDLen = '0eeee000ccccbbbbaaaa123450000000'.length + +export function getPageIDFromPageURL(str: string): string { + let splitArr = str.split('/') + splitArr = (splitArr.pop() || "").split('-') + + let pageID = splitArr.pop() + if (pageID && pageID.length === noDashIDLen) { + return toDashID(pageID) + } else { + throw new Error(`Cannot get pageID from ${str}`) + } +} + +export function getPageIDFromCollectionPageURL(str: string): string { + let splitArr = str.split('/') + splitArr = (splitArr.pop() || "").split('-') + splitArr = (splitArr.pop() || "").split('?') + + let pageID = splitArr[0] + if (pageID && pageID.length === noDashIDLen) { + return toDashID(pageID) + } else { + throw new Error(`Cannot get pageID from ${str}`) + } +} + +export function getBookmarkLinkFromPageURL(str: string): string { + let splitArr = str.split('/') + splitArr = (splitArr.pop() || "").split('-') + splitArr = (splitArr.pop() || "").split('#') + + let blockID = splitArr[1] + if (blockID && blockID.length === noDashIDLen) { + return `#${toDashID(blockID)}` + } else { + return str + } +} + +export function toDashID(str: string): string { + if (isValidDashID(str)) { + return str + } + + let s = str.replace(/-/g, '') + if (s.length !== noDashIDLen) { + return str + } + + let res = + str.substring(0, 8) + '-' + str.substring(8, 12) + '-' + + str.substring(12, 16) + '-' + str.substring(16, 20) + '-' + + str.substring(20) + return res +} + +export function isValidDashID(str: string): boolean { + if (str.length !== dashIDLen) { + return false + } + if (str.indexOf('-') === -1) { + return false + } + return true +} \ No newline at end of file diff --git a/src/ntable.ts b/src/ntable.ts new file mode 100644 index 0000000..6411806 --- /dev/null +++ b/src/ntable.ts @@ -0,0 +1,299 @@ +import { getPageIDFromPageURL } from './notion-utils' +import { objAccess } from './util' + +type SelectOption = { + id: string + color: Notion.Collection.ColumnPropertyOptionColor + value: string +} + +interface Property { + id: string + type: string + records: Map +} + +interface Record { + id: string + properties: Map +} + +interface Cell { + property: Property + record: Record +} + +interface Table { + id: string + schema: Property[] + records: Record[] +} + +class NProperty implements Property { + id: string + type: string + records: Map + + name: string + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + this.id = id + this.name = rawProperty.name + this.type = '' + this.records = new Map() + } +} + +class NTextProperty extends NProperty { + type: 'text' + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + super(id, rawProperty) + this.type = 'text' + } +} + +class NCheckboxProperty extends NProperty { + type: 'checkbox' + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + super(id, rawProperty) + this.type = 'checkbox' + } +} + +class NSelectProperty extends NProperty { + type: 'select' + options: SelectOption[] + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + super(id, rawProperty) + this.type = 'select' + this.options = rawProperty.options || [] + } +} + +class NMultiSelectProperty extends NProperty { + type: 'multi_select' + options: SelectOption[] + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + super(id, rawProperty) + this.type = 'multi_select' + this.options = rawProperty.options || [] + } +} + +class NDateTimeProperty extends NProperty { + type: 'date' + + constructor(id: string, rawProperty: Notion.Collection.ColumnProperty) { + super(id, rawProperty) + this.type = 'date' + } +} + +type NPropertyUnion = + NTextProperty | NCheckboxProperty | NSelectProperty | + NMultiSelectProperty | NDateTimeProperty + +class NRecord implements Record { + id: string + properties: Map + + uri: NAST.URI + title: NAST.SemanticString[] + icon?: NAST.Emoji | NAST.PublicUrl + cover?: NAST.PublicUrl + coverPosition: number + fullWidth: boolean + + constructor(rawPage: NAST.Page) { + this.id = getPageIDFromPageURL(rawPage.uri) + this.properties = new Map() + + this.uri = rawPage.uri + this.title = rawPage.title + this.icon = rawPage.icon + this.cover = rawPage.cover + this.coverPosition = rawPage.coverPosition + this.fullWidth = rawPage.fullWidth + } +} + +class NCell implements Cell { + property: NProperty + record: NRecord + + constructor(property: NProperty, record: NRecord) { + this.property = property + this.record = record + } +} + +class NTextCell extends NCell { + value: NAST.SemanticString[] + + constructor(property: NTextProperty, record: NRecord, + rawValue: NAST.SemanticString[]) { + super(property, record) + this.value = rawValue + } +} + +class NCheckboxCell extends NCell { + value: boolean + + constructor(property: NCheckboxProperty, record: NRecord, + rawValue: NAST.SemanticString[]) { + super(property, record) + this.value = rawValue ? rawValue[0][0] === 'Yes' : false + } +} + +class NSelectCell extends NCell { + value: SelectOption | undefined + + constructor(property: NSelectProperty, record: NRecord, + rawValue: NAST.SemanticString[]) { + super(property, record) + const optionNames = rawValue ? rawValue[0][0].split(',') : [] + this.value = property.options.find(o => o.value === optionNames[0]) + } +} + +class NMultiSelectCell extends NCell { + value: SelectOption[] + + constructor(property: NMultiSelectProperty, record: NRecord, + rawValue: NAST.SemanticString[]) { + super(property, record) + const optionNames = rawValue ? rawValue[0][0].split(',') : [] + this.value = optionNames.reduce((result, optionName) => { + const option = property.options.find(o => o.value === optionName) + if (option) result.push(option) + return result + }, [] as SelectOption[]) + } +} + +class NDateTimeCell extends NCell { + value: NAST.DateTime | undefined + + constructor(property: NDateTimeProperty, record: NRecord, + rawValue: NAST.SemanticString[]) { + super(property, record) + /** + * rawValue + * [0]: SemanticString + * [0][1]: FormattingAll[] + * [0][1][0]: FormattingMentionDate + * [0][1][0][1]: DateTime + */ + this.value = objAccess(rawValue)(0)(1)(0)(1)() + } +} + +type NCellUnion = + NTextCell | NCheckboxCell | NSelectCell | NMultiSelectCell | + NDateTimeCell + +export class NTable implements Table { + id: string + schema: NPropertyUnion[] + records: NRecord[] + + constructor(rawTable: NAST.Collection) { + this.id = getPageIDFromPageURL(rawTable.uri) + + const rawTableColumnProps = rawTable.views[0].format.table_properties + /** + * Using rawTableColumnProps to initialize schema make the order of + * properties match the order of columns in the UI. + */ + if (rawTableColumnProps) { + this.schema = rawTableColumnProps + /** Filter out properties that do not exist in schema. */ + .filter(tableProperty => rawTable.schema[tableProperty.property]) + .map(tableProperty => { + const propertyId = tableProperty.property + const rawProperty = rawTable.schema[propertyId] + return createNProperty(propertyId, rawProperty) + }) + } else { + this.schema = Object.entries(rawTable.schema).map(tuple => { + const [propertyId, rawProperty] = tuple + return createNProperty(propertyId, rawProperty) + }) + } + + this.records = [] + rawTable.children.forEach(rawPage => { + const record = new NRecord(rawPage) + this.records.push(record) + this.schema.forEach(property => { + const rawPropertyValue = (rawPage.properties || {})[property.id] + const cell = createNCell(property, record, rawPropertyValue) + property.records.set(record, cell) + record.properties.set(property, cell) + }) + }) + } + + /** Print the table structure so you can see what it looks like. */ + peekStructure() { + let head = '' + for (let i = 0; i < this.schema.length; i++) { + head += this.schema[i].constructor.name + ' ' + } + console.log(head) + console.log(''.padEnd(head.length, '-')) + for (let i = 0; i < this.records.length; i++) { + const record = this.records[i] + let row = '' + record.properties.forEach((cell, property) => { + row += cell.constructor.name + .padEnd(property.constructor.name.length) + ' ' + }) + row += '-> ' + record.constructor.name + console.log(row) + } + } +} + +function createNProperty( + propertyId: Notion.Collection.ColumnID, + rawProperty: Notion.Collection.ColumnProperty +) { + switch (rawProperty.type) { + case 'checkbox': + return new NCheckboxProperty(propertyId, rawProperty) + case 'select': + return new NSelectProperty(propertyId, rawProperty) + case 'multi_select': + return new NMultiSelectProperty(propertyId, rawProperty) + case 'date': + return new NDateTimeProperty(propertyId, rawProperty) + default: + return new NTextProperty(propertyId, rawProperty) + } +} + +function createNCell( + property: NPropertyUnion, + record: NRecord, + rawValue: NAST.SemanticString[] +): NCellUnion { + switch (property.type) { + case 'checkbox': + return new NCheckboxCell(property, record, rawValue) + case 'select': + return new NSelectCell(property, record, rawValue) + case 'multi_select': + return new NMultiSelectCell(property, record, rawValue) + case 'date': + return new NDateTimeCell(property, record, rawValue) + default: + return new NTextCell(property, record, rawValue) + } +} \ No newline at end of file diff --git a/src/parse-table.js b/src/parse-table.ts similarity index 79% rename from src/parse-table.js rename to src/parse-table.ts index a49f0c0..27b87fa 100644 --- a/src/parse-table.js +++ b/src/parse-table.ts @@ -1,21 +1,14 @@ -const { getOnePageAsTree } = require('nast-util-from-notionapi') -const { renderToHTML } = require('nast-util-to-react') - -const { getPageIDFromNotionDatabaseURL } = require('./notion-utils') -const { log } = require('./util') - -module.exports = { - parseTable -} +import { getOnePageAsTree } from 'nast-util-from-notionapi' +import { renderToHTML } from 'nast-util-to-react' +import { getPageIDFromCollectionPageURL } from './notion-utils' +import { log } from './util' /** * Extract interested data for blog generation from a Notion table. - * @param {string} notionDatabaseURL - * @param {NotionAgent} notionAgent */ -async function parseTable(notionDatabaseURL, notionAgent) { - let pageID = getPageIDFromNotionDatabaseURL(notionDatabaseURL) - let pageCollection = (await getOnePageAsTree(pageID, notionAgent)) +export async function parseTable(collectionPageURL, notionAgent) { + let pageID = getPageIDFromCollectionPageURL(collectionPageURL) + let pageCollection = (await getOnePageAsTree(pageID, notionAgent)) as NAST.CollectionPage /** * Create map for property_name -> property_id. @@ -51,8 +44,8 @@ column with id "${propertyId}" is used`) * Create map for tag -> color */ let tagColorMap = {} - let classPrefix = '' - pageCollection.schema[schemaMap['tags']].options.forEach(tag => { + let classPrefix = ''; + (pageCollection.schema[schemaMap['tags']].options || []).forEach(tag => { tagColorMap[tag.value] = `${classPrefix}${tag.color}` }) @@ -60,42 +53,11 @@ column with id "${propertyId}" is used`) let pagesValid = pageCollection.children .filter(page => page.properties) - /** - * Select Option - * @typedef {Object} SelectOption - * @property {string} value - * @property {string} color - */ - /** - * Metadata of a page - * @typedef {Object} PageMetadata - * @property {string} id - * @property {string} icon - * @property {string} iconHTML - * @property {string} cover - * @property {string} title - * @property {SelectOption[]} tags - * @property {boolean} publish - * @property {boolean} inMenu - * @property {boolean} inList - * @property {string} template - * @property {string} url - * @property {Notion.StyledString[]} description - * @property {string} descriptionPlain - * @property {string} descriptionHTML - * @property {string} date - * @property {string} dateString - * @property {number} createdTime - * @property {number} lastEditedTime - */ - /** - * @type {PageMetadata[]} - */ let pagesConverted = pagesValid .map(row => { return { - id: row.uri.split('/').pop().split('?')[0], + id: (row.uri.split('/').pop() || "").split('?')[0], icon: row.icon, iconHTML: renderIconToHTML(row.icon), cover: row.cover, @@ -121,22 +83,6 @@ column with id "${propertyId}" is used`) } }) - /** - * The site metadata - * @typedef {Object} SiteMetadata - * @property {string} icon - * @property {string} iconHTML - * @property {string} cover - * @property {string} title - * @property {Notion.StyledString[]} description - * @property {string} descriptionPlain - * @property {string} descriptionHTML - * @property {PageMetadata[]} pages - * @property {Map} tagMap - */ - /** - * @type {SiteMetadata} - */ let siteMeta = { icon: pageCollection.icon, iconHTML: renderIconToHTML(pageCollection.icon), diff --git a/src/preview.js b/src/preview.ts similarity index 76% rename from src/preview.js rename to src/preview.ts index 525b7b0..354986c 100644 --- a/src/preview.js +++ b/src/preview.ts @@ -1,6 +1,6 @@ -const path = require('path') -const { spawn } = require('child_process') -const { getConfig, outDir } = require('./util') +import path from 'path' +import { spawn } from 'child_process' +import { getConfig, outDir } from './util' /** * Open `index` with `bin`. @@ -17,13 +17,11 @@ function open(bin, index) { * Preview the generate blog. * @param {string} workDir */ -function preview(workDir) { +export function preview(workDir) { const c = getConfig(workDir) if (c.previewBrowser) { open(c.previewBrowser, path.join(outDir(workDir), 'index.html')) } else { throw new Error('"previewBrowser" property is not set in your Notablog config file.') } -} - -module.exports = { preview } \ No newline at end of file +} \ No newline at end of file diff --git a/src/render-index.js b/src/render-index.ts similarity index 61% rename from src/render-index.js rename to src/render-index.ts index af3e80a..b52d8dc 100644 --- a/src/render-index.js +++ b/src/render-index.ts @@ -1,14 +1,14 @@ -const fs = require('fs') -const path = require('path') -const Sqrl = require('squirrelly') +import fs from 'fs' +import path from 'path' +import Sqrl from 'squirrelly' -const { log } = require('./util') +import { log } from './util' -module.exports = { - renderIndex +function escapeTag(tag: string) { + return tag.replace(/[&\/\\#, +()$~%.'":*?<>{}]/g, '-'); } -function renderIndex(task) { +export function renderIndex(task) { const siteMeta = task.data.siteMeta const templateProvider = task.tools.templateProvider const config = task.config @@ -17,6 +17,7 @@ function renderIndex(task) { const indexPath = path.join(outDir, 'index.html') Sqrl.autoEscaping(false) + Sqrl.defineFilter('escapeTag', (str: string) => escapeTag(str)) log.info('Render home page') const html = Sqrl.Render(templateProvider.get('index'), { @@ -31,6 +32,6 @@ function renderIndex(task) { tagName: tagVal, pages: pageMetas }) - fs.writeFileSync(`${config.tagDir}/${tagVal}.html`, html, { encoding: 'utf-8' }) + fs.writeFileSync(`${config.tagDir}/${escapeTag(tagVal)}.html`, html, { encoding: 'utf-8' }) }) } \ No newline at end of file diff --git a/src/render-post.js b/src/render-post.ts similarity index 77% rename from src/render-post.js rename to src/render-post.ts index 075b818..6cab65b 100644 --- a/src/render-post.js +++ b/src/render-post.ts @@ -1,22 +1,18 @@ -const fs = require('fs') -const fsPromises = fs.promises -const path = require('path') -const { getOnePageAsTree } = require('nast-util-from-notionapi') -const { renderToHTML } = require('nast-util-to-react') -const Sqrl = require('squirrelly') +import fs from 'fs' +import fsPromises = fs.promises +import path from 'path' +import { getOnePageAsTree } from 'nast-util-from-notionapi' +import { renderToHTML } from 'nast-util-to-react' +import Sqrl from 'squirrelly' -const { log, parseJSON } = require('./util') -const { toDashID } = require('./notion-utils') - -module.exports = { - renderPost -} +import { log, parseJSON } from './util' +import { toDashID } from './notion-utils' /** * Render a post. * @param {RenderPostTask} task */ -async function renderPost(task) { +export async function renderPost(task) { const siteMeta = task.data.siteMeta const templateProvider = task.tools.templateProvider @@ -50,7 +46,7 @@ Cache of page "${pageID}" is corrupted, delete source/notion_cache to rebuild`) /** Render with template. */ if (page.publish) { log.info(`Render page "${pageID}"`) - contentHTML = renderToHTML(nast, { contentOnly: true }) + contentHTML = renderToHTML(nast) const outDir = config.outDir const postPath = path.join(outDir, page.url) diff --git a/src/table.ts b/src/table.ts new file mode 100644 index 0000000..ccbac53 --- /dev/null +++ b/src/table.ts @@ -0,0 +1,258 @@ +import { numToOrder } from "./util" + +interface PropertyValue { + type: Notion.Collection.ColumnPropertyType + value: any, + groupKeys?: string[] +} + +function parsePropertyValue( + column: Notion.Collection.ColumnProperty, + columnId: Notion.Collection.ColumnID, + row: NAST.Page +): PropertyValue { + /** + * A common place to get the column value. + * However, some types of column values are at other places. + */ + const data = (row.properties || {})[columnId] + switch (column.type) { + case "title": + return { + type: column.type, + value: row.title + } + case "checkbox": + return { + type: column.type, + value: data ? data[0][0] === "Yes" : false, + groupKeys: [data ? (data[0][0] === "Yes").toString() : "false"] + } + case "select": + case "multi_select": { + const optionNames = data ? data[0][0].split(",") : [] + const optionVals = optionNames.map(optionName => { + const option = (column.options || []) + .find(o => o.value === optionName) + if (!option) { + console.log(`Select option "${optionName}" is \ +not found on property "${columnId}:${column.name}".`) + return { + color: "default", + value: optionName + } + } else { + return { + color: option.color, + value: option.value + } + } + }) + return { + type: column.type, + value: optionVals, + groupKeys: optionVals.map(o => o.value) + } + } + // TODO: NAST currently do not have the following 2 information. + case "created_by": + case "last_edited_by": + return { + type: column.type, + value: "Someone" + } + case "created_time": + return { + type: column.type, + value: row.createdTime + } + case "last_edited_time": + return { + type: column.type, + value: row.lastEditedTime + } + default: + return { + type: column.type, + value: (row.properties || {})[columnId] + } + } +} + +export interface ISchema { + idPropertyMap: { + [key in Notion.Collection.ColumnID]: Notion.Collection.ColumnProperty + } + nameIdsMap: { + [key in Notion.Collection.ColumnProperty["name"]]: Notion.Collection.ColumnID[] + } + lookupIdsByName: (name: string) => Notion.Collection.ColumnID[] +} + +export interface IRecord { + uri: NAST.URI + title: NAST.SemanticString[] + icon?: NAST.Emoji | NAST.PublicUrl + cover?: NAST.PublicUrl + coverPosition: number + fullWidth: boolean + schema: Schema + getPropertyValueById: (id: Notion.Collection.ColumnID) => PropertyValue | undefined + getPropertyValuesByName: (name: Notion.Collection.ColumnProperty["name"]) => PropertyValue[] +} + +export interface ITable { + uri: NAST.URI + name: NAST.SemanticString[] + description?: NAST.SemanticString[] + icon?: NAST.Emoji | NAST.PublicUrl + cover?: NAST.PublicUrl + coverPosition: number + schema: Schema + records: Record[] +} + +export class Schema implements ISchema { + idPropertyMap: { + [key in Notion.Collection.ColumnID]: Notion.Collection.ColumnProperty + } + nameIdsMap: { + [key in Notion.Collection.ColumnProperty["name"]]: Notion.Collection.ColumnID[] + } + + constructor(rawSchema: NAST.Collection["schema"]) { + this.idPropertyMap = rawSchema + this.nameIdsMap = Object.entries(rawSchema).reduce((map, pair) => { + const propertyId = pair[0] + const propertyName = pair[1].name + if (map[propertyName] && Array.isArray(map[propertyName])) { + map[propertyName].push(propertyId) + } else { + map[propertyName] = [propertyId] + } + return map + }, {}) + } + + lookupIdsByName(name: string) { + const ids = this.nameIdsMap[name] + if (ids) return ids + else return [] + } +} + +export class Record implements IRecord { + uri: NAST.URI + title: NAST.SemanticString[] + icon?: NAST.Emoji | NAST.PublicUrl + cover?: NAST.PublicUrl + coverPosition: number + fullWidth: boolean + schema: Schema + private _rawRecord: NAST.Page + + constructor(rawRecord: NAST.Page, schema: Schema) { + this.uri = rawRecord.uri + this.title = rawRecord.title + this.icon = rawRecord.icon + this.cover = rawRecord.cover + this.coverPosition = rawRecord.coverPosition + this.fullWidth = rawRecord.fullWidth + this.schema = schema + this._rawRecord = rawRecord + } + + /** A property has an unique id. */ + getPropertyValueById(id: Notion.Collection.ColumnID) { + const property = this.schema.idPropertyMap[id] + if (property) + return parsePropertyValue(property, id, this._rawRecord) + else + return undefined + } + + /** Properties may have the same name. */ + getPropertyValuesByName(name: Notion.Collection.ColumnProperty["name"]) { + const propertyIds = this.schema.lookupIdsByName(name) + const propertyValues = propertyIds.reduce((pvs, id) => { + const pv = this.getPropertyValueById(id) + if (pv) pvs.push(pv) + return pvs + }, [] as PropertyValue[]) + return propertyValues + } +} + +export class Table implements ITable { + uri: NAST.URI + name: NAST.SemanticString[] + description?: NAST.SemanticString[] + icon?: NAST.Emoji | NAST.PublicUrl + cover?: NAST.PublicUrl + coverPosition: number + schema: Schema + records: Record[] + + constructor(rawTable: NAST.CollectionPage) { + this.uri = rawTable.uri + this.name = rawTable.name + this.description = rawTable.description + this.icon = rawTable.icon + this.cover = rawTable.cover + this.coverPosition = rawTable.coverPosition + this.schema = new Schema(rawTable.schema) + this.records = rawTable.children + .map(record => new Record(record, this.schema)) + } + + recordsGroupByProperty(propertyName: string, which: number = 1) { + const groups: { [key: string]: Record[] } = {} + const ungrouped: Record[] = [] + const propertyIds = this.schema.lookupIdsByName(propertyName) + const selectedPropertyId = propertyIds[which - 1] + if (!selectedPropertyId) { + console.log(`\ +Cannot find property of name "${propertyName}" that is the \ +${numToOrder(which)} of properties having the name in schema. +Return all records as ungrouped.`) + return { groups, ungrouped: this.records } + } + + for (let i = 0; i < this.records.length; i++) { + const record = this.records[i] + const propertyValue = + record.getPropertyValueById(selectedPropertyId) + if (!propertyValue) { + console.log(`\ +Cannot get property value on record \ +"${record.title.reduce((str, ss) => str += ss[0], "")}", \ +but the requested property exists in schema. +Is the table broken?`) + ungrouped.push(record) + continue + } + + const groupKeys = propertyValue.groupKeys + if (!groupKeys) { + console.log(`\ +Cannot group records by property name "${propertyName}" of type \ +"${propertyValue.type}".`) + ungrouped.push(record) + continue + } + + groupKeys.forEach(key => { + if (groups[key]) { + groups[key].push(record) + } else { + groups[key] = [record] + } + }) + } + + return { + groups, ungrouped + } + } +} + diff --git a/src/template-provider.js b/src/template-provider.ts similarity index 85% rename from src/template-provider.js rename to src/template-provider.ts index 87b418f..87145f9 100644 --- a/src/template-provider.js +++ b/src/template-provider.ts @@ -1,9 +1,13 @@ -const path = require('path') -const fs = require('fs') +import path from 'path' +import fs from 'fs' -const { log } = require('./util') +import { log } from './util' -class TemplateProvider { +export class TemplateProvider { + private templateDir: string + private templateMap: { + [key: string]: string + } /** * @param {string} themeDir @@ -36,7 +40,7 @@ class TemplateProvider { * If loading failed, return an error message string. * @param {string} templateName */ - _load(templateName) { + private _load(templateName) { log.debug(`Load template "${templateName}"`) let templatePath = path.join(this.templateDir, `${templateName}.html`) try { @@ -51,6 +55,4 @@ class TemplateProvider { return 'Template name has length 0, please check "template" field of your table on Notion' } } -} - -module.exports = TemplateProvider \ No newline at end of file +} \ No newline at end of file diff --git a/src/util.js b/src/util.js deleted file mode 100644 index 987eaeb..0000000 --- a/src/util.js +++ /dev/null @@ -1,63 +0,0 @@ -const fs = require('fs') -const path = require('path') -const { Logger } = require('@dnpr/logger') - -/** - * Wrapper of console.log(). - */ -const log = new Logger('notablog', { - logLevel: typeof process.env.DEBUG_EN !== 'undefined' ? 'debug' : 'info', - useColor: typeof process.env.NO_COLOR !== 'undefined' ? false : true -}) - -/** - * Failsafe JSON.parse() wrapper. - * @param {*} str - Payload to parse. - * @returns {Object} Parsed object when success, undefined when fail. - */ -function parseJSON(str) { - try { - return JSON.parse(str) - } catch (error) { - return void 0 - } -} - -/** - * @typedef {Object} NotablogConfig - * @property {string} url - URL of a Notion table. - * @property {string} theme - Name of a theme. - * @property {string} previewBrowser - Path to a browser executable. - */ - -/** - * Read and parse the JSON config file. - * @param {string} workDir - A valid Notablog starter directory. - * @returns {NotablogConfig} - */ -function getConfig(workDir) { - const cPath = path.join(workDir, 'config.json') - const cFile = fs.readFileSync(cPath, { encoding: 'utf-8' }) - try { - const config = JSON.parse(cFile) - return config - } catch (error) { - console.error(error) - throw new Error(`Fail to parse config at ${cPath}`) - } -} - -/** - * Get the path of output dir and ensure it is available. - * @param {string} workDir - * @returns {string} - */ -function outDir(workDir) { - const outDir = path.join(workDir, 'public') - if (!fs.existsSync(outDir)) { - fs.mkdirSync(outDir, { recursive: true }) - } - return outDir -} - -module.exports = { log, parseJSON, getConfig, outDir } \ No newline at end of file diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..1af28f5 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,119 @@ +import fs from 'fs' +import path from 'path' +import { Logger } from '@dnpr/logger' + +/** + * Wrapper of console.log(). + */ +export const log = new Logger('notablog', { + logLevel: typeof process.env.DEBUG !== 'undefined' ? 'debug' : 'info', + useColor: typeof process.env.NO_COLOR !== 'undefined' ? false : true +}) + +/** + * Log a message to indicate a feature is being deprecated. + * @param msg - The message. + */ +export function DEPRECATE(msg: string) { + log.warn(msg) +} + +/** + * Failsafe JSON.parse() wrapper. + * @param str - Payload to parse. + * @returns Parsed object when success, undefined when fail. + */ +export function parseJSON(str): object | undefined { + try { + return JSON.parse(str) + } catch (error) { + return void 0 + } +} + +/** + * Get the path of output dir and ensure it is available. + * @param {string} workDir + * @returns {string} + */ +export function outDir(workDir) { + const outDir = path.join(workDir, 'public') + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }) + } + return outDir +} + +export function numToOrder(n: number) { + switch (n) { + case 1: + return '1st' + case 2: + return '2nd' + case 3: + return '3rd' + default: + return `${n}th` + } +} + +/** + * Make doing multi-layer object or array access like `obj.a.b.c.d` or + * `arr[0][1][0][1]` more easily. + * + * Example Usage: + * + * In the constructor of {@link NDateTimeCell}, we want to access + * a `NAST.DateTime` stored in a `NAST.SemanticString[]`. + * + * With vanilla JS, we would write: + * + * ``` + * something[0][1][0][1] + * ``` + * + * which is prone to get the error: + * + * ``` + * TypeError: Cannot read property '0' of undefined + * ``` + * + * We could use `try...catch...` to wrap it: + * + * ``` + * try { + * result = something[0][1][0][1] + * } catch(error) { + * result = undefined + * } + * ``` + * + * But with this helper function, we could simply write: + * + * ``` + * result = objAccess(something)(0)(1)(0)(1)() + * ``` + * + * However, note that the cost is that an `undefined` occurred in the + * middle of the function call chain would be passed to the end + * instead of stop execution. + * + * @param objLike - An object (or an array). + */ +export function objAccess(objLike) { + return function (key) { + /** Call with no parameter to signal the end of the access chain. */ + if (typeof key === 'undefined') { + return objLike + } + /** + * Try to access the array if it is truthy. + * Otherwise, just pass the falsy value. + */ + if (objLike) { + return objAccess(objLike[key]) + } else { + return objAccess(objLike) + } + } +} \ No newline at end of file diff --git a/test/cache/cache.spec.ts b/test/cache/cache.spec.ts new file mode 100644 index 0000000..2a20c2e --- /dev/null +++ b/test/cache/cache.spec.ts @@ -0,0 +1,40 @@ +import fs from 'fs' +import { test } from 'zora' +import { Timer, sleep } from '../util' +import { Cache } from '../../src/cache' + +test('Cache', async (t) => { + const timer = new Timer() + + const cache = new Cache(__dirname) + const namespace = 'test' + const id = '1234' + const data = { title: 'test' } + const fPath = cache.fPath(namespace, id) + + t.equal(typeof cache.get(namespace, id), 'undefined', + 'get() non-existing object should return undefined.') + + cache.set(namespace, id, data) + t.equal(fs.existsSync(fPath), true, + 'set() should write an object to a file.') + + t.deepEqual(cache.get(namespace, id), data, + 'get() existing object should return the object.') + + timer.pause() + + /** Ensure Date.now() can get a larger value. */ + await sleep(500) + + timer.continue() + + t.equal(cache.shouldUpdate(namespace, id, Date.now()), true, + 'shouldUpdate() query with a timestamp larger than the \ +lastModifiedTime of the cache onject should return true.') + + /** Clean up. */ + fs.unlinkSync(fPath) + + timer.stop() +}) \ No newline at end of file diff --git a/test/ntable.spec.ts b/test/ntable.spec.ts new file mode 100644 index 0000000..97977a4 --- /dev/null +++ b/test/ntable.spec.ts @@ -0,0 +1,34 @@ +import { createAgent } from "notionapi-agent" +import { getOnePageAsTree } from "nast-util-from-notionapi" +import { getPageIDFromCollectionPageURL } from "../src/notion-utils" +import { Table } from "../src/table" +import { NTable } from "../src/ntable" + +async function main() { + try { + const pageId = getPageIDFromCollectionPageURL("https://www.notion.so/595365eeed0845fb9f4d641b7b845726?v=a1cb648704784afea1d5cdfb8ac2e9f0") + const collectionPage = (await getOnePageAsTree(pageId, createAgent())) as NAST.CollectionPage + const table = new Table(collectionPage) + const table2 = new NTable(collectionPage) + table.records.forEach(record => { + console.log(record.title) + console.log(record.getPropertyValuesByName("Tag")) + console.dir(record.getPropertyValuesByName("tags"), { depth: 100 }) + }) + console.dir(table.recordsGroupByProperty("Tag"), { depth: 2 }) + console.dir(table.recordsGroupByProperty("tags", 2), { depth: 2 }) + console.dir(table.recordsGroupByProperty("tags"), { depth: 2 }) + console.dir(table.recordsGroupByProperty("title"), { depth: 2 }) + console.dir(table.recordsGroupByProperty("publish"), { depth: 2 }) + console.dir(table.recordsGroupByProperty("template"), { depth: 2 }) + console.dir(table2.schema, { depth: 2 }) + table2.records.forEach(record => { + console.dir((record.properties.get(table2.schema[8]) || {}).value) + }) + table2.peekStructure() + } catch (error) { + console.error(error) + } +} + +main() \ No newline at end of file diff --git a/test/util.spec.ts b/test/util.spec.ts new file mode 100644 index 0000000..a1c4620 --- /dev/null +++ b/test/util.spec.ts @@ -0,0 +1,36 @@ +import { test } from 'zora' +import { objAccess, numToOrder } from '../src/util' + +test('objAccess', (t) => { + + const arr = [[[[1234]]]] + const obj = { + a: { + b: { + c: { + d: 'hello' + } + } + } + } + + t.equal(objAccess(arr)(0)(0)(0)(0)(), 1234, + 'Array: access existing value returns the value.') + t.equal(objAccess(arr)(0)(0)(1)(1)(), undefined, + 'Array: access non-existing value returns undefined.') + t.equal(objAccess(obj)('a')('b')('c')('d')(), 'hello', + 'Object: access existing value returns the value.') + t.equal(objAccess(obj)('a')('b')('e')('c')(), undefined, + 'Object: access non-existing value returns undefined.') + +}) + +test('numToOrder', (t) => { + + t.equal(numToOrder(1), '1st', 'Convert 1 to 1st.') + t.equal(numToOrder(2), '2nd', 'Convert 2 to 2nd.') + t.equal(numToOrder(3), '3rd', 'Convert 3 to 3rd.') + t.equal(numToOrder(4), '4th', 'Convert others (4) to nth (4th).') + t.equal(numToOrder(10), '10th', 'Convert others (10) to nth (10th).') + +}) \ No newline at end of file diff --git a/test/util.ts b/test/util.ts new file mode 100644 index 0000000..0a163a5 --- /dev/null +++ b/test/util.ts @@ -0,0 +1,41 @@ +export async function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +export function hrDiffToMs(hrDiff: [number, number]) { + return hrDiff[0] * 1e3 + hrDiff[1] / 1e6 +} + +export class Timer { + private start: [number, number] + private elapsedMs: number + private paused: boolean + + /** Start the timer. */ + constructor() { + this.start = process.hrtime() + this.elapsedMs = 0 + this.paused = false + } + + pause() { + const diff = process.hrtime(this.start) + this.elapsedMs += hrDiffToMs(diff) + this.paused = true + } + + continue() { + this.paused = false + this.start = process.hrtime() + } + + /** Stop the timer and print the elapsed time. */ + stop() { + if (!this.paused) { + this.pause() + } + console.log(`Execution time: ${this.elapsedMs} ms`) + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ae05c3b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "target": "esnext", + "module": "commonjs", + "lib": [ + "es7" + ], + "moduleResolution": "node", + "strict": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "removeComments": false, + "preserveConstEnums": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "noImplicitAny": false // to use untyped modules + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "test/**/*" + ] +} \ No newline at end of file