CSVファイルの中身をちょっと確認したいだけなのに、Excelを起動して…「0落ち」を気にしながらインポートして…って面倒すぎますよね。
かといってメモ帳で開くとカンマ区切りで目がチカチカする。
そこで今回は 「CSVファイルをドラッグ&ドロップするだけで、検索・ソート・SQLフィルタまでできる単一のHTMLファイルに変換してくれるバッチ」 を作りました。
名付けて 「csViewer」 です!!(そのまんま笑)
どんなツール?
-
変換型: CSVを投げると読み込んで、データ埋め込み済みの
.htmlを出力して自動で開きます。 - 爆速描画: 「仮想スクロール」を実装しているので、数万行のCSVでもサクサク動きます。
-
高機能:
- 全文検索: インクリメンタルサーチ対応。
-
SQLモード:
値段 > 1000 && 部署 == '開発'のような条件で絞り込めます。 - 出力: 絞り込み状態でTab区切り、csv出力対応。
- 列設定: ドラッグで列の入れ替えや、表示/非表示の切り替えが可能。
ソースコード
以下のコードをコピペして、拡張子 .bat (例:CsvViewer.bat)で保存してください。
保存時の文字コードは UTF-8 (BOMなし) を推奨します。
※例によって使用の際は自己責任でお願いします。
<# :
@echo off
cls
set "TARGET=%*"
set "MY_PATH=%~f0"
powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-Expression ([System.IO.File]::ReadAllText($env:MY_PATH, [System.Text.Encoding]::UTF8))"
exit /b
#>
# ============================================================
# csViewer v2.0
# ============================================================
$ErrorActionPreference = "Stop"
function Get-RobustEncoding {
param($Path)
$utf8Strict = New-Object System.Text.UTF8Encoding($true, $true)
try {
$reader = New-Object System.IO.StreamReader($Path, $utf8Strict)
$null = $reader.ReadToEnd(); $reader.Close(); return $utf8Strict
} catch { return [System.Text.Encoding]::GetEncoding(932) }
}
function Escape-Json {
param([string]$str)
if ([string]::IsNullOrEmpty($str)) { return "" }
return $str.Replace('\', '\\').Replace('"', '\"').Replace("`r", "").Replace("`n", "\n").Replace("`t", "\t")
}
# --- メイン処理 ---
$targetPath = $env:TARGET
if ([string]::IsNullOrWhiteSpace($targetPath)) {
Write-Host " CSVファイルをドラッグ&ドロップしてください。" -ForegroundColor Cyan; $null = Read-Host; exit
}
$targetPath = $targetPath.Trim('"').Trim("'")
if (-not (Test-Path $targetPath)) { Write-Host " ファイルが見つかりません" -ForegroundColor Red; Pause; exit }
$fileNameOnly = [System.IO.Path]::GetFileNameWithoutExtension($targetPath)
Write-Host " 読み込み中... $fileNameOnly" -ForegroundColor Green
try {
$encObj = Get-RobustEncoding -Path $targetPath
$csvEncParam = if ($encObj.CodePage -eq 65001) { "UTF8" } else { "Default" }
$data = Import-Csv -Path $targetPath -Encoding $csvEncParam
if ($data -isnot [array]) { $data = @($data) }
if ($data.Count -eq 0) { throw "データが空です" }
$headers = $data[0].psobject.Properties.Name
# --- JSON構築 ---
Write-Host " データを変換中... ($( $data.Count ) 行)" -ForegroundColor Gray
$sb = New-Object System.Text.StringBuilder
$null = $sb.Append('[')
$headerParts = @(); foreach ($h in $headers) { $headerParts += '"' + (Escape-Json $h) + '"' }
$null = $sb.Append("[" + ($headerParts -join ",") + "]")
$totalRows = $data.Count
for ($i = 0; $i -lt $totalRows; $i++) {
$null = $sb.Append(",")
$row = $data[$i]
$rowParts = @();
foreach ($h in $headers) {
$val = if ($null -ne $row.$h) { $row.$h.ToString() } else { "" }
$rowParts += '"' + (Escape-Json $val) + '"'
}
$null = $sb.Append("[" + ($rowParts -join ",") + "]")
}
$null = $sb.Append(']')
# --- HTMLテンプレート ---
$htmlContent = @'
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[[TITLE]]</title>
<style>
:root {
--primary: #0078d4;
--row-h: 24px;
--no-col-w: 60px;
/* Colors */
--header-bg: #0078d4;
--header-txt: #fff;
--corner-bg: #4b4b4b;
--corner-txt: #fff;
--rownum-bg: #28a745; /* Bright Green */
--rownum-txt: #fff;
--sel-bg: rgba(0, 120, 212, 0.2);
--mark-bg: #FFFF00; --mark-txt: #D32F2F;
--border-color: #ccc;
}
* { box-sizing: border-box; }
body { font-family: "Segoe UI", sans-serif; margin: 0; display: flex; flex-direction: column; height: 100vh; overflow: hidden; font-size: 13px; color:#333; background: #fff; }
/* Toolbar */
.toolbar { background: #f0f0f0; padding: 6px 15px; border-bottom: 1px solid #ccc; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; height: auto; min-height: 45px; user-select: none; z-index: 100; }
.title-area { font-weight: bold; margin-right: 10px; display:flex; align-items:baseline; gap:10px; }
.btn { padding: 4px 10px; background: #fff; border: 1px solid #999; border-radius: 3px; cursor: pointer; font-size: 12px; height: 28px; display:flex; align-items:center; white-space:nowrap; }
.btn:hover { background: #e0e0e0; }
.search-box { padding: 4px 6px; border: 1px solid #999; width: 180px; }
/* SQL Panel */
.sql-panel { display: none; background: #f0fdf0; padding: 8px 15px; border-bottom: 1px solid #107c10; font-size: 12px; }
.sql-input { width: 400px; padding: 4px; font-family: monospace; border: 1px solid #107c10; }
/* Grid Layout Container */
.grid-container { flex: 1; display: flex; flex-direction: column; overflow: hidden; position: relative; }
/* Header Area */
.header-area { display: flex; height: var(--row-h); background: var(--header-bg); border-bottom: 1px solid #999; z-index: 50; }
/* Corner Cell */
.corner-cell {
width: var(--no-col-w); min-width: var(--no-col-w); flex-shrink: 0;
background: var(--corner-bg); color: var(--corner-txt);
border-right: 1px solid #666;
display: flex; align-items: center; justify-content: space-between; font-weight: bold;
user-select: none; padding: 0 4px; z-index: 51;
}
.corner-cell:hover { background: #5a5a5a; }
/* Header Viewport */
.header-viewport { flex: 1; overflow: hidden; display: flex; }
.header-row { display: flex; height: 100%; }
.header-cell {
position: relative; padding: 0 4px; font-weight: 600;
color: var(--header-txt); border-right: 1px solid rgba(255,255,255,0.3);
white-space: nowrap; overflow: hidden; display: flex; align-items: center; justify-content: space-between;
user-select: none; height: 100%;
}
.header-cell:hover { background: rgba(255,255,255,0.1); }
.header-text { flex:1; overflow:hidden; text-overflow:ellipsis; cursor:pointer; padding: 0 4px; }
.sort-btn { width:16px; display:flex; justify-content:center; cursor:pointer; color:rgba(255,255,255,0.7); }
.sort-btn:hover { color:#fff; }
.resizer { position: absolute; right: 0; top: 0; bottom: 0; width: 4px; cursor: col-resize; z-index: 10; }
/* Body Area */
.body-viewport { flex: 1; overflow: auto; position: relative; outline: none; }
.virtual-content { position: relative; overflow: hidden; }
.row {
display: flex; position: absolute; left: 0; min-width: 100%; height: var(--row-h);
align-items: center; border-bottom: 1px solid #e0e0e0;
}
.row.odd { background-color: #fff; }
/* Row Number (Sticky & Clickable) */
.line-num {
width: var(--no-col-w); min-width: var(--no-col-w); flex-shrink: 0;
height: 100%;
position: sticky; left: 0; z-index: 40;
background: var(--rownum-bg); color: var(--rownum-txt);
border-right: 1px solid rgba(255,255,255,0.3);
border-bottom: 1px solid rgba(255,255,255,0.1);
display: flex; align-items: center; justify-content: center;
cursor: e-resize; user-select: none;
}
.line-num:hover { filter: brightness(0.9); }
.line-num.selected-row-head { background: #FFD700; color: #000; border-right: 2px solid #d4a000; }
.cell {
padding: 0 6px; border-right: 1px solid #e0e0e0; height: 100%;
white-space: nowrap; overflow: hidden; display: flex; align-items: center;
cursor: default; user-select: none; z-index: 1;
}
.cell.marked { background-color: var(--mark-bg); color: var(--mark-txt); font-weight: bold; }
.cell.selected { background-color: var(--sel-bg) !important; }
#editorInput {
position: absolute; display: none; z-index: 100; font-family: inherit; font-size: 13px;
padding: 0 6px; border: 2px solid var(--primary); outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.3); z-index: 999; justify-content: center; align-items: center; }
.modal { background: white; width: 350px; max-height: 80vh; border-radius: 4px; display: flex; flex-direction: column; box-shadow: 0 4px 15px rgba(0,0,0,0.2); }
.modal-header { padding: 10px 15px; border-bottom: 1px solid #eee; font-weight: bold; display: flex; justify-content: space-between; background: #f9f9f9; }
.modal-body { padding: 0; overflow-y: auto; flex: 1; }
.col-item { display: flex; align-items: center; padding: 8px 15px; border-bottom: 1px solid #f5f5f5; }
</style>
</head>
<body>
<div class="toolbar">
<div class="title-area"><span>📊 [[TITLE]]</span> <span class="meta-info" id="countInfo">...</span></div>
<input type="text" id="globalSearch" class="search-box" placeholder="検索..." oninput="app.handleSearch()">
<button class="btn" onclick="app.toggleSqlMode()">🔍 SQL</button>
<button class="btn" onclick="app.openColSettings()">📋 列</button>
<button class="btn" onclick="app.toggleMark()">🖍️ マーク (M)</button>
<div style="flex:1"></div>
<div style="display:flex; align-items:center; gap:5px; margin-right:15px; border-left:1px solid #ccc; padding-left:10px;">
<input type="checkbox" id="chkCsv"><label for="chkCsv" style="font-size:11px; user-select:none; cursor:pointer">CSV形式</label>
<button class="btn" onclick="app.export('all')">📤 全件</button>
<button class="btn" onclick="app.export('sel')">📤 選択</button>
<button class="btn" onclick="app.export('mark')">📤 マーク</button>
</div>
<button class="btn" style="border-color:#0078d4; color:#0078d4; font-weight:bold;" onclick="app.saveFile()">💾 保存</button>
</div>
<div id="sqlPanel" class="sql-panel">
<span style="color:#107c10; font-weight:bold;">WHERE</span>
<input type="text" id="sqlInput" class="sql-input" placeholder="ID > 100 && contains(部署, '開発')" oninput="app.handleSqlSearch()">
<span id="sqlError" style="color:red; margin-left:10px;"></span>
</div>
<div class="grid-container">
<div class="header-area">
<div class="corner-cell">
<span class="header-text" onclick="app.selectAll()" title="全セル選択" style="text-align:center">No.</span>
<span class="sort-btn" onclick="app.toggleSortNo()">▼</span>
</div>
<div class="header-viewport" id="hVP">
<div class="header-row" id="hRow"></div>
</div>
</div>
<div class="body-viewport" id="bVP" tabindex="0">
<div class="virtual-content" id="vContent"></div>
</div>
<input type="text" id="editorInput">
</div>
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-header"><span>列設定</span><span style="cursor:pointer" onclick="document.getElementById('modalOverlay').style.display='none'">✕</span></div>
<div class="modal-body" id="colList"></div>
<div class="modal-footer" style="padding:10px; text-align:right;">
<button class="btn" onclick="app.applyColSettings()">適用</button>
</div>
</div>
</div>
<script id="mainScript">
const INIT_DATA = [[JSON_DATA]];
const INIT_CONFIG = null;
const INIT_MARKS = [];
class App {
constructor(rawData, config, marks) {
this.allHeaders = rawData[0];
this.allRows = rawData.slice(1);
if (config) {
this.colConfig = config;
} else {
this.colConfig = this.allHeaders.map((h, i) => ({ id: i, name: h, visible: true, width: 120, order: i }));
}
this.marks = new Set(marks || []);
this.viewIndices = this.allRows.map((_, i) => i);
this.sortState = { colId: -1, order: 0 };
this.sortNoOrder = 0;
this.isSqlMode = false;
this.sel = {
active: false, anchorR: 0, anchorC: 0, headR: 0, headC: 0,
minR:0, maxR:0, minC:0, maxC:0, multiCells: new Set()
};
this.isSelecting = false;
this.rowH = 24;
this.buffer = 15;
this.els = {
hRow: document.getElementById('hRow'),
hVP: document.getElementById('hVP'),
bVP: document.getElementById('bVP'),
vContent: document.getElementById('vContent'),
countInfo: document.getElementById('countInfo'),
sqlPanel: document.getElementById('sqlPanel'),
sqlInput: document.getElementById('sqlInput'),
sqlError: document.getElementById('sqlError'),
editor: document.getElementById('editorInput'),
modal: document.getElementById('modalOverlay'),
colList: document.getElementById('colList')
};
this.updateCount();
this.renderHeader();
this.updateVirtualScroll();
this.bindEvents();
}
getSortedCols() {
return this.colConfig.filter(c => c.visible).sort((a, b) => a.order - b.order);
}
bindEvents() {
const b = this.els.bVP;
b.addEventListener('scroll', () => {
this.els.hVP.scrollLeft = b.scrollLeft;
this.updateVirtualScroll();
});
b.addEventListener('mousedown', e => this.onMouseDown(e));
window.addEventListener('mousemove', e => this.onMouseMove(e));
window.addEventListener('mouseup', () => this.isSelecting = false);
window.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'c') { e.preventDefault(); this.export('sel'); }
if (e.key === 'F2') this.startEdit();
if (e.key === 'm' && !this.els.editor.style.display) this.toggleMark();
});
this.els.editor.addEventListener('keydown', e => {
if(e.key === 'Enter') this.commitEdit();
if(e.key === 'Escape') this.cancelEdit();
});
this.els.editor.addEventListener('blur', () => this.commitEdit());
window.addEventListener('resize', () => this.updateVirtualScroll(true));
}
renderHeader() {
const cols = this.getSortedCols();
// Update No. Icon in Corner
let noIcon = '▼'; if(this.sortNoOrder === -1) noIcon = '▲';
const cornerBtn = document.querySelector('.corner-cell .sort-btn');
if(cornerBtn) cornerBtn.innerText = noIcon;
// Render Column Headers
let html = '';
let totalW = 0;
cols.forEach(c => {
let sortIcon = '▼';
if (c.id === this.sortState.colId) {
if(this.sortState.order === 1) sortIcon = '▲';
else if(this.sortState.order === -1) sortIcon = '▼';
}
let bgStyle = (c.id === this.sortState.colId) ? 'background:rgba(255,255,255,0.2);' : '';
html += `<div class="header-cell" style="width:${c.width}px; ${bgStyle}">
<span class="header-text" onclick="app.onHeaderClick(${c.id}, event)" title="クリック:列選択">${this.esc(c.name)}</span>
<span class="sort-btn" onclick="app.toggleSort(${c.id})" title="ソート">${sortIcon}</span>
<div class="resizer" onclick="event.stopPropagation()" onmousedown="app.startResize(${c.id}, event)" ondblclick="app.autoFit(${c.id}, event)"></div>
</div>`;
totalW += c.width;
});
this.els.hRow.innerHTML = html;
this.els.hRow.style.width = totalW + 'px';
this.els.vContent.style.width = totalW + 'px'; // Matches body width
this.updateVirtualScroll(true);
}
updateVirtualScroll(force = false) {
const vp = this.els.bVP;
const scrollTop = vp.scrollTop;
const viewportH = vp.clientHeight;
const count = this.viewIndices.length;
this.els.vContent.style.height = (count * this.rowH) + 'px';
const start = Math.floor(scrollTop / this.rowH);
const end = Math.min(count, Math.ceil((scrollTop + viewportH) / this.rowH) + this.buffer);
const renderStart = Math.max(0, start - this.buffer);
if (!force && this.lastStart === renderStart && this.lastEnd === end) return;
this.lastStart = renderStart; this.lastEnd = end;
const cols = this.getSortedCols();
let html = '';
const colMap = cols.map((c, i) => ({ id: c.id, w: c.width, vIdx: i }));
for (let i = renderStart; i < end; i++) {
const realRowIdx = this.viewIndices[i];
const rowData = this.allRows[realRowIdx];
const top = i * this.rowH;
html += `<div class="row" style="transform:translateY(${top}px)" data-ri="${i}">`;
// Row Number (Sticky & Clickable)
let rowCls = 'line-num';
if(this.sel.active && i >= this.sel.minR && i <= this.sel.maxR && this.sel.minC === 0 && this.sel.maxC === cols.length-1) {
rowCls += ' selected-row-head';
}
// ADDED: onmousedown for Row Selection
html += `<div class="${rowCls}" onmousedown="app.selectRow(${i}, event)">${realRowIdx + 1}</div>`;
// Cells
colMap.forEach((cm) => {
let cls = 'cell';
let isSel = false;
if(this.sel.active) {
if(i >= this.sel.minR && i <= this.sel.maxR && cm.vIdx >= this.sel.minC && cm.vIdx <= this.sel.maxC) isSel = true;
if(this.sel.multiCells.has(i + '_' + cm.vIdx)) isSel = true;
}
if (isSel) cls += ' selected';
if (this.marks.has(realRowIdx + '_' + cm.id)) cls += ' marked';
html += `<span class="${cls}" style="width:${cm.w}px"
onmousedown="event.preventDefault()"
ondblclick="app.startEdit(${i}, ${cm.vIdx})">${this.esc(rowData[cm.id])}</span>`;
});
html += `</div>`;
}
this.els.vContent.innerHTML = html;
}
getCellFromPoint(x, y) {
const rect = this.els.bVP.getBoundingClientRect();
const relY = y - rect.top + this.els.bVP.scrollTop;
const relX = x - rect.left + this.els.bVP.scrollLeft;
if(relY < 0 || relX < 60) return null;
const r = Math.floor(relY / this.rowH);
if(r >= this.viewIndices.length) return null;
const effectiveX = relX - 60;
let cw = 0; let c = -1;
const cols = this.getSortedCols();
for(let i=0; i<cols.length; i++) {
cw += cols[i].width;
if(effectiveX < cw) { c = i; break; }
}
return (c >= 0) ? { r, c } : null;
}
updateSelRange() {
this.sel.minR = Math.min(this.sel.anchorR, this.sel.headR);
this.sel.maxR = Math.max(this.sel.anchorR, this.sel.headR);
this.sel.minC = Math.min(this.sel.anchorC, this.sel.headC);
this.sel.maxC = Math.max(this.sel.anchorC, this.sel.headC);
}
onMouseDown(e) {
if(e.target.classList.contains('line-num')) return;
const cell = this.getCellFromPoint(e.clientX, e.clientY);
if(!cell) return;
this.sel.active = true; this.isSelecting = true;
if(e.ctrlKey) {
const k = cell.r + '_' + cell.c;
if(this.sel.multiCells.has(k)) this.sel.multiCells.delete(k); else this.sel.multiCells.add(k);
} else if (e.shiftKey) {
this.sel.headR = cell.r; this.sel.headC = cell.c; this.updateSelRange();
} else {
this.sel.anchorR = this.sel.headR = cell.r; this.sel.anchorC = this.sel.headC = cell.c;
this.sel.multiCells.clear(); this.updateSelRange();
}
this.updateVirtualScroll(true);
if(this.els.editor.style.display === 'block') this.commitEdit();
}
selectRow(rowIdx, e) {
e.stopPropagation();
const cols = this.getSortedCols();
this.sel.active = true;
if (e.ctrlKey) {
// Multi-Row Selection (Toggle all cells in row)
// Simple toggle: If first cell in row is set, remove row, else add row.
const k0 = rowIdx + '_0';
const removing = this.sel.multiCells.has(k0);
for(let j=0; j<cols.length; j++) {
const k = rowIdx + '_' + j;
if(removing) this.sel.multiCells.delete(k); else this.sel.multiCells.add(k);
}
} else if (e.shiftKey) {
this.sel.headR = rowIdx;
this.sel.headC = cols.length - 1;
} else {
this.sel.anchorR = rowIdx;
this.sel.headR = rowIdx;
this.sel.anchorC = 0;
this.sel.headC = cols.length - 1;
this.sel.multiCells.clear();
}
this.updateSelRange();
this.updateVirtualScroll(true);
}
onMouseMove(e) {
if(!this.isSelecting) return;
const cell = this.getCellFromPoint(e.clientX, e.clientY);
if(cell) {
if(!e.ctrlKey) {
this.sel.headR = cell.r; this.sel.headC = cell.c; this.updateSelRange(); this.updateVirtualScroll(true);
}
}
}
onHeaderClick(colId, e) {
const cols = this.getSortedCols();
const vIdx = cols.findIndex(c => c.id === colId);
this.sel.active = true;
if (e.shiftKey) {
this.sel.headR = this.viewIndices.length - 1; this.sel.headC = vIdx;
} else {
this.sel.anchorR = 0; this.sel.headR = this.viewIndices.length - 1;
this.sel.anchorC = this.sel.headC = vIdx; this.sel.multiCells.clear();
}
this.updateSelRange(); this.updateVirtualScroll(true);
}
selectAll() {
if(this.viewIndices.length === 0) return;
const cols = this.getSortedCols();
this.sel.active = true;
this.sel.anchorR = 0; this.sel.headR = this.viewIndices.length - 1;
this.sel.anchorC = 0; this.sel.headC = cols.length - 1;
this.sel.multiCells.clear();
this.updateSelRange(); this.updateVirtualScroll(true);
}
toggleSort(colId) {
if(this.sortState.colId !== colId) { this.sortState = { colId: colId, order: 1 }; }
else {
if(this.sortState.order === 1) this.sortState.order = -1;
else if(this.sortState.order === -1) this.sortState.order = 0;
else this.sortState.order = 1;
}
this.applySort();
}
toggleSortNo() {
if(this.sortNoOrder === 0) this.sortNoOrder = -1; else this.sortNoOrder = 0;
this.sortState = { colId: -1, order: 0 }; this.applySort();
}
applySort() {
if(this.sortState.order === 0 && this.sortNoOrder !== 0) {
if(this.sortNoOrder === -1) this.viewIndices.sort((a,b) => b - a); else this.viewIndices.sort((a,b) => a - b);
} else if(this.sortState.order !== 0) {
const colId = this.sortState.colId;
let isNum = true;
for (let i = 0; i < Math.min(50, this.viewIndices.length); i++) {
let v = this.allRows[this.viewIndices[i]][colId];
if (v !== "" && isNaN(Number(v))) { isNum = false; break; }
}
const dir = this.sortState.order;
this.viewIndices.sort((a, b) => {
let valA = this.allRows[a][colId]; let valB = this.allRows[b][colId];
if (valA == valB) return 0; if (valA === "") return 1; if (valB === "") return -1;
return isNum ? dir * (Number(valA) - Number(valB)) : dir * String(valA).localeCompare(String(valB));
});
} else { this.viewIndices.sort((a,b) => a - b); }
this.renderHeader(); this.updateVirtualScroll(true);
}
toggleMark() {
if(!this.sel.active) return;
const cols = this.getSortedCols();
let targets = [];
for(let i=this.sel.minR; i<=this.sel.maxR; i++) {
for(let j=this.sel.minC; j<=this.sel.maxC; j++) { targets.push({r: i, c: cols[j].id}); }
}
this.sel.multiCells.forEach(k => { const [r, c] = k.split('_').map(Number); targets.push({r, c: cols[c].id}); });
targets.forEach(t => {
const rIdx = this.viewIndices[t.r]; const key = rIdx + '_' + t.c;
if(this.marks.has(key)) this.marks.delete(key); else this.marks.add(key);
});
this.updateVirtualScroll(true);
}
export(mode) {
const isCsv = document.getElementById('chkCsv').checked;
const sep = isCsv ? ',' : '\t';
const cols = this.getSortedCols();
let txt = "";
let rowIndices = [];
if(mode === 'all') { rowIndices = this.viewIndices; }
else if(mode === 'sel') {
for(let i=this.sel.minR; i<=this.sel.maxR; i++) rowIndices.push(i);
this.sel.multiCells.forEach(k => {
const r = parseInt(k.split('_')[0]); if(!rowIndices.includes(r)) rowIndices.push(r);
});
rowIndices.sort((a,b)=>a-b);
} else if(mode === 'mark') {
for(let i=0; i<this.viewIndices.length; i++) {
const rIdx = this.viewIndices[i]; let hasMark = false;
for(let c of cols) { if(this.marks.has(rIdx + '_' + c.id)) { hasMark = true; break; } }
if(hasMark) rowIndices.push(i);
}
if(rowIndices.length === 0) { alert("マーク行なし"); return; }
}
let cMin = 0, cMax = cols.length - 1;
if(mode === 'sel') { cMin = this.sel.minC; cMax = this.sel.maxC; }
const targetCols = (mode === 'sel') ? cols.slice(cMin, cMax+1) : cols;
if(isCsv) txt += targetCols.map(c => this.quote(c.name)).join(sep) + "\n";
rowIndices.forEach(vIdx => {
const rIdx = this.viewIndices[vIdx]; const rowData = this.allRows[rIdx]; const lineParts = [];
targetCols.forEach(c => {
let val = rowData[c.id];
if(mode === 'mark' && !this.marks.has(rIdx + '_' + c.id)) val = "";
if(isCsv) val = this.quote(val);
lineParts.push(val);
});
txt += lineParts.join(sep) + "\n";
});
const ta = document.createElement('textarea');
ta.value = txt; document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
alert("コピー完了");
}
handleSearch() {
if (this.isSqlMode) return;
const term = document.getElementById('globalSearch').value.toLowerCase();
if (!term) { this.viewIndices = this.allRows.map((_, i) => i); this.updateCount(); this.applySort(); return; }
else {
const visibleCols = this.colConfig.filter(c => c.visible).map(c => c.id);
this.viewIndices = [];
for (let i = 0; i < this.allRows.length; i++) {
const row = this.allRows[i];
for (let cid of visibleCols) {
if (String(row[cid]).toLowerCase().includes(term)) { this.viewIndices.push(i); break; }
}
}
}
this.els.bVP.scrollTop = 0; this.updateCount(); this.applySort();
}
toggleSqlMode() {
this.isSqlMode = !this.isSqlMode;
this.els.sqlPanel.style.display = this.isSqlMode ? 'block' : 'none';
document.getElementById('globalSearch').disabled = this.isSqlMode;
if(this.isSqlMode) this.els.sqlInput.focus(); else this.handleSearch();
}
handleSqlSearch() {
const query = this.els.sqlInput.value.trim();
this.els.sqlError.innerText = "";
if (!query) { this.handleSearch(); return; }
try {
let jsExpr = query.replace(/==/g, '===').replace(/!=/g, '!==').replace(/contains\s*\(([^,]+),\s*([^\)]+)\)/g, '(String($1).indexOf($2)>-1)');
const sortedHeader = [...this.allHeaders].map((h,i)=>({name:h, idx:i})).sort((a,b)=>b.name.length - a.name.length);
sortedHeader.forEach(h => {
const escaped = h.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(?<=^|[^a-zA-Z0-9_'"\\]])${escaped}(?=[^a-zA-Z0-9_'"\\[]|$)`, 'g');
jsExpr = jsExpr.replace(re, `r[${h.idx}]`);
});
const fn = new Function('r', 'return ' + jsExpr);
this.viewIndices = [];
for(let i=0; i<this.allRows.length; i++) { if(fn(this.allRows[i])) this.viewIndices.push(i); }
this.els.bVP.scrollTop = 0; this.updateCount(); this.applySort();
} catch (e) { this.els.sqlError.innerText = "構文エラー"; }
}
quote(s) { s = String(s); if (s.includes('"') || s.includes(',') || s.includes('\n')) return '"' + s.replace(/"/g, '""') + '"'; return s; }
startEdit(r, cIdxInView) {
if(r < 0) return;
const realRowIdx = this.viewIndices[r]; const cols = this.getSortedCols(); const colDef = cols[cIdxInView];
let left = 60; // Offset by No Col Width
for(let i=0; i<cIdxInView; i++) left += cols[i].width;
const ed = this.els.editor;
ed.style.display = 'block'; ed.style.top = (r * this.rowH) + 'px'; ed.style.left = left + 'px';
ed.style.width = colDef.width + 'px'; ed.style.height = this.rowH + 'px';
ed.value = this.allRows[realRowIdx][colDef.id]; ed.focus();
this.editing = { r: realRowIdx, c: colDef.id };
}
commitEdit() {
if(!this.editing || this.els.editor.style.display === 'none') return;
this.allRows[this.editing.r][this.editing.c] = this.els.editor.value;
this.els.editor.style.display = 'none'; this.editing = null;
this.els.bVP.focus(); this.updateVirtualScroll(true);
}
cancelEdit() { this.els.editor.style.display = 'none'; this.editing = null; this.els.bVP.focus(); }
startResize(colId, e) {
e.stopPropagation();
const col = this.colConfig.find(c => c.id === colId);
const startX = e.pageX; const startW = col.width;
const onMove = (ev) => { col.width = Math.max(30, startW + (ev.pageX - startX)); this.renderHeader(); };
const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp);
}
autoFit(colId, e) {
e.stopPropagation();
const col = this.colConfig.find(c => c.id === colId);
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = "13px 'Segoe UI', sans-serif";
let maxW = ctx.measureText(col.name).width + 30;
this.viewIndices.slice(0, 100).forEach(i => {
const w = ctx.measureText(this.allRows[i][col.id]||"").width + 20; if(w > maxW) maxW = w;
});
col.width = Math.min(Math.ceil(maxW), 500); this.renderHeader();
}
openColSettings() {
this.els.modal.style.display = 'flex'; const list = this.els.colList; list.innerHTML = '';
this.colConfig.sort((a,b)=>a.order-b.order).forEach(c => {
const div = document.createElement('div'); div.className = 'col-item';
div.innerHTML = `<span style="cursor:ns-resize;margin-right:10px" onclick="app.moveCol(${c.id},-1)">▲</span><span style="cursor:ns-resize;margin-right:10px" onclick="app.moveCol(${c.id},1)">▼</span><input type="checkbox" id="chk_${c.id}" ${c.visible?'checked':''}><label for="chk_${c.id}" style="margin-left:8px">${this.esc(c.name)}</label>`;
list.appendChild(div);
});
}
moveCol(id, dir) {
const sorted = [...this.colConfig].sort((a,b)=>a.order-b.order);
const idx = sorted.findIndex(c => c.id === id);
if(idx+dir >= 0 && idx+dir < sorted.length) {
[sorted[idx].order, sorted[idx+dir].order] = [sorted[idx+dir].order, sorted[idx].order]; this.openColSettings();
}
}
applyColSettings() {
this.colConfig.forEach(c => { const el = document.getElementById('chk_'+c.id); if(el) c.visible = el.checked; });
this.els.modal.style.display = 'none'; this.renderHeader();
}
updateCount() { this.els.countInfo.innerText = `${this.viewIndices.length.toLocaleString()} 行 / 全 ${this.allRows.length.toLocaleString()} 行`; }
esc(s) { if (s == null) return ""; return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
saveFile() {
const fullData = [this.allHeaders, ...this.allRows];
const jsonStr = JSON.stringify(fullData);
const marksStr = JSON.stringify(Array.from(this.marks));
const confStr = JSON.stringify(this.colConfig);
const scriptContent = document.getElementById('mainScript').innerHTML;
let newScript = scriptContent
.replace(/const INIT_DATA = .*?;/, `const INIT_DATA = ${jsonStr};`)
.replace(/const INIT_CONFIG = .*?;/, `const INIT_CONFIG = ${confStr};`)
.replace(/const INIT_MARKS = .*?;/, `const INIT_MARKS = ${marksStr};`);
const html = `<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${document.title}</title>
${document.querySelector('style').outerHTML}
</head>
<body>
${document.querySelector('.toolbar').outerHTML}
<div id="sqlPanel" class="sql-panel">${document.getElementById('sqlPanel').innerHTML}</div>
${document.querySelector('.grid-container').outerHTML}
${document.querySelector('.modal-overlay').outerHTML}
<script id="mainScript">${newScript}<\/script>
</body>
</html>`;
const blob = new Blob([html], {type: "text/html"});
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
let fName = location.pathname.split('/').pop() || "data_view.html";
try { fName = decodeURIComponent(fName); } catch(e){}
link.download = fName;
link.click();
}
}
const app = new App(INIT_DATA, INIT_CONFIG, INIT_MARKS);
</script>
</body>
</html>
'@
$finalHtml = $htmlContent.Replace('[[JSON_DATA]]', $sb.ToString())
$finalHtml = $finalHtml.Replace('[[TITLE]]', $fileNameOnly)
$outFile = [System.IO.Path]::Combine([System.IO.Path]::GetDirectoryName($env:MY_PATH), ($fileNameOnly + "_view.html"))
[System.IO.File]::WriteAllText($outFile, $finalHtml, [System.Text.Encoding]::UTF8)
Write-Host " 完了! $outFile を開きます" -ForegroundColor Green
Start-Process $outFile
} catch {
Write-Host " [FATAL ERROR] $($_.Exception.Message)" -ForegroundColor Red
Pause
}
使い方
-
バッチ化: 上記コードをメモ帳などにコピペし、
csViewer.batという名前で保存します。 - ドラッグ&ドロップ: 見たいCSVファイルを、バッチファイルの上にドラッグ&ドロップします。
-
自動オープン: CSVと同じフォルダに
(ファイル名)_view.htmlというファイルが生成され、自動的にブラウザで開きます。
※生成されたHTMLファイルは単体で動作するので、メールで添付して他の人に渡すこともできます(ただしデータが含まれているので取り扱い注意)。
技術的な解説
geminiくんに「モダンなデータグリッドをバッチファイルの中に詰め込んで」とオーダーした結果、かなりアクロバティックな実装が返ってきました。
1. PowerShellでJSON生成
バッチ内部でPowerShellを呼び出し、CSVをパースして、巨大なJSON文字列に変換しています。
Escape-Json 関数でダブルクォートなどを適切にエスケープ処理し、HTMLテンプレート内の [[JSON_DATA]] というプレースホルダーに流し込む方式です。
2. Vanilla JSによる仮想スクロール
「外部ライブラリ(ReactやVueなど)は使えない」という制約をあたえたので、geminiくんは 素のJavaScript(Vanilla JS) だけで仮想スクロールを実装してきました。
// スクロール位置から表示すべき行を計算して描画
const startIdx = Math.floor(scrollTop / this.rowHeight);
const endIdx = ...;
// 画面に見える部分だけDOMを生成
for (let i = renderStart; i < endIdx; i++) { ... }
これにより、DOM要素の数を常に一定(画面に収まる数+バッファ分)に保つことができ、10万行レベルのCSVでもブラウザが固まりません。
3. SQLライクな検索機能
単なる文字検索だけでなく、「価格が1000円以上」のような条件指定ができるように、JavaScriptの new Function を使った動的なフィルタリング機能が搭載されています。
// 入力された条件式(例: ID > 100)をJSの関数としてコンパイル
const fn = new Function('r', 'return ' + jsExpr);
カラム名を自動的に配列のインデックス参照(r[0], r[1]...)に置換する正規表現処理も入っており、ユーザーは 価格 > 1000 と直感的に書けるようになっています。
まとめ
こいつを使えば「CSVファイルをダブルクリックしたらExcelが起動して重い…」というストレスから開放されるかもしれません。
ログ解析や、大量のマスターデータの確認作業などで地味に重宝するかもなぁと思い作りました。
前回・前々回のツールと合わせて、改造・再配布等自己責任でご自由にどうぞ。