kanbanqube 1.0.1 → 1.0.8

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,34 @@
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
+ async function importDemoBoard(_request, response) {
16
+ const currentBoard = await boardService.loadBoard();
17
+ if ((currentBoard.cards || []).length > 0) {
18
+ response.status(409).json({ error: "Demo board can only be loaded when the board has no cards." });
19
+ return;
20
+ }
21
+
22
+ const demoBoard = await importService.readDemoBoard();
23
+ response.json(await boardService.saveBoard(demoBoard));
24
+ }
25
+
26
+ return {
27
+ importBoard,
28
+ importDemoBoard
29
+ };
30
+ }
31
+
32
+ module.exports = {
33
+ createImportController
34
+ };
@@ -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.8",
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
  }
package/public/app.js CHANGED
@@ -217,11 +217,14 @@ function isBoardEmpty() {
217
217
  }
218
218
 
219
219
  async function loadDemoBoard() {
220
- const response = await fetch(DEMO_BOARD_PATH);
220
+ const response = await fetch("/api/demo-board", {
221
+ method: "POST"
222
+ });
223
+ const payload = await response.json();
221
224
  if (!response.ok) {
222
- throw new Error("Could not load demo board.");
225
+ throw new Error(payload.error || "Could not load demo board.");
223
226
  }
224
- return response.json();
227
+ return payload;
225
228
  }
226
229
 
227
230
  function wireEvents() {
@@ -0,0 +1,34 @@
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("/demo-board", asyncHandler(importController.importDemoBoard));
26
+ router.post("/sync", asyncHandler(syncController.syncBoard));
27
+ router.get("/sync-status", syncController.syncStatus);
28
+
29
+ return router;
30
+ }
31
+
32
+ module.exports = {
33
+ createApiRoutes
34
+ };