Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ jobs:
- name: 🔨 Build TypeScript
run: npm run build

- name: ✅ Type check
run: npx tsc --noEmit

- name: 📋 Verify build artifacts
run: |
test -d dist || (echo "dist directory not found" && exit 1)
test -f dist/index.js || (echo "dist/index.js not found" && exit 1)
echo "Build artifacts verified successfully"

- name: ✅ Run tests
run: npm test
641 changes: 220 additions & 421 deletions package-lock.json

Large diffs are not rendered by default.

19 changes: 9 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"dev": "tsx src/index.ts",
"dev:watch": "tsx watch src/index.ts",
"build": "tsc",
"postbuild": "mkdir -p dist/logo dist/i18n/locales && cp src/logo/logo.txt dist/logo/ && cp src/logo/ascii-logo.txt dist/logo/ && cp src/i18n/locales/*.json dist/i18n/locales/",
"postbuild": "node scripts/copy-assets.js",
"clean": "rm -rf dist",
"prebuild": "npm run clean",
"prepublishOnly": "npm run build",
Expand All @@ -38,21 +38,20 @@
"interactive"
],
"dependencies": {
"@clack/prompts": "^1.0.1",
"axios": "^1.6.2",
"chalk": "^5.4.1",
"@clack/prompts": "^1.2.0",
"chalk": "^5.6.2",
"gradient-string": "^3.0.0",
"ical.js": "^2.0.1",
"marked": "^11.1.0",
"ical.js": "^2.2.1",
"marked": "^15.0.12",
"marked-terminal": "^7.0.0",
"open": "^10.1.2"
"open": "^11.0.0"
},
"devDependencies": {
"@types/gradient-string": "^1.1.6",
"@types/marked-terminal": "^3.1.3",
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
"@types/node": "^22.19.17",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=20.12.0"
Expand Down
7 changes: 7 additions & 0 deletions scripts/copy-assets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { cpSync, mkdirSync } from 'fs';

mkdirSync('dist/logo', { recursive: true });
mkdirSync('dist/i18n/locales', { recursive: true });

cpSync('src/logo', 'dist/logo', { recursive: true });
cpSync('src/i18n/locales', 'dist/i18n/locales', { recursive: true });
4 changes: 2 additions & 2 deletions scripts/test-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ fi

tmp_home="$(mktemp -d)"
trap 'rm -rf "$tmp_home"' EXIT
HOME="$tmp_home" node dist/index.js theme icon ascii >/dev/null
if ! grep -q '"iconMode": "ascii"' "$tmp_home/.nbtca/preferences.json"; then
HOME="$tmp_home" XDG_CONFIG_HOME="$tmp_home/.config" node dist/index.js theme icon ascii >/dev/null
if ! grep -q '"iconMode": "ascii"' "$tmp_home/.config/nbtca/preferences.json"; then
echo "theme preference was not persisted" >&2
rm -rf "$tmp_home"
exit 1
Expand Down
28 changes: 25 additions & 3 deletions src/config/paths.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import path from 'path';
import { existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';

function getXdgConfigDir(): string {
const xdgHome = process.env['XDG_CONFIG_HOME'] || join(homedir(), '.config');
return join(xdgHome, 'nbtca');
}

function getLegacyConfigDir(): string {
return join(homedir(), '.nbtca');
}

export function getConfigDir(): string {
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
return path.join(homeDir, '.nbtca');
const xdgDir = getXdgConfigDir();
if (existsSync(xdgDir)) return xdgDir;

const legacyDir = getLegacyConfigDir();
if (existsSync(legacyDir)) return legacyDir;

return xdgDir;
}

export function getWritableConfigDir(): string {
const dir = getXdgConfigDir();
mkdirSync(dir, { recursive: true });
return dir;
}
12 changes: 4 additions & 8 deletions src/config/preferences.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'fs';
import path from 'path';
import { getConfigDir } from './paths.js';
import { getConfigDir, getWritableConfigDir } from './paths.js';

export type IconMode = 'auto' | 'ascii' | 'unicode';
export type ColorMode = 'auto' | 'on' | 'off';
Expand All @@ -19,11 +19,8 @@ function getPreferencesPath(): string {
return path.join(getConfigDir(), 'preferences.json');
}

function ensureConfigDir(): void {
const configDir = getConfigDir();
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
function getWritablePreferencesPath(): string {
return path.join(getWritableConfigDir(), 'preferences.json');
}

export function loadPreferences(): Preferences {
Expand All @@ -46,8 +43,7 @@ export function loadPreferences(): Preferences {

function savePreferences(preferences: Preferences): boolean {
try {
ensureConfigDir();
fs.writeFileSync(getPreferencesPath(), JSON.stringify(preferences, null, 2));
fs.writeFileSync(getWritablePreferencesPath(), JSON.stringify(preferences, null, 2));
return true;
} catch {
return false;
Expand Down
24 changes: 14 additions & 10 deletions src/core/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@
function charWidth(ch: string): 1 | 2 {
const cp = ch.codePointAt(0) ?? 0;
return (
(cp >= 0x1100 && cp <= 0x115F) || // Hangul Jamo
(cp >= 0x2E80 && cp <= 0x303F) || // CJK Radicals / Kangxi
(cp >= 0x3040 && cp <= 0x33FF) || // Japanese kana + CJK symbols
(cp >= 0x3400 && cp <= 0x4DBF) || // CJK Extension A
(cp >= 0x4E00 && cp <= 0x9FFF) || // CJK Unified Ideographs
(cp >= 0xAC00 && cp <= 0xD7AF) || // Hangul Syllables
(cp >= 0xF900 && cp <= 0xFAFF) || // CJK Compatibility Ideographs
(cp >= 0xFE30 && cp <= 0xFE4F) || // CJK Compatibility Forms
(cp >= 0xFF00 && cp <= 0xFF60) || // Fullwidth Forms
(cp >= 0xFFE0 && cp <= 0xFFE6) // Fullwidth Signs
(cp >= 0x1100 && cp <= 0x115F) ||
(cp >= 0x2E80 && cp <= 0x303F) ||
(cp >= 0x3040 && cp <= 0x33FF) ||
(cp >= 0x3400 && cp <= 0x4DBF) ||
(cp >= 0x4E00 && cp <= 0x9FFF) ||
(cp >= 0xAC00 && cp <= 0xD7AF) ||
(cp >= 0xF900 && cp <= 0xFAFF) ||
(cp >= 0xFE30 && cp <= 0xFE4F) ||
(cp >= 0xFF00 && cp <= 0xFF60) ||
(cp >= 0xFFE0 && cp <= 0xFFE6) ||
(cp >= 0x20000 && cp <= 0x2A6DF) ||
(cp >= 0x2A700 && cp <= 0x2CEAF) ||
(cp >= 0x2CEB0 && cp <= 0x2EBEF) ||
(cp >= 0x30000 && cp <= 0x323AF)
) ? 2 : 1;
}

Expand Down
16 changes: 16 additions & 0 deletions src/core/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { log, spinner as clackSpinner } from '@clack/prompts';
import chalk from 'chalk';
import { pickIcon } from './icons.js';
import { t } from '../i18n/index.js';

/**
* Display success message
Expand Down Expand Up @@ -71,3 +72,18 @@ export function createSpinner(msg: string) {
s.start(msg);
return s;
}

export function handleGracefulExit(err: unknown): never {
const message = err instanceof Error ? err.message : String(err ?? '');
if (message.includes('SIGINT') || message.includes('User force closed')) {
console.log();
console.log(chalk.dim(t().common.goodbye));
process.exit(0);
}
if (message) {
console.error(message);
} else {
console.error('Error occurred:', err);
}
process.exit(1);
}
8 changes: 7 additions & 1 deletion src/core/vim-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ const VIM_TO_SEQ: Record<string, Buffer> = {
q: Buffer.from('\u0003'), // quit
};

let vimActive = true;

export function setVimKeysActive(active: boolean): void {
vimActive = active;
}

export function enableVimKeys(): void {
const stdin = process.stdin;
if (!stdin.isTTY) return;

const originalEmit = stdin.emit.bind(stdin);

(stdin.emit as any) = function (event: string, ...args: any[]) {
if (event === 'data') {
if (event === 'data' && vimActive) {
const chunk = args[0];
if (Buffer.isBuffer(chunk) && chunk.length === 1) {
const seq = VIM_TO_SEQ[String.fromCharCode(chunk[0] as number)];
Expand Down
26 changes: 17 additions & 9 deletions src/features/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
* Fetches and renders upcoming events with Unicode box table.
*/

import axios from 'axios';
import ICAL from 'ical.js';
import chalk from 'chalk';
import { select, isCancel } from '@clack/prompts';
Expand Down Expand Up @@ -33,14 +32,17 @@ export interface EventOutputItem {

export async function fetchEvents(): Promise<Event[]> {
try {
const response = await axios.get('https://ical.nbtca.space', {
timeout: 5000,
headers: {
'User-Agent': `NBTCA-CLI/${APP_INFO.version}`
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch('https://ical.nbtca.space', {
signal: controller.signal,
headers: { 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` },
});
clearTimeout(timeout);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.text();

const jcalData = ICAL.parse(response.data);
const jcalData = ICAL.parse(data);
const comp = new ICAL.Component(jcalData);
const vevents = comp.getAllSubcomponents('vevent');

Expand Down Expand Up @@ -71,7 +73,9 @@ export async function fetchEvents(): Promise<Event[]> {
events.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
return events;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
const detail = err instanceof Error
? (err.name === 'AbortError' ? 'Request timed out' : err.message)
: String(err);
throw new Error(`${t().calendar.error}: ${detail}`);
}
}
Expand All @@ -89,8 +93,12 @@ export function serializeEvents(events: Event[]): EventOutputItem[] {


function formatDate(date: Date): string {
const now = new Date();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
if (date.getFullYear() !== now.getFullYear()) {
return `${date.getFullYear()}-${month}-${day}`;
}
return `${month}-${day}`;
}

Expand All @@ -109,7 +117,7 @@ export function renderEventsTable(events: Event[], options?: { color?: boolean }

if (events.length === 0) return trans.calendar.noEvents;

const dateWidth = 13;
const dateWidth = 16;
const titleWidth = 30;
const locationWidth = 16;

Expand Down
Loading
Loading