@richhaase/c2 0.3.2 → 0.4.0

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.
@@ -0,0 +1,172 @@
1
+ import { sep } from "node:path";
2
+ import type { Command } from "commander";
3
+ import { loadConfig, saveConfig } from "../config.ts";
4
+ import { inspectDataDir, moveStore, storeSummary } from "../data.ts";
5
+ import { formatMeters } from "../display.ts";
6
+ import { runDoctor } from "../doctor.ts";
7
+ import { printJSON } from "../envelope.ts";
8
+ import { compactNotes } from "../notes.ts";
9
+ import { canonicalRoot, dataPaths, pathsFor } from "../paths.ts";
10
+
11
+ export function registerData(program: Command): void {
12
+ const data = program.command("data").description("Manage the c2 data store");
13
+
14
+ data
15
+ .command("info")
16
+ .description("Show data store location and contents")
17
+ .option("--json", "output as JSON")
18
+ .action(async (opts: { json?: boolean }) => {
19
+ const cfg = await loadConfig();
20
+ const paths = dataPaths(cfg);
21
+ const inspection = await inspectDataDir(paths);
22
+
23
+ if (inspection.state === "missing") {
24
+ console.error(`No data store at ${paths.root}. Run \`c2 setup\` or \`c2 sync\` first.`);
25
+ process.exit(1);
26
+ }
27
+ if (inspection.state === "foreign") {
28
+ console.error(
29
+ `${paths.root} exists but is not a c2 data store. Fix data_dir via \`c2 setup\`.`,
30
+ );
31
+ process.exit(1);
32
+ }
33
+ if (inspection.state === "empty") {
34
+ console.error(
35
+ `${paths.root} is an empty directory, not yet a data store. Run \`c2 sync\` to initialize it.`,
36
+ );
37
+ process.exit(1);
38
+ }
39
+
40
+ const summary = await storeSummary(paths);
41
+ const legacyStore = summary.schemaVersion == null && summary.workouts > 0;
42
+ const lastSync = summary.lastSync ?? (legacyStore ? (cfg.sync.last_sync ?? null) : null);
43
+ if (opts.json) {
44
+ printJSON("c2.data.info.v1", {
45
+ root: paths.root,
46
+ state: inspection.state,
47
+ writable: inspection.writable,
48
+ schema_version: summary.schemaVersion,
49
+ last_sync: lastSync,
50
+ workouts: summary.workouts,
51
+ first_date: summary.firstDate || null,
52
+ last_date: summary.lastDate || null,
53
+ stroke_files: summary.strokeFiles,
54
+ notes: summary.notes,
55
+ });
56
+ return;
57
+ }
58
+
59
+ console.log(`Data store: ${paths.root}`);
60
+ console.log(`Schema version: ${summary.schemaVersion ?? "(no meta.json — legacy store)"}`);
61
+ console.log(`Last sync: ${lastSync ?? "never"}`);
62
+ console.log(
63
+ `Workouts: ${formatMeters(summary.workouts)}${summary.firstDate ? ` (${summary.firstDate} → ${summary.lastDate})` : ""}`,
64
+ );
65
+ console.log(`Stroke files: ${formatMeters(summary.strokeFiles)}`);
66
+ console.log(`Notes: ${formatMeters(summary.notes)}`);
67
+ });
68
+
69
+ data
70
+ .command("compact")
71
+ .description("Archive notes older than 7 days into yearly files")
72
+ .action(async () => {
73
+ const cfg = await loadConfig();
74
+ const paths = dataPaths(cfg);
75
+ const inspection = await inspectDataDir(paths);
76
+ if (inspection.state !== "store") {
77
+ console.error(`${paths.root} is not a c2 data store; nothing to compact.`);
78
+ process.exit(1);
79
+ }
80
+ if (!inspection.writable) {
81
+ console.error(`Cannot write to ${paths.root}.`);
82
+ process.exit(1);
83
+ }
84
+ const result = await compactNotes(paths, new Date());
85
+ for (const year of result.skippedYears) {
86
+ console.error(
87
+ `Warning: notes/archive/${year}.jsonl could not be safely rewritten; notes left loose (run \`c2 data doctor\`).`,
88
+ );
89
+ }
90
+ if (result.archived > 0) {
91
+ console.log(
92
+ `Compacted ${result.archived} note${result.archived === 1 ? "" : "s"} into ${result.years.map((y) => `notes/archive/${y}.jsonl`).join(", ")}.`,
93
+ );
94
+ } else if (result.skippedYears.length === 0) {
95
+ console.log("Nothing to compact.");
96
+ }
97
+ if (result.skippedYears.length > 0) {
98
+ process.exit(1);
99
+ }
100
+ });
101
+
102
+ data
103
+ .command("doctor")
104
+ .description("Validate the data store and report problems")
105
+ .action(async () => {
106
+ const cfg = await loadConfig();
107
+ const paths = dataPaths(cfg);
108
+ const inspection = await inspectDataDir(paths);
109
+ if (inspection.state !== "store") {
110
+ console.error(`No data store at ${paths.root}.`);
111
+ process.exit(1);
112
+ }
113
+ const report = await runDoctor(paths);
114
+ if (report.issues.length === 0) {
115
+ console.log(`OK — ${report.checkedFiles} files checked, no problems found.`);
116
+ return;
117
+ }
118
+ console.error(
119
+ `${report.issues.length} problem${report.issues.length === 1 ? "" : "s"} found:`,
120
+ );
121
+ for (const issue of report.issues) {
122
+ console.error(` - ${issue}`);
123
+ }
124
+ process.exit(1);
125
+ });
126
+
127
+ data
128
+ .command("move <dir>")
129
+ .description("Relocate the data store and update config")
130
+ .action(async (dir: string) => {
131
+ const cfg = await loadConfig();
132
+ const from = pathsFor(await canonicalRoot(cfg.data_dir));
133
+ const source = await inspectDataDir(from);
134
+ if (source.state !== "store") {
135
+ console.error(`${from.root} is not a c2 data store; nothing to move.`);
136
+ process.exit(1);
137
+ }
138
+
139
+ const to = pathsFor(await canonicalRoot(dir));
140
+ if (to.root === from.root) {
141
+ console.error("Target is the current data directory.");
142
+ process.exit(1);
143
+ }
144
+ if (to.root.startsWith(from.root + sep) || from.root.startsWith(to.root + sep)) {
145
+ console.error("Target must not be inside the current data directory (or contain it).");
146
+ process.exit(1);
147
+ }
148
+
149
+ let copied: { files: number; bytes: number };
150
+ try {
151
+ copied = await moveStore(from, to);
152
+ } catch (err) {
153
+ console.error(`Error: ${(err as Error).message}`);
154
+ process.exit(1);
155
+ }
156
+ try {
157
+ cfg.data_dir = to.root;
158
+ await saveConfig(cfg);
159
+ } catch (err) {
160
+ console.error(`Error: copy completed but config update failed: ${(err as Error).message}`);
161
+ console.error(
162
+ `Set data_dir to ${to.root} in ~/.config/c2/config.json manually, or remove ${to.root} and retry.`,
163
+ );
164
+ process.exit(1);
165
+ }
166
+ console.log(
167
+ `Copied ${copied.files} files (${formatMeters(copied.bytes)} bytes) to ${to.root}`,
168
+ );
169
+ console.log(`Config updated: data_dir = ${to.root}`);
170
+ console.log(`Old data left at ${from.root} — remove it manually once satisfied.`);
171
+ });
172
+ }
@@ -0,0 +1,184 @@
1
+ import { readdir, readFile, writeFile } from "node:fs/promises";
2
+ import type { Command } from "commander";
3
+ import { loadConfig } from "../config.ts";
4
+ import { ensureStoreForWrite, rejectForeignStore } from "../data.ts";
5
+ import { printJSON } from "../envelope.ts";
6
+ import { isValidYMD } from "../models.ts";
7
+ import type { DataPaths } from "../paths.ts";
8
+ import { dataPaths } from "../paths.ts";
9
+
10
+ async function readContent(source: string | undefined): Promise<string> {
11
+ if (source != null && source !== "-") {
12
+ return readFile(source, "utf-8");
13
+ }
14
+ return new Response(Bun.stdin.stream()).text();
15
+ }
16
+
17
+ async function readIfExists(path: string): Promise<string | null> {
18
+ try {
19
+ return await readFile(path, "utf-8");
20
+ } catch (err: unknown) {
21
+ const code = (err as NodeJS.ErrnoException).code;
22
+ if (code === "ENOENT" || code === "ENOTDIR") return null;
23
+ throw err;
24
+ }
25
+ }
26
+
27
+ function registerDoc(
28
+ program: Command,
29
+ name: "plan" | "playbook",
30
+ description: string,
31
+ pathOf: (paths: DataPaths) => string,
32
+ ): void {
33
+ const doc = program.command(name).description(description);
34
+
35
+ doc
36
+ .command("show")
37
+ .description(`Print the ${name}`)
38
+ .action(async () => {
39
+ const cfg = await loadConfig();
40
+ const paths = dataPaths(cfg);
41
+ const foreign = await rejectForeignStore(paths);
42
+ if (foreign != null) {
43
+ console.error(foreign);
44
+ process.exit(1);
45
+ }
46
+ const content = await readIfExists(pathOf(paths));
47
+ if (content == null) {
48
+ console.error(`No ${name} recorded yet. Set one with \`c2 ${name} set <file|->\`.`);
49
+ process.exit(1);
50
+ }
51
+ process.stdout.write(content);
52
+ });
53
+
54
+ doc
55
+ .command("set [file]")
56
+ .description(`Replace the ${name} from a file or stdin`)
57
+ .action(async (file: string | undefined) => {
58
+ const content = await readContent(file);
59
+ if (content.trim() === "") {
60
+ console.error(`Error: refusing to save an empty ${name}.`);
61
+ process.exit(1);
62
+ }
63
+ const cfg = await loadConfig();
64
+ const paths = dataPaths(cfg);
65
+ const storeError = await ensureStoreForWrite(paths, new Date());
66
+ if (storeError != null) {
67
+ console.error(storeError);
68
+ process.exit(1);
69
+ }
70
+ const target = pathOf(paths);
71
+ await writeFile(target, content.endsWith("\n") ? content : `${content}\n`, "utf-8");
72
+ console.log(`${name} updated (${content.length} chars).`);
73
+ });
74
+ }
75
+
76
+ export function registerDocs(program: Command): void {
77
+ registerDoc(program, "plan", "Training plan (managed document)", (p) => p.plan);
78
+ registerDoc(
79
+ program,
80
+ "playbook",
81
+ "Coaching knowledge playbook (managed document)",
82
+ (p) => p.playbook,
83
+ );
84
+
85
+ const narrative = program.command("narrative").description("Dated coaching report narratives");
86
+
87
+ narrative
88
+ .command("add <date> [file]")
89
+ .description("Save the narrative for a date (YYYY-MM-DD) from a file or stdin")
90
+ .action(async (date: string, file: string | undefined) => {
91
+ if (!isValidYMD(date)) {
92
+ console.error(`Error: invalid date "${date}" (expected YYYY-MM-DD).`);
93
+ process.exit(1);
94
+ }
95
+ const content = await readContent(file);
96
+ if (content.trim() === "") {
97
+ console.error("Error: refusing to save an empty narrative.");
98
+ process.exit(1);
99
+ }
100
+ const cfg = await loadConfig();
101
+ const paths = dataPaths(cfg);
102
+ const storeError = await ensureStoreForWrite(paths, new Date());
103
+ if (storeError != null) {
104
+ console.error(storeError);
105
+ process.exit(1);
106
+ }
107
+ await writeFile(
108
+ paths.narrativeFile(date),
109
+ content.endsWith("\n") ? content : `${content}\n`,
110
+ "utf-8",
111
+ );
112
+ console.log(`Narrative saved for ${date}.`);
113
+ });
114
+
115
+ narrative
116
+ .command("show [date]")
117
+ .description("Print the narrative for a date (latest if omitted)")
118
+ .action(async (date: string | undefined) => {
119
+ const cfg = await loadConfig();
120
+ const paths = dataPaths(cfg);
121
+ const foreign = await rejectForeignStore(paths);
122
+ if (foreign != null) {
123
+ console.error(foreign);
124
+ process.exit(1);
125
+ }
126
+ let target = date;
127
+ if (target != null && !isValidYMD(target)) {
128
+ console.error(`Error: invalid date "${target}" (expected YYYY-MM-DD).`);
129
+ process.exit(1);
130
+ }
131
+ if (target == null) {
132
+ const dates = await listNarratives(paths);
133
+ target = dates[dates.length - 1];
134
+ if (target == null) {
135
+ console.error("No narratives recorded yet.");
136
+ process.exit(1);
137
+ }
138
+ }
139
+ const content = await readIfExists(paths.narrativeFile(target));
140
+ if (content == null) {
141
+ console.error(`No narrative for ${target}.`);
142
+ process.exit(1);
143
+ }
144
+ process.stdout.write(content);
145
+ });
146
+
147
+ narrative
148
+ .command("list")
149
+ .description("List narrative dates")
150
+ .option("--json", "output as JSON")
151
+ .action(async (opts: { json?: boolean }) => {
152
+ const cfg = await loadConfig();
153
+ const paths = dataPaths(cfg);
154
+ const foreign = await rejectForeignStore(paths);
155
+ if (foreign != null) {
156
+ console.error(foreign);
157
+ process.exit(1);
158
+ }
159
+ const dates = await listNarratives(paths);
160
+ if (opts.json) {
161
+ printJSON("c2.narratives.v1", { count: dates.length, dates });
162
+ return;
163
+ }
164
+ if (dates.length === 0) {
165
+ console.log("No narratives recorded yet.");
166
+ return;
167
+ }
168
+ for (const d of dates) {
169
+ console.log(d);
170
+ }
171
+ });
172
+ }
173
+
174
+ export async function listNarratives(paths: DataPaths): Promise<string[]> {
175
+ try {
176
+ return (await readdir(paths.reportsDir))
177
+ .filter((f) => f.endsWith(".md"))
178
+ .map((f) => f.slice(0, -3))
179
+ .filter((d) => isValidYMD(d))
180
+ .sort();
181
+ } catch {
182
+ return [];
183
+ }
184
+ }
@@ -1,6 +1,9 @@
1
1
  import type { Command } from "commander";
2
+ import { loadConfig } from "../config.ts";
3
+ import { printJSON } from "../envelope.ts";
2
4
  import type { Workout } from "../models.ts";
3
- import { pace500m } from "../models.ts";
5
+ import { isValidYMD, pace500m } from "../models.ts";
6
+ import { dataPaths } from "../paths.ts";
4
7
  import { readWorkouts } from "../storage.ts";
5
8
 
6
9
  export function filterByDate(workouts: Workout[], from: string, to: string): Workout[] {
@@ -72,7 +75,7 @@ function exportCSV(workouts: Workout[]): void {
72
75
  }
73
76
 
74
77
  function exportJSON(workouts: Workout[]): void {
75
- console.log(JSON.stringify(workouts, null, 2));
78
+ printJSON("c2.export.v1", { count: workouts.length, workouts });
76
79
  }
77
80
 
78
81
  function exportJSONL(workouts: Workout[]): void {
@@ -89,7 +92,22 @@ export function registerExport(program: Command): void {
89
92
  .option("--from <date>", "filter workouts from date (YYYY-MM-DD)")
90
93
  .option("--to <date>", "filter workouts to date (YYYY-MM-DD)")
91
94
  .action(async (opts: { format: string; from?: string; to?: string }) => {
92
- let workouts = await readWorkouts();
95
+ for (const [flag, value] of [
96
+ ["--from", opts.from],
97
+ ["--to", opts.to],
98
+ ] as const) {
99
+ if (value && !isValidYMD(value)) {
100
+ console.error(`Error: invalid ${flag} date "${value}" (expected YYYY-MM-DD).`);
101
+ process.exit(1);
102
+ }
103
+ }
104
+ if (!["csv", "json", "jsonl"].includes(opts.format)) {
105
+ console.error(`Unsupported format "${opts.format}": must be csv, json, or jsonl`);
106
+ process.exit(1);
107
+ }
108
+
109
+ const cfg = await loadConfig();
110
+ let workouts = await readWorkouts(dataPaths(cfg));
93
111
  if (workouts.length === 0) {
94
112
  console.error("No workouts found. Run `c2 sync` first.");
95
113
  process.exit(1);
@@ -98,7 +116,6 @@ export function registerExport(program: Command): void {
98
116
  workouts = filterByDate(workouts, opts.from ?? "", opts.to ?? "");
99
117
  if (workouts.length === 0) {
100
118
  console.error("No workouts match the specified date range.");
101
- process.exit(1);
102
119
  }
103
120
 
104
121
  workouts.sort((a, b) => a.date.localeCompare(b.date));
@@ -113,9 +130,6 @@ export function registerExport(program: Command): void {
113
130
  case "jsonl":
114
131
  exportJSONL(workouts);
115
132
  break;
116
- default:
117
- console.error(`Unsupported format "${opts.format}": must be csv, json, or jsonl`);
118
- process.exit(1);
119
133
  }
120
134
  });
121
135
  }
@@ -1,31 +1,62 @@
1
1
  import type { Command } from "commander";
2
2
  import { loadConfig } from "../config.ts";
3
- import { formatWorkoutLine } from "../display.ts";
3
+ import { formatWorkoutLine, workoutJSON } from "../display.ts";
4
+ import { printJSON } from "../envelope.ts";
5
+ import { isValidYMD } from "../models.ts";
6
+ import { dataPaths } from "../paths.ts";
4
7
  import { readWorkouts } from "../storage.ts";
8
+ import { filterByDate } from "./export.ts";
5
9
 
6
10
  export function registerLog(program: Command): void {
7
11
  program
8
12
  .command("log")
9
13
  .description("Show recent workouts")
10
14
  .option("-n, --count <n>", "number of workouts to display", "10")
11
- .action(async (opts: { count: string }) => {
15
+ .option("--from <date>", "only workouts on or after date (YYYY-MM-DD)")
16
+ .option("--to <date>", "only workouts on or before date (YYYY-MM-DD)")
17
+ .option("--json", "output as JSON")
18
+ .action(async (opts: { count: string; from?: string; to?: string; json?: boolean }) => {
12
19
  const cfg = await loadConfig();
13
- const workouts = await readWorkouts();
14
- if (workouts.length === 0) {
15
- console.log("No workouts found. Run `c2 sync` first.");
16
- return;
17
- }
20
+ const paths = dataPaths(cfg);
18
21
 
19
- workouts.sort((a, b) => b.date.localeCompare(a.date));
22
+ for (const [flag, value] of [
23
+ ["--from", opts.from],
24
+ ["--to", opts.to],
25
+ ] as const) {
26
+ if (value && !isValidYMD(value)) {
27
+ console.error(`Error: invalid ${flag} date "${value}" (expected YYYY-MM-DD).`);
28
+ process.exit(1);
29
+ }
30
+ }
20
31
 
21
32
  const count = parseInt(opts.count, 10);
22
33
  if (Number.isNaN(count) || count < 1) {
23
34
  console.error("Error: --count must be a positive integer.");
24
35
  process.exit(1);
25
36
  }
26
- const n = Math.min(count, workouts.length);
27
- for (const w of workouts.slice(0, n)) {
28
- console.log(formatWorkoutLine(w, cfg.display.date_format));
37
+
38
+ const all = await readWorkouts(paths);
39
+ const workouts = filterByDate(all, opts.from ?? "", opts.to ?? "");
40
+ workouts.sort((a, b) => b.date.localeCompare(a.date));
41
+ const shown = workouts.slice(0, Math.min(count, workouts.length));
42
+
43
+ if (opts.json) {
44
+ printJSON("c2.log.v1", { count: shown.length, workouts: shown.map(workoutJSON) });
45
+ return;
46
+ }
47
+
48
+ if (all.length === 0) {
49
+ console.log("No workouts found. Run `c2 sync` first.");
50
+ return;
51
+ }
52
+ if (shown.length === 0) {
53
+ console.log("No workouts match the specified date range.");
54
+ return;
55
+ }
56
+
57
+ for (const w of shown) {
58
+ const line = formatWorkoutLine(w, cfg.display.date_format);
59
+ console.log(w.comments ? `${line} — ${w.comments}` : line);
29
60
  }
30
61
  });
31
62
  }