v2.2.297: stocks 목록 클라우드 백업 — Sheets _backup 탭 자동 백업 + /stocks backup·restore
로컬 .astra/stocks.json 뿐이던 워치리스트를 spreadsheet _backup 탭에 원본 JSON 그대로 자동 백업(변경 시 30초 디바운스). 새 컴퓨터에서는 /stocks restore 한 번으로 복원. sheetsApi 에 ensureSheetTab/clearSheetRange 추가, writeStocksStore 성공 시 변경 리스너 발화로 모든 변경 경로 포착. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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; }
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user