diff --git a/README.md b/README.md index 7fba5ef..97dad83 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Many useful spreadsheets are trapped inside screenshots: KakaoTalk settlement me Screenshot-to-Spreadsheet focuses on the review workflow, not blind automation: extract locally, highlight uncertain cells, mask sensitive-looking values, let the user fix the grid, and export CSV/XLSX with a review log. +When sharing bugs or asking for help, use synthetic fixtures or the redacted sample copy action instead of posting raw reviewed values. + ![Desktop QA screenshot](docs/qa-desktop-1440x960.png) ## Current MVP @@ -20,6 +22,7 @@ Screenshot-to-Spreadsheet focuses on the review workflow, not blind automation: - Review extracted data in a unified review workspace with the grid, warnings, low-confidence navigation, and export actions in one place. - Highlight low-confidence and sensitive-looking cells. - Switch redaction presets between Basic, Finance, and Share sample display modes. +- Copy a redacted tab-separated sample to the clipboard for safer issue reports and discussions. - Apply browser-local crop/rotate/grayscale/contrast preprocessing before OCR. - Export CSV and XLSX, including a `review_log` sheet for XLSX. - Register a production service worker and web app manifest for an offline/PWA app shell. diff --git a/scripts/qa-browser.mjs b/scripts/qa-browser.mjs index 54e1afa..f1e5956 100644 --- a/scripts/qa-browser.mjs +++ b/scripts/qa-browser.mjs @@ -186,6 +186,10 @@ try { await send("Page.enable"); await send("Runtime.enable"); await send("Network.enable"); + await send("Browser.grantPermissions", { + permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"], + origin: appOrigin + }); await send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: downloadDir @@ -252,6 +256,25 @@ try { if (redactionAfter.result.value.firstNameCell === "민수") { throw new Error("Share sample redaction did not mask the first name cell"); } + await send("Runtime.evaluate", { + expression: `[...document.querySelectorAll('button')].find((button) => /Copy redacted sample|가린 샘플 복사/.test(button.textContent)).click()` + }); + await sleep(150); + const clipboardState = await send("Runtime.evaluate", { + awaitPromise: true, + returnByValue: true, + expression: `navigator.clipboard.readText().then((text) => ({ + text, + hasMaskedName: /민\\*|M\\*+/.test(text), + includesRawName: text.includes("민수") + }))` + }); + if (!clipboardState.result.value.text.includes("\t")) { + throw new Error("Redacted clipboard sample was not copied as tabular text"); + } + if (!clipboardState.result.value.hasMaskedName || clipboardState.result.value.includesRawName) { + throw new Error("Redacted clipboard sample did not mask the sample name"); + } await send("Runtime.evaluate", { expression: `document.querySelectorAll('.mode-button')[2].click()` @@ -343,6 +366,7 @@ try { before: redactionBefore.result.value, after: redactionAfter.result.value }, + clipboardState: clipboardState.result.value, csvPath, xlsxPath, networkProof: { diff --git a/src/App.tsx b/src/App.tsx index 7ef4e63..6b0ac50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,7 +20,7 @@ import { SourceWorkspace } from "./components/SourceWorkspace"; import { ReviewWorkspace } from "./components/ReviewWorkspace"; import { parseTextToGrid } from "./lib/parser"; import { validateGrid } from "./lib/validation"; -import { exportCsv, exportXlsx } from "./lib/exporters"; +import { copyRedactedSample, exportCsv, exportXlsx } from "./lib/exporters"; import { runOcr } from "./lib/ocr"; const makeReviewRows = (issues: ValidationIssue[]) => [ @@ -85,6 +85,7 @@ export default function App() { const [preprocess, setPreprocess] = useState(defaultPreprocess); const [reviewCursor, setReviewCursor] = useState(0); const [activeMobileView, setActiveMobileView] = useState<"setup" | "source" | "review">("setup"); + const [exportMessage, setExportMessage] = useState(""); const inputRef = useRef(null); const copy = uiCopy[uiLanguage]; @@ -123,6 +124,7 @@ export default function App() { } else if (text?.trim()) { setRawText(text); setGrid(parseTextToGrid(text, mode)); + setExportMessage(""); } }; @@ -148,6 +150,7 @@ export default function App() { const runExtractionFromText = (text = rawText) => { const parsed = parseTextToGrid(text, mode); setGrid(parsed); + setExportMessage(""); }; const runImageOcr = async () => { @@ -170,6 +173,7 @@ export default function App() { setGrid(parseTextToGrid(result.text, mode)); setOcrStatus(`${copy.ocrComplete} (${Math.round(result.confidence)} confidence)`); setOcrProgress(100); + setExportMessage(""); } catch (error) { setOcrStatus(error instanceof Error ? error.message : copy.ocrFailed); } finally { @@ -194,12 +198,14 @@ export default function App() { setRawText(sample); setGrid(parseTextToGrid(sample, mode)); setOcrStatus(copy.sampleLoaded); + setExportMessage(""); }; const changeMode = (nextMode: ExtractionMode) => { setMode(nextMode); setRawText(sampleTextByMode[nextMode]); setGrid(parseTextToGrid(sampleTextByMode[nextMode], nextMode)); + setExportMessage(""); }; const reset = () => { @@ -212,6 +218,16 @@ export default function App() { setOcrProgress(0); setRedactionPreset("basic"); setPreprocess(defaultPreprocess); + setExportMessage(""); + }; + + const handleCopyRedactedSample = async () => { + try { + await copyRedactedSample(grid, redactionPreset); + setExportMessage(copy.redactedSampleCopied); + } catch { + setExportMessage(copy.redactedSampleCopyFailed); + } }; return ( @@ -314,8 +330,10 @@ export default function App() { maskSensitive={maskSensitive} redactionPreset={redactionPreset} ocrResult={ocrResult} + exportMessage={exportMessage} onReviewCursorChange={setReviewCursor} onUpdateCell={updateCell} + onCopyRedactedSample={handleCopyRedactedSample} onExportCsv={() => exportCsv(grid)} onExportXlsx={() => exportXlsx(grid, makeReviewRows(issues))} /> diff --git a/src/components/ReviewWorkspace.tsx b/src/components/ReviewWorkspace.tsx index 84ca023..386a12d 100644 --- a/src/components/ReviewWorkspace.tsx +++ b/src/components/ReviewWorkspace.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, Download, Grid3X3, ScanLine } from "lucide-react"; +import { AlertTriangle, ClipboardCopy, Download, Grid3X3, ScanLine } from "lucide-react"; import type { DataGrid, GridCell, OcrResult, RedactionPreset, UiLanguage, ValidationIssue } from "../types"; import { uiCopy } from "../data/uiCopy"; import { maskSensitiveValue } from "../lib/validation"; @@ -19,8 +19,10 @@ type ReviewWorkspaceProps = { maskSensitive: boolean; redactionPreset: RedactionPreset; ocrResult: OcrResult | null; + exportMessage: string; onReviewCursorChange: (cursor: number) => void; onUpdateCell: (rowIndex: number, colIndex: number, value: string) => void; + onCopyRedactedSample: () => void; onExportCsv: () => void; onExportXlsx: () => void; }; @@ -39,8 +41,10 @@ export function ReviewWorkspace({ maskSensitive, redactionPreset, ocrResult, + exportMessage, onReviewCursorChange, onUpdateCell, + onCopyRedactedSample, onExportCsv, onExportXlsx }: ReviewWorkspaceProps) { @@ -58,6 +62,10 @@ export function ReviewWorkspace({

{copy.reviewHint}

+
+

{copy.copyRedactedHint}

+ {exportMessage ?

{exportMessage}

: null}
diff --git a/src/data/uiCopy.ts b/src/data/uiCopy.ts index d729d53..3b68afb 100644 --- a/src/data/uiCopy.ts +++ b/src/data/uiCopy.ts @@ -74,6 +74,10 @@ export const uiCopy = { noReviewQueue: "No low-confidence cells", validation: "Validation", noWarnings: "No review warnings.", + copyRedactedSample: "Copy redacted sample", + copyRedactedHint: "Share only synthetic or redacted samples in issues and discussions.", + redactedSampleCopied: "Redacted sample copied to clipboard.", + redactedSampleCopyFailed: "Could not copy the redacted sample.", exportCsv: "Export CSV", exportXlsx: "Export XLSX", ocrTokens: "OCR tokens", @@ -144,6 +148,10 @@ export const uiCopy = { noReviewQueue: "확인 필요한 셀이 없습니다", validation: "검수 알림", noWarnings: "검토 경고가 없습니다.", + copyRedactedSample: "가린 샘플 복사", + copyRedactedHint: "이슈나 토론에는 합성 데이터나 가린 샘플만 공유하세요.", + redactedSampleCopied: "가린 샘플을 클립보드에 복사했습니다.", + redactedSampleCopyFailed: "가린 샘플을 복사하지 못했습니다.", exportCsv: "CSV 내보내기", exportXlsx: "XLSX 내보내기", ocrTokens: "OCR 토큰", diff --git a/src/lib/exporters.ts b/src/lib/exporters.ts index 429d21a..5644cc7 100644 --- a/src/lib/exporters.ts +++ b/src/lib/exporters.ts @@ -1,5 +1,6 @@ -import type { DataGrid } from "../types"; +import type { DataGrid, RedactionPreset } from "../types"; import { gridToRows } from "./parser"; +import { maskSensitiveValue } from "./validation"; const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); @@ -18,6 +19,51 @@ const escapeCsv = (value: string): string => { return value; }; +const normalizeClipboardCell = (value: string) => value.replace(/\r?\n/g, " ").trim(); + +const applyRedactionPreset = (grid: DataGrid, preset: RedactionPreset) => { + const headers = grid[0]?.map((cell) => cell.value) ?? []; + + return gridToRows(grid).map((row, rowIndex) => + row.map((value, colIndex) => + rowIndex === 0 + ? normalizeClipboardCell(value) + : normalizeClipboardCell(maskSensitiveValue(value, preset, headers[colIndex] ?? "")) + ) + ); +}; + +const buildDelimitedText = (rows: string[][], delimiter: string) => + rows.map((row) => row.join(delimiter)).join("\n"); + +const copyWithExecCommand = (text: string) => { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + document.execCommand("copy"); + textarea.remove(); +}; + +export const copyRedactedSample = async ( + grid: DataGrid, + preset: RedactionPreset, + delimiter = "\t" +) => { + const text = buildDelimitedText(applyRedactionPreset(grid, preset), delimiter); + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return text; + } + + copyWithExecCommand(text); + return text; +}; + export const exportCsv = (grid: DataGrid, filename = "screenshot-to-spreadsheet.csv") => { const csv = gridToRows(grid) .map((row) => row.map(escapeCsv).join(","))