first commit
Some checks are pending
Test / test (push) Waiting to run

This commit is contained in:
jorming.chong 2026-01-07 09:29:07 +08:00
parent f665fcbb54
commit 148182c680
66 changed files with 10329 additions and 0 deletions

42
.dockerignore Normal file
View File

@ -0,0 +1,42 @@
# Git
**/.git/
# Backup
**/*.bak
# NodeJS
**/node_modules/
# coverage test output
.nyc_output
coverage/
# log files
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Yarn2 (PnP)
.pnp.*
.yarn/*
!.yarn/releases/
# tsc buildinfo
*.tsbuildinfo
# VScode
.vscode/
# Test output
**/.nyc_output
# Volumes (for Docker deployment)
volumes/*
# App specific
.env
dist/
scripts/

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*]
tab_width = 2
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
dist/
node_modules/
.pnp.*
.yarn/*

42
.eslintrc.js Normal file
View File

@ -0,0 +1,42 @@
// eslint-disable-next-line no-undef
module.exports = {
env: {
node: true,
es2021: true,
},
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'prettier',
],
settings: {
'import/resolver': { typescript: {} },
'import/external-module-folders': ['node_modules', '.yarn'],
},
parserOptions: {
project: './tsconfig.json',
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'no-underscore-dangle': 'off',
'require-await': 'warn',
eqeqeq: 'warn',
'import/extensions': 'off',
'import/order': [
'error',
{
named: true,
alphabetize: { order: 'asc' },
warnOnUnassignedImports: true,
},
],
'import/no-cycle': ['error', { ignoreExternal: true }],
'import/no-self-import': 'error',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
},
};

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated

View File

@ -0,0 +1,34 @@
name: Release Docker Image
on:
push:
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Login to Gitea
uses: docker/login-action@v3
with:
registry: ${{ vars._CI_REGISTRY }}
username: ${{ vars._CI_REGISTRY_USER }}
password: ${{ secrets._CI_REGISTRY_ACCESS_TOKEN }}
# Remarks: Setting up QEMU and Buildx are useful for cross-platform build but it will slow the CI pipeline.
# - name: Set up QEMU
# uses: docker/setup-qemu-action@v3
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
secrets: JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN=${{ secrets._CI_REGISTRY_ACCESS_TOKEN }}
push: true
tags: ${{ vars._CI_REGISTRY }}/${{ gitea.repository }}:${{ gitea.ref_name }},${{ vars._CI_REGISTRY }}/${{ gitea.repository }}:latest

View File

@ -0,0 +1,27 @@
name: Test
on:
push:
paths:
- .gitea/workflows/test.yaml
- src/**/*
- test/**/*
- .mocharc.js
- tsconfig.json
- package.json
- .yarnrc.yml
- yarn.lock
jobs:
test:
runs-on: ubuntu-latest
container:
image: node:lts
env:
JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN: ${{ secrets._CI_REGISTRY_ACCESS_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install packages
run: corepack enable && yarn
- name: Test code
run: yarn test:coverage

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
node_modules
# OS
.DS_Store
# npm settings
.npmrc
# coverage test output
.nyc_output
/coverage/
# log files
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Backup
*.bak
# yarn files (sdks are excluded as it depends on vscode version)
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# tsc buildinfo
*.tsbuildinfo
# tsc build output
/dist/
# Volumes (for Docker deployment)
/volumes/*
# env
/.env
# GitKeep
!.gitkeep

15
.mocharc.js Normal file
View File

@ -0,0 +1,15 @@
const [_major, _minor] = process.version.slice(1).split('.', 2);
const major = parseInt(_major, 10);
const minor = parseInt(_minor, 10);
// Disable experimental-strip-types to handle enum transform with ts-node
module.exports = {
require: 'ts-node/register',
spec: ['test/**/*.spec.ts'],
'node-option':
major > 22 || (major === 22 && minor >= 6)
? ['no-experimental-strip-types']
: [],
};

5
.prettierignore Normal file
View File

@ -0,0 +1,5 @@
dist/
node_modules/
.pnp.*
.yarn/*
.vscode/*

15
.prettierrc Normal file
View File

@ -0,0 +1,15 @@
{
"semi": true,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"singleQuote": true,
"overrides": [
{
"files": ".yarnrc.yml",
"options": {
"singleQuote": false
}
}
]
}

7
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"recommendations": [
"arcanis.vscode-zipfs",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}

10
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"search.exclude": {
"**/.yarn": true,
"**/.pnp.*": true
},
"eslint.nodePath": ".yarn/sdks",
"prettier.prettierPath": ".yarn/sdks/prettier/index.cjs",
"typescript.tsdk": ".yarn/sdks/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

BIN
.yarn/releases/yarn-4.12.0.cjs vendored Executable file

Binary file not shown.

32
.yarn/sdks/eslint/bin/eslint.js vendored Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/bin/eslint.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint/bin/eslint.js your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint/bin/eslint.js`));

32
.yarn/sdks/eslint/lib/api.js vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint`));

View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/use-at-your-own-risk
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real eslint/use-at-your-own-risk your application uses
module.exports = wrapWithUserWrapper(absRequire(`eslint/use-at-your-own-risk`));

14
.yarn/sdks/eslint/package.json vendored Normal file
View File

@ -0,0 +1,14 @@
{
"name": "eslint",
"version": "8.57.1-sdk",
"main": "./lib/api.js",
"type": "commonjs",
"bin": {
"eslint": "./bin/eslint.js"
},
"exports": {
"./package.json": "./package.json",
".": "./lib/api.js",
"./use-at-your-own-risk": "./lib/unsupported-api.js"
}
}

5
.yarn/sdks/integrations.yml vendored Normal file
View File

@ -0,0 +1,5 @@
# This file is automatically generated by @yarnpkg/sdks.
# Manual changes might be lost!
integrations:
- vscode

32
.yarn/sdks/prettier/bin/prettier.cjs vendored Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/bin/prettier.cjs
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real prettier/bin/prettier.cjs your application uses
module.exports = wrapWithUserWrapper(absRequire(`prettier/bin/prettier.cjs`));

32
.yarn/sdks/prettier/index.cjs vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real prettier your application uses
module.exports = wrapWithUserWrapper(absRequire(`prettier`));

7
.yarn/sdks/prettier/package.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"name": "prettier",
"version": "3.7.4-sdk",
"main": "./index.cjs",
"type": "commonjs",
"bin": "./bin/prettier.cjs"
}

32
.yarn/sdks/typescript/bin/tsc vendored Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsc
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real typescript/bin/tsc your application uses
module.exports = wrapWithUserWrapper(absRequire(`typescript/bin/tsc`));

32
.yarn/sdks/typescript/bin/tsserver vendored Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsserver
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real typescript/bin/tsserver your application uses
module.exports = wrapWithUserWrapper(absRequire(`typescript/bin/tsserver`));

32
.yarn/sdks/typescript/lib/tsc.js vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = wrapWithUserWrapper(absRequire(`typescript/lib/tsc.js`));

248
.yarn/sdks/typescript/lib/tsserver.js vendored Normal file
View File

@ -0,0 +1,248 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
const moduleWrapper = exports => {
return wrapWithUserWrapper(moduleWrapperFn(exports));
};
const moduleWrapperFn = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
const [major, minor] = absRequire(`typescript/package.json`).version.split(`.`, 2).map(value => parseInt(value, 10));
// In TypeScript@>=5.5 the tsserver uses the public TypeScript API so that needs to be patched as well.
// Ref https://github.com/microsoft/TypeScript/pull/55326
if (major > 5 || (major === 5 && minor >= 5)) {
moduleWrapper(absRequire(`typescript`));
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View File

@ -0,0 +1,248 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserverlibrary.js
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
const moduleWrapper = exports => {
return wrapWithUserWrapper(moduleWrapperFn(exports));
};
const moduleWrapperFn = tsserver => {
if (!process.versions.pnp) {
return tsserver;
}
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//);
const isPortal = str => str.startsWith("portal:/");
const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes it much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str;
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) {
str = resolved;
}
}
str = normalize(str);
if (str.match(/\.zip\//)) {
switch (hostInfo) {
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
// 2021-10-08: VSCode changed the format in 1.61.
// Before | ^zip:/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
// 2022-04-06: VSCode changed the format in 1.66.
// Before | ^/zip//c:/foo/bar.zip/package.json
// After | ^/zip/c:/foo/bar.zip/package.json
//
// 2022-05-06: VSCode changed the format in 1.68
// Before | ^/zip/c:/foo/bar.zip/package.json
// After | ^/zip//c:/foo/bar.zip/package.json
//
case `vscode <1.61`: {
str = `^zip:${str}`;
} break;
case `vscode <1.66`: {
str = `^/zip/${str}`;
} break;
case `vscode <1.68`: {
str = `^/zip${str}`;
} break;
case `vscode`: {
str = `^/zip/${str}`;
} break;
// To make "go to definition" work,
// We have to resolve the actual file system path from virtual path
// and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip)
case `coc-nvim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = resolve(`zipfile:${str}`);
} break;
// Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server)
// We have to resolve the actual file system path from virtual path,
// everything else is up to neovim
case `neovim`: {
str = normalize(resolved).replace(/\.zip\//, `.zip::`);
str = `zipfile://${str}`;
} break;
default: {
str = `zip:${str}`;
} break;
}
} else {
str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`);
}
}
return str;
}
function fromEditorPath(str) {
switch (hostInfo) {
case `coc-nvim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/...
// So in order to convert it back, we use .* to match all the thing
// before `zipfile:`
return process.platform === `win32`
? str.replace(/^.*zipfile:\//, ``)
: str.replace(/^.*zipfile:/, ``);
} break;
case `neovim`: {
str = str.replace(/\.zip::/, `.zip/`);
// The path for neovim is in format of zipfile:///<pwd>/.yarn/...
return str.replace(/^zipfile:\/\//, ``);
} break;
case `vscode`:
default: {
return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`)
} break;
}
}
// Force enable 'allowLocalPluginLoads'
// TypeScript tries to resolve plugins using a path relative to itself
// which doesn't work when using the global cache
// https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238
// VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but
// TypeScript already does local loads and if this code is running the user trusts the workspace
// https://github.com/microsoft/vscode/issues/45856
const ConfiguredProject = tsserver.server.ConfiguredProject;
const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype;
ConfiguredProject.prototype.enablePluginsWithOptions = function() {
this.projectService.allowLocalPluginLoads = true;
return originalEnablePluginsWithOptions.apply(this, arguments);
};
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let hostInfo = `unknown`;
Object.assign(Session.prototype, {
onMessage(/** @type {string | object} */ message) {
const isStringMessage = typeof message === 'string';
const parsedMessage = isStringMessage ? JSON.parse(message) : message;
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
typeof parsedMessage.arguments.hostInfo === `string`
) {
hostInfo = parsedMessage.arguments.hostInfo;
if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) {
const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match(
// The RegExp from https://semver.org/ but without the caret at the start
/(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
) ?? []).map(Number)
if (major === 1) {
if (minor < 61) {
hostInfo += ` <1.61`;
} else if (minor < 66) {
hostInfo += ` <1.66`;
} else if (minor < 68) {
hostInfo += ` <1.68`;
}
}
}
}
const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => {
return typeof value === 'string' ? fromEditorPath(value) : value;
});
return originalOnMessage.call(
this,
isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON)
);
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
return tsserver;
};
const [major, minor] = absRequire(`typescript/package.json`).version.split(`.`, 2).map(value => parseInt(value, 10));
// In TypeScript@>=5.5 the tsserver uses the public TypeScript API so that needs to be patched as well.
// Ref https://github.com/microsoft/TypeScript/pull/55326
if (major > 5 || (major === 5 && minor >= 5)) {
moduleWrapper(absRequire(`typescript`));
}
// Defer to the real typescript/lib/tsserverlibrary.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`));

32
.yarn/sdks/typescript/lib/typescript.js vendored Normal file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, register} = require(`module`);
const {resolve} = require(`path`);
const {pathToFileURL} = require(`url`);
const relPnpApiPath = "../../../../.pnp.cjs";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`);
const absRequire = createRequire(absPnpApiPath);
const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`);
const isPnpLoaderEnabled = existsSync(absPnpLoaderPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript
require(absPnpApiPath).setup();
if (isPnpLoaderEnabled && register) {
register(pathToFileURL(absPnpLoaderPath));
}
}
}
const wrapWithUserWrapper = existsSync(absUserWrapperPath)
? exports => absRequire(absUserWrapperPath)(exports)
: exports => exports;
// Defer to the real typescript your application uses
module.exports = wrapWithUserWrapper(absRequire(`typescript`));

10
.yarn/sdks/typescript/package.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"name": "typescript",
"version": "5.9.3-sdk",
"main": "./lib/typescript.js",
"type": "commonjs",
"bin": {
"tsc": "./bin/tsc",
"tsserver": "./bin/tsserver"
}
}

12
.yarnrc.yml Normal file
View File

@ -0,0 +1,12 @@
enableGlobalCache: false
nodeLinker: pnp
npmScopes:
jig-software:
npmAlwaysAuth: false
npmAuthToken: "${JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN:-NONE}"
npmPublishRegistry: "https://gitea.jig.com.hk/api/packages/jig-software/npm/"
npmRegistryServer: "https://gitea.jig.com.hk/api/packages/jig-software/npm/"
yarnPath: .yarn/releases/yarn-4.12.0.cjs

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY .yarnrc.yml package.json yarn.lock ./
COPY .yarn/releases ./.yarn/releases
RUN --mount=type=secret,id=JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN,env=JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN yarn
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN yarn build
RUN yarn workspaces focus --all --production
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY .yarnrc.yml package.json yarn.lock ./
COPY --from=build /app/dist ./dist
COPY --from=build /app/.yarn ./.yarn
COPY --from=build /app/.pnp.* ./
CMD ["yarn", "start"]
# Uncomment if healthcheck is needed
# HEALTHCHECK --interval=1m --timeout=10s --start-period=10s --retries=3 \
# CMD [ "yarn", "health-check"]

BIN
asset/fast_logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
asset/jec_logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

10
config.example.json Normal file
View File

@ -0,0 +1,10 @@
{
"port": 3000,
"apiPrefix": "/api",
"databaseURL": "mongodb://mongo:27017/?replicaSet=rs0",
"databaseName": "example",
"logDirectory": "volumes/logs",
"printLogLevel": "verbose",
"persistLogLevel": "info",
"defaultSettings": {}
}

BIN
event_report.xlsx Normal file

Binary file not shown.

83
init.sh Executable file
View File

@ -0,0 +1,83 @@
#!/bin/bash
# Workaround to nullify yarn 2+ auto git init with master branch
git init --initial-branch=main
yarn init --yes
yarn set version berry --yarn-path
yarn add \
@jig-software/sdk \
@jig-software/trest-core \
@jig-software/trest-client \
@jig-software/zod-bson \
@jig-software/logger \
@jig-software/logger-winston \
debug \
bson \
koa \
koa-body \
@koa/router \
zod \
mongodb \
eventemitter3 \
reflect-metadata
yarn add --dev \
@types/koa \
@types/koa__router \
@types/debug \
@types/node \
commitizen cz-emoji \
ts-node-dev \
@typescript-eslint/eslint-plugin \
@typescript-eslint/parser \
eslint@8 \
eslint-import-resolver-typescript \
eslint-plugin-import \
eslint-config-prettier \
typescript \
prettier \
mocha \
@types/mocha \
chai@^4.3.10 \
@types/chai@^4.3.11 \
nyc \
ts-node \
@types/node \
rimraf
yarn dlx @yarnpkg/sdks vscode
# Format by prettier
yarn prettier . --write
# Set npm environment to skip confirmation prompt
orig_npm_config_yes=$npm_config_yes
export npm_config_yes=yes
# Add lint script and package config to the project
./patch-package-json.sh
# Enable .gitea/workflows
mv .gitea/workflows.template .gitea/workflows
# Restore the original env
export npm_config_yes=$orig_npm_config_yes
for (( ; ; )); do
read -p "The initialization process is done. Do you want to remove this script file now? (Y/n): " remove
remove=${remove:-y}
echo $remove
if [ "$remove" = "y" ] || [ "$remove" = "Y" ]; then
rm ./package.patch.json
rm ./patch-package-json.sh
rm -- "$0"
break
else
if [ "$remove" = "n" ] || [ "$remove" = "N" ]; then
break
fi
fi
done

68
package.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "report-generation",
"main": "dist/index.js",
"files": [
"dist"
],
"scripts": {
"build": "yarn clean && tsc -b tsconfig.build.json",
"clean": "rimraf dist",
"commit": "cz",
"dev": "ts-node-dev --respawn --project tsconfig.json --watch ./volumes/config.json ./src/bootstrap.ts",
"health-check": "node ./dist/healthCheck.js",
"lint": "eslint '{src,test}/**/*.ts'",
"lint:src": "eslint 'src/**/*.ts'",
"lint:test": "eslint 'test/**/*.ts'",
"start": "node ./dist/bootstrap.js",
"test": "nyc mocha",
"test:coverage": "nyc --reporter cobertura mocha"
},
"config": {
"commitizen": {
"path": "cz-emoji"
}
},
"dependencies": {
"@jig-software/logger": "^1.1.0",
"@jig-software/logger-winston": "^1.0.0",
"@jig-software/sdk": "^7.0.0",
"@jig-software/trest-client": "^3.0.0",
"@jig-software/trest-core": "^3.0.0",
"@jig-software/zod-bson": "^2.0.0",
"@koa/router": "^15.2.0",
"bson": "^7.0.0",
"debug": "^4.4.3",
"eventemitter3": "^5.0.1",
"exceljs": "^4.4.0",
"koa": "^3.1.1",
"koa-body": "^7.0.1",
"mongodb": "^7.0.0",
"reflect-metadata": "^0.2.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/chai": "^4.3.11",
"@types/debug": "^4.1.12",
"@types/koa": "^3.0.1",
"@types/koa__router": "^12.0.5",
"@types/mocha": "^10.0.10",
"@types/node": "^25.0.3",
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0",
"chai": "^4.3.10",
"commitizen": "^4.3.1",
"cz-emoji": "^1.3.2-canary.2",
"eslint": "8",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"mocha": "^11.7.5",
"nyc": "^17.1.0",
"prettier": "^3.7.4",
"rimraf": "^6.1.2",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.9.3"
},
"packageManager": "yarn@4.12.0"
}

22
package.patch.json Normal file
View File

@ -0,0 +1,22 @@
{
"files": ["dist"],
"main": "dist/index.js",
"config": {
"commitizen": {
"path": "cz-emoji"
}
},
"scripts": {
"build": "yarn clean && tsc -b tsconfig.build.json",
"clean": "rimraf dist",
"commit": "cz",
"dev": "ts-node-dev --respawn --project tsconfig.json --watch ./volumes/config.json ./src/bootstrap.ts",
"lint": "eslint '{src,test}/**/*.ts'",
"lint:src": "eslint 'src/**/*.ts'",
"lint:test": "eslint 'test/**/*.ts'",
"start": "node ./dist/bootstrap.js",
"test": "nyc mocha",
"test:coverage": "nyc --reporter cobertura mocha",
"health-check": "node ./dist/healthCheck.js"
}
}

6
patch-package-json.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
# Workaround for yarn workspace
cat ./package.json ./package.patch.json | npx json --deep-merge | tee ./package.json.temp
mv ./package.json.temp ./package.json
npx sort-package-json

4
scripts/build_image.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
NAME=$(cat ./package.json | npx json name)
echo "Build image: $NAME"
docker build . -t $NAME --secret id=JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN,env=JIG_SOFTWARE_GITEA_DEPLOYMENT_TOKEN

12
src/Main.ts Normal file
View File

@ -0,0 +1,12 @@
import { Inject, KOA_APP, KoaApp, LC, Lifecycle } from '@jig-software/sdk';
import { ActiveAppSettings } from './providers';
export class Main extends Lifecycle {
@LC.bind
@Inject()
activeAppSettings!: ActiveAppSettings;
@LC.bind
@Inject(KOA_APP())
koaApp!: KoaApp;
}

37
src/bootstrap.ts Normal file
View File

@ -0,0 +1,37 @@
import { WinstonLogger } from '@jig-software/logger-winston';
import {
ConfigDI,
MongoDI,
KoaDI,
container,
LoggerDI,
} from '@jig-software/sdk';
import { Main } from './Main';
import {
AppConfig,
KoaAppOptionsFactory,
MongoDIOptionsFactory,
WinstonLoggerParamsFactory,
} from './config';
import { HealthCheckController, ReadingReportController } from './controllers';
import { ActiveAppSettings } from './providers';
import { AppSettingsRepository } from './repositories';
async function bootstrap() {
container.use(
ConfigDI.config(AppConfig, { configPath: './volumes/config.json' }),
LoggerDI.logger(WinstonLogger, {
paramsFactory: WinstonLoggerParamsFactory,
}),
MongoDI.root({ optionsFactory: MongoDIOptionsFactory }),
KoaDI.root({ optionsFactory: KoaAppOptionsFactory }),
MongoDI.repository('appSettings', AppSettingsRepository),
ActiveAppSettings,
);
await container.create(KoaDI.controller(HealthCheckController));
await container.create(KoaDI.controller(ReadingReportController));
const main = await container.create(Main);
await main.start();
process.on('unhandledRejection', console.error);
}
bootstrap().catch(console.error);

74
src/config.ts Normal file
View File

@ -0,0 +1,74 @@
import { WinstonLoggerOptions } from '@jig-software/logger-winston';
import {
Config,
Inject,
KoaAppOptions,
MongoDIOptions,
} from '@jig-software/sdk';
import { z } from 'zod';
import { AppSettingsInit } from './shared';
// Note: AppConfig is for Static props.
const LogLevel = z.enum([
'error',
'warn',
'info',
'http',
'verbose',
'debug',
'silly',
]);
const _AppConfig = z.object({
port: z.number().optional(),
apiPrefix: z.string().optional(),
databaseURL: z.string(),
databaseName: z.string(),
logDirectory: z.string().optional(),
printLogLevel: LogLevel.optional(),
persistLogLevel: LogLevel.optional(),
defaultSettings: AppSettingsInit,
});
export const AppConfig = Config.extend('AppConfig', _AppConfig);
export type AppConfig = Config.infer<typeof AppConfig>;
export class KoaAppOptionsFactory {
@Inject()
private appConfig!: AppConfig;
create(): KoaAppOptions {
return {
port: this.appConfig.port ?? 3000,
prefix: this.appConfig.apiPrefix,
};
}
}
export class MongoDIOptionsFactory {
@Inject()
private appConfig!: AppConfig;
create(): MongoDIOptions {
return {
uri: this.appConfig.databaseURL,
database: this.appConfig.databaseName,
clientOptions: { ignoreUndefined: true },
};
}
}
export class WinstonLoggerParamsFactory {
@Inject()
private appConfig!: AppConfig;
create(): [WinstonLoggerOptions] {
return [
{
logDirname: this.appConfig.logDirectory,
printLogLevel: this.appConfig.printLogLevel,
persistLogLevel: this.appConfig.persistLogLevel,
},
];
}
}

View File

@ -0,0 +1,22 @@
import { Logger } from '@jig-software/logger';
import { Controller, Inject } from '@jig-software/sdk';
import { healthCheckContract } from '../shared/contracts';
export class HealthCheckController extends Controller {
@Inject()
protected logger!: Logger;
constructor() {
super();
this.logger.setTag(this.constructor.name);
this.implement(healthCheckContract, (r) =>
r.handlers({
checkHealth: {
handler: () => {
return null;
},
},
}),
);
}
}

View File

@ -0,0 +1,184 @@
import { Logger } from '@jig-software/logger';
import { Controller, Inject } from '@jig-software/sdk';
import ExcelJS from 'exceljs';
import path from 'path';
import { readingReportContract } from '../shared';
import { createFileOutput } from '@jig-software/trest-core';
import fs from 'fs';
interface ReadingStats {
min?: string;
max?: string;
avg?: string;
}
interface DeviceData {
building: string;
floor: string;
device_name: string;
min_max_avg: Record<string, ReadingStats>;
readings: Record<string, Record<string, string>>;
}
interface ReportParams {
start_date?: string;
end_date?: string;
time_step?: string;
}
export class ReadingReportController extends Controller {
@Inject()
protected logger!: Logger;
constructor() {
super();
this.logger.setTag(this.constructor.name);
this.implement(readingReportContract, (r) =>
r.handlers({
generateReadingsReport: {
handler: async (FullReportSchema) => {
const data: DeviceData[] = FullReportSchema.data;
const reportParams: ReportParams = FullReportSchema.report_params;
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Event Records');
// ---------------- Logos ----------------
const jecLogoId = workbook.addImage({
filename: path.resolve('asset/jec_logo.jpg'),
extension: 'jpeg',
});
const fastLogoId = workbook.addImage({
filename: path.resolve('asset/fast_logo.jpg'),
extension: 'jpeg',
});
worksheet.addImage(jecLogoId, {
tl: { col: 0, row: 0 },
ext: { width: 120, height: 60 },
});
worksheet.addImage(fastLogoId, {
tl: { col: 7, row: 0 },
ext: { width: 120, height: 60 },
});
// ---------------- Title ----------------
worksheet.mergeCells('B1:G2');
const titleCell = worksheet.getCell('B1');
titleCell.value = 'Event Report';
titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
titleCell.font = { bold: true, size: 18 };
// ---------------- Fixed params ----------------
const fixedParams = [
['Start Date:', reportParams.start_date ?? ''],
['End Date:', reportParams.end_date ?? ''],
['Time Step:', reportParams.time_step ?? ''],
];
fixedParams.forEach(([label, value], idx) => {
worksheet.getCell(4 + idx, 2).value = label;
worksheet.getCell(4 + idx, 3).value = value;
});
// ---------------- Headers ----------------
const allDates = Array.from(
new Set(
data.flatMap((device) =>
Object.values(device.readings).flatMap((r) => Object.keys(r)),
),
),
).sort();
const headers = [
'Building',
'Floor',
'Device Name',
'Reading Type',
'Minimum',
'Maximum',
'Average',
...allDates,
];
const headerRowIndex = 7;
const headerRow = worksheet.getRow(headerRowIndex);
headerRow.values = headers;
headerRow.eachCell((cell) => {
cell.font = { bold: true };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFD9D9D9' },
};
});
// ---------------- Data ----------------
let currentRow = headerRowIndex + 1;
for (const device of data) {
const types = Object.keys(device.readings);
const startRow = currentRow;
const endRow = currentRow + types.length - 1;
if (types.length > 1) {
worksheet.mergeCells(startRow, 1, endRow, 1);
worksheet.mergeCells(startRow, 2, endRow, 2);
worksheet.mergeCells(startRow, 3, endRow, 3);
}
worksheet.getCell(startRow, 1).value = device.building;
worksheet.getCell(startRow, 2).value = device.floor;
worksheet.getCell(startRow, 3).value = device.device_name;
for (const type of types) {
const row = worksheet.getRow(currentRow);
row.getCell(4).value = type;
const stats = device.min_max_avg?.[type] ?? {};
row.getCell(5).value = stats.min ?? '-';
row.getCell(6).value = stats.max ?? '-';
row.getCell(7).value = stats.avg ?? '-';
allDates.forEach((date, idx) => {
row.getCell(8 + idx).value =
device.readings[type]?.[date] ?? '-';
});
currentRow++;
}
console.log(
`Processed device '${device.device_name}' rows ${startRow}${endRow}`,
);
}
// ---------------- Auto-width ----------------
worksheet.columns.forEach((col) => {
let max = 10;
col.eachCell!({ includeEmpty: true }, (cell) => {
const len = String(cell.value ?? '').length;
max = Math.max(max, len);
});
col.width = max + 2;
});
worksheet.autoFilter = {
from: { row: headerRowIndex, column: 1 },
to: { row: currentRow - 1, column: headers.length },
};
// ---------------- Save file ----------------
const outputPath = path.resolve('event_report.xlsx');
await workbook.xlsx.writeFile(outputPath);
console.log(`Excel report generated: ${outputPath}`);
const readStream = fs.createReadStream(outputPath);
return createFileOutput(readStream, 'event_report.xlsx');
},
},
}),
);
}
}

2
src/controllers/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './HealthCheckController';
export * from './ReadingReportController';

24
src/healthCheck.ts Normal file
View File

@ -0,0 +1,24 @@
import { ConfigDI, container } from '@jig-software/sdk';
import { TRestClient } from '@jig-software/trest-client';
import { AppConfig } from './config';
import { healthCheckContract } from './shared';
async function healthCheck() {
const appConfig = await container.create(
ConfigDI.config(AppConfig, {
configPath: './volumes/config.json',
}),
);
const { port, apiPrefix } = appConfig;
const baseUrl = apiPrefix
? `http://localhost:${port}/${apiPrefix.replace(/^\//, '')}`
: `http://localhost:${port}/`;
const healthCheckClient = new TRestClient(healthCheckContract, baseUrl);
await healthCheckClient.actions.checkHealth().unwrap();
console.info('OK');
}
healthCheck().catch((err) => {
console.error(err);
process.exit(1);
});

3
src/middlewares/index.ts Normal file
View File

@ -0,0 +1,3 @@
// * Export middlewares here
// * export * from './someMiddleware'
export {};

View File

@ -0,0 +1,170 @@
import { Logger } from '@jig-software/logger';
import { Inject, Lifecycle, MongoOrmValidationError } from '@jig-software/sdk';
import { EventEmitter } from 'eventemitter3';
import { ChangeStream } from 'mongodb';
import { AppConfig } from '../config';
import { AppSettingsRepository } from '../repositories';
import { AppSettings, AppSettingsInit } from '../shared';
export type AppSettingsChangeEmitter = EventEmitter<{
change: (appSettings: AppSettingsInit) => void;
error: (err: unknown) => void;
}>;
export class ActiveAppSettings extends Lifecycle {
@Inject()
protected logger!: Logger;
@Inject()
private appConfig!: AppConfig;
@Inject()
private appSettingsRepo!: AppSettingsRepository;
private _changeStream: ChangeStream | null = null;
private _appSettings: AppSettings | null = null;
private _changeEmitter: AppSettingsChangeEmitter = new EventEmitter();
constructor() {
super();
this.logger.setTag(this.constructor.name);
}
get changeEmitter() {
return this._changeEmitter;
}
get(): Readonly<AppSettingsInit> {
return (
(this._appSettings as unknown as AppSettingsInit) ??
this.appConfig.defaultSettings
);
}
/**
* Modify appSettings in-place.
* Will persist modification. If fails will rollback to existing one.
*/
async set(modify: (appSettings: AppSettingsInit) => void) {
if (!this._appSettings) {
throw new Error('AppSettings is not ready.'); // This should not happens.
}
const _id = this._appSettings._id;
modify(this._appSettings as unknown as AppSettingsInit);
try {
await this.appSettingsRepo.persist(this._appSettings);
} catch (err) {
this._appSettings = await this.appSettingsRepo.get(_id);
throw err;
}
}
async onStart() {
await this.ensureAppSettings();
this.setUpChangeStream();
}
onStop() {
this._appSettings = null;
this.cleanUpChangeStream();
}
private async ensureAppSettings() {
const count = await this.appSettingsRepo.count({});
if (count === 0) {
this.logger.info('AppSettings is not found. Create one with default.');
this._appSettings = await this.appSettingsRepo.create(
this.appConfig.defaultSettings,
);
} else {
if (count > 1)
this.logger.warn(
`Found ${count} AppSettings. Only the latest will be used.`,
);
else this.logger.info('Found AppSettings.');
this._appSettings = await this.appSettingsRepo.findOne(
{},
{ sort: { updatedAt: -1 } },
);
try {
this.appSettingsRepo.validatePersisted(this._appSettings);
} catch (err) {
this.logger.warn(
'Invalid AppSettings. AppSettings may not be properly migrated!',
);
if (err instanceof MongoOrmValidationError)
this.logger.error(err.message);
}
}
this._changeEmitter.emit(
'change',
this._appSettings! as unknown as AppSettingsInit,
);
}
private setUpChangeStream() {
if (this._changeStream) {
this.logger.warn(`ChangeStream already setUp. Skip.`);
return;
}
this._changeStream = this.appSettingsRepo
.watch(
[
{
$match: {
operationType: { $in: ['replace', 'update', 'delete'] },
},
},
],
{ fullDocument: 'updateLookup' },
)
.on('change', (change) => {
switch (change.operationType) {
case 'update':
case 'replace': {
const appSettings = change.fullDocument!;
this.logger.debug(`Detect ${change.operationType}.`);
if (this._appSettings?._id.equals(appSettings._id)) {
this.logger.info('Current AppSettings is updated.');
this._appSettings = appSettings;
this._changeEmitter.emit(
'change',
this._appSettings! as unknown as AppSettingsInit,
);
}
break;
}
case 'delete': {
const { _id: deletedId } = change.documentKey;
this.logger.debug(`Detect delete (${deletedId}).`);
if (this._appSettings?._id.equals(deletedId)) {
this.logger.info('Current AppSettings is deleted. Reinitialize.');
this._appSettings = null;
this.ensureAppSettings().catch((err) => {
this.logger.error('Fail to reinitialize.');
this.logger.error(err);
});
}
break;
}
}
})
.once('error', (err) => {
this.logger.error(`Error in ChangeStream. Restart.`);
this.logger.error(err.message);
this.launch(() => {
throw err;
}).catch(() => {});
});
}
private cleanUpChangeStream() {
this._changeStream?.close().catch((err) => {
this.logger.error('Fail to close ChangeStream.');
this.logger.error(err);
});
this._changeStream = null;
}
}

1
src/providers/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './ActiveAppSettings';

View File

@ -0,0 +1,16 @@
import { Repository } from '@jig-software/sdk';
import { AppSettings } from '../shared';
export const AppSettingsRepository = Repository.extend(
'AppSettingsRepository',
{
schema: AppSettings,
autoTimestamps: {
createTimestampKey: 'createdAt',
updateTimestampKey: 'updatedAt',
},
},
);
export type AppSettingsRepository = Repository.infer<
typeof AppSettingsRepository
>;

View File

@ -0,0 +1 @@
export * from './AppSettingsRepository';

View File

@ -0,0 +1,5 @@
// * Export constants here
// * export * from './someConstant'
// * export const SOME_OTHER_CONSTANT = 'constant value';
export {};
export * from './reading';

View File

@ -0,0 +1,53 @@
import z from 'zod';
export const FullReportInput = z.object({
data: z.array(
z.object({
building: z.string(),
floor: z.string(),
device_name: z.string(),
min_max_avg: z.object({
Temperature: z.object({
min: z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid min value'),
max: z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid max value'),
avg: z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid avg value'),
}),
Humidity: z.object({
min: z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid min value'),
max: z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid max value'),
avg: z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid avg value'),
}),
Battery: z.object({
min: z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid min value'),
max: z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid max value'),
avg: z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid avg value'),
}),
}),
readings: z.object({
Temperature: z.record(
z.string(),
z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid reading value'),
),
Humidity: z.record(
z.string(),
z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid reading value'),
),
Battery: z.record(
z.string(),
z.string().regex(/^\d+(\.\d+)?[°%]$/, 'Invalid reading value'),
),
}),
}),
),
report_params: z.object({
start_date: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: 'Invalid start_date',
}),
end_date: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: 'Invalid end_date',
}),
time_step: z.enum(['daily', 'weekly', 'monthly']),
}),
});
export type FullReportInput = z.infer<typeof FullReportInput>;

View File

@ -0,0 +1,10 @@
import { buildContract } from '@jig-software/trest-core';
export const healthCheckContract = buildContract((c) =>
c
.prefix('/health-check')
.parse((p) => p.error.text(400))
.endpoints({
checkHealth: (e) => e.method('GET').parse((p) => p.success.null(200)),
}),
);

View File

@ -0,0 +1,2 @@
export * from './healthCheckContract';
export * from './readingReportContract';

View File

@ -0,0 +1,16 @@
import { A, buildContract } from '@jig-software/trest-core';
import { FullReportInput } from '../constants';
export const readingReportContract = buildContract((c) =>
c
.prefix('/reports')
.parse((p) => p.error.text(400).error.text(401))
.endpoints({
generateReadingsReport: (e) =>
e
.method('POST')
.path('/reading-report')
.prepare((p) => p.body.json(FullReportInput).annotate<[query: A]>())
.parse((p) => p.success.file(200)),
}),
);

3
src/shared/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './constants';
export * from './contracts';
export * from './schemas';

View File

@ -0,0 +1,21 @@
import { objectId } from '@jig-software/zod-bson';
import { z } from 'zod';
// Note: AppSettings is for Dynamic props.
export const AppSettings = z.object({
_id: objectId(),
// Include other properties here.
createdAt: z.date(),
updatedAt: z.date(),
});
export type AppSettings = z.infer<typeof AppSettings>;
export const AppSettingsInit = AppSettings.omit({
_id: true,
createdAt: true,
updatedAt: true,
});
export type AppSettingsInit = z.infer<typeof AppSettingsInit>;

View File

@ -0,0 +1,2 @@
export * from './AppSettings';
export * from './readingReport';

View File

@ -0,0 +1,24 @@
import z from 'zod';
import { objectId } from '@jig-software/zod-bson';
export const ReadingReport = z.object({
reportId: objectId(), // ✅ call the function
generatedAt: z
.string()
.refine((date) => !isNaN(Date.parse(date)), {
message: 'Invalid date format',
}),
generatedBy: z.string(),
readings: z.array(
z.object({
sensorId: objectId(), // ✅ call the function here too
timestamp: z
.string()
.refine((date) => !isNaN(Date.parse(date)), {
message: 'Invalid date format',
}),
value: z.number(),
unit: z.string(),
}),
),
});

8
test/sample.spec.ts Normal file
View File

@ -0,0 +1,8 @@
import { expect } from 'chai';
describe('sample', () => {
it('should test all critical logic of the library', () => {
const it = true;
expect(it).to.be.eq(true);
});
});

15
tsconfig.build.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": false,
"noEmit": false,
"sourceMap": false,
"removeComments": true,
"declaration": false,
"module": "CommonJS",
"rootDir": "./src",
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules", "**/*.spec.ts", "test"]
}

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"target": "ES2019",
"noEmit": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false,
"baseUrl": "./"
},
"include": ["src", "test", ".eslintrc.js"]
}

0
volumes/.gitkeep Normal file
View File

8301
yarn.lock Normal file

File diff suppressed because it is too large Load Diff