0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

bat&PowerShellで爆速csvビューアーをgeminiと作ってみた。

0
Last updated at Posted at 2026-02-16

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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }

    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
}

使い方

  1. バッチ化: 上記コードをメモ帳などにコピペし、csViewer.bat という名前で保存します。
  2. ドラッグ&ドロップ: 見たいCSVファイルを、バッチファイルの上にドラッグ&ドロップします。
  3. 自動オープン: 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が起動して重い…」というストレスから開放されるかもしれません。
ログ解析や、大量のマスターデータの確認作業などで地味に重宝するかもなぁと思い作りました。

前回・前々回のツールと合わせて、改造・再配布等自己責任でご自由にどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?