443488a3b6
로컬 .astra/stocks.json 뿐이던 워치리스트를 spreadsheet _backup 탭에 원본 JSON 그대로 자동 백업(변경 시 30초 디바운스). 새 컴퓨터에서는 /stocks restore 한 번으로 복원. sheetsApi 에 ensureSheetTab/clearSheetRange 추가, writeStocksStore 성공 시 변경 리스너 발화로 모든 변경 경로 포착. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
134 lines
6.2 KiB
TypeScript
134 lines
6.2 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import { clearSheetRange, ensureSheetTab, readSheetRange, writeSheetRange, type SheetValues } from '../sheets';
|
|
import { logError, logInfo } from '../../utils';
|
|
import { onStocksStoreChanged, readStocksStore, writeStocksStore } from './stocksStore';
|
|
import type { Stock } from './types';
|
|
|
|
/**
|
|
* stocks.json 원본 클라우드 백업 — Google Sheets `_backup` 탭 (v2.2.297).
|
|
*
|
|
* 문제: 목록의 source of truth 가 로컬 `.astra/stocks.json` 뿐이라 컴퓨터를 바꾸면
|
|
* 데이터가 사라진다. Sheet1~3 sync 는 분류 *표시용* 이라 복원 불가.
|
|
*
|
|
* 해결: 같은 spreadsheet(`g1nation.stocks.spreadsheetId`)에 `_backup` 탭을 자동
|
|
* 생성하고, 종목당 한 행으로 **원본 JSON 그대로** 백업. 새 컴퓨터에서는
|
|
* `/stocks restore` 한 번으로 복원.
|
|
*
|
|
* 백업 시점: writeStocksStore 성공 → onStocksStoreChanged → 디바운스 30초 후 1회.
|
|
* (add/remove/check/discover 자동편입/워처 가격갱신 전부 이 경로를 지남.)
|
|
*
|
|
* 레이아웃:
|
|
* A1: BACKUP_MARKER B1: ISO timestamp C1: 종목 수
|
|
* A2~: 심볼 B2~: 이름 C2~: JSON.stringify(stock)
|
|
* 심볼/이름 컬럼은 사람이 시트에서 훑어보기용 — 복원은 C열 JSON 만 사용.
|
|
*/
|
|
|
|
const BACKUP_TAB = '_backup';
|
|
const BACKUP_MARKER = 'ASTRA_STOCKS_BACKUP_V1';
|
|
const DEBOUNCE_MS = 30_000;
|
|
|
|
function getSpreadsheetId(): string {
|
|
return (vscode.workspace.getConfiguration('g1nation').get<string>('stocks.spreadsheetId') || '').trim();
|
|
}
|
|
|
|
function autoBackupEnabled(): boolean {
|
|
return vscode.workspace.getConfiguration('g1nation').get<boolean>('stocks.autoBackup', true) !== false;
|
|
}
|
|
|
|
/** 지금 즉시 백업 1회. spreadsheetId 미설정이면 skip (ok:false + reason). */
|
|
export async function backupToSheet(
|
|
context: vscode.ExtensionContext,
|
|
): Promise<{ ok: boolean; reason?: string; count?: number }> {
|
|
const spreadsheetId = getSpreadsheetId();
|
|
if (!spreadsheetId) {
|
|
return { ok: false, reason: 'g1nation.stocks.spreadsheetId 미설정 — Settings 에서 spreadsheet ID 를 넣어주세요.' };
|
|
}
|
|
const store = readStocksStore();
|
|
|
|
const ensured = await ensureSheetTab(context, spreadsheetId, BACKUP_TAB);
|
|
if (!ensured.ok) return { ok: false, reason: `백업 탭 확인 실패: ${ensured.error}` };
|
|
if (ensured.created) logInfo(`Stocks backup: '${BACKUP_TAB}' 탭 생성.`);
|
|
|
|
// 목록이 줄었을 때 잔여 행이 남지 않게 먼저 클리어. 클리어 실패는 치명적이지
|
|
// 않으므로 (덮어쓰기는 어차피 진행) 로그만 남기고 계속.
|
|
const cleared = await clearSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A:C`);
|
|
if (!cleared.ok) logError('Stocks backup: 기존 행 클리어 실패 — 덮어쓰기로 계속.', { error: cleared.error });
|
|
|
|
const rows: SheetValues = [[BACKUP_MARKER, new Date().toISOString(), store.length]];
|
|
for (const s of store) {
|
|
rows.push([s.심볼, s.이름, JSON.stringify(s)]);
|
|
}
|
|
const wrote = await writeSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A1`, rows);
|
|
if (!wrote.ok) return { ok: false, reason: `백업 쓰기 실패: ${wrote.error}` };
|
|
|
|
logInfo('Stocks backup: 완료.', { count: store.length });
|
|
return { ok: true, count: store.length };
|
|
}
|
|
|
|
/**
|
|
* `_backup` 탭에서 목록 복원 — 로컬 stocks.json 을 백업 내용으로 **교체**.
|
|
* silent write — 복원 직후 같은 내용을 되백업하는 루프 방지.
|
|
*/
|
|
export async function restoreFromSheet(
|
|
context: vscode.ExtensionContext,
|
|
): Promise<{ ok: boolean; reason?: string; count?: number; backedUpAt?: string; previousCount?: number }> {
|
|
const spreadsheetId = getSpreadsheetId();
|
|
if (!spreadsheetId) {
|
|
return { ok: false, reason: 'g1nation.stocks.spreadsheetId 미설정 — Settings 에서 spreadsheet ID 를 넣어주세요.' };
|
|
}
|
|
const read = await readSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A1:C`);
|
|
if (!read.ok) return { ok: false, reason: `백업 탭 읽기 실패: ${read.error} — 아직 백업한 적이 없다면 /stocks backup 먼저.` };
|
|
|
|
const values = read.values;
|
|
const header = values[0] ?? [];
|
|
if (String(header[0] ?? '') !== BACKUP_MARKER) {
|
|
return { ok: false, reason: `'${BACKUP_TAB}' 탭이 백업 형식이 아닙니다 (헤더 마커 불일치).` };
|
|
}
|
|
const backedUpAt = String(header[1] ?? '');
|
|
|
|
const restored: Stock[] = [];
|
|
for (const row of values.slice(1)) {
|
|
const raw = String(row[2] ?? '').trim();
|
|
if (!raw) continue;
|
|
try {
|
|
const s = JSON.parse(raw);
|
|
if (s && typeof s === 'object' && typeof s.심볼 === 'string' && typeof s.이름 === 'string') {
|
|
restored.push(s as Stock);
|
|
}
|
|
} catch { /* 손상 행은 skip — 나머지는 살린다 */ }
|
|
}
|
|
if (restored.length === 0) {
|
|
return { ok: false, reason: '백업에 유효한 종목이 없습니다 — 로컬 목록을 건드리지 않았습니다.' };
|
|
}
|
|
|
|
const previousCount = readStocksStore().length;
|
|
if (!writeStocksStore(restored, { silent: true })) {
|
|
return { ok: false, reason: 'stocks.json 쓰기 실패 (워크스페이스 확인).' };
|
|
}
|
|
logInfo('Stocks restore: 완료.', { count: restored.length, backedUpAt, previousCount });
|
|
return { ok: true, count: restored.length, backedUpAt, previousCount };
|
|
}
|
|
|
|
/**
|
|
* 자동 백업 배선 — extension activate 에서 1회 호출.
|
|
* store 변경 → 30초 디바운스 → backupToSheet. spreadsheetId 미설정/설정 OFF 면 조용히 skip.
|
|
*/
|
|
export function registerStocksAutoBackup(context: vscode.ExtensionContext): vscode.Disposable {
|
|
let timer: NodeJS.Timeout | undefined;
|
|
const sub = onStocksStoreChanged(() => {
|
|
if (!autoBackupEnabled() || !getSpreadsheetId()) return;
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(async () => {
|
|
timer = undefined;
|
|
const r = await backupToSheet(context);
|
|
if (!r.ok) logError('Stocks 자동 백업 실패.', { reason: r.reason });
|
|
}, DEBOUNCE_MS);
|
|
});
|
|
return {
|
|
dispose: () => {
|
|
sub.dispose();
|
|
if (timer) { clearTimeout(timer); timer = undefined; }
|
|
},
|
|
};
|
|
}
|