kl-sqlite-card-state 0.1.1
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/README.md +101 -0
- package/dist/index.cjs +199 -0
- package/dist/index.d.ts +66 -0
- package/package.json +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# kl-sqlite-card-state
|
|
2
|
+
|
|
3
|
+
A first-party [kanban-lite](https://github.com/borgius/kanban-lite) package for a SQLite-backed `card.state` provider.
|
|
4
|
+
|
|
5
|
+
## What it provides
|
|
6
|
+
|
|
7
|
+
This package implements the shared `card.state` contract used by `kanban-lite` for:
|
|
8
|
+
|
|
9
|
+
- actor-scoped unread cursor persistence
|
|
10
|
+
- explicit open-card state persistence
|
|
11
|
+
- parity with the built-in file-backed `card.state` backend for unread derivation inputs and read/open mutations when exercised through the SDK
|
|
12
|
+
|
|
13
|
+
Unread derivation itself remains SDK-owned; this package persists the actor/card/domain state that the SDK reads and writes.
|
|
14
|
+
|
|
15
|
+
## Provider id
|
|
16
|
+
|
|
17
|
+
`sqlite`
|
|
18
|
+
|
|
19
|
+
## Capability
|
|
20
|
+
|
|
21
|
+
- `card.state`
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install kl-sqlite-card-state
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configure
|
|
30
|
+
|
|
31
|
+
Use the `sqlite` compatibility id under `plugins['card.state']`:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"version": 2,
|
|
36
|
+
"plugins": {
|
|
37
|
+
"card.state": {
|
|
38
|
+
"provider": "sqlite",
|
|
39
|
+
"options": {
|
|
40
|
+
"sqlitePath": ".kanban/card-state.db"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Options
|
|
48
|
+
|
|
49
|
+
- `sqlitePath` — optional relative or absolute SQLite database path. Defaults to `.kanban/card-state.db`.
|
|
50
|
+
|
|
51
|
+
## Exports
|
|
52
|
+
|
|
53
|
+
The package exports:
|
|
54
|
+
|
|
55
|
+
- `createCardStateProvider(context)`
|
|
56
|
+
- `default` → `createCardStateProvider`
|
|
57
|
+
- `SQLITE_CARD_STATE_PROVIDER_ID`
|
|
58
|
+
- `DEFAULT_SQLITE_CARD_STATE_PATH`
|
|
59
|
+
|
|
60
|
+
The factory returns a contract-compatible provider with the same four operations expected by the SDK capability loader:
|
|
61
|
+
|
|
62
|
+
- `getCardState(...)`
|
|
63
|
+
- `setCardState(...)`
|
|
64
|
+
- `getUnreadCursor(...)`
|
|
65
|
+
- `markUnreadReadThrough(...)`
|
|
66
|
+
|
|
67
|
+
## Semantics
|
|
68
|
+
|
|
69
|
+
- unread state is scoped by `actorId + boardId + cardId`
|
|
70
|
+
- explicit open state is stored independently from unread cursor state
|
|
71
|
+
- reads are side-effect free until the SDK calls an explicit mutation (`markCardOpened()` / `markCardRead()`)
|
|
72
|
+
- auth-absent mode still uses the same stable default actor contract as the built-in backend because actor resolution lives in the SDK, not in the provider
|
|
73
|
+
|
|
74
|
+
## Build output
|
|
75
|
+
|
|
76
|
+
The published CommonJS entrypoint is:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
dist/index.cjs
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Declaration output is emitted to `dist/index.d.ts`.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm install
|
|
88
|
+
npm run build
|
|
89
|
+
npm test
|
|
90
|
+
npm run test:integration
|
|
91
|
+
npm run typecheck
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
From the repository root you can also run:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pnpm --filter kl-sqlite-card-state build
|
|
98
|
+
pnpm --filter kl-sqlite-card-state test
|
|
99
|
+
pnpm --filter kl-sqlite-card-state test:integration
|
|
100
|
+
pnpm --filter kl-sqlite-card-state typecheck
|
|
101
|
+
```
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
DEFAULT_SQLITE_CARD_STATE_PATH: () => DEFAULT_SQLITE_CARD_STATE_PATH,
|
|
34
|
+
SQLITE_CARD_STATE_PROVIDER_ID: () => SQLITE_CARD_STATE_PROVIDER_ID,
|
|
35
|
+
createCardStateProvider: () => createCardStateProvider,
|
|
36
|
+
default: () => src_default
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(src_exports);
|
|
39
|
+
var fs = __toESM(require("node:fs"));
|
|
40
|
+
var path = __toESM(require("node:path"));
|
|
41
|
+
var import_better_sqlite3 = __toESM(require("better-sqlite3"));
|
|
42
|
+
var SQLITE_CARD_STATE_PROVIDER_ID = "sqlite";
|
|
43
|
+
var DEFAULT_SQLITE_CARD_STATE_PATH = ".kanban/card-state.db";
|
|
44
|
+
var SQLITE_CARD_STATE_MANIFEST = Object.freeze({
|
|
45
|
+
id: SQLITE_CARD_STATE_PROVIDER_ID,
|
|
46
|
+
provides: ["card.state"]
|
|
47
|
+
});
|
|
48
|
+
var SQLITE_SCHEMA_VERSION = 1;
|
|
49
|
+
var CREATE_SCHEMA_SQL = `
|
|
50
|
+
PRAGMA journal_mode = WAL;
|
|
51
|
+
PRAGMA foreign_keys = ON;
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
54
|
+
version INTEGER NOT NULL
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS card_state (
|
|
58
|
+
actor_id TEXT NOT NULL,
|
|
59
|
+
board_id TEXT NOT NULL,
|
|
60
|
+
card_id TEXT NOT NULL,
|
|
61
|
+
domain TEXT NOT NULL,
|
|
62
|
+
value_json TEXT NOT NULL,
|
|
63
|
+
updated_at TEXT NOT NULL,
|
|
64
|
+
PRIMARY KEY (actor_id, board_id, card_id, domain)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_card_state_lookup
|
|
68
|
+
ON card_state (actor_id, board_id, card_id, domain);
|
|
69
|
+
`;
|
|
70
|
+
function isRecord(value) {
|
|
71
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
function isCardStateCursor(value) {
|
|
74
|
+
return isRecord(value) && typeof value.cursor === "string" && (value.updatedAt === void 0 || typeof value.updatedAt === "string");
|
|
75
|
+
}
|
|
76
|
+
function resolveSqlitePath(context) {
|
|
77
|
+
const rawPath = typeof context.options?.["sqlitePath"] === "string" && context.options["sqlitePath"].trim().length > 0 ? context.options["sqlitePath"].trim() : DEFAULT_SQLITE_CARD_STATE_PATH;
|
|
78
|
+
return path.isAbsolute(rawPath) ? rawPath : path.join(context.workspaceRoot, rawPath);
|
|
79
|
+
}
|
|
80
|
+
function createDatabase(sqlitePath) {
|
|
81
|
+
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
|
82
|
+
const db = new import_better_sqlite3.default(sqlitePath);
|
|
83
|
+
db.exec(CREATE_SCHEMA_SQL);
|
|
84
|
+
const versionRow = db.prepare("SELECT version FROM schema_version LIMIT 1").get();
|
|
85
|
+
if (!versionRow) {
|
|
86
|
+
db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(SQLITE_SCHEMA_VERSION);
|
|
87
|
+
} else if (versionRow.version !== SQLITE_SCHEMA_VERSION) {
|
|
88
|
+
db.prepare("UPDATE schema_version SET version = ?").run(SQLITE_SCHEMA_VERSION);
|
|
89
|
+
}
|
|
90
|
+
return db;
|
|
91
|
+
}
|
|
92
|
+
function getUpdatedAt(updatedAt) {
|
|
93
|
+
return updatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
94
|
+
}
|
|
95
|
+
function parseValue(valueJson) {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(valueJson);
|
|
98
|
+
return isRecord(parsed) ? parsed : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function toCardStateRecord(input, row, value) {
|
|
104
|
+
return {
|
|
105
|
+
actorId: input.actorId,
|
|
106
|
+
boardId: input.boardId,
|
|
107
|
+
cardId: input.cardId,
|
|
108
|
+
domain: input.domain,
|
|
109
|
+
value,
|
|
110
|
+
updatedAt: row.updated_at
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function createCardStateProvider(context) {
|
|
114
|
+
const sqlitePath = resolveSqlitePath(context);
|
|
115
|
+
const db = createDatabase(sqlitePath);
|
|
116
|
+
const selectState = db.prepare(`
|
|
117
|
+
SELECT actor_id, board_id, card_id, domain, value_json, updated_at
|
|
118
|
+
FROM card_state
|
|
119
|
+
WHERE actor_id = ? AND board_id = ? AND card_id = ? AND domain = ?
|
|
120
|
+
`);
|
|
121
|
+
const upsertState = db.prepare(`
|
|
122
|
+
INSERT INTO card_state (actor_id, board_id, card_id, domain, value_json, updated_at)
|
|
123
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
124
|
+
ON CONFLICT(actor_id, board_id, card_id, domain)
|
|
125
|
+
DO UPDATE SET
|
|
126
|
+
value_json = excluded.value_json,
|
|
127
|
+
updated_at = excluded.updated_at
|
|
128
|
+
`);
|
|
129
|
+
return {
|
|
130
|
+
manifest: SQLITE_CARD_STATE_MANIFEST,
|
|
131
|
+
async getCardState(_input) {
|
|
132
|
+
const row = selectState.get(
|
|
133
|
+
_input.actorId,
|
|
134
|
+
_input.boardId,
|
|
135
|
+
_input.cardId,
|
|
136
|
+
_input.domain
|
|
137
|
+
);
|
|
138
|
+
if (!row)
|
|
139
|
+
return null;
|
|
140
|
+
const value = parseValue(row.value_json);
|
|
141
|
+
if (!value)
|
|
142
|
+
return null;
|
|
143
|
+
return toCardStateRecord(_input, row, value);
|
|
144
|
+
},
|
|
145
|
+
async setCardState(_input) {
|
|
146
|
+
const updatedAt = getUpdatedAt(_input.updatedAt);
|
|
147
|
+
upsertState.run(
|
|
148
|
+
_input.actorId,
|
|
149
|
+
_input.boardId,
|
|
150
|
+
_input.cardId,
|
|
151
|
+
_input.domain,
|
|
152
|
+
JSON.stringify(_input.value),
|
|
153
|
+
updatedAt
|
|
154
|
+
);
|
|
155
|
+
return {
|
|
156
|
+
actorId: _input.actorId,
|
|
157
|
+
boardId: _input.boardId,
|
|
158
|
+
cardId: _input.cardId,
|
|
159
|
+
domain: _input.domain,
|
|
160
|
+
value: _input.value,
|
|
161
|
+
updatedAt
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
async getUnreadCursor(_input) {
|
|
165
|
+
const record = await this.getCardState({ ..._input, domain: "unread" });
|
|
166
|
+
return record && isCardStateCursor(record.value) ? record.value : null;
|
|
167
|
+
},
|
|
168
|
+
async markUnreadReadThrough(_input) {
|
|
169
|
+
const updatedAt = getUpdatedAt(_input.cursor.updatedAt);
|
|
170
|
+
const value = {
|
|
171
|
+
cursor: _input.cursor.cursor,
|
|
172
|
+
updatedAt
|
|
173
|
+
};
|
|
174
|
+
upsertState.run(
|
|
175
|
+
_input.actorId,
|
|
176
|
+
_input.boardId,
|
|
177
|
+
_input.cardId,
|
|
178
|
+
"unread",
|
|
179
|
+
JSON.stringify(value),
|
|
180
|
+
updatedAt
|
|
181
|
+
);
|
|
182
|
+
return {
|
|
183
|
+
actorId: _input.actorId,
|
|
184
|
+
boardId: _input.boardId,
|
|
185
|
+
cardId: _input.cardId,
|
|
186
|
+
domain: "unread",
|
|
187
|
+
value,
|
|
188
|
+
updatedAt
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
var src_default = createCardStateProvider;
|
|
194
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
195
|
+
0 && (module.exports = {
|
|
196
|
+
DEFAULT_SQLITE_CARD_STATE_PATH,
|
|
197
|
+
SQLITE_CARD_STATE_PROVIDER_ID,
|
|
198
|
+
createCardStateProvider
|
|
199
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Shared plugin manifest shape for `card.state` capability providers. */
|
|
2
|
+
export interface CardStateProviderManifest {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly provides: readonly ['card.state'];
|
|
5
|
+
}
|
|
6
|
+
/** Opaque JSON-like payload stored for a card-state domain. */
|
|
7
|
+
export type CardStateValue = Record<string, unknown>;
|
|
8
|
+
/** Stable actor/card/domain lookup key used by card-state providers. */
|
|
9
|
+
export interface CardStateKey {
|
|
10
|
+
actorId: string;
|
|
11
|
+
boardId: string;
|
|
12
|
+
cardId: string;
|
|
13
|
+
domain: string;
|
|
14
|
+
}
|
|
15
|
+
/** Stored card-state record returned by provider operations. */
|
|
16
|
+
export interface CardStateRecord<TValue = CardStateValue> extends CardStateKey {
|
|
17
|
+
value: TValue;
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
}
|
|
20
|
+
/** Write input for card-state domain mutations. */
|
|
21
|
+
export interface CardStateWriteInput<TValue = CardStateValue> extends CardStateKey {
|
|
22
|
+
value: TValue;
|
|
23
|
+
updatedAt?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Unread cursor payload persisted by card-state providers. */
|
|
26
|
+
export interface CardStateCursor extends Record<string, unknown> {
|
|
27
|
+
cursor: string;
|
|
28
|
+
updatedAt?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Lookup key for unread cursor state. */
|
|
31
|
+
export interface CardStateUnreadKey {
|
|
32
|
+
actorId: string;
|
|
33
|
+
boardId: string;
|
|
34
|
+
cardId: string;
|
|
35
|
+
}
|
|
36
|
+
/** Mutation input for marking unread state through a cursor. */
|
|
37
|
+
export interface CardStateReadThroughInput extends CardStateUnreadKey {
|
|
38
|
+
cursor: CardStateCursor;
|
|
39
|
+
}
|
|
40
|
+
/** Shared runtime context passed to and exposed for `card.state` providers. */
|
|
41
|
+
export interface CardStateModuleContext {
|
|
42
|
+
workspaceRoot: string;
|
|
43
|
+
kanbanDir: string;
|
|
44
|
+
provider: string;
|
|
45
|
+
backend: 'builtin' | 'external';
|
|
46
|
+
options?: Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
/** Contract for first-class `card.state` capability providers. */
|
|
49
|
+
export interface CardStateProvider {
|
|
50
|
+
readonly manifest: CardStateProviderManifest;
|
|
51
|
+
getCardState(input: CardStateKey): Promise<CardStateRecord | null>;
|
|
52
|
+
setCardState(input: CardStateWriteInput): Promise<CardStateRecord>;
|
|
53
|
+
getUnreadCursor(input: CardStateUnreadKey): Promise<CardStateCursor | null>;
|
|
54
|
+
markUnreadReadThrough(input: CardStateReadThroughInput): Promise<CardStateRecord<CardStateCursor>>;
|
|
55
|
+
}
|
|
56
|
+
export declare const SQLITE_CARD_STATE_PROVIDER_ID = "sqlite";
|
|
57
|
+
export declare const DEFAULT_SQLITE_CARD_STATE_PATH = ".kanban/card-state.db";
|
|
58
|
+
/**
|
|
59
|
+
* Creates the SQLite-backed `card.state` provider.
|
|
60
|
+
*
|
|
61
|
+
* Data is stored in a dedicated SQLite database so actor-scoped unread and
|
|
62
|
+
* explicit-open state can be shared across SDK instances without writing any
|
|
63
|
+
* per-user state into markdown cards or active-card UI storage.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createCardStateProvider(context: CardStateModuleContext): CardStateProvider;
|
|
66
|
+
export default createCardStateProvider;
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kl-sqlite-card-state",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "kanban-lite card.state plugin for SQLite-backed actor-scoped card state",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/borgius/kl-sqlite-card-state.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/borgius/kl-sqlite-card-state#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/borgius/kl-sqlite-card-state/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "dist/index.cjs",
|
|
16
|
+
"types": "dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"require": "./dist/index.cjs",
|
|
21
|
+
"default": "./dist/index.cjs"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "esbuild src/index.ts --bundle --outfile=dist/index.cjs --format=cjs --platform=node --external:better-sqlite3 --external:kanban-lite --external:kanban-lite/sdk && tsc -p tsconfig.json --declaration --emitDeclarationOnly --outDir dist",
|
|
33
|
+
"clean": "rm -rf dist",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run src/index.test.ts",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"test:integration": "npm run build && vitest run src/sqlite-card-state.workspace.test.ts",
|
|
38
|
+
"release": "npm run clean && npm run build && npm version patch && npm publish",
|
|
39
|
+
"release:minor": "npm run clean && npm run build && npm version minor && npm publish",
|
|
40
|
+
"release:major": "npm run clean && npm run build && npm version major && npm publish"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"better-sqlite3": "^12.6.2"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
47
|
+
"@types/node": "^20.10.0",
|
|
48
|
+
"esbuild": "^0.19.0",
|
|
49
|
+
"typescript": "^5.3.0",
|
|
50
|
+
"vitest": "^4.0.18"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"kanban-lite": ">=1.0.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"kanban-lite": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"keywords": [
|
|
61
|
+
"kanban-lite",
|
|
62
|
+
"kanban",
|
|
63
|
+
"card.state",
|
|
64
|
+
"sqlite",
|
|
65
|
+
"plugin"
|
|
66
|
+
]
|
|
67
|
+
}
|