kanbanqube 1.0.1 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.js ADDED
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+
3
+ const express = require("express");
4
+ const path = require("node:path");
5
+ const mimeTypes = require("./config/mimeTypes");
6
+ const { errorHandler } = require("./middleware/errorHandler");
7
+ const { notFound } = require("./middleware/notFound");
8
+ const { createApiRoutes } = require("./routes/apiRoutes");
9
+ const { createBoardService } = require("./services/boardService");
10
+ const { createGitSyncService } = require("./services/gitSyncService");
11
+ const { createImportService } = require("./services/importService");
12
+ const { createUploadService } = require("./services/uploadService");
13
+
14
+ function createApp(config) {
15
+ const app = express();
16
+ const boardService = createBoardService(config);
17
+ const gitSyncService = createGitSyncService(config);
18
+ const importService = createImportService(config);
19
+ const uploadService = createUploadService(config, boardService);
20
+
21
+ app.disable("x-powered-by");
22
+ app.use(express.json({ limit: "5mb", type: "application/json" }));
23
+ app.use(noStore);
24
+
25
+ app.use("/api", createApiRoutes({
26
+ config,
27
+ boardService,
28
+ gitSyncService,
29
+ importService,
30
+ uploadService
31
+ }));
32
+
33
+ app.get(`/${config.demoBoardFileName}`, (_request, response) => {
34
+ response.type("json").sendFile(config.demoBoardFilePath);
35
+ });
36
+ app.get(`/${config.uploadsDirName}/:fileName`, (request, response, next) => {
37
+ let fileName = "";
38
+ try {
39
+ fileName = uploadService.safeUploadFileName(request.params.fileName);
40
+ } catch (error) {
41
+ error.statusCode = 400;
42
+ next(error);
43
+ return;
44
+ }
45
+ const filePath = path.join(config.uploadsDir, fileName);
46
+ response.type(mimeTypes[path.extname(filePath).toLowerCase()] || "application/octet-stream");
47
+ response.sendFile(filePath, (error) => {
48
+ if (error) next(error);
49
+ });
50
+ });
51
+ app.use(express.static(config.publicDir, {
52
+ etag: false,
53
+ lastModified: false,
54
+ setHeaders: noStoreHeaders
55
+ }));
56
+ app.use(notFound);
57
+ app.use(errorHandler);
58
+
59
+ return {
60
+ app,
61
+ services: {
62
+ boardService,
63
+ gitSyncService,
64
+ importService,
65
+ uploadService
66
+ }
67
+ };
68
+ }
69
+
70
+ function noStore(_request, response, next) {
71
+ noStoreHeaders(response);
72
+ next();
73
+ }
74
+
75
+ function noStoreHeaders(response) {
76
+ response.setHeader("Cache-Control", "no-store");
77
+ }
78
+
79
+ module.exports = {
80
+ createApp
81
+ };
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+
3
+ const path = require("node:path");
4
+
5
+ function resolveWorkspaceDirectory(argument) {
6
+ if (typeof argument === "string" && argument.trim()) {
7
+ return path.resolve(argument);
8
+ }
9
+ return process.cwd();
10
+ }
11
+
12
+ function createConfig(options = {}) {
13
+ const appDir = options.appDir || path.resolve(__dirname, "..");
14
+ const workspaceDir = resolveWorkspaceDirectory(options.workspaceArgument);
15
+ const boardFileName = "board.json";
16
+ const demoBoardFileName = "demo_board.json";
17
+ const boardDirName = "board";
18
+ const uploadsDirName = "uploads";
19
+
20
+ return {
21
+ appDir,
22
+ workspaceDir,
23
+ publicDir: path.join(appDir, "public"),
24
+ boardFileName,
25
+ boardFilePath: path.join(workspaceDir, boardFileName),
26
+ demoBoardFileName,
27
+ demoBoardFilePath: path.join(appDir, demoBoardFileName),
28
+ boardDirName,
29
+ boardDir: path.join(workspaceDir, boardDirName),
30
+ boardMetaFilePath: path.join(workspaceDir, boardDirName, "meta.json"),
31
+ uploadsDirName,
32
+ uploadsDir: path.join(workspaceDir, uploadsDirName),
33
+ sampleExportDir: path.join(workspaceDir, "trello_export"),
34
+ port: Number(options.port || process.env.PORT || 3000),
35
+ gitExecutableCandidates: [
36
+ "/usr/bin/git",
37
+ "/bin/git",
38
+ "/usr/local/bin/git",
39
+ "/opt/homebrew/bin/git"
40
+ ],
41
+ sshExecutableCandidates: [
42
+ "/usr/bin/ssh",
43
+ "/bin/ssh",
44
+ "/usr/local/bin/ssh",
45
+ "/opt/homebrew/bin/ssh"
46
+ ],
47
+ gitSafePath: "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin"
48
+ };
49
+ }
50
+
51
+ module.exports = {
52
+ createConfig,
53
+ resolveWorkspaceDirectory
54
+ };
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+
3
+ const mimeTypes = {
4
+ ".css": "text/css; charset=utf-8",
5
+ ".html": "text/html; charset=utf-8",
6
+ ".js": "application/javascript; charset=utf-8",
7
+ ".json": "application/json; charset=utf-8",
8
+ ".svg": "image/svg+xml",
9
+ ".png": "image/png",
10
+ ".jpg": "image/jpeg",
11
+ ".jpeg": "image/jpeg",
12
+ ".gif": "image/gif",
13
+ ".webp": "image/webp",
14
+ ".pdf": "application/pdf",
15
+ ".txt": "text/plain; charset=utf-8"
16
+ };
17
+
18
+ module.exports = mimeTypes;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+
3
+ function createBoardController(boardService) {
4
+ async function getBoard(_request, response) {
5
+ response.json(await boardService.loadBoard());
6
+ }
7
+
8
+ async function saveBoard(request, response) {
9
+ response.json(await boardService.saveBoard(request.body || {}));
10
+ }
11
+
12
+ return {
13
+ getBoard,
14
+ saveBoard
15
+ };
16
+ }
17
+
18
+ module.exports = {
19
+ createBoardController
20
+ };
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+
3
+ function createConfigController(config, gitService) {
4
+ async function getConfig(_request, response) {
5
+ response.json({
6
+ boardFile: config.boardFileName,
7
+ storagePath: config.boardDirName,
8
+ workspacePath: config.workspaceDir,
9
+ hasGitRepo: await gitService.hasGitRepository(config.workspaceDir),
10
+ gitRemote: await gitService.gitRemoteOrigin(config.workspaceDir),
11
+ gitUserName: await gitService.gitUserName(config.workspaceDir),
12
+ gitUserEmail: await gitService.gitUserEmail(config.workspaceDir)
13
+ });
14
+ }
15
+
16
+ return {
17
+ getConfig
18
+ };
19
+ }
20
+
21
+ module.exports = {
22
+ createConfigController
23
+ };
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+
3
+ function createImportController(boardService, importService) {
4
+ async function importBoard(request, response) {
5
+ const currentBoard = await boardService.loadBoard();
6
+ if ((currentBoard.cards || []).length > 0) {
7
+ response.status(409).json({ error: "Import is only available when the board has no cards." });
8
+ return;
9
+ }
10
+
11
+ const importedBoard = await importService.readImportedBoard(request);
12
+ response.json(await boardService.saveBoard(importedBoard));
13
+ }
14
+
15
+ return {
16
+ importBoard
17
+ };
18
+ }
19
+
20
+ module.exports = {
21
+ createImportController
22
+ };
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+
3
+ const { getSyncStatus } = require("../models/syncStatusStore");
4
+
5
+ function createSyncController(gitSyncService) {
6
+ return {
7
+ syncStatus,
8
+ syncBoard: (request, response) => syncBoard(request, response, gitSyncService)
9
+ };
10
+ }
11
+
12
+ function syncStatus(_request, response) {
13
+ response.json(getSyncStatus());
14
+ }
15
+
16
+ async function syncBoard(_request, response, gitSyncService) {
17
+ const status = getSyncStatus();
18
+ if (status.running) {
19
+ response.status(409).json({
20
+ ok: false,
21
+ output: status.output || "A git sync is already in progress.",
22
+ startedAt: status.startedAt,
23
+ finishedAt: status.finishedAt
24
+ });
25
+ return;
26
+ }
27
+
28
+ const result = await gitSyncService.syncBoardRepository();
29
+ response.status(result.ok ? 200 : 500).json(result);
30
+ }
31
+
32
+ module.exports = {
33
+ createSyncController,
34
+ syncStatus
35
+ };
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+
3
+ function createUploadController(uploadService) {
4
+ async function uploadFiles(request, response) {
5
+ const files = await uploadService.saveUploadedFiles(request);
6
+ response.json({ files });
7
+ }
8
+
9
+ async function deleteUpload(request, response) {
10
+ response.json(await uploadService.deleteUploadedFile(request.params.fileName));
11
+ }
12
+
13
+ return {
14
+ uploadFiles,
15
+ deleteUpload
16
+ };
17
+ }
18
+
19
+ module.exports = {
20
+ createUploadController
21
+ };
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+
3
+ function asyncHandler(handler) {
4
+ return (request, response, next) => {
5
+ Promise.resolve(handler(request, response, next)).catch(next);
6
+ };
7
+ }
8
+
9
+ module.exports = {
10
+ asyncHandler
11
+ };
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+
3
+ function errorHandler(error, _request, response, _next) {
4
+ if (response.headersSent) return;
5
+ response.status(error.statusCode || 500).json({
6
+ error: error.message || "Unexpected server error."
7
+ });
8
+ }
9
+
10
+ module.exports = {
11
+ errorHandler
12
+ };
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+
3
+ function notFound(_request, response) {
4
+ response.status(405).json({ error: "Method not allowed." });
5
+ }
6
+
7
+ module.exports = {
8
+ notFound
9
+ };
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs/promises");
4
+ const path = require("node:path");
5
+ const { createHexId } = require("../utils/idUtils");
6
+ const { readJsonFile, writeJsonIfChanged } = require("../utils/fileUtils");
7
+ const { nonEmptyString } = require("../utils/stringUtils");
8
+
9
+ function createBoardRepository(config) {
10
+ async function readSplitBoard() {
11
+ const meta = await readJsonFile(config.boardMetaFilePath, {});
12
+ return {
13
+ ...meta,
14
+ lists: await readJsonCollection("lists"),
15
+ labels: await readJsonCollection("labels"),
16
+ members: await readJsonCollection("members"),
17
+ cards: await readJsonCollection("cards"),
18
+ checklists: await readJsonCollection("checklists"),
19
+ actions: await readJsonCollection("actions")
20
+ };
21
+ }
22
+
23
+ async function writeSplitBoard(board) {
24
+ await fs.mkdir(config.boardDir, { recursive: true });
25
+ const {
26
+ lists,
27
+ labels,
28
+ members,
29
+ cards,
30
+ checklists,
31
+ actions,
32
+ ...meta
33
+ } = board;
34
+
35
+ await writeJsonIfChanged(config.boardMetaFilePath, meta);
36
+ await writeJsonCollection("lists", lists || []);
37
+ await writeJsonCollection("labels", labels || []);
38
+ await writeJsonCollection("members", members || []);
39
+ await writeJsonCollection("cards", cards || []);
40
+ await writeJsonCollection("checklists", checklists || []);
41
+ await writeJsonCollection("actions", actions || []);
42
+ }
43
+
44
+ async function readJsonCollection(name) {
45
+ const directory = path.join(config.boardDir, name);
46
+ let entries = [];
47
+ try {
48
+ entries = await fs.readdir(directory, { withFileTypes: true });
49
+ } catch (error) {
50
+ if (error.code === "ENOENT") return [];
51
+ throw error;
52
+ }
53
+
54
+ const items = [];
55
+ for (const entry of entries) {
56
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
57
+ items.push(await readJsonFile(path.join(directory, entry.name), null));
58
+ }
59
+ return items.filter(Boolean);
60
+ }
61
+
62
+ async function writeJsonCollection(name, items) {
63
+ const directory = path.join(config.boardDir, name);
64
+ await fs.mkdir(directory, { recursive: true });
65
+ const desiredFiles = new Set();
66
+
67
+ for (const item of items) {
68
+ if (!item || typeof item !== "object") continue;
69
+ const id = nonEmptyString(item.id) || createHexId();
70
+ item.id = id;
71
+ const fileName = `${encodeURIComponent(id)}.json`;
72
+ desiredFiles.add(fileName);
73
+ await writeJsonIfChanged(path.join(directory, fileName), item);
74
+ }
75
+
76
+ let entries = [];
77
+ try {
78
+ entries = await fs.readdir(directory, { withFileTypes: true });
79
+ } catch (error) {
80
+ if (error.code === "ENOENT") return;
81
+ throw error;
82
+ }
83
+
84
+ for (const entry of entries) {
85
+ if (entry.isFile() && entry.name.endsWith(".json") && !desiredFiles.has(entry.name)) {
86
+ await fs.unlink(path.join(directory, entry.name));
87
+ }
88
+ }
89
+ }
90
+
91
+ return {
92
+ readSplitBoard,
93
+ writeSplitBoard
94
+ };
95
+ }
96
+
97
+ module.exports = {
98
+ createBoardRepository
99
+ };
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+
3
+ const syncStatus = {
4
+ running: false,
5
+ startedAt: "",
6
+ finishedAt: "",
7
+ ok: null,
8
+ output: ""
9
+ };
10
+
11
+ function getSyncStatus() {
12
+ return { ...syncStatus };
13
+ }
14
+
15
+ function updateSyncStatus(values) {
16
+ Object.assign(syncStatus, values);
17
+ return getSyncStatus();
18
+ }
19
+
20
+ module.exports = {
21
+ getSyncStatus,
22
+ updateSyncStatus
23
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kanbanqube",
3
- "version": "1.0.1",
3
+ "version": "1.0.7",
4
4
  "description": "Local-first Kanban board backed by normal files",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Mathias Conradt",
@@ -25,6 +25,14 @@
25
25
  },
26
26
  "files": [
27
27
  "server.js",
28
+ "app.js",
29
+ "config/",
30
+ "controllers/",
31
+ "middleware/",
32
+ "models/",
33
+ "routes/",
34
+ "services/",
35
+ "utils/",
28
36
  "public/",
29
37
  "demo_board.json",
30
38
  "promo.jpg",
@@ -33,14 +41,18 @@
33
41
  "LICENSE"
34
42
  ],
35
43
  "bin": {
36
- "kanbanqube": "./server.js"
44
+ "kanbanqube": "server.js"
37
45
  },
38
46
  "scripts": {
39
47
  "start": "node server.js",
40
- "test": "node --check server.js && node --check --input-type=module < public/app.js",
48
+ "test": "npm run check:registry && node --check server.js && node --check app.js && find config controllers middleware models routes services utils -name '*.js' -exec node --check {} \\; && node --check --input-type=module < public/app.js",
49
+ "check:registry": "node -e \"const fs=require('fs'); const lock=fs.readFileSync('package-lock.json','utf8'); if(/repox|jfrog/i.test(lock)){ console.error('package-lock.json must use public npm registry URLs, not local Repox/JFrog URLs.'); process.exit(1); }\"",
41
50
  "prepublishOnly": "npm test"
42
51
  },
43
52
  "publishConfig": {
44
53
  "access": "public"
54
+ },
55
+ "dependencies": {
56
+ "express": "^5.2.1"
45
57
  }
46
58
  }
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+
3
+ const express = require("express");
4
+ const { createBoardController } = require("../controllers/boardController");
5
+ const { createConfigController } = require("../controllers/configController");
6
+ const { createImportController } = require("../controllers/importController");
7
+ const { createSyncController } = require("../controllers/syncController");
8
+ const { createUploadController } = require("../controllers/uploadController");
9
+ const { asyncHandler } = require("../middleware/asyncHandler");
10
+
11
+ function createApiRoutes(services) {
12
+ const router = express.Router();
13
+ const boardController = createBoardController(services.boardService);
14
+ const configController = createConfigController(services.config, services.gitSyncService.gitService);
15
+ const importController = createImportController(services.boardService, services.importService);
16
+ const syncController = createSyncController(services.gitSyncService);
17
+ const uploadController = createUploadController(services.uploadService);
18
+
19
+ router.get("/board", asyncHandler(boardController.getBoard));
20
+ router.put("/board", asyncHandler(boardController.saveBoard));
21
+ router.get("/config", asyncHandler(configController.getConfig));
22
+ router.post("/uploads", asyncHandler(uploadController.uploadFiles));
23
+ router.delete("/uploads/:fileName", asyncHandler(uploadController.deleteUpload));
24
+ router.post("/import", asyncHandler(importController.importBoard));
25
+ router.post("/sync", asyncHandler(syncController.syncBoard));
26
+ router.get("/sync-status", syncController.syncStatus);
27
+
28
+ return router;
29
+ }
30
+
31
+ module.exports = {
32
+ createApiRoutes
33
+ };