openmemo 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 arkjun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ja.md ADDED
@@ -0,0 +1,96 @@
1
+ # openmemo
2
+
3
+ mattn/memo に着想を得た OpenTUI ベースのメモアプリです。
4
+
5
+ ## 特長
6
+ - ターミナル UI でメモを閲覧・開く。
7
+ - new / list / edit / delete / grep / cat コマンドに対応。
8
+ - title, date, tags を含む軽量なフロントマター付き Markdown 保存。
9
+ - 環境変数でエディタを指定可能。
10
+
11
+ ## 要件
12
+ - `readline/promises` をサポートする Node.js(Node 17+)。
13
+
14
+ ## インストール
15
+ ```bash
16
+ npm install
17
+ npm run build
18
+ ```
19
+
20
+ CLI を有効化:
21
+ ```bash
22
+ npm link
23
+ # または
24
+ npm install -g .
25
+ ```
26
+
27
+ ## 使い方
28
+ TUI を起動:
29
+ ```bash
30
+ openmemo
31
+ ```
32
+
33
+ メモを作成:
34
+ ```bash
35
+ openmemo new
36
+ ```
37
+
38
+ その他のコマンド:
39
+ ```bash
40
+ openmemo list
41
+ openmemo edit <query>
42
+ openmemo delete <query>
43
+ openmemo grep <pattern>
44
+ openmemo cat <query>
45
+ openmemo help
46
+ ```
47
+
48
+ メモ:
49
+ - `<query>` はメモの id またはタイトル(部分一致)で検索します。複数一致した場合は選択を促します。
50
+ - `grep` は有効な場合は大文字小文字を無視した JavaScript 正規表現、無効な場合は大文字小文字を無視した部分一致検索として動作します。
51
+ - TUI 終了: `q` または `Esc`。
52
+
53
+ ## 設定
54
+ 環境変数:
55
+ - `OPEN_MEMO_DIR`: メモ保存ディレクトリを指定。
56
+ - `OPEN_MEMO_EDITOR`: 使用するエディタのコマンド。
57
+ - `VISUAL` / `EDITOR`: `OPEN_MEMO_EDITOR` が未設定の場合のフォールバック。
58
+
59
+ エディタ優先順位:
60
+ 1. `OPEN_MEMO_EDITOR`
61
+ 2. `VISUAL`
62
+ 3. `EDITOR`
63
+ 4. `vi`
64
+
65
+ ## データ保存
66
+ デフォルト保存先:
67
+ ```
68
+ ~/.openmemo/memos
69
+ ```
70
+
71
+ ファイル名形式:
72
+ ```
73
+ YYYY-MM-DD-<slug>.md
74
+ ```
75
+
76
+ テンプレート例:
77
+ ```markdown
78
+ ---
79
+ title: Your Title
80
+ date: 2026-01-29 12:34
81
+ tags: tag1, tag2
82
+ ---
83
+
84
+ # Your Title
85
+ ```
86
+
87
+ ## 開発
88
+ ビルド:
89
+ ```bash
90
+ npm run build
91
+ ```
92
+
93
+ ソースから実行:
94
+ ```bash
95
+ npm start
96
+ ```
package/README.ko.md ADDED
@@ -0,0 +1,96 @@
1
+ # openmemo
2
+
3
+ mattn/memo에서 영감을 받은 OpenTUI 기반 메모 앱입니다.
4
+
5
+ ## 주요 기능
6
+ - 터미널 UI로 메모 목록을 탐색하고 열기.
7
+ - new, list, edit, delete, grep, cat 명령 지원.
8
+ - title, date, tags를 포함한 가벼운 프런트매터로 Markdown 저장.
9
+ - 환경 변수로 에디터 지정.
10
+
11
+ ## 요구사항
12
+ - `readline/promises`를 지원하는 Node.js (Node 17+).
13
+
14
+ ## 설치
15
+ ```bash
16
+ npm install
17
+ npm run build
18
+ ```
19
+
20
+ CLI 노출:
21
+ ```bash
22
+ npm link
23
+ # 또는
24
+ npm install -g .
25
+ ```
26
+
27
+ ## 사용 방법
28
+ TUI 실행:
29
+ ```bash
30
+ openmemo
31
+ ```
32
+
33
+ 메모 생성:
34
+ ```bash
35
+ openmemo new
36
+ ```
37
+
38
+ 기타 명령:
39
+ ```bash
40
+ openmemo list
41
+ openmemo edit <query>
42
+ openmemo delete <query>
43
+ openmemo grep <pattern>
44
+ openmemo cat <query>
45
+ openmemo help
46
+ ```
47
+
48
+ 참고:
49
+ - `<query>`는 메모 id 또는 제목(부분 일치)을 기준으로 검색합니다. 여러 개가 일치하면 선택을 요청합니다.
50
+ - `grep`는 유효한 경우 대소문자 무시 JavaScript 정규식을 사용하고, 그렇지 않으면 대소문자 무시 부분 문자열 검색으로 동작합니다.
51
+ - TUI 종료: `q` 또는 `Esc`.
52
+
53
+ ## 설정
54
+ 환경 변수:
55
+ - `OPEN_MEMO_DIR`: 메모 저장 경로를 지정합니다.
56
+ - `OPEN_MEMO_EDITOR`: 선호하는 에디터 명령.
57
+ - `VISUAL` / `EDITOR`: `OPEN_MEMO_EDITOR`가 없을 때의 대안.
58
+
59
+ 에디터 우선순위:
60
+ 1. `OPEN_MEMO_EDITOR`
61
+ 2. `VISUAL`
62
+ 3. `EDITOR`
63
+ 4. `vi`
64
+
65
+ ## 데이터 저장
66
+ 기본 경로:
67
+ ```
68
+ ~/.openmemo/memos
69
+ ```
70
+
71
+ 파일 이름 형식:
72
+ ```
73
+ YYYY-MM-DD-<slug>.md
74
+ ```
75
+
76
+ 템플릿 예시:
77
+ ```markdown
78
+ ---
79
+ title: Your Title
80
+ date: 2026-01-29 12:34
81
+ tags: tag1, tag2
82
+ ---
83
+
84
+ # Your Title
85
+ ```
86
+
87
+ ## 개발
88
+ 빌드:
89
+ ```bash
90
+ npm run build
91
+ ```
92
+
93
+ 소스에서 실행:
94
+ ```bash
95
+ npm start
96
+ ```
package/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # openmemo
2
+
3
+ [English](#english) | [한국어](#한국어) | [日本語](#日本語)
4
+
5
+ ---
6
+
7
+ ## English
8
+
9
+ OpenTUI-based memo app inspired by mattn/memo.
10
+
11
+ ### Features
12
+ - Terminal UI for browsing and opening memos
13
+ - Create, list, edit, delete, grep, and cat commands
14
+ - Markdown storage with lightweight frontmatter (title, date, tags)
15
+ - Uses your preferred editor via environment variables
16
+
17
+ ### Requirements
18
+ - Node.js 17+ or Bun
19
+ - pnpm
20
+
21
+ ### Install
22
+ ```bash
23
+ pnpm install
24
+ pnpm build
25
+ ```
26
+
27
+ Expose the CLI:
28
+ ```bash
29
+ pnpm link --global
30
+ ```
31
+
32
+ ### Usage
33
+ Launch the TUI:
34
+ ```bash
35
+ openmemo
36
+ ```
37
+
38
+ Create a memo:
39
+ ```bash
40
+ openmemo new
41
+ ```
42
+
43
+ Other commands:
44
+ ```bash
45
+ openmemo list
46
+ openmemo edit <query>
47
+ openmemo delete <query>
48
+ openmemo grep <pattern>
49
+ openmemo cat <query>
50
+ openmemo help
51
+ ```
52
+
53
+ Notes:
54
+ - `<query>` matches the memo id or title (partial match). If multiple match, you will be prompted to select.
55
+ - `grep` uses a case-insensitive JavaScript regex when the pattern is valid; otherwise it falls back to a case-insensitive substring search.
56
+ - TUI: press `q` or `Esc` to quit.
57
+
58
+ ### Configuration
59
+ Environment variables:
60
+ - `OPEN_MEMO_DIR`: override memo storage directory
61
+ - `OPEN_MEMO_EDITOR`: preferred editor command
62
+ - `VISUAL` / `EDITOR`: fallbacks if `OPEN_MEMO_EDITOR` is not set
63
+
64
+ Editor resolution order:
65
+ 1. `OPEN_MEMO_EDITOR`
66
+ 2. `VISUAL`
67
+ 3. `EDITOR`
68
+ 4. `vi`
69
+
70
+ ### Data Storage
71
+ Default directory:
72
+ ```
73
+ ~/.openmemo/memos
74
+ ```
75
+
76
+ File naming format:
77
+ ```
78
+ YYYY-MM-DD-<slug>.md
79
+ ```
80
+
81
+ Template content:
82
+ ```markdown
83
+ ---
84
+ title: Your Title
85
+ date: 2026-01-30 12:34
86
+ tags: tag1, tag2
87
+ ---
88
+
89
+ # Your Title
90
+ ```
91
+
92
+ ### Development
93
+ ```bash
94
+ pnpm build # Build
95
+ pnpm start # Run from dist
96
+ pnpm test # Run tests (watch mode)
97
+ pnpm test:run # Run tests once
98
+ pnpm test:coverage # Coverage report
99
+ ```
100
+
101
+ ---
102
+
103
+ ## 한국어
104
+
105
+ mattn/memo에서 영감을 받은 OpenTUI 기반 터미널 메모 앱입니다.
106
+
107
+ ### 기능
108
+ - 메모 탐색 및 열기를 위한 터미널 UI
109
+ - 생성, 목록, 편집, 삭제, 검색, 출력 명령어 지원
110
+ - 마크다운 저장 (title, date, tags frontmatter 포함)
111
+ - 환경 변수를 통한 선호 에디터 설정
112
+
113
+ ### 요구사항
114
+ - Node.js 17+ 또는 Bun
115
+ - pnpm
116
+
117
+ ### 설치
118
+ ```bash
119
+ pnpm install
120
+ pnpm build
121
+ ```
122
+
123
+ CLI 전역 등록:
124
+ ```bash
125
+ pnpm link --global
126
+ ```
127
+
128
+ ### 사용법
129
+ TUI 실행:
130
+ ```bash
131
+ openmemo
132
+ ```
133
+
134
+ 메모 생성:
135
+ ```bash
136
+ openmemo new
137
+ ```
138
+
139
+ 기타 명령어:
140
+ ```bash
141
+ openmemo list # 메모 목록
142
+ openmemo edit <query> # 메모 편집
143
+ openmemo delete <query> # 메모 삭제
144
+ openmemo grep <pattern> # 내용 검색
145
+ openmemo cat <query> # 메모 출력
146
+ openmemo help # 도움말
147
+ ```
148
+
149
+ 참고:
150
+ - `<query>`는 메모 ID 또는 제목과 부분 일치합니다. 여러 개가 일치하면 선택 프롬프트가 표시됩니다.
151
+ - `grep`은 유효한 정규식이면 대소문자 무시 정규식 검색을, 아니면 부분 문자열 검색을 수행합니다.
152
+ - TUI에서 `q` 또는 `Esc`를 눌러 종료합니다.
153
+
154
+ ### 설정
155
+ 환경 변수:
156
+ - `OPEN_MEMO_DIR`: 메모 저장 디렉토리 변경
157
+ - `OPEN_MEMO_EDITOR`: 선호 에디터 명령어
158
+ - `VISUAL` / `EDITOR`: `OPEN_MEMO_EDITOR` 미설정 시 대체
159
+
160
+ 에디터 우선순위:
161
+ 1. `OPEN_MEMO_EDITOR`
162
+ 2. `VISUAL`
163
+ 3. `EDITOR`
164
+ 4. `vi`
165
+
166
+ ### 데이터 저장
167
+ 기본 디렉토리:
168
+ ```
169
+ ~/.openmemo/memos
170
+ ```
171
+
172
+ 파일명 형식:
173
+ ```
174
+ YYYY-MM-DD-<slug>.md
175
+ ```
176
+
177
+ 템플릿:
178
+ ```markdown
179
+ ---
180
+ title: 제목
181
+ date: 2026-01-30 12:34
182
+ tags: 태그1, 태그2
183
+ ---
184
+
185
+ # 제목
186
+ ```
187
+
188
+ ### 개발
189
+ ```bash
190
+ pnpm build # 빌드
191
+ pnpm start # dist에서 실행
192
+ pnpm test # 테스트 (watch 모드)
193
+ pnpm test:run # 테스트 단일 실행
194
+ pnpm test:coverage # 커버리지 리포트
195
+ ```
196
+
197
+ ---
198
+
199
+ ## 日本語
200
+
201
+ mattn/memoにインスパイアされたOpenTUIベースのターミナルメモアプリです。
202
+
203
+ ### 機能
204
+ - メモの閲覧・開くためのターミナルUI
205
+ - 作成、一覧、編集、削除、検索、表示コマンド
206
+ - マークダウン保存(title, date, tags frontmatter付き)
207
+ - 環境変数でお好みのエディターを設定
208
+
209
+ ### 必要条件
210
+ - Node.js 17+ または Bun
211
+ - pnpm
212
+
213
+ ### インストール
214
+ ```bash
215
+ pnpm install
216
+ pnpm build
217
+ ```
218
+
219
+ CLIをグローバル登録:
220
+ ```bash
221
+ pnpm link --global
222
+ ```
223
+
224
+ ### 使い方
225
+ TUIを起動:
226
+ ```bash
227
+ openmemo
228
+ ```
229
+
230
+ メモを作成:
231
+ ```bash
232
+ openmemo new
233
+ ```
234
+
235
+ その他のコマンド:
236
+ ```bash
237
+ openmemo list # メモ一覧
238
+ openmemo edit <query> # メモ編集
239
+ openmemo delete <query> # メモ削除
240
+ openmemo grep <pattern> # 内容検索
241
+ openmemo cat <query> # メモ表示
242
+ openmemo help # ヘルプ
243
+ ```
244
+
245
+ 備考:
246
+ - `<query>`はメモIDまたはタイトルに部分一致します。複数一致する場合は選択プロンプトが表示されます。
247
+ - `grep`は有効な正規表現なら大文字小文字無視の正規表現検索を、そうでなければ部分文字列検索を行います。
248
+ - TUIでは`q`または`Esc`で終了します。
249
+
250
+ ### 設定
251
+ 環境変数:
252
+ - `OPEN_MEMO_DIR`: メモ保存ディレクトリの変更
253
+ - `OPEN_MEMO_EDITOR`: お好みのエディターコマンド
254
+ - `VISUAL` / `EDITOR`: `OPEN_MEMO_EDITOR`未設定時の代替
255
+
256
+ エディター優先順位:
257
+ 1. `OPEN_MEMO_EDITOR`
258
+ 2. `VISUAL`
259
+ 3. `EDITOR`
260
+ 4. `vi`
261
+
262
+ ### データ保存
263
+ デフォルトディレクトリ:
264
+ ```
265
+ ~/.openmemo/memos
266
+ ```
267
+
268
+ ファイル名形式:
269
+ ```
270
+ YYYY-MM-DD-<slug>.md
271
+ ```
272
+
273
+ テンプレート:
274
+ ```markdown
275
+ ---
276
+ title: タイトル
277
+ date: 2026-01-30 12:34
278
+ tags: タグ1, タグ2
279
+ ---
280
+
281
+ # タイトル
282
+ ```
283
+
284
+ ### 開発
285
+ ```bash
286
+ pnpm build # ビルド
287
+ pnpm start # distから実行
288
+ pnpm test # テスト(watchモード)
289
+ pnpm test:run # テスト単発実行
290
+ pnpm test:coverage # カバレッジレポート
291
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ import readline from "node:readline/promises";
3
+ import process from "node:process";
4
+ import { openEditor } from "./editor.js";
5
+ import { createMemo, deleteMemo, listMemos, } from "./storage.js";
6
+ import { runTui } from "./tui.js";
7
+ const COLUMN_WIDTH = 30;
8
+ const COMMANDS = ["new", "list", "edit", "delete", "grep", "cat", "help"];
9
+ async function main() {
10
+ const [command, ...args] = process.argv.slice(2);
11
+ if (!command) {
12
+ await runTui();
13
+ return;
14
+ }
15
+ if (command === "help" || command === "--help" || command === "-h") {
16
+ printHelp();
17
+ return;
18
+ }
19
+ if (!COMMANDS.includes(command)) {
20
+ console.error("Unknown command: " + command);
21
+ printHelp();
22
+ process.exit(1);
23
+ }
24
+ switch (command) {
25
+ case "new":
26
+ await handleNew();
27
+ return;
28
+ case "list":
29
+ await handleList();
30
+ return;
31
+ case "edit":
32
+ await handleEdit(args.join(" ").trim());
33
+ return;
34
+ case "delete":
35
+ await handleDelete(args.join(" ").trim());
36
+ return;
37
+ case "grep":
38
+ await handleGrep(args.join(" ").trim());
39
+ return;
40
+ case "cat":
41
+ await handleCat(args.join(" ").trim());
42
+ return;
43
+ default:
44
+ printHelp();
45
+ return;
46
+ }
47
+ }
48
+ async function handleNew() {
49
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
50
+ try {
51
+ const title = (await rl.question("Title: ")).trim();
52
+ if (!title) {
53
+ console.error("Title is required.");
54
+ process.exit(1);
55
+ }
56
+ const tagsRaw = (await rl.question("Tags (comma separated, optional): ")).trim();
57
+ const tags = tagsRaw ? tagsRaw.split(",").map((tag) => tag.trim()).filter(Boolean) : [];
58
+ const memo = await createMemo(title, tags);
59
+ openEditor(memo.filePath);
60
+ }
61
+ finally {
62
+ rl.close();
63
+ }
64
+ }
65
+ async function handleList() {
66
+ const memos = await listMemos();
67
+ if (memos.length === 0) {
68
+ console.log("No memos found.");
69
+ return;
70
+ }
71
+ memos.forEach((memo) => {
72
+ const name = memo.id.padEnd(COLUMN_WIDTH, " ");
73
+ console.log(name + " : " + memo.title);
74
+ });
75
+ }
76
+ async function handleEdit(query) {
77
+ const memo = await selectMemo(query);
78
+ openEditor(memo.filePath);
79
+ }
80
+ async function handleDelete(query) {
81
+ const memo = await selectMemo(query);
82
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
83
+ try {
84
+ const answer = (await rl.question('Delete "' + memo.title + '"? [y/N]: ')).trim().toLowerCase();
85
+ if (answer === "y" || answer === "yes") {
86
+ await deleteMemo(memo.id);
87
+ console.log("Deleted " + memo.id);
88
+ }
89
+ else {
90
+ console.log("Canceled.");
91
+ }
92
+ }
93
+ finally {
94
+ rl.close();
95
+ }
96
+ }
97
+ async function handleCat(query) {
98
+ const memo = await selectMemo(query);
99
+ console.log(memo.content);
100
+ }
101
+ async function handleGrep(pattern) {
102
+ if (!pattern) {
103
+ console.error("Pattern is required for grep.");
104
+ process.exit(1);
105
+ }
106
+ const memos = await listMemos();
107
+ if (memos.length === 0) {
108
+ console.log("No memos found.");
109
+ return;
110
+ }
111
+ const matcher = buildMatcher(pattern);
112
+ memos.forEach((memo) => {
113
+ const lines = memo.content.split("\n");
114
+ lines.forEach((line, index) => {
115
+ const match = matcher ? matcher.test(line) : line.toLowerCase().includes(pattern.toLowerCase());
116
+ if (match) {
117
+ console.log(memo.id + ":" + String(index + 1) + ":" + line);
118
+ }
119
+ });
120
+ });
121
+ }
122
+ async function selectMemo(query) {
123
+ const memos = await listMemos();
124
+ if (memos.length === 0) {
125
+ console.error("No memos found.");
126
+ process.exit(1);
127
+ }
128
+ if (!query) {
129
+ return promptSelect(memos, "Select memo");
130
+ }
131
+ const normalized = query.toLowerCase();
132
+ const exact = memos.find((memo) => memo.id === query);
133
+ if (exact)
134
+ return exact;
135
+ const matches = memos.filter((memo) => memo.title.toLowerCase().includes(normalized) || memo.id.toLowerCase().includes(normalized));
136
+ if (matches.length === 1)
137
+ return matches[0];
138
+ if (matches.length === 0) {
139
+ console.error('No memo matches "' + query + '".');
140
+ process.exit(1);
141
+ }
142
+ return promptSelect(matches, 'Multiple matches for "' + query + '"');
143
+ }
144
+ async function promptSelect(memos, label) {
145
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
146
+ try {
147
+ console.log(label);
148
+ memos.forEach((memo, index) => {
149
+ console.log(String(index + 1) + ". " + memo.title + " (" + memo.id + ")");
150
+ });
151
+ const answer = (await rl.question("Choose number: ")).trim();
152
+ const index = Number(answer) - 1;
153
+ if (Number.isNaN(index) || index < 0 || index >= memos.length) {
154
+ console.error("Invalid selection.");
155
+ process.exit(1);
156
+ }
157
+ return memos[index];
158
+ }
159
+ finally {
160
+ rl.close();
161
+ }
162
+ }
163
+ function buildMatcher(pattern) {
164
+ try {
165
+ return new RegExp(pattern, "i");
166
+ }
167
+ catch {
168
+ return null;
169
+ }
170
+ }
171
+ function printHelp() {
172
+ console.log([
173
+ "openmemo - OpenTUI-based memo app",
174
+ "",
175
+ "Usage:",
176
+ " openmemo Launch TUI",
177
+ " openmemo new Create memo",
178
+ " openmemo list List memos",
179
+ " openmemo edit <query> Edit memo",
180
+ " openmemo delete <query> Delete memo",
181
+ " openmemo grep <pattern> Search memo contents",
182
+ " openmemo cat <query> View memo",
183
+ " openmemo help Show help",
184
+ "",
185
+ ].join("\n"));
186
+ }
187
+ main().catch((error) => {
188
+ console.error(error);
189
+ process.exit(1);
190
+ });
package/dist/config.js ADDED
@@ -0,0 +1,15 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ export function getMemoDir() {
4
+ const fromEnv = process.env.OPEN_MEMO_DIR;
5
+ if (fromEnv && fromEnv.trim() !== "") {
6
+ return fromEnv;
7
+ }
8
+ return path.join(os.homedir(), ".openmemo", "memos");
9
+ }
10
+ export function getEditorCommand() {
11
+ return (process.env.OPEN_MEMO_EDITOR ||
12
+ process.env.VISUAL ||
13
+ process.env.EDITOR ||
14
+ "vi");
15
+ }
package/dist/editor.js ADDED
@@ -0,0 +1,11 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { getEditorCommand } from "./config.js";
3
+ export function openEditor(filePath) {
4
+ const editor = getEditorCommand();
5
+ const result = spawnSync(editor, [filePath], {
6
+ stdio: "inherit",
7
+ });
8
+ if (result.error) {
9
+ throw result.error;
10
+ }
11
+ }
@@ -0,0 +1,133 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getMemoDir } from "./config.js";
4
+ import { formatDate, formatDateTime, slugify } from "./utils.js";
5
+ export async function ensureMemoDir() {
6
+ const memoDir = getMemoDir();
7
+ await fs.mkdir(memoDir, { recursive: true });
8
+ return memoDir;
9
+ }
10
+ export async function listMemos() {
11
+ const memoDir = await ensureMemoDir();
12
+ const entries = await fs.readdir(memoDir, { withFileTypes: true });
13
+ const files = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".md"));
14
+ const memos = await Promise.all(files.map(async (entry) => {
15
+ const filePath = path.join(memoDir, entry.name);
16
+ const content = await fs.readFile(filePath, "utf8");
17
+ const { title, date, tags } = parseMemoMetadata(content, entry.name);
18
+ return {
19
+ id: entry.name,
20
+ title,
21
+ date,
22
+ tags,
23
+ filePath,
24
+ content,
25
+ };
26
+ }));
27
+ return memos.sort((a, b) => b.date.getTime() - a.date.getTime());
28
+ }
29
+ export async function loadMemo(id) {
30
+ const memoDir = await ensureMemoDir();
31
+ const filePath = path.join(memoDir, id);
32
+ try {
33
+ const content = await fs.readFile(filePath, "utf8");
34
+ const { title, date, tags } = parseMemoMetadata(content, id);
35
+ return { id, title, date, tags, filePath, content };
36
+ }
37
+ catch (error) {
38
+ if (error.code === "ENOENT") {
39
+ return null;
40
+ }
41
+ throw error;
42
+ }
43
+ }
44
+ export async function createMemo(title, tags) {
45
+ const memoDir = await ensureMemoDir();
46
+ const now = new Date();
47
+ const slug = slugify(title) || "memo";
48
+ const fileName = `${formatDate(now)}-${slug}.md`;
49
+ const filePath = path.join(memoDir, fileName);
50
+ const content = buildMemoTemplate(title, tags, now);
51
+ await fs.writeFile(filePath, content, "utf8");
52
+ return {
53
+ id: fileName,
54
+ title,
55
+ date: now,
56
+ tags,
57
+ filePath,
58
+ content,
59
+ };
60
+ }
61
+ export async function deleteMemo(id) {
62
+ const memoDir = await ensureMemoDir();
63
+ const filePath = path.join(memoDir, id);
64
+ await fs.unlink(filePath);
65
+ }
66
+ export function buildMemoTemplate(title, tags, date) {
67
+ const tagValue = tags.length ? tags.join(", ") : "";
68
+ return [
69
+ "---",
70
+ `title: ${title}`,
71
+ `date: ${formatDateTime(date)}`,
72
+ `tags: ${tagValue}`,
73
+ "---",
74
+ "",
75
+ `# ${title}`,
76
+ "",
77
+ ].join("\n");
78
+ }
79
+ export function parseMemoMetadata(content, fallbackName) {
80
+ const frontmatter = parseFrontmatter(content);
81
+ const fallbackTitle = fallbackName.replace(/\.md$/, "");
82
+ const title = frontmatter.meta.title || extractTitleFromBody(frontmatter.body) || fallbackTitle;
83
+ const date = frontmatter.meta.date ? new Date(frontmatter.meta.date) : dateFromFilename(fallbackName);
84
+ const tags = parseTags(frontmatter.meta.tags);
85
+ return {
86
+ title,
87
+ date: isNaN(date.getTime()) ? new Date() : date,
88
+ tags,
89
+ };
90
+ }
91
+ function parseFrontmatter(content) {
92
+ if (!content.startsWith("---\n")) {
93
+ return { meta: {}, body: content };
94
+ }
95
+ const endIndex = content.indexOf("\n---", 4);
96
+ if (endIndex === -1) {
97
+ return { meta: {}, body: content };
98
+ }
99
+ const raw = content.slice(4, endIndex).trim();
100
+ const body = content.slice(endIndex + 4).replace(/^\s*\n/, "");
101
+ const meta = {};
102
+ raw.split("\n").forEach((line) => {
103
+ const [key, ...rest] = line.split(":");
104
+ const normalizedKey = key?.trim();
105
+ if (!normalizedKey)
106
+ return;
107
+ meta[normalizedKey] = rest.join(":").trim();
108
+ });
109
+ return { meta, body };
110
+ }
111
+ function extractTitleFromBody(body) {
112
+ const lines = body.split("\n").map((line) => line.trim());
113
+ const heading = lines.find((line) => line.startsWith("#"));
114
+ if (heading) {
115
+ return heading.replace(/^#+\s*/, "").trim();
116
+ }
117
+ return lines.find((line) => line.length > 0) || "";
118
+ }
119
+ function parseTags(raw) {
120
+ if (!raw)
121
+ return [];
122
+ const trimmed = raw.replace(/^[\[]|[\]]$/g, "");
123
+ return trimmed
124
+ .split(",")
125
+ .map((tag) => tag.trim())
126
+ .filter((tag) => tag.length > 0);
127
+ }
128
+ function dateFromFilename(fileName) {
129
+ const match = fileName.match(/^(\d{4}-\d{2}-\d{2})-/);
130
+ if (!match)
131
+ return new Date();
132
+ return new Date(`${match[1]}T00:00:00`);
133
+ }
package/dist/tui.js ADDED
@@ -0,0 +1,116 @@
1
+ import { BoxRenderable, createCliRenderer, SelectRenderable, SelectRenderableEvents, TextRenderable, } from "@opentui/core";
2
+ import process from "node:process";
3
+ import { openEditor } from "./editor.js";
4
+ import { listMemos } from "./storage.js";
5
+ import { truncateLines } from "./utils.js";
6
+ const EMPTY_MESSAGE = "No memos yet. Run openmemo new to create one.";
7
+ export async function runTui() {
8
+ const memos = await listMemos();
9
+ const renderer = await createCliRenderer({
10
+ exitOnCtrlC: true,
11
+ targetFps: 30,
12
+ });
13
+ renderer.setBackgroundColor("#0f172a");
14
+ const container = new BoxRenderable(renderer, {
15
+ id: "openmemo-container",
16
+ flexDirection: "row",
17
+ width: "100%",
18
+ height: "100%",
19
+ padding: 1,
20
+ });
21
+ renderer.root.add(container);
22
+ const listBox = new BoxRenderable(renderer, {
23
+ id: "openmemo-list",
24
+ flexGrow: 1,
25
+ marginRight: 1,
26
+ border: true,
27
+ borderStyle: "single",
28
+ borderColor: "#334155",
29
+ title: "Memos",
30
+ titleAlignment: "left",
31
+ backgroundColor: "#0b1220",
32
+ shouldFill: true,
33
+ });
34
+ const previewBox = new BoxRenderable(renderer, {
35
+ id: "openmemo-preview",
36
+ flexGrow: 2,
37
+ border: true,
38
+ borderStyle: "single",
39
+ borderColor: "#334155",
40
+ title: "Preview",
41
+ titleAlignment: "left",
42
+ backgroundColor: "#0b1220",
43
+ shouldFill: true,
44
+ });
45
+ container.add(listBox);
46
+ container.add(previewBox);
47
+ const previewText = new TextRenderable(renderer, {
48
+ id: "openmemo-preview-text",
49
+ width: "100%",
50
+ height: "100%",
51
+ fg: "#e2e8f0",
52
+ content: "",
53
+ });
54
+ previewBox.add(previewText);
55
+ if (memos.length === 0) {
56
+ previewText.content = EMPTY_MESSAGE;
57
+ renderer.start();
58
+ return;
59
+ }
60
+ const options = memos.map((memo) => ({
61
+ name: memo.title,
62
+ description: memo.id,
63
+ value: memo,
64
+ }));
65
+ const selectElement = new SelectRenderable(renderer, {
66
+ id: "openmemo-select",
67
+ height: "100%",
68
+ options,
69
+ backgroundColor: "transparent",
70
+ focusedBackgroundColor: "transparent",
71
+ selectedBackgroundColor: "#1e293b",
72
+ textColor: "#e2e8f0",
73
+ selectedTextColor: "#38bdf8",
74
+ descriptionColor: "#94a3b8",
75
+ selectedDescriptionColor: "#cbd5e1",
76
+ showDescription: true,
77
+ showScrollIndicator: true,
78
+ wrapSelection: false,
79
+ fastScrollStep: 5,
80
+ });
81
+ listBox.add(selectElement);
82
+ selectElement.focus();
83
+ const updatePreview = (memo) => {
84
+ if (!memo) {
85
+ previewText.content = EMPTY_MESSAGE;
86
+ return;
87
+ }
88
+ const header = `${memo.title}\n${memo.id}\n`;
89
+ const body = truncateLines(stripFrontmatter(memo.content), 24);
90
+ previewText.content = `${header}\n${body}`;
91
+ };
92
+ updatePreview(memos[0]);
93
+ selectElement.on(SelectRenderableEvents.SELECTION_CHANGED, (index, option) => {
94
+ updatePreview(option.value);
95
+ });
96
+ selectElement.on(SelectRenderableEvents.ITEM_SELECTED, (index, option) => {
97
+ const memo = option.value;
98
+ renderer.destroy();
99
+ openEditor(memo.filePath);
100
+ process.exit(0);
101
+ });
102
+ renderer.keyInput.on("keypress", (key) => {
103
+ if (key.name === "q" || key.name === "escape") {
104
+ renderer.destroy();
105
+ }
106
+ });
107
+ renderer.start();
108
+ }
109
+ function stripFrontmatter(content) {
110
+ if (!content.startsWith("---\n"))
111
+ return content;
112
+ const endIndex = content.indexOf("\n---", 4);
113
+ if (endIndex === -1)
114
+ return content;
115
+ return content.slice(endIndex + 4).replace(/^\s*\n/, "");
116
+ }
package/dist/utils.js ADDED
@@ -0,0 +1,23 @@
1
+ export function pad2(value) {
2
+ return String(value).padStart(2, "0");
3
+ }
4
+ export function formatDate(date) {
5
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
6
+ }
7
+ export function formatDateTime(date) {
8
+ return `${formatDate(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
9
+ }
10
+ export function slugify(input) {
11
+ return input
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9\s-]/g, "")
14
+ .trim()
15
+ .replace(/\s+/g, "-")
16
+ .replace(/-+/g, "-");
17
+ }
18
+ export function truncateLines(input, maxLines) {
19
+ const lines = input.split("\n");
20
+ if (lines.length <= maxLines)
21
+ return input;
22
+ return `${lines.slice(0, maxLines).join("\n")}\n...`;
23
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "openmemo",
3
+ "version": "0.1.0",
4
+ "description": "OpenTUI-based memo app inspired by mattn/memo",
5
+ "type": "module",
6
+ "bin": {
7
+ "openmemo": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/arkjun/openmemo.git"
15
+ },
16
+ "author": "arkjun",
17
+ "license": "MIT",
18
+ "keywords": [
19
+ "memo",
20
+ "cli",
21
+ "terminal",
22
+ "tui",
23
+ "note",
24
+ "markdown"
25
+ ],
26
+ "homepage": "https://github.com/arkjun/openmemo#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/arkjun/openmemo/issues"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "start": "bun dist/cli.js",
36
+ "test": "vitest",
37
+ "test:run": "vitest run",
38
+ "test:watch": "vitest watch",
39
+ "test:coverage": "vitest run --coverage",
40
+ "prepublishOnly": "npm run build && npm run test:run"
41
+ },
42
+ "dependencies": {
43
+ "@opentui/core": "^0.1.75"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^20.11.30",
47
+ "@vitest/coverage-v8": "^4.0.18",
48
+ "typescript": "^5.4.5",
49
+ "vitest": "^4.0.18"
50
+ }
51
+ }