443488a3b6
로컬 .astra/stocks.json 뿐이던 워치리스트를 spreadsheet _backup 탭에 원본 JSON 그대로 자동 백업(변경 시 30초 디바운스). 새 컴퓨터에서는 /stocks restore 한 번으로 복원. sheetsApi 에 ensureSheetTab/clearSheetRange 추가, writeStocksStore 성공 시 변경 리스너 발화로 모든 변경 경로 포착. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
124 lines
5.0 KiB
TypeScript
124 lines
5.0 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as vscode from 'vscode';
|
|
import { logError, logInfo } from '../../utils';
|
|
import type { Stock, StocksStore } from './types';
|
|
|
|
/**
|
|
* 워크스페이스 루트의 `.astra/stocks.json` 을 source of truth 로 사용.
|
|
*
|
|
* 결정 근거 (q1=A): 사용자가 워크스페이스 단위로 다른 종목 리스트를 가질 수 있게.
|
|
* 워크스페이스가 없으면 (= VS Code 가 폴더 열지 않고 시작) 빈 store 반환 — 이 경우
|
|
* watcher / slash 명령 모두 silent skip.
|
|
*
|
|
* Atomic write: tmp 파일에 쓰고 rename — 동시 read 또는 SIGKILL 중간에도 partial JSON
|
|
* 으로 안 깨지게.
|
|
*/
|
|
|
|
const STORE_REL_PATH = '.astra/stocks.json';
|
|
|
|
/**
|
|
* 저장 성공 시 알림 리스너 (v2.2.297) — 모든 변경 경로(add/remove/update/check/
|
|
* discover 자동편입/워처 가격갱신)가 writeStocksStore 한 곳을 지나므로, 여기서
|
|
* fire 하면 Sheets 자동 백업이 변경을 빠짐없이 따라간다.
|
|
* 리스너 예외는 삼킨다 — 백업 실패가 저장 자체를 깨면 안 됨.
|
|
*/
|
|
type StoreChangeListener = () => void;
|
|
const changeListeners = new Set<StoreChangeListener>();
|
|
|
|
export function onStocksStoreChanged(listener: StoreChangeListener): { dispose(): void } {
|
|
changeListeners.add(listener);
|
|
return { dispose: () => { changeListeners.delete(listener); } };
|
|
}
|
|
|
|
function fireStoreChanged(): void {
|
|
for (const l of changeListeners) {
|
|
try { l(); } catch (e: any) {
|
|
logError('stocks store 변경 리스너 예외.', { error: e?.message ?? String(e) });
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getStocksFilePath(): string | null {
|
|
const folders = vscode.workspace.workspaceFolders;
|
|
if (!folders || folders.length === 0) return null;
|
|
return path.join(folders[0].uri.fsPath, STORE_REL_PATH);
|
|
}
|
|
|
|
/** 파일 없으면 빈 배열 반환. 파일 파싱 실패해도 빈 배열 + 에러 로그. */
|
|
export function readStocksStore(): StocksStore {
|
|
const filePath = getStocksFilePath();
|
|
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) {
|
|
logError('stocks.json 가 배열이 아닙니다 — 빈 store 반환.', { filePath });
|
|
return [];
|
|
}
|
|
return parsed as StocksStore;
|
|
} catch (e: any) {
|
|
logError('stocks.json 읽기 실패.', { filePath, error: e?.message ?? String(e) });
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내).
|
|
* opts.silent: 변경 리스너 미발화 — restore 처럼 백업에서 방금 읽어온 내용을
|
|
* 다시 백업하는 루프를 끊을 때만 사용.
|
|
*/
|
|
export function writeStocksStore(store: StocksStore, opts?: { silent?: boolean }): boolean {
|
|
const filePath = getStocksFilePath();
|
|
if (!filePath) {
|
|
logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.');
|
|
return false;
|
|
}
|
|
try {
|
|
const dir = path.dirname(filePath);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
|
|
fs.renameSync(tmp, filePath);
|
|
if (!opts?.silent) fireStoreChanged();
|
|
return true;
|
|
} catch (e: any) {
|
|
logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** 한 종목 추가. 같은 심볼이 이미 있으면 false (caller 가 안내). */
|
|
export function addStock(stock: Stock): { ok: boolean; reason?: string } {
|
|
const store = readStocksStore();
|
|
if (store.some(s => s.심볼 === stock.심볼)) {
|
|
return { ok: false, reason: `심볼 ${stock.심볼} 이미 존재` };
|
|
}
|
|
store.push(stock);
|
|
const wrote = writeStocksStore(store);
|
|
if (!wrote) return { ok: false, reason: '쓰기 실패 (워크스페이스 없음 또는 권한)' };
|
|
logInfo('Stocks: 종목 추가.', { symbol: stock.심볼, name: stock.이름 });
|
|
return { ok: true };
|
|
}
|
|
|
|
/** 한 종목 제거. 못 찾으면 false. */
|
|
export function removeStock(symbol: string): { ok: boolean; reason?: string } {
|
|
const store = readStocksStore();
|
|
const idx = store.findIndex(s => s.심볼 === symbol);
|
|
if (idx < 0) return { ok: false, reason: `심볼 ${symbol} 못 찾음` };
|
|
const removed = store.splice(idx, 1)[0];
|
|
const wrote = writeStocksStore(store);
|
|
if (!wrote) return { ok: false, reason: '쓰기 실패' };
|
|
logInfo('Stocks: 종목 제거.', { symbol, name: removed.이름 });
|
|
return { ok: true };
|
|
}
|
|
|
|
/** 한 종목의 필드 patch — 현재가 갱신 / 필터 업데이트 등. */
|
|
export function updateStock(symbol: string, patch: Partial<Stock>): boolean {
|
|
const store = readStocksStore();
|
|
const idx = store.findIndex(s => s.심볼 === symbol);
|
|
if (idx < 0) return false;
|
|
store[idx] = { ...store[idx], ...patch };
|
|
return writeStocksStore(store);
|
|
}
|