pacatui 0.1.15 → 0.1.16
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/assets/paca-icon.png +0 -0
- package/assets/paca-mascot.png +0 -0
- package/package.json +1 -1
- package/src/App.tsx +154 -8
- package/src/components/DatabaseSelectModal.tsx +124 -0
- package/src/components/SettingsView.tsx +67 -38
- package/src/components/index.ts +1 -0
- package/src/db-path.ts +98 -0
- package/src/db.ts +20 -18
- package/src/index.tsx +14 -6
- package/src/menubar/db-lite.ts +3 -6
- package/src/menubar/index.ts +1 -1
- package/src/menubar/swift-source.ts +26 -4
- package/src/types.ts +3 -1
|
Binary file
|
package/assets/paca-mascot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
EditTimeEntryModal,
|
|
21
21
|
CreateInvoiceModal,
|
|
22
22
|
ThemeSelectModal,
|
|
23
|
+
DatabaseSelectModal,
|
|
23
24
|
} from "./components/index.ts";
|
|
24
25
|
import { InvoicesView } from "./components/InvoicesView.tsx";
|
|
25
26
|
import {
|
|
@@ -31,7 +32,16 @@ import {
|
|
|
31
32
|
database,
|
|
32
33
|
customers,
|
|
33
34
|
invoices,
|
|
35
|
+
switchDatabase,
|
|
34
36
|
} from "./db.ts";
|
|
37
|
+
import {
|
|
38
|
+
getActiveDbFilename,
|
|
39
|
+
setActiveDbFilename,
|
|
40
|
+
getActiveDbPath,
|
|
41
|
+
listDatabases,
|
|
42
|
+
sanitizeDbName,
|
|
43
|
+
getPacaDir,
|
|
44
|
+
} from "./db-path.ts";
|
|
35
45
|
import { getOrCreateStripeCustomer, createDraftInvoice, listInvoices, clearInvoiceCache, type StripeInvoiceItem } from "./stripe.ts";
|
|
36
46
|
import {
|
|
37
47
|
getEffectiveTimezone,
|
|
@@ -152,6 +162,9 @@ export function App() {
|
|
|
152
162
|
const [invoicesHasMore, setInvoicesHasMore] = useState(false);
|
|
153
163
|
const [invoicesCursors, setInvoicesCursors] = useState<string[]>([]); // Stack of cursors for pagination
|
|
154
164
|
|
|
165
|
+
// Database State
|
|
166
|
+
const [currentDbFilename, setCurrentDbFilename] = useState(getActiveDbFilename());
|
|
167
|
+
|
|
155
168
|
// Modal State
|
|
156
169
|
const [inputMode, setInputMode] = useState<InputMode | null>(null);
|
|
157
170
|
const [statusMessage, setStatusMessage] =
|
|
@@ -498,14 +511,19 @@ export function App() {
|
|
|
498
511
|
return;
|
|
499
512
|
}
|
|
500
513
|
|
|
501
|
-
// Handle input mode escape (except
|
|
502
|
-
if (inputMode && inputMode !== "select_timer_project") {
|
|
514
|
+
// Handle input mode escape (except modals that handle their own escape)
|
|
515
|
+
if (inputMode && inputMode !== "select_timer_project" && inputMode !== "select_database" && inputMode !== "create_database_name") {
|
|
503
516
|
if (key.name === "escape") {
|
|
504
517
|
setInputMode(null);
|
|
505
518
|
}
|
|
506
519
|
return;
|
|
507
520
|
}
|
|
508
521
|
|
|
522
|
+
// Handle select_database and create_database_name modals (they have their own handlers)
|
|
523
|
+
if (inputMode === "select_database" || inputMode === "create_database_name") {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
509
527
|
// Handle select timer project modal
|
|
510
528
|
if (inputMode === "select_timer_project") {
|
|
511
529
|
// The modal has its own keyboard handler
|
|
@@ -871,6 +889,7 @@ export function App() {
|
|
|
871
889
|
}
|
|
872
890
|
|
|
873
891
|
// Select/activate setting
|
|
892
|
+
// Order matches: Business (0: businessName, 1: stripeApiKey) + App (2: database, 3: theme, 4: timezone, 5: menuBar, 6: export, 7: import)
|
|
874
893
|
if (key.name === "return") {
|
|
875
894
|
switch (selectedSettingsIndex) {
|
|
876
895
|
case 0: // Business Name
|
|
@@ -879,19 +898,22 @@ export function App() {
|
|
|
879
898
|
case 1: // Stripe API Key
|
|
880
899
|
setInputMode("edit_stripe_key");
|
|
881
900
|
break;
|
|
882
|
-
case 2: //
|
|
901
|
+
case 2: // Database
|
|
902
|
+
setInputMode("select_database");
|
|
903
|
+
break;
|
|
904
|
+
case 3: // Theme
|
|
883
905
|
setInputMode("select_theme");
|
|
884
906
|
break;
|
|
885
|
-
case
|
|
907
|
+
case 4: // Timezone
|
|
886
908
|
setInputMode("edit_timezone");
|
|
887
909
|
break;
|
|
888
|
-
case
|
|
910
|
+
case 5: // Menu Bar
|
|
889
911
|
handleToggleMenuBar();
|
|
890
912
|
break;
|
|
891
|
-
case
|
|
913
|
+
case 6: // Export Database
|
|
892
914
|
handleExportDatabase();
|
|
893
915
|
break;
|
|
894
|
-
case
|
|
916
|
+
case 7: // Import Database
|
|
895
917
|
setConfirmMessage("Import will replace all data. Continue?");
|
|
896
918
|
setConfirmAction(() => () => handleImportDatabase());
|
|
897
919
|
break;
|
|
@@ -1160,7 +1182,8 @@ export function App() {
|
|
|
1160
1182
|
}
|
|
1161
1183
|
|
|
1162
1184
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
1163
|
-
const
|
|
1185
|
+
const dbName = currentDbFilename.replace(/\.db$/, "");
|
|
1186
|
+
const exportPath = `${backupDir}/${dbName}-backup-${timestamp}.db`;
|
|
1164
1187
|
try {
|
|
1165
1188
|
await database.exportToFile(exportPath);
|
|
1166
1189
|
showMessage(`Exported to ${exportPath}`);
|
|
@@ -1217,6 +1240,105 @@ export function App() {
|
|
|
1217
1240
|
showMessage(msg);
|
|
1218
1241
|
};
|
|
1219
1242
|
|
|
1243
|
+
// Database handlers
|
|
1244
|
+
const handleSwitchDatabase = async (filename: string) => {
|
|
1245
|
+
if (filename === currentDbFilename) {
|
|
1246
|
+
setInputMode(null);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
try {
|
|
1251
|
+
const { join } = await import("path");
|
|
1252
|
+
const { existsSync } = await import("fs");
|
|
1253
|
+
const dbPath = join(getPacaDir(), filename);
|
|
1254
|
+
|
|
1255
|
+
// Run migration if database file doesn't exist yet
|
|
1256
|
+
if (!existsSync(dbPath)) {
|
|
1257
|
+
const { execSync } = await import("child_process");
|
|
1258
|
+
const { dirname } = await import("path");
|
|
1259
|
+
const projectDir = dirname(dirname(import.meta.path));
|
|
1260
|
+
execSync(`cd "${projectDir}" && bunx prisma migrate deploy`, {
|
|
1261
|
+
stdio: "pipe",
|
|
1262
|
+
env: {
|
|
1263
|
+
...process.env,
|
|
1264
|
+
DATABASE_URL: `file:${dbPath}`,
|
|
1265
|
+
},
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Disable menu bar before switching (it points at the old db)
|
|
1270
|
+
if (appSettings.menuBar === "enabled") {
|
|
1271
|
+
const { disableMenuBar } = await import("./menubar/index.ts");
|
|
1272
|
+
await disableMenuBar();
|
|
1273
|
+
await settings.set("menuBar", "disabled");
|
|
1274
|
+
setAppSettings((prev) => ({ ...prev, menuBar: "disabled" }));
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Write .active file
|
|
1278
|
+
setActiveDbFilename(filename);
|
|
1279
|
+
|
|
1280
|
+
// Swap PrismaClient
|
|
1281
|
+
await switchDatabase(dbPath);
|
|
1282
|
+
|
|
1283
|
+
// Update state
|
|
1284
|
+
setCurrentDbFilename(filename);
|
|
1285
|
+
|
|
1286
|
+
// Reload all app state
|
|
1287
|
+
const [settingsData, projectData, dashboardData, recentData, weeklyData, runningData, customerData] = await Promise.all([
|
|
1288
|
+
settings.getAppSettings(),
|
|
1289
|
+
projects.getAll(false),
|
|
1290
|
+
stats.getDashboardStats(),
|
|
1291
|
+
stats.getRecentActivity(10),
|
|
1292
|
+
stats.getWeeklyTimeStats(6),
|
|
1293
|
+
timeEntries.getRunning(),
|
|
1294
|
+
customers.getAll(),
|
|
1295
|
+
]);
|
|
1296
|
+
|
|
1297
|
+
setAppSettings(settingsData);
|
|
1298
|
+
setProjectList(projectData);
|
|
1299
|
+
setDashboardStats(dashboardData);
|
|
1300
|
+
setRecentTasks(recentData as (Task & { project: { name: string; color: string } })[]);
|
|
1301
|
+
setWeeklyTimeData(weeklyData);
|
|
1302
|
+
setCustomerList(customerData);
|
|
1303
|
+
|
|
1304
|
+
if (runningData) {
|
|
1305
|
+
setRunningTimer({
|
|
1306
|
+
id: runningData.id,
|
|
1307
|
+
startTime: runningData.startTime,
|
|
1308
|
+
project: runningData.project,
|
|
1309
|
+
});
|
|
1310
|
+
} else {
|
|
1311
|
+
setRunningTimer(null);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Reset selection indices
|
|
1315
|
+
setSelectedProjectIndex(0);
|
|
1316
|
+
setSelectedTaskIndex(0);
|
|
1317
|
+
setSelectedDashboardTaskIndex(0);
|
|
1318
|
+
setSelectedSettingsIndex(0);
|
|
1319
|
+
|
|
1320
|
+
setInputMode(null);
|
|
1321
|
+
showMessage(`Switched to database: ${filename.replace(/\.db$/, "")}`);
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
showMessage(`Failed to switch database: ${error}`);
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
const handleCreateDatabase = async (name: string) => {
|
|
1328
|
+
const filename = sanitizeDbName(name);
|
|
1329
|
+
const { join } = await import("path");
|
|
1330
|
+
const { existsSync } = await import("fs");
|
|
1331
|
+
const dbPath = join(getPacaDir(), filename);
|
|
1332
|
+
|
|
1333
|
+
if (existsSync(dbPath)) {
|
|
1334
|
+
showMessage(`Database "${filename.replace(/\.db$/, "")}" already exists`);
|
|
1335
|
+
setInputMode("select_database");
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
await handleSwitchDatabase(filename);
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1220
1342
|
// Modal handlers
|
|
1221
1343
|
const handleCreateProject = async (name: string, rate: number | null) => {
|
|
1222
1344
|
await projects.create({ name, hourlyRate: rate });
|
|
@@ -1473,6 +1595,8 @@ export function App() {
|
|
|
1473
1595
|
settings={appSettings}
|
|
1474
1596
|
selectedIndex={selectedSettingsIndex}
|
|
1475
1597
|
theme={getTheme(appSettings.theme)}
|
|
1598
|
+
currentDbFilename={currentDbFilename}
|
|
1599
|
+
onSelectDatabase={() => setInputMode("select_database")}
|
|
1476
1600
|
onEditBusinessName={() => setInputMode("edit_business_name")}
|
|
1477
1601
|
onEditStripeKey={() => setInputMode("edit_stripe_key")}
|
|
1478
1602
|
onEditTimezone={() => setInputMode("edit_timezone")}
|
|
@@ -1667,6 +1791,28 @@ export function App() {
|
|
|
1667
1791
|
/>
|
|
1668
1792
|
)}
|
|
1669
1793
|
|
|
1794
|
+
{inputMode === "select_database" && (
|
|
1795
|
+
<DatabaseSelectModal
|
|
1796
|
+
databases={listDatabases()}
|
|
1797
|
+
currentDatabase={currentDbFilename}
|
|
1798
|
+
onSelect={handleSwitchDatabase}
|
|
1799
|
+
onCreateNew={() => setInputMode("create_database_name")}
|
|
1800
|
+
onCancel={() => setInputMode(null)}
|
|
1801
|
+
theme={theme}
|
|
1802
|
+
/>
|
|
1803
|
+
)}
|
|
1804
|
+
|
|
1805
|
+
{inputMode === "create_database_name" && (
|
|
1806
|
+
<InputModal
|
|
1807
|
+
mode={inputMode}
|
|
1808
|
+
title="Create New Database"
|
|
1809
|
+
placeholder="Database name (e.g. work, personal)..."
|
|
1810
|
+
onSubmit={handleCreateDatabase}
|
|
1811
|
+
onCancel={() => setInputMode("select_database")}
|
|
1812
|
+
theme={theme}
|
|
1813
|
+
/>
|
|
1814
|
+
)}
|
|
1815
|
+
|
|
1670
1816
|
{inputMode === "select_timer_project" && (
|
|
1671
1817
|
<ProjectSelectModal
|
|
1672
1818
|
projects={projectList.filter((p) => !p.archived)}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useKeyboard } from "@opentui/react";
|
|
3
|
+
import type { Theme } from "../types.ts";
|
|
4
|
+
import Modal from "./Modal.tsx";
|
|
5
|
+
|
|
6
|
+
interface DatabaseSelectModalProps {
|
|
7
|
+
databases: string[];
|
|
8
|
+
currentDatabase: string;
|
|
9
|
+
onSelect: (filename: string) => void;
|
|
10
|
+
onCreateNew: () => void;
|
|
11
|
+
onCancel: () => void;
|
|
12
|
+
theme: Theme;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function DatabaseSelectModal({
|
|
16
|
+
databases,
|
|
17
|
+
currentDatabase,
|
|
18
|
+
onSelect,
|
|
19
|
+
onCreateNew,
|
|
20
|
+
onCancel,
|
|
21
|
+
theme,
|
|
22
|
+
}: DatabaseSelectModalProps) {
|
|
23
|
+
const currentIndex = databases.indexOf(currentDatabase);
|
|
24
|
+
const [selectedIndex, setSelectedIndex] = useState(
|
|
25
|
+
currentIndex >= 0 ? currentIndex : 0,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const colors = theme.colors;
|
|
29
|
+
|
|
30
|
+
// Total items = databases + "Create new" option
|
|
31
|
+
const totalItems = databases.length + 1;
|
|
32
|
+
|
|
33
|
+
useKeyboard((key) => {
|
|
34
|
+
if (key.name === "escape") {
|
|
35
|
+
onCancel();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (key.name === "return") {
|
|
39
|
+
if (selectedIndex === databases.length) {
|
|
40
|
+
onCreateNew();
|
|
41
|
+
} else {
|
|
42
|
+
const selected = databases[selectedIndex];
|
|
43
|
+
if (selected) {
|
|
44
|
+
onSelect(selected);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (key.name === "down" || key.name === "j") {
|
|
50
|
+
setSelectedIndex((i) => Math.min(i + 1, totalItems - 1));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (key.name === "up" || key.name === "k") {
|
|
54
|
+
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// border(2) + padding(2) + title(1) + marginTop(1) + items + spacer(1) + create_new(1) + marginTop(1) + help(1)
|
|
60
|
+
const height = Math.min(totalItems + 10, 20);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Modal title="Select Database" height={height} theme={theme}>
|
|
64
|
+
<box style={{ flexGrow: 1, marginTop: 1 }}>
|
|
65
|
+
{databases.map((db, index) => {
|
|
66
|
+
const isSelected = index === selectedIndex;
|
|
67
|
+
const isCurrent = db === currentDatabase;
|
|
68
|
+
const displayName = db.replace(/\.db$/, "");
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<box
|
|
72
|
+
key={db}
|
|
73
|
+
style={{
|
|
74
|
+
paddingLeft: 1,
|
|
75
|
+
paddingRight: 1,
|
|
76
|
+
backgroundColor: isSelected ? colors.selectedRowBg : "transparent",
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<text>
|
|
80
|
+
<span fg={isSelected ? colors.selectedText : colors.textPrimary}>
|
|
81
|
+
{isSelected ? "▸ " : " "}
|
|
82
|
+
</span>
|
|
83
|
+
<span
|
|
84
|
+
fg={isSelected ? colors.selectedText : colors.textPrimary}
|
|
85
|
+
attributes={isSelected ? "bold" : undefined}
|
|
86
|
+
>
|
|
87
|
+
{displayName}
|
|
88
|
+
</span>
|
|
89
|
+
{isCurrent && (
|
|
90
|
+
<span fg={colors.success}> (active)</span>
|
|
91
|
+
)}
|
|
92
|
+
</text>
|
|
93
|
+
</box>
|
|
94
|
+
);
|
|
95
|
+
})}
|
|
96
|
+
|
|
97
|
+
{/* Create new option */}
|
|
98
|
+
<box
|
|
99
|
+
style={{
|
|
100
|
+
paddingLeft: 1,
|
|
101
|
+
paddingRight: 1,
|
|
102
|
+
marginTop: databases.length > 0 ? 1 : 0,
|
|
103
|
+
backgroundColor: selectedIndex === databases.length ? colors.selectedRowBg : "transparent",
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<text>
|
|
107
|
+
<span fg={selectedIndex === databases.length ? colors.selectedText : colors.accent}>
|
|
108
|
+
{selectedIndex === databases.length ? "▸ " : " "}
|
|
109
|
+
</span>
|
|
110
|
+
<span
|
|
111
|
+
fg={selectedIndex === databases.length ? colors.selectedText : colors.accent}
|
|
112
|
+
attributes={selectedIndex === databases.length ? "bold" : undefined}
|
|
113
|
+
>
|
|
114
|
+
+ Create new database...
|
|
115
|
+
</span>
|
|
116
|
+
</text>
|
|
117
|
+
</box>
|
|
118
|
+
</box>
|
|
119
|
+
<text fg={colors.textMuted} style={{ marginTop: 1 }}>
|
|
120
|
+
↑/↓ to navigate, Enter to select, Esc to cancel
|
|
121
|
+
</text>
|
|
122
|
+
</Modal>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -5,6 +5,8 @@ interface SettingsViewProps {
|
|
|
5
5
|
settings: AppSettings;
|
|
6
6
|
selectedIndex: number;
|
|
7
7
|
theme: Theme;
|
|
8
|
+
currentDbFilename: string;
|
|
9
|
+
onSelectDatabase: () => void;
|
|
8
10
|
onEditBusinessName: () => void;
|
|
9
11
|
onEditStripeKey: () => void;
|
|
10
12
|
onEditTimezone: () => void;
|
|
@@ -14,20 +16,30 @@ interface SettingsViewProps {
|
|
|
14
16
|
onImportDatabase: () => void;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
type SettingsItem = { key: string; label: string; type: string };
|
|
20
|
+
|
|
21
|
+
const BUSINESS_SETTINGS: SettingsItem[] = [
|
|
18
22
|
{ key: "businessName", label: "Business Name", type: "text" },
|
|
19
23
|
{ key: "stripeApiKey", label: "Stripe API Key", type: "secret" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const APP_SETTINGS: SettingsItem[] = [
|
|
27
|
+
{ key: "database", label: "Database", type: "select" },
|
|
20
28
|
{ key: "theme", label: "Theme", type: "select" },
|
|
21
29
|
{ key: "timezone", label: "Timezone", type: "text" },
|
|
22
30
|
{ key: "menuBar", label: "Menu Bar", type: "toggle" },
|
|
23
31
|
{ key: "exportDatabase", label: "Export Database", type: "action" },
|
|
24
32
|
{ key: "importDatabase", label: "Import Database", type: "action" },
|
|
25
|
-
]
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const ALL_SETTINGS = [...BUSINESS_SETTINGS, ...APP_SETTINGS];
|
|
26
36
|
|
|
27
37
|
export function SettingsView({
|
|
28
38
|
settings,
|
|
29
39
|
selectedIndex,
|
|
30
40
|
theme,
|
|
41
|
+
currentDbFilename,
|
|
42
|
+
onSelectDatabase,
|
|
31
43
|
onEditBusinessName,
|
|
32
44
|
onEditStripeKey,
|
|
33
45
|
onEditTimezone,
|
|
@@ -58,6 +70,8 @@ export function SettingsView({
|
|
|
58
70
|
|
|
59
71
|
const getValue = (key: string) => {
|
|
60
72
|
switch (key) {
|
|
73
|
+
case "database":
|
|
74
|
+
return currentDbFilename.replace(/\.db$/, "");
|
|
61
75
|
case "theme":
|
|
62
76
|
return getThemeDisplay();
|
|
63
77
|
case "businessName":
|
|
@@ -79,6 +93,8 @@ export function SettingsView({
|
|
|
79
93
|
|
|
80
94
|
const getAction = (key: string) => {
|
|
81
95
|
switch (key) {
|
|
96
|
+
case "database":
|
|
97
|
+
return onSelectDatabase;
|
|
82
98
|
case "theme":
|
|
83
99
|
return onSelectTheme;
|
|
84
100
|
case "businessName":
|
|
@@ -105,6 +121,38 @@ export function SettingsView({
|
|
|
105
121
|
return "edit";
|
|
106
122
|
};
|
|
107
123
|
|
|
124
|
+
const renderItem = (item: SettingsItem, globalIndex: number) => {
|
|
125
|
+
const isSelected = globalIndex === selectedIndex;
|
|
126
|
+
return (
|
|
127
|
+
<box
|
|
128
|
+
key={item.key}
|
|
129
|
+
style={{
|
|
130
|
+
flexDirection: "row",
|
|
131
|
+
paddingLeft: 1,
|
|
132
|
+
paddingRight: 1,
|
|
133
|
+
backgroundColor: isSelected ? colors.selectedRowBg : "transparent",
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<box style={{ width: 20 }}>
|
|
137
|
+
<text
|
|
138
|
+
fg={isSelected ? colors.selectedText : colors.textPrimary}
|
|
139
|
+
attributes={isSelected ? "bold" : undefined}
|
|
140
|
+
>
|
|
141
|
+
{item.label}
|
|
142
|
+
</text>
|
|
143
|
+
</box>
|
|
144
|
+
<box style={{ flexGrow: 1 }}>
|
|
145
|
+
<text fg={isSelected ? colors.selectedText : colors.textSecondary}>
|
|
146
|
+
{getValue(item.key)}
|
|
147
|
+
</text>
|
|
148
|
+
</box>
|
|
149
|
+
{isSelected && (
|
|
150
|
+
<text fg={colors.accent}>[Enter to {getActionLabel(item.type)}]</text>
|
|
151
|
+
)}
|
|
152
|
+
</box>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
108
156
|
return (
|
|
109
157
|
<box
|
|
110
158
|
style={{
|
|
@@ -113,50 +161,31 @@ export function SettingsView({
|
|
|
113
161
|
padding: 1,
|
|
114
162
|
}}
|
|
115
163
|
>
|
|
116
|
-
<box
|
|
117
|
-
|
|
164
|
+
<box
|
|
165
|
+
title="Business"
|
|
166
|
+
style={{
|
|
167
|
+
border: true,
|
|
168
|
+
borderColor: colors.borderSubtle,
|
|
169
|
+
padding: 1,
|
|
170
|
+
flexDirection: "column",
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{BUSINESS_SETTINGS.map((item, i) => renderItem(item, i))}
|
|
118
174
|
</box>
|
|
119
175
|
|
|
120
176
|
<box
|
|
121
|
-
title="
|
|
177
|
+
title="App"
|
|
122
178
|
style={{
|
|
123
179
|
border: true,
|
|
124
180
|
borderColor: colors.borderSubtle,
|
|
125
181
|
padding: 1,
|
|
126
182
|
flexDirection: "column",
|
|
183
|
+
marginTop: 1,
|
|
127
184
|
}}
|
|
128
185
|
>
|
|
129
|
-
{
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
<box
|
|
133
|
-
key={item.key}
|
|
134
|
-
style={{
|
|
135
|
-
flexDirection: "row",
|
|
136
|
-
paddingLeft: 1,
|
|
137
|
-
paddingRight: 1,
|
|
138
|
-
backgroundColor: isSelected ? colors.selectedRowBg : "transparent",
|
|
139
|
-
}}
|
|
140
|
-
>
|
|
141
|
-
<box style={{ width: 20 }}>
|
|
142
|
-
<text
|
|
143
|
-
fg={isSelected ? colors.selectedText : colors.textPrimary}
|
|
144
|
-
attributes={isSelected ? "bold" : undefined}
|
|
145
|
-
>
|
|
146
|
-
{item.label}
|
|
147
|
-
</text>
|
|
148
|
-
</box>
|
|
149
|
-
<box style={{ flexGrow: 1 }}>
|
|
150
|
-
<text fg={isSelected ? colors.selectedText : colors.textSecondary}>
|
|
151
|
-
{getValue(item.key)}
|
|
152
|
-
</text>
|
|
153
|
-
</box>
|
|
154
|
-
{isSelected && (
|
|
155
|
-
<text fg={colors.accent}>[Enter to {getActionLabel(item.type)}]</text>
|
|
156
|
-
)}
|
|
157
|
-
</box>
|
|
158
|
-
);
|
|
159
|
-
})}
|
|
186
|
+
{APP_SETTINGS.map((item, i) =>
|
|
187
|
+
renderItem(item, BUSINESS_SETTINGS.length + i),
|
|
188
|
+
)}
|
|
160
189
|
</box>
|
|
161
190
|
|
|
162
191
|
<box style={{ marginTop: 2 }}>
|
|
@@ -166,10 +195,10 @@ export function SettingsView({
|
|
|
166
195
|
</box>
|
|
167
196
|
|
|
168
197
|
<box style={{ marginTop: 1 }}>
|
|
169
|
-
<text fg={colors.textMuted}>Database
|
|
198
|
+
<text fg={colors.textMuted}>Database: ~/.paca/{currentDbFilename}</text>
|
|
170
199
|
</box>
|
|
171
200
|
</box>
|
|
172
201
|
);
|
|
173
202
|
}
|
|
174
203
|
|
|
175
|
-
export const SETTINGS_COUNT =
|
|
204
|
+
export const SETTINGS_COUNT = ALL_SETTINGS.length;
|
package/src/components/index.ts
CHANGED
|
@@ -15,3 +15,4 @@ export { CustomerSelectModal } from "./CustomerSelectModal.tsx";
|
|
|
15
15
|
export { EditTimeEntryModal } from "./EditTimeEntryModal.tsx";
|
|
16
16
|
export { CreateInvoiceModal } from "./CreateInvoiceModal.tsx";
|
|
17
17
|
export { ThemeSelectModal } from "./ThemeSelectModal.tsx";
|
|
18
|
+
export { DatabaseSelectModal } from "./DatabaseSelectModal.tsx";
|
package/src/db-path.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, copyFileSync, unlinkSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
|
|
5
|
+
const ACTIVE_FILE = ".active";
|
|
6
|
+
|
|
7
|
+
export function getPacaDir(): string {
|
|
8
|
+
const dir = join(homedir(), ".paca");
|
|
9
|
+
if (!existsSync(dir)) {
|
|
10
|
+
mkdirSync(dir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getActiveDbFilename(): string {
|
|
16
|
+
const activePath = join(getPacaDir(), ACTIVE_FILE);
|
|
17
|
+
try {
|
|
18
|
+
if (existsSync(activePath)) {
|
|
19
|
+
const filename = readFileSync(activePath, "utf-8").trim();
|
|
20
|
+
if (filename) return filename;
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// Fall back to default
|
|
24
|
+
}
|
|
25
|
+
return "paca.db";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getActiveDbPath(): string {
|
|
29
|
+
return join(getPacaDir(), getActiveDbFilename());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function setActiveDbFilename(filename: string): void {
|
|
33
|
+
const activePath = join(getPacaDir(), ACTIVE_FILE);
|
|
34
|
+
writeFileSync(activePath, filename, "utf-8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function listDatabases(): string[] {
|
|
38
|
+
const dir = getPacaDir();
|
|
39
|
+
const active = getActiveDbFilename();
|
|
40
|
+
try {
|
|
41
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".db"));
|
|
42
|
+
if (!files.includes(active)) {
|
|
43
|
+
files.push(active);
|
|
44
|
+
}
|
|
45
|
+
return files.sort();
|
|
46
|
+
} catch {
|
|
47
|
+
return [active];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function performDailyBackup(): void {
|
|
52
|
+
const dbFilename = getActiveDbFilename();
|
|
53
|
+
const dbPath = getActiveDbPath();
|
|
54
|
+
if (!existsSync(dbPath)) return;
|
|
55
|
+
|
|
56
|
+
const backupDir = join(getPacaDir(), "backups");
|
|
57
|
+
if (!existsSync(backupDir)) {
|
|
58
|
+
mkdirSync(backupDir, { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const dbName = dbFilename.replace(/\.db$/, "");
|
|
62
|
+
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
|
63
|
+
const todayBackup = `${dbName}-auto-${today}.db`;
|
|
64
|
+
|
|
65
|
+
// Skip if today's backup already exists
|
|
66
|
+
if (existsSync(join(backupDir, todayBackup))) return;
|
|
67
|
+
|
|
68
|
+
// Create today's backup
|
|
69
|
+
copyFileSync(dbPath, join(backupDir, todayBackup));
|
|
70
|
+
|
|
71
|
+
// Clean up auto-backups older than 30 days
|
|
72
|
+
const cutoff = new Date();
|
|
73
|
+
cutoff.setDate(cutoff.getDate() - 30);
|
|
74
|
+
const prefix = `${dbName}-auto-`;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
for (const file of readdirSync(backupDir)) {
|
|
78
|
+
if (!file.startsWith(prefix) || !file.endsWith(".db")) continue;
|
|
79
|
+
const dateStr = file.slice(prefix.length, -3); // extract YYYY-MM-DD
|
|
80
|
+
const fileDate = new Date(dateStr);
|
|
81
|
+
if (!isNaN(fileDate.getTime()) && fileDate < cutoff) {
|
|
82
|
+
unlinkSync(join(backupDir, file));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Non-critical — skip cleanup on error
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function sanitizeDbName(name: string): string {
|
|
91
|
+
const sanitized = name
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.trim()
|
|
94
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
95
|
+
.replace(/^-+|-+$/g, "");
|
|
96
|
+
if (!sanitized) return "database.db";
|
|
97
|
+
return sanitized.endsWith(".db") ? sanitized : `${sanitized}.db`;
|
|
98
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
import { PrismaClient } from "../generated/prisma/client.ts";
|
|
2
2
|
import { PrismaLibSql } from "@prisma/adapter-libsql";
|
|
3
|
-
import {
|
|
4
|
-
import { join } from "path";
|
|
5
|
-
import { homedir } from "os";
|
|
6
|
-
|
|
7
|
-
// Ensure the .paca directory exists in user's home
|
|
8
|
-
const pacaDir = join(homedir(), ".paca");
|
|
9
|
-
if (!existsSync(pacaDir)) {
|
|
10
|
-
mkdirSync(pacaDir, { recursive: true });
|
|
11
|
-
}
|
|
3
|
+
import { getActiveDbPath } from "./db-path.ts";
|
|
12
4
|
|
|
13
|
-
//
|
|
14
|
-
export
|
|
5
|
+
// Get current database path (reads active db each time)
|
|
6
|
+
export function getDbPath(): string {
|
|
7
|
+
return getActiveDbPath();
|
|
8
|
+
}
|
|
15
9
|
|
|
16
|
-
// Create
|
|
17
|
-
|
|
18
|
-
url: `file:${
|
|
19
|
-
});
|
|
10
|
+
// Create a new PrismaClient for a given database path
|
|
11
|
+
function createClient(dbPath: string): PrismaClient {
|
|
12
|
+
const adapter = new PrismaLibSql({ url: `file:${dbPath}` });
|
|
13
|
+
return new PrismaClient({ adapter });
|
|
14
|
+
}
|
|
20
15
|
|
|
21
16
|
// Create Prisma client with adapter
|
|
22
|
-
export
|
|
17
|
+
export let db = createClient(getDbPath());
|
|
18
|
+
|
|
19
|
+
// Switch to a different database
|
|
20
|
+
export async function switchDatabase(dbPath: string): Promise<void> {
|
|
21
|
+
await db.$disconnect();
|
|
22
|
+
db = createClient(dbPath);
|
|
23
|
+
await db.$connect();
|
|
24
|
+
}
|
|
23
25
|
|
|
24
26
|
// Initialize database connection
|
|
25
27
|
export async function initDatabase() {
|
|
@@ -738,7 +740,7 @@ export const invoices = {
|
|
|
738
740
|
export const database = {
|
|
739
741
|
async exportToFile(targetPath: string): Promise<void> {
|
|
740
742
|
const { copyFileSync } = await import("fs");
|
|
741
|
-
copyFileSync(
|
|
743
|
+
copyFileSync(getDbPath(), targetPath);
|
|
742
744
|
},
|
|
743
745
|
|
|
744
746
|
async importFromFile(sourcePath: string): Promise<void> {
|
|
@@ -748,7 +750,7 @@ export const database = {
|
|
|
748
750
|
}
|
|
749
751
|
// Close connection, copy file, reconnect
|
|
750
752
|
await db.$disconnect();
|
|
751
|
-
copyFileSync(sourcePath,
|
|
753
|
+
copyFileSync(sourcePath, getDbPath());
|
|
752
754
|
await db.$connect();
|
|
753
755
|
},
|
|
754
756
|
};
|
package/src/index.tsx
CHANGED
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
initDatabase,
|
|
7
7
|
closeDatabase,
|
|
8
8
|
cleanupOldCompletedTasks,
|
|
9
|
-
DB_PATH,
|
|
10
9
|
} from "./db.ts";
|
|
10
|
+
import { getActiveDbPath } from "./db-path.ts";
|
|
11
11
|
import { getTheme, type Theme } from "./types.ts";
|
|
12
12
|
import { execSync } from "child_process";
|
|
13
13
|
import { existsSync, readFileSync } from "fs";
|
|
@@ -19,10 +19,10 @@ let renderer: CliRenderer | null = null;
|
|
|
19
19
|
// Try to read the theme setting from the database
|
|
20
20
|
function getStoredTheme(): Theme {
|
|
21
21
|
try {
|
|
22
|
-
if (!existsSync(
|
|
22
|
+
if (!existsSync(getActiveDbPath())) {
|
|
23
23
|
return getTheme("catppuccin-mocha");
|
|
24
24
|
}
|
|
25
|
-
const db = new Database(
|
|
25
|
+
const db = new Database(getActiveDbPath(), { readonly: true });
|
|
26
26
|
const result = db
|
|
27
27
|
.query("SELECT value FROM Setting WHERE key = 'theme'")
|
|
28
28
|
.get() as { value: string } | null;
|
|
@@ -156,7 +156,7 @@ async function main() {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
// Check if database exists, if not run migration
|
|
159
|
-
if (!existsSync(
|
|
159
|
+
if (!existsSync(getActiveDbPath())) {
|
|
160
160
|
console.log("Initializing Paca database...");
|
|
161
161
|
try {
|
|
162
162
|
// Run prisma migration
|
|
@@ -165,7 +165,7 @@ async function main() {
|
|
|
165
165
|
stdio: "inherit",
|
|
166
166
|
env: {
|
|
167
167
|
...process.env,
|
|
168
|
-
DATABASE_URL: `file:${
|
|
168
|
+
DATABASE_URL: `file:${getActiveDbPath()}`,
|
|
169
169
|
},
|
|
170
170
|
});
|
|
171
171
|
console.log("Database initialized successfully!");
|
|
@@ -175,6 +175,14 @@ async function main() {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// Daily auto-backup (rolling 30 days)
|
|
179
|
+
try {
|
|
180
|
+
const { performDailyBackup } = await import("./db-path.ts");
|
|
181
|
+
performDailyBackup();
|
|
182
|
+
} catch {
|
|
183
|
+
// Non-critical — skip if backup fails
|
|
184
|
+
}
|
|
185
|
+
|
|
178
186
|
// Initialize database connection
|
|
179
187
|
const connected = await initDatabase();
|
|
180
188
|
if (!connected) {
|
|
@@ -187,7 +195,7 @@ async function main() {
|
|
|
187
195
|
|
|
188
196
|
// Auto-launch menu bar helper if enabled
|
|
189
197
|
try {
|
|
190
|
-
const db2 = new Database(
|
|
198
|
+
const db2 = new Database(getActiveDbPath(), { readonly: true });
|
|
191
199
|
const menuBarSetting = db2
|
|
192
200
|
.query("SELECT value FROM Setting WHERE key = 'menuBar'")
|
|
193
201
|
.get() as { value: string } | null;
|
package/src/menubar/db-lite.ts
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import Database from "bun:sqlite";
|
|
2
|
-
import {
|
|
3
|
-
import { homedir } from "os";
|
|
4
|
-
|
|
5
|
-
const DB_PATH = join(homedir(), ".paca", "paca.db");
|
|
2
|
+
import { getActiveDbPath } from "../db-path.ts";
|
|
6
3
|
|
|
7
4
|
function openReadonly(): Database {
|
|
8
|
-
const db = new Database(
|
|
5
|
+
const db = new Database(getActiveDbPath(), { readonly: true });
|
|
9
6
|
db.exec("PRAGMA busy_timeout=5000");
|
|
10
7
|
return db;
|
|
11
8
|
}
|
|
12
9
|
|
|
13
10
|
function openReadWrite(): Database {
|
|
14
|
-
const db = new Database(
|
|
11
|
+
const db = new Database(getActiveDbPath());
|
|
15
12
|
db.exec("PRAGMA busy_timeout=5000");
|
|
16
13
|
db.exec("PRAGMA journal_mode=WAL");
|
|
17
14
|
return db;
|
package/src/menubar/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ function hasSwiftCompiler(): boolean {
|
|
|
23
23
|
function prepareMascotIcon(): string {
|
|
24
24
|
// Find the mascot image relative to source directory
|
|
25
25
|
const srcDir = dirname(dirname(import.meta.dir));
|
|
26
|
-
const mascotPath = join(srcDir, "assets", "paca-
|
|
26
|
+
const mascotPath = join(srcDir, "assets", "paca-icon.png");
|
|
27
27
|
|
|
28
28
|
if (!existsSync(mascotPath)) {
|
|
29
29
|
return "";
|
|
@@ -29,7 +29,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
29
29
|
|
|
30
30
|
override init() {
|
|
31
31
|
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
32
|
-
|
|
32
|
+
let pacaDir = home.appendingPathComponent(".paca")
|
|
33
|
+
let activePath = pacaDir.appendingPathComponent(".active").path
|
|
34
|
+
var dbFilename = "paca.db"
|
|
35
|
+
if let activeContent = try? String(contentsOfFile: activePath, encoding: .utf8) {
|
|
36
|
+
let trimmed = activeContent.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
37
|
+
if !trimmed.isEmpty { dbFilename = trimmed }
|
|
38
|
+
}
|
|
39
|
+
dbPath = pacaDir.appendingPathComponent(dbFilename).path
|
|
33
40
|
super.init()
|
|
34
41
|
}
|
|
35
42
|
|
|
@@ -43,9 +50,22 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
43
50
|
}
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
func resolveDbPath() -> String {
|
|
54
|
+
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
55
|
+
let pacaDir = home.appendingPathComponent(".paca")
|
|
56
|
+
let activePath = pacaDir.appendingPathComponent(".active").path
|
|
57
|
+
var dbFilename = "paca.db"
|
|
58
|
+
if let activeContent = try? String(contentsOfFile: activePath, encoding: .utf8) {
|
|
59
|
+
let trimmed = activeContent.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
60
|
+
if !trimmed.isEmpty { dbFilename = trimmed }
|
|
61
|
+
}
|
|
62
|
+
return pacaDir.appendingPathComponent(dbFilename).path
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
func updateMenu() {
|
|
66
|
+
let currentDbPath = resolveDbPath()
|
|
47
67
|
var db: OpaquePointer?
|
|
48
|
-
guard sqlite3_open_v2(
|
|
68
|
+
guard sqlite3_open_v2(currentDbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else {
|
|
49
69
|
setIdleAppearance()
|
|
50
70
|
statusItem.menu = buildFallbackMenu("Database not found")
|
|
51
71
|
return
|
|
@@ -209,8 +229,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
209
229
|
|
|
210
230
|
let desc = response == .alertFirstButtonReturn ? input.stringValue : nil
|
|
211
231
|
|
|
232
|
+
let currentDbPath = resolveDbPath()
|
|
212
233
|
var db: OpaquePointer?
|
|
213
|
-
guard sqlite3_open(
|
|
234
|
+
guard sqlite3_open(currentDbPath, &db) == SQLITE_OK else { return }
|
|
214
235
|
defer { sqlite3_close(db) }
|
|
215
236
|
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
|
216
237
|
sqlite3_busy_timeout(db, 5000)
|
|
@@ -234,8 +255,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
234
255
|
@objc func startTimerClicked(_ sender: NSMenuItem) {
|
|
235
256
|
guard let projectId = sender.representedObject as? String else { return }
|
|
236
257
|
|
|
258
|
+
let currentDbPath = resolveDbPath()
|
|
237
259
|
var db: OpaquePointer?
|
|
238
|
-
guard sqlite3_open(
|
|
260
|
+
guard sqlite3_open(currentDbPath, &db) == SQLITE_OK else { return }
|
|
239
261
|
defer { sqlite3_close(db) }
|
|
240
262
|
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
|
|
241
263
|
sqlite3_busy_timeout(db, 5000)
|
package/src/types.ts
CHANGED