initialize

This commit is contained in:
Pakin 2025-08-06 16:01:57 +07:00
commit 99b6232436
24 changed files with 3872 additions and 0 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
.git
node_modules
.DS_Store
.gitignore
*.log
*.md
logs
priceslot.*
debug.*

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.env
**/.DS_Store
logs
node_modules
*.zip
*.log

8
README.md Normal file
View file

@ -0,0 +1,8 @@
change directory:
$ cd ./server
install dependencies:
$ npm install
run the app:
$ DEBUG=server:* PORT=36530 npm start

128
app.js Normal file
View file

@ -0,0 +1,128 @@
var createError = require("http-errors");
var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
// var morgan = require("morgan");
var indexRouter = require("./routes/index");
var usersRouter = require("./routes/users");
// const { createLogger, format, transports } = require('winston');
const {
logger,
Log,
getTestSpreadSheet,
GoogleFunctions,
PluginsManager,
} = require("./lib/common");
const { SyncText } = require("./lib/sync_text");
const { CronJobs } = require("./cron-jobs");
const { google } = require("googleapis");
const { EventEmitter } = require("stream");
require("dotenv").config();
Log.debug(process.env.TEST_SHEET_ID);
var app = express();
// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");
app
// .use(require("morgan")("combined", { stream: logger.stream }))
.use(express.json())
.use(express.urlencoded({ extended: false }))
.use(cookieParser())
.use(express.static(path.join(__dirname, "public")));
app.use("/", indexRouter);
app.use("/users", usersRouter);
Log.debug(`running on port ${process.env.PORT}`);
// =========================================================================
// CRON JOB (Trigger)
// =========================================================================
var cronTasks =
CronJobs.MAXIMUM_CRON_JOBS > 0 ? new Array(CronJobs.MAXIMUM_CRON_JOBS) : [];
const auth = GoogleFunctions.auth();
const sheet = GoogleFunctions.SpreadSheets(auth);
// let heartbeatTask = CronJobs.doEveryMinute(() => {
// Log.debug(`All running => ${JSON.stringify(cronTasks)}`);
// cronTasks[0].exec_times += 1;
// if(cronTasks[0].exec_times >= 1){
// Log.debug("stop hb");
// this.emit('stop');
// }
// }, 'hb');
// heartbeatTask.on('stop', () => heartbeatTask.stop());
// cronTasks[0] = {
// task: heartbeatTask,
// exec_times: 0
// };
// getTestSpreadSheet(sheet);
// const tha_sheets = GoogleFunctions.getAllSheetNamesByCountry(sheet, "tha", (sheetDetail) => {
// return sheetDetail.data.sheets.map((sheet) => {
// return {
// name: sheet.properties.title,
// id: sheet.properties.sheetId,
// };
// });
// });
// GoogleFunctions.getCountrySheetByName(sheet, "tha", "PriceSlot1");
// GoogleFunctions.getPriceSlotValues(sheet, "tha");
// v3 !!!!
// GoogleFunctions.syncProfilePrice(sheet, "tha", false);
//
// Test Taobin SyncText
SyncText.run(sheet, "uae", false);
const pm = new PluginsManager(cronTasks, CronJobs);
pm.load();
pm.checkScript();
// pm.runScript("heartbeat.js");
// Expect variable from plugins
let endpointMap;
// Object.keys(pm.scripts).forEach((scriptName) => {
// });
for (let scriptName of Object.keys(pm.scripts)) {
Log.debug(`running ${scriptName}`);
eval(pm.scripts[scriptName]);
pm.createEndpoint(app, express, scriptName, endpointMap);
endpointMap = undefined; // reset
}
// pm.loop()
// catch 404 and forward to error handler
app.use(function (req, res, next) {
next(createError(404));
});
// error handler
app.use(function (err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get("env") === "development" ? err : {};
// render the error page
res.status(err.status || 500);
res.render("error");
});
module.exports = app;

90
bin/www Executable file
View file

@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
var app = require('../app');
var debug = require('debug')('server:server');
var http = require('http');
/**
* Get port from environment and store in Express.
*/
var port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
/**
* Create HTTP server.
*/
var server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Normalize a port into a number, string, or false.
*/
function normalizePort(val) {
var port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
var addr = server.address();
var bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
debug('Listening on ' + bind);
}

6
config.js Normal file
View file

@ -0,0 +1,6 @@
require("dotenv").config();
module.exports = {
PORT: 36530 || process.env.PORT,
DEBUG: true || process.env.DEBUG,
};

37
cron-jobs.js Normal file
View file

@ -0,0 +1,37 @@
const config = require("./config");
const cron = require("node-cron");
const { Log } = require("./lib/common");
// CRON JOB GUIDE
// # ┌────────────── วินาที (optional)
// # │ ┌──────────── นาที
// # │ │ ┌────────── ชั่วโมง
// # │ │ │ ┌──────── วันที่
// # │ │ │ │ ┌────── เดือน
// # │ │ │ │ │ ┌──── วันในสัปดาห์นั้น ๆ
// # │ │ │ │ │ │
// # │ │ │ │ │ │
// # * * * * * *
const CronScheduleList = {
everyMin: "* * * * *",
midNight: "0 0 * * *",
};
const CronJobs = {
MAXIMUM_CRON_JOBS: -1,
getAllRunning: cron.getTasks(),
doEveryMinute: (task, id) => {
if(config.DEBUG) Log.debug(`verify cron ${cron.validate(CronScheduleList.everyMin)}`);
return cron.schedule(CronScheduleList.everyMin, task, { name: id });
},
doEveryMidnight: (task, id) => {
if(config.DEBUG) Log.debug(`verify cron ${cron.validate(CronScheduleList.midNight)}`);
return cron.schedule(CronScheduleList.midNight, task, { name: id });
},
};
module.exports = {
CronJobs,
};

1407
lib/common.js Normal file

File diff suppressed because it is too large Load diff

215
lib/sync_text.js Normal file
View file

@ -0,0 +1,215 @@
const {
logger,
Log,
getTestSpreadSheet,
GoogleFunctions,
PluginsManager,
getCountrySheetByName,
diff2DArraysCustom,
saveJsonToFile,
} = require("./common");
// TODO:
// get source sheet --> tha
// get remote sheet --> input
//
class TextTable {
constructor() {
// [ Language,... ]
this.supportedLanguages = [];
// { category: [ rows ] }
this.data = {};
// update tracker
// { category: { row_number: value } }
this.update = {};
//
this.ordered_rows = [];
}
add_category(category) {
this.data[category] = [];
}
add_row(category, row) {
this.data[category].push(row);
}
add_ordered_row(row) {
this.ordered_rows.push(row);
}
add_update(category, row_number, value) {
this.update[category][row_number.toString()] = value;
}
size() {
let size = 0;
for (let category in this.data) {
size += this.data[category].length;
}
return size;
}
/**
* Differs between two TextTable objects,
* return what this table has to update
*
* @param {TextTable} another_table
*
* @returns {Map}
*/
diff_by_category(another_table) {
let column_to_focus = {};
for (let [idx, lang] of Object.entries(another_table.supportedLanguages)) {
if (this.supportedLanguages.includes(lang)) {
column_to_focus[lang] = {
target: idx,
source: this.supportedLanguages.indexOf(lang),
};
}
}
// need add entire category
Object.keys(another_table.data).forEach((category) => {
if (!this.data[category]) {
this.data[category] = another_table.data[category];
this.update[category] = {};
}
});
// iter through map category first
for (let category of Object.keys(this.data)) {
let another_table_data = another_table.data[category];
if (another_table_data != null) {
// Log.debug(
// `[${category}] source: ${this.data[category].length}, target: ${another_table_data.length}`,
// );
let equal_length =
this.data[category].length == another_table_data.length;
let need_update =
another_table_data.length > this.data[category].length;
if (need_update) {
Log.debug(`[${category}] need update`);
// loop check
for (let i = 0; i < another_table_data.length; i++) {
if (
this.data[category][i] &&
this.data[category][i] != another_table_data[i]
) {
// this.update[category][i] = another_table_data[i];
//
//
// check by column
for (let [k, v] of Object.entries(column_to_focus)) {
let source_column = v.source;
let target_column = v.target;
if (
this.data[category][i][source_column] !=
another_table_data[i][target_column]
) {
Log.debug(
`[${category}] ${source_column} != ${target_column} -- ${this.data[category][i][source_column]} != ${another_table_data[i][target_column]}`,
);
}
}
} else if (!this.data[category][i]) {
// this.update[category][i] = another_table_data[i];
// search line number
let index = another_table_data.ordered_rows.indexOf(
another_table_data[i],
);
Log.debug(
`[${category}] index: ${index} --> ${another_table_data[i]}`,
);
}
}
} else if (this.update[category]) {
Log.debug(`[${category}] update **NEW**`);
}
} else {
Log.err(
`[${category}] source: ${this.data[category].length}, target: 0`,
);
}
}
}
}
function buildTable(raw_data) {
var table = new TextTable();
// build table
Log.debug("Building table...");
let current_category;
for (let [idx, row] of Object.entries(raw_data)) {
if ((row[0] == "TextID" || row[1] == "Note") && row.length >= 3) {
// expect header
table.supportedLanguages = row.slice(2);
}
switch (row.length) {
case 2:
// detect category
table.add_category(row[1].trim());
current_category = row[1].trim();
Log.debug(`Found Category: '${current_category}'`);
break;
case 0:
break;
default:
// detect row
let text_id = row[0];
if (text_id.toString().length > 0 && current_category != null) {
// add to table
table.add_row(current_category, row);
}
}
// Log.debug(`${idx}/${row.length}: ${row}`);
table.add_ordered_row(row);
}
// Log.debug(`Table built --> ${JSON.stringify(table)}`);
return table;
}
// process
async function syncTaobinText(sheet, country_short, is_test_mode) {
var source = await getCountrySheetByName(sheet, "tha", "Taobin-Text");
var remote = await getCountrySheetByName(sheet, country_short, "Taobin-Text");
// build table
let sourceTable = buildTable(source);
let remoteTable = buildTable(remote);
Log.debug(`source: ${sourceTable.size()}`);
Log.debug(`remote: ${remoteTable.size()}`);
// remoteTable.diff_by_category(sourceTable);
if (sourceTable.size() != remoteTable.size()) {
// detect missing rows
} else {
// detect similarity
}
let diffs = diff2DArraysCustom(remote, source);
saveJsonToFile(`diff_tha_${country_short}.json`, diffs);
// Log.debug(`diffs: ${JSON.stringify(diffs)}`);
}
const SyncText = {
version: "1.0.0_250725",
name: "SyncText",
run: syncTaobinText,
};
module.exports = {
SyncText,
};

1794
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "server",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www"
},
"dependencies": {
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"dotenv": "^16.4.7",
"express": "~4.16.1",
"googleapis": "^148.0.0",
"http-errors": "~1.6.3",
"jade": "~1.11.0",
"morgan": "~1.9.1",
"node-cron": "^3.0.3",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
}
}

31
plugins/heartbeat.js Normal file
View file

@ -0,0 +1,31 @@
// continue;
const heartbeatApiInfo = {
version: 1,
changelogs: [
"17/4/25, create endpoint map @Pakin"
],
};
let heartbeatTask = CronJobs.doEveryMinute(() => {
Log.debug("[hb] test heartbeat");
Log.debug(`[hb] current running tasks: ${JSON.stringify(CronJobs.getAllRunning.size)}`);
heartbeatTask.stop();
}, 'heartbeat');
heartbeatTask.on('stop-heartbeat', () => heartbeatTask.stop());
cronTasks[0] = heartbeatTask;
endpointMap = {
'': function(req, res, next){
res.locals.title = "heartbeat";
res.locals.version = heartbeatApiInfo.version;
res.locals.task_num = CronJobs.getAllRunning.size;
res.render("heartbeat");
},
};

View file

@ -0,0 +1,9 @@
let syncProfilePriceSlot8Task = CronJobs.doEveryMidnight(() => {
}, 'sync-price-slot-aot');
syncProfilePriceSlot8Task.on('stop-sync-price-slot-aot', () => syncProfilePriceSlot8Task.stop());
cronTasks.push(syncProfilePriceSlot8Task);

View file

@ -0,0 +1,37 @@
const syncThaiProfilePriceSlotApiInfo = {
version: 1,
semver: "1.0.1",
changelogs: [
"17/4/25, create endpoint map @Pakin",
"29/5/25, fix price slot sync incorrect @Pakin",
],
desc: "Sync price between sheets expected sheet with prefix `PriceSlot` for country `tha` (Thai), | does not allow modification to function while running except reloaded, Endpoints:\n/run/now,/run/test\n",
};
let syncProfilePriceSlotsTask = CronJobs.doEveryMidnight(() => {
GoogleFunctions.syncProfilePrice(sheet, "tha", false);
}, "sync-gg-price_slot_tha");
syncProfilePriceSlotsTask.on("stop-sync-gg-price_slot_tha_test", () =>
syncProfilePriceSlotsTask.stop(),
);
cronTasks.push(syncProfilePriceSlotsTask);
endpointMap = {
"": function (req, res, next) {
res.locals.title = "Sync Thai Price Slots";
res.locals.version = syncThaiProfilePriceSlotApiInfo.version;
res.locals.desc = syncThaiProfilePriceSlotApiInfo.desc;
res.render("apiInfo");
},
"run/now": function (req, res, next) {
GoogleFunctions.syncProfilePrice(sheet, "tha", false);
res.send("Force run `Sync Thai Price Slots`");
},
"run/test": function (req, res, next) {
GoogleFunctions.syncProfilePrice(sheet, "tha", true);
res.send("Force run `Sync Thai Price Slots` test mode");
},
};

View file

@ -0,0 +1,8 @@
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
color: #00B7FF;
}

9
routes/index.js Normal file
View file

@ -0,0 +1,9 @@
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index', { title: 'Express' });
});
module.exports = router;

14
routes/pm.js Normal file
View file

@ -0,0 +1,14 @@
var express = require('express');
var router = express.Router();
router.get('/', function(req, res, next){
res.send('Plugin Manager, mounted on `plugins` folder, try add new `.js` file to this folder and run `/reload`')
});
router.get('/reload', function(req, res, next){
});
router.get('/update', function(req, res, next){
});

9
routes/users.js Normal file
View file

@ -0,0 +1,9 @@
var express = require('express');
var router = express.Router();
/* GET users listing. */
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
module.exports = router;

2
run.test.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/bash
DEBUG=server:* PORT=36530 npm start

7
views/apiInfo.jade Normal file
View file

@ -0,0 +1,7 @@
extends layout
block content
h1 #{title}
h2 version #{version}
p Description:
p #{desc}

6
views/error.jade Normal file
View file

@ -0,0 +1,6 @@
extends layout
block content
h1= message
h2= error.status
pre #{error.stack}

7
views/heartbeat.jade Normal file
View file

@ -0,0 +1,7 @@
extends layout
block content
h1 #{title}
h2 version #{version}
p Description:
p heartbeat runs every minutes, current running: #{task_num}

5
views/index.jade Normal file
View file

@ -0,0 +1,5 @@
extends layout
block content
h1= title
p Welcome to #{title}

7
views/layout.jade Normal file
View file

@ -0,0 +1,7 @@
doctype html
html
head
title= title
link(rel='stylesheet', href='/stylesheets/style.css')
body
block content