subtrack 1.0.1 → 1.0.4
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/dist/index.mjs +222 -0
- package/package.json +12 -10
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { input, select } from "@inquirer/prompts";
|
|
4
|
+
import Table from "cli-table3";
|
|
5
|
+
import { consola } from "consola";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
//#region src/basefs.ts
|
|
11
|
+
let _db = null;
|
|
12
|
+
function getDbDir() {
|
|
13
|
+
return process.env.SUBSC_CLI_DB_DIR ?? path.join(homedir(), ".config", "subtrack");
|
|
14
|
+
}
|
|
15
|
+
function getDb() {
|
|
16
|
+
if (_db) return _db;
|
|
17
|
+
const dbdir = getDbDir();
|
|
18
|
+
mkdirSync(dbdir, { recursive: true });
|
|
19
|
+
_db = new Database(path.join(dbdir, "subtrack.db"));
|
|
20
|
+
_db.pragma("journal_mode = WAL");
|
|
21
|
+
_db.pragma("foreign_keys = ON");
|
|
22
|
+
_db.exec(`
|
|
23
|
+
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
name TEXT NOT NULL,
|
|
26
|
+
price INTEGER NOT NULL,
|
|
27
|
+
currency TEXT NOT NULL,
|
|
28
|
+
cycle TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
`);
|
|
31
|
+
_db.exec(`
|
|
32
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
33
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
34
|
+
name TEXT NOT NULL UNIQUE
|
|
35
|
+
);
|
|
36
|
+
`);
|
|
37
|
+
_db.exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS subscription_tags (
|
|
39
|
+
subscription_id INTEGER NOT NULL,
|
|
40
|
+
tag_id INTEGER NOT NULL,
|
|
41
|
+
PRIMARY KEY (subscription_id, tag_id),
|
|
42
|
+
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE,
|
|
43
|
+
FOREIGN KEY (tag_id) REFERENCES tags(id)
|
|
44
|
+
);
|
|
45
|
+
`);
|
|
46
|
+
return _db;
|
|
47
|
+
}
|
|
48
|
+
function mapTags(subs) {
|
|
49
|
+
if (subs.length === 0) return subs;
|
|
50
|
+
const getTags = getDb().prepare(`
|
|
51
|
+
SELECT tags.name FROM tags
|
|
52
|
+
JOIN subscription_tags ON subscription_tags.tag_id = tags.id
|
|
53
|
+
WHERE subscription_tags.subscription_id = ?
|
|
54
|
+
`);
|
|
55
|
+
for (const sub of subs) sub.tags = getTags.all(sub.id).map((r) => r.name);
|
|
56
|
+
return subs;
|
|
57
|
+
}
|
|
58
|
+
const getSubscriptions = () => {
|
|
59
|
+
try {
|
|
60
|
+
return mapTags(getDb().prepare("SELECT id, name, price, currency, cycle FROM subscriptions ORDER BY id").all());
|
|
61
|
+
} catch (error) {
|
|
62
|
+
consola.error("Failed to fetch subscriptions:", error);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const writeSubscription = (data) => {
|
|
67
|
+
try {
|
|
68
|
+
const db = getDb();
|
|
69
|
+
const uniqueTags = Array.from(new Set(data.tags));
|
|
70
|
+
db.transaction(() => {
|
|
71
|
+
const result = db.prepare(`
|
|
72
|
+
INSERT INTO subscriptions (name, price, currency, cycle)
|
|
73
|
+
VALUES (?, ?, ?, ?)
|
|
74
|
+
`).run(data.name, data.price, data.currency, data.cycle);
|
|
75
|
+
const subscriptionId = Number(result.lastInsertRowid);
|
|
76
|
+
const insertTag = db.prepare(`
|
|
77
|
+
INSERT OR IGNORE INTO tags (name)
|
|
78
|
+
VALUES (?)
|
|
79
|
+
`);
|
|
80
|
+
const getTagId = db.prepare(`
|
|
81
|
+
SELECT id FROM tags WHERE name = ?
|
|
82
|
+
`);
|
|
83
|
+
const insertRel = db.prepare(`
|
|
84
|
+
INSERT INTO subscription_tags (subscription_id, tag_id)
|
|
85
|
+
VALUES (?, ?)
|
|
86
|
+
`);
|
|
87
|
+
for (const t of uniqueTags) {
|
|
88
|
+
insertTag.run(t);
|
|
89
|
+
const tagRow = getTagId.get(t);
|
|
90
|
+
if (tagRow) insertRel.run(subscriptionId, tagRow.id);
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
consola.error("Failed to add subscription:", error);
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const deleteSubscription = (id) => {
|
|
99
|
+
try {
|
|
100
|
+
if (getDb().prepare("DELETE FROM subscriptions WHERE id = ?").run(id).changes === 0) consola.warn(`No subscription found with id ${id}`);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
consola.error("Failed to delete subscription:", error);
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const tagsSubscription = (tag) => {
|
|
107
|
+
try {
|
|
108
|
+
const db = getDb();
|
|
109
|
+
const tags = Array.from(new Set(Array.isArray(tag) ? tag : [tag]));
|
|
110
|
+
if (tags.length === 0) return [];
|
|
111
|
+
const placeholders = tags.map(() => "?").join(",");
|
|
112
|
+
const ids = db.prepare(`
|
|
113
|
+
SELECT subscription_tags.subscription_id
|
|
114
|
+
FROM subscription_tags
|
|
115
|
+
JOIN tags ON tags.id = subscription_tags.tag_id
|
|
116
|
+
WHERE tags.name IN (${placeholders})
|
|
117
|
+
GROUP BY subscription_tags.subscription_id
|
|
118
|
+
HAVING COUNT(DISTINCT tags.name) = ?
|
|
119
|
+
`).all(...tags, tags.length).map((r) => r.subscription_id);
|
|
120
|
+
if (ids.length === 0) return [];
|
|
121
|
+
return mapTags(db.prepare(`
|
|
122
|
+
SELECT id, name, price, currency, cycle FROM subscriptions
|
|
123
|
+
WHERE id IN (${ids.map(() => "?").join(",")})
|
|
124
|
+
`).all(...ids));
|
|
125
|
+
} catch (error) {
|
|
126
|
+
consola.error("Failed to filter by tags:", error);
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/table.ts
|
|
132
|
+
const spreadSubscription = (get) => {
|
|
133
|
+
const list = get ?? getSubscriptions();
|
|
134
|
+
if (list.length === 0) {
|
|
135
|
+
consola.info("No subscriptions found");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const table = new Table({ head: [
|
|
139
|
+
"name",
|
|
140
|
+
"cycle",
|
|
141
|
+
"tags",
|
|
142
|
+
"price"
|
|
143
|
+
] });
|
|
144
|
+
for (const sub of list) table.push([
|
|
145
|
+
String(sub.name),
|
|
146
|
+
String(sub.cycle),
|
|
147
|
+
sub.tags.length > 0 ? sub.tags.join(", ") : "-",
|
|
148
|
+
sub.currency === "USD" ? String(`$${sub.price}`) : String(`¥${sub.price}`)
|
|
149
|
+
]);
|
|
150
|
+
const usdtotal = list.filter((n) => n.currency === "USD").reduce((sum, n) => sum + n.price, 0);
|
|
151
|
+
const jpytotal = list.filter((n) => n.currency === "JPY").reduce((sum, n) => sum + n.price, 0);
|
|
152
|
+
table.push([
|
|
153
|
+
"",
|
|
154
|
+
"",
|
|
155
|
+
"JPY TOTAL",
|
|
156
|
+
`¥${jpytotal}`
|
|
157
|
+
], [
|
|
158
|
+
"",
|
|
159
|
+
"",
|
|
160
|
+
"USD TOTAL",
|
|
161
|
+
`$${usdtotal}`
|
|
162
|
+
]);
|
|
163
|
+
consola.log(table.toString());
|
|
164
|
+
};
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/index.ts
|
|
167
|
+
const runCLI = () => {
|
|
168
|
+
const program = new Command();
|
|
169
|
+
program.name("subtrack");
|
|
170
|
+
program.command("list").action(() => {
|
|
171
|
+
spreadSubscription();
|
|
172
|
+
});
|
|
173
|
+
program.command("add").action(async () => {
|
|
174
|
+
const name = await input({ message: "subscription name" });
|
|
175
|
+
const price = await input({
|
|
176
|
+
message: "monthly payment amount",
|
|
177
|
+
validate: (value) => {
|
|
178
|
+
if (value.trim() === "") return "Please enter a valid number";
|
|
179
|
+
if (isNaN(Number(value)) || Number(value) < 0) return "Please enter a valid non-negative number";
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
const currency = await select({
|
|
184
|
+
message: "currency",
|
|
185
|
+
choices: [{
|
|
186
|
+
name: "JPY",
|
|
187
|
+
value: "JPY"
|
|
188
|
+
}, {
|
|
189
|
+
name: "USD",
|
|
190
|
+
value: "USD"
|
|
191
|
+
}]
|
|
192
|
+
});
|
|
193
|
+
const cycle = await select({
|
|
194
|
+
message: "cycle",
|
|
195
|
+
choices: [{
|
|
196
|
+
name: "monthly",
|
|
197
|
+
value: "monthly"
|
|
198
|
+
}, {
|
|
199
|
+
name: "yearly",
|
|
200
|
+
value: "yearly"
|
|
201
|
+
}]
|
|
202
|
+
});
|
|
203
|
+
const tag = (await input({ message: "tags" })).split(",").map((tag) => tag.trim()).filter(Boolean);
|
|
204
|
+
writeSubscription({
|
|
205
|
+
name,
|
|
206
|
+
price: Number(price),
|
|
207
|
+
currency,
|
|
208
|
+
cycle,
|
|
209
|
+
tags: tag
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
program.command("delete").argument("<number>").action((number) => {
|
|
213
|
+
deleteSubscription(Number(number));
|
|
214
|
+
});
|
|
215
|
+
program.command("tags").argument("<taglist...>").action((taglist) => {
|
|
216
|
+
spreadSubscription(tagsSubscription(taglist));
|
|
217
|
+
});
|
|
218
|
+
program.parse();
|
|
219
|
+
};
|
|
220
|
+
runCLI();
|
|
221
|
+
//#endregion
|
|
222
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "subtrack",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"packageManager": "pnpm@11.5.1",
|
|
6
7
|
"bin": {
|
|
7
|
-
"
|
|
8
|
+
"subtrack": "./dist/index.mjs"
|
|
8
9
|
},
|
|
9
10
|
"publishConfig": {
|
|
10
11
|
"access": "public"
|
|
11
12
|
},
|
|
12
13
|
"repository": {
|
|
13
14
|
"type": "git",
|
|
14
|
-
"url": "https://github.com/nazozokc/
|
|
15
|
+
"url": "https://github.com/nazozokc/subtrack.git"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsdown",
|
|
19
|
+
"start": "tsx src/index.ts",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"lint:typos": "typos"
|
|
15
23
|
},
|
|
16
24
|
"dependencies": {
|
|
17
25
|
"@inquirer/prompts": "^8.5.2",
|
|
@@ -26,11 +34,5 @@
|
|
|
26
34
|
"tsx": "^4.19.0",
|
|
27
35
|
"typescript": "^5",
|
|
28
36
|
"vitest": "^3.0.0"
|
|
29
|
-
},
|
|
30
|
-
"scripts": {
|
|
31
|
-
"build": "tsdown",
|
|
32
|
-
"start": "tsx src/index.ts",
|
|
33
|
-
"test": "vitest run",
|
|
34
|
-
"test:watch": "vitest"
|
|
35
37
|
}
|
|
36
|
-
}
|
|
38
|
+
}
|