subtrack 1.0.1 → 1.0.3

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.
Files changed (2) hide show
  1. package/dist/index.mjs +222 -0
  2. package/package.json +11 -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,24 @@
1
1
  {
2
2
  "name": "subtrack",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
+ "packageManager": "pnpm@11.5.1",
6
7
  "bin": {
7
- "subsc-cli": "./dist/index.mjs"
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/subsc-cli.git"
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"
15
22
  },
16
23
  "dependencies": {
17
24
  "@inquirer/prompts": "^8.5.2",
@@ -26,11 +33,5 @@
26
33
  "tsx": "^4.19.0",
27
34
  "typescript": "^5",
28
35
  "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
36
  }
36
- }
37
+ }