| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Spreadsheet Labeler</title> |
| <style> |
| *{box-sizing:border-box;margin:0;padding:0} |
| body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:#f5f5f5;color:#222} |
| |
| |
| .topbar{position:sticky;top:0;z-index:100;background:#fff;border-bottom:1px solid #ddd; |
| padding:10px 24px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;box-shadow:0 1px 4px rgba(0,0,0,.08)} |
| .topbar h1{font-size:18px;font-weight:600;white-space:nowrap} |
| .progress{font-size:14px;color:#555;min-width:100px} |
| .sheet-info{font-size:13px;color:#777;flex:1} |
| #username-input{padding:6px 10px;border:1px solid #ccc;border-radius:6px;font-size:13px;width:140px} |
| #save-btn{padding:10px 28px;font-size:15px;font-weight:600;background:#2563eb;color:#fff; |
| border:none;border-radius:8px;cursor:pointer;transition:background .15s} |
| #save-btn:hover{background:#1d4ed8} |
| #save-btn:disabled{background:#94a3b8;cursor:not-allowed} |
| #skip-btn{padding:8px 16px;font-size:13px;font-weight:600;background:#fff;color:#dc2626; |
| border:2px solid #dc2626;border-radius:8px;cursor:pointer;transition:all .15s} |
| #skip-btn:hover{background:#fef2f2} |
| #skip-btn:disabled{opacity:.4;cursor:not-allowed} |
| #clear-all-btn{padding:8px 16px;font-size:13px;font-weight:600;background:#fff;color:#dc2626; |
| border:2px solid #dc2626;border-radius:8px;cursor:pointer;transition:all .15s;white-space:nowrap} |
| #clear-all-btn:hover{background:#dc2626;color:#fff} |
| |
| |
| #rag-label-btn{padding:3px 10px;font-size:11px;color:#666;background:#e8e8e8;border:1px solid #bbb; |
| border-radius:4px;cursor:pointer;margin-left:6px} |
| #rag-label-btn:hover{color:#0f766e;border-color:#5eead4;background:#ecfdf5} |
| #rag-label-btn.active{color:#0f766e;border-color:#14b8a6;background:#ccfbf1;font-weight:600} |
| #review-btn{padding:3px 10px;font-size:11px;color:#888;background:#fafafa;border:1px solid #ccc; |
| border-radius:4px;cursor:pointer;transition:all .15s;letter-spacing:.3px} |
| #review-btn:hover{color:#2563eb;border-color:#93c5fd} |
| #review-btn.active{color:#2563eb;border-color:#2563eb;background:#eff6ff} |
| .review-nav{display:none;align-items:center;gap:6px;font-size:11px;color:#555} |
| .review-nav.visible{display:flex} |
| .review-nav button{padding:2px 8px;font-size:11px;border:1px solid #ccc;border-radius:4px; |
| background:#fff;cursor:pointer;transition:all .1s} |
| .review-nav button:hover{border-color:#2563eb;color:#2563eb} |
| #review-counter{font-variant-numeric:tabular-nums} |
| #exit-review{color:#dc2626;border-color:#fca5a5} |
| #exit-review:hover{color:#fff;background:#dc2626;border-color:#dc2626} |
| #exit-rag-label{color:#dc2626;border-color:#fca5a5} |
| #exit-rag-label:hover{color:#fff;background:#dc2626;border-color:#dc2626} |
| |
| |
| .prev-wrapper{position:relative} |
| #prev-btn{padding:8px 16px;font-size:13px;font-weight:600;background:#fff;color:#555; |
| border:2px solid #ccc;border-radius:8px;cursor:pointer;transition:all .15s;white-space:nowrap} |
| #prev-btn:hover{border-color:#2563eb;color:#2563eb} |
| .history-popup{position:absolute;top:100%;left:0;margin-top:6px;background:#fff; |
| border:1px solid #ddd;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12); |
| z-index:200;min-width:340px;max-height:360px;overflow-y:auto;display:none} |
| .history-popup.open{display:block} |
| .history-popup .history-header{padding:10px 14px;font-size:13px;font-weight:600;color:#333; |
| border-bottom:1px solid #eee} |
| .history-popup .history-empty{padding:16px 14px;font-size:13px;color:#888;text-align:center} |
| .history-popup .history-item{padding:8px 14px;cursor:pointer;border-bottom:1px solid #f0f0f0; |
| transition:background .1s;display:flex;flex-direction:column;gap:2px} |
| .history-popup .history-item:hover{background:#f0f7ff} |
| .history-popup .history-item:last-child{border-bottom:none} |
| .history-popup .history-item .hi-name{font-size:13px;font-weight:500;color:#222; |
| overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| .history-popup .history-item .hi-meta{font-size:11px;color:#888} |
| |
| |
| .selector-bar{display:flex;align-items:center;gap:6px;padding:0 24px;background:#fff; |
| border-bottom:1px solid #eee;height:34px} |
| .selector-bar .bar-label{font-size:12px;color:#555;font-weight:600;min-width:42px} |
| .sel-btn{padding:2px 10px;font-size:11px;font-weight:600;border:2px solid #bbb;border-radius:5px; |
| cursor:pointer;background:#fff;transition:all .12s} |
| .sel-btn:hover{border-color:#666} |
| .sel-btn.active{color:#fff} |
| .add-btn{padding:2px 8px;font-size:13px;font-weight:700;border:2px dashed #bbb;border-radius:5px; |
| cursor:pointer;background:#fff;color:#888;transition:all .12s} |
| .add-btn:hover{border-color:#666;color:#444} |
| |
| |
| .legend{display:flex;gap:6px;padding:6px 24px;background:#fff;border-bottom:1px solid #eee;flex-wrap:wrap} |
| .legend-item{display:flex;align-items:center;gap:3px;font-size:10px;padding:2px 6px;border-radius:4px; |
| border:1px solid #ddd} |
| .legend-item .swatch{width:12px;height:12px;border-radius:2px;border:1px solid #bbb} |
| .legend-sep{width:1px;background:#ddd;align-self:stretch;margin:0 2px} |
| |
| |
| .table-wrap{overflow:auto;padding:16px 24px;max-height:calc(100vh - 210px)} |
| table{border-collapse:collapse;background:#fff;font-size:13px} |
| td{border:1px solid #d0d0d0;padding:4px 6px;min-width:50px;max-width:300px; |
| overflow:hidden;text-overflow:ellipsis;cursor:cell;user-select:none; |
| transition:background .1s;white-space:nowrap;position:relative} |
| td.wrap-cell{white-space:normal;word-wrap:break-word} |
| td.bold-cell{font-weight:700} |
| td.italic-cell{font-style:italic} |
| td.underline-cell{text-decoration:underline} |
| td.selected{outline:2px solid #2563eb;outline-offset:-2px;z-index:1} |
| |
| |
| td.src-bt{border-top:2.5px solid #333} |
| td.src-bb{border-bottom:2.5px solid #333} |
| td.src-bl{border-left:2.5px solid #333} |
| td.src-br{border-right:2.5px solid #333} |
| |
| |
| td.label-value{background:#c6efce !important} |
| td.label-aggregation{background:#d5a6e6 !important} |
| td.label-header{background:#a8d8ea !important} |
| td.label-metadata{background:#bdd7ee !important} |
| td.label-comment{background:#fde68a !important;border:2px dashed #d97706 !important} |
| td.label-empty{background:#f0f0f0 !important;color:#aaa} |
| td.label-junk{background:#f8d7da !important;color:#999;text-decoration:line-through} |
| |
| |
| td.link-pending{animation:linkPulse 1s ease-in-out infinite} |
| @keyframes linkPulse{0%,100%{box-shadow:0 0 0 0 rgba(217,119,6,.5)}50%{box-shadow:0 0 0 6px rgba(217,119,6,0)}} |
| |
| |
| .link-arrows{position:absolute;top:0;left:0;pointer-events:none;z-index:50} |
| |
| |
| .link-banner{position:fixed;bottom:0;left:0;right:0;z-index:200;background:#fffbeb; |
| border-top:2px solid #d97706;padding:10px 24px;display:flex;align-items:center;gap:12px; |
| box-shadow:0 -2px 8px rgba(0,0,0,.1);display:none} |
| .link-banner.active{display:flex} |
| .link-banner .link-text{flex:1;font-size:14px;font-weight:500;color:#92400e} |
| .link-banner .link-cancel{padding:6px 16px;font-size:13px;font-weight:600; |
| background:#fff;border:2px solid #d97706;border-radius:6px;color:#d97706;cursor:pointer} |
| .link-banner .link-cancel:hover{background:#fef3c7} |
| |
| |
| td .comment-target-badge{position:absolute;bottom:1px;right:1px;font-size:7px;font-weight:700; |
| color:#92400e;background:#fde68a;padding:0 3px;border-radius:3px;line-height:12px;pointer-events:none} |
| |
| |
| td .formula-sub{display:block;font-size:9px;color:#888;font-weight:400;font-style:normal; |
| text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px; |
| line-height:1.2;margin-top:1px} |
| |
| |
| td .tid{position:absolute;top:1px;right:1px;font-size:8px;font-weight:700;color:#fff; |
| padding:0 3px;border-radius:3px;line-height:13px;opacity:.9;pointer-events:none} |
| |
| |
| td .comment-mark{position:absolute;top:0;right:0;width:0;height:0; |
| border-style:solid;border-width:0 8px 8px 0;border-color:transparent #e67e22 transparent transparent; |
| pointer-events:none} |
| td.has-comment{cursor:help} |
| |
| |
| td .label-tag{position:absolute;bottom:1px;left:1px;font-size:7px;font-weight:700; |
| color:rgba(0,0,0,.5);line-height:1;pointer-events:none;letter-spacing:.3px} |
| |
| |
| .login-overlay{position:fixed;inset:0;z-index:999;background:#f5f5f5;display:flex; |
| align-items:center;justify-content:center} |
| .login-box{background:#fff;padding:40px 36px;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.12); |
| text-align:center;max-width:360px;width:100%} |
| .login-box h2{margin-bottom:8px;font-size:20px} |
| .login-box p{color:#666;font-size:13px;margin-bottom:20px} |
| .login-box input{width:100%;padding:10px 14px;border:1px solid #ccc;border-radius:8px; |
| font-size:15px;margin-bottom:12px} |
| .login-box button{width:100%;padding:10px;font-size:15px;font-weight:600;background:#2563eb; |
| color:#fff;border:none;border-radius:8px;cursor:pointer} |
| .login-box button:hover{background:#1d4ed8} |
| .login-error{color:#dc2626;font-size:13px;margin-top:8px;display:none} |
| |
| .done-msg{text-align:center;padding:80px 20px;font-size:22px;color:#16a34a;font-weight:600} |
| |
| |
| .hint{padding:4px 24px;background:#fafafa;border-bottom:1px solid #eee;font-size:11px;color:#888;line-height:1.7} |
| .hint kbd{background:#eee;border:1px solid #ccc;border-radius:3px;padding:1px 5px;font-family:inherit;font-size:10px} |
| |
| |
| #help-btn{width:34px;height:34px;border-radius:50%;border:2px solid #ccc;background:#fff; |
| font-size:16px;font-weight:700;color:#666;cursor:pointer;transition:all .15s;flex-shrink:0} |
| #help-btn:hover{border-color:#2563eb;color:#2563eb} |
| |
| |
| .lang-toggle{display:flex;gap:0;flex-shrink:0} |
| .lang-toggle button{padding:4px 8px;font-size:11px;font-weight:600;border:1.5px solid #ccc; |
| background:#fff;cursor:pointer;color:#666;transition:all .12s} |
| .lang-toggle button:first-child{border-radius:5px 0 0 5px;border-right:none} |
| .lang-toggle button:last-child{border-radius:0 5px 5px 0} |
| .lang-toggle button.active{background:#2563eb;color:#fff;border-color:#2563eb} |
| |
| |
| .help-overlay{position:fixed;inset:0;z-index:500;background:rgba(0,0,0,.3);display:none} |
| .help-overlay.open{display:flex;justify-content:flex-end} |
| .help-panel{width:min(620px,90vw);height:100vh;background:#fff;overflow-y:auto; |
| box-shadow:-4px 0 24px rgba(0,0,0,.15);padding:0} |
| .help-header{position:sticky;top:0;z-index:1;background:#fff;padding:16px 24px; |
| border-bottom:1px solid #ddd;display:flex;align-items:center;gap:12px} |
| .help-header h2{flex:1;font-size:17px;font-weight:600;margin:0} |
| .help-lang-btn{padding:4px 12px;font-size:12px;font-weight:600;border:2px solid #ccc; |
| border-radius:5px;cursor:pointer;background:#fff;transition:all .12s} |
| .help-lang-btn.active{background:#2563eb;color:#fff;border-color:#2563eb} |
| .help-lang-btn:hover{border-color:#2563eb} |
| .help-close{width:30px;height:30px;border:none;background:none;font-size:22px;color:#888; |
| cursor:pointer;border-radius:4px} |
| .help-close:hover{background:#f0f0f0;color:#333} |
| .help-body{padding:20px 24px 40px} |
| .help-body h3{font-size:15px;font-weight:600;margin:20px 0 8px;padding-bottom:4px;border-bottom:1px solid #eee} |
| .help-body h3:first-child{margin-top:0} |
| .help-body h4{font-size:13px;font-weight:600;margin:14px 0 4px;color:#444} |
| .help-body p,.help-body li{font-size:13px;line-height:1.6;color:#333} |
| .help-body ul,.help-body ol{padding-left:20px;margin:4px 0 8px} |
| .help-body table.ref-table{border-collapse:collapse;width:100%;margin:8px 0 12px;font-size:12px} |
| .help-body table.ref-table th,.help-body table.ref-table td{border:1px solid #ddd;padding:4px 8px;text-align:left} |
| .help-body table.ref-table th{background:#f5f5f5;font-weight:600} |
| .help-body kbd{background:#eee;border:1px solid #ccc;border-radius:3px;padding:1px 5px;font-family:inherit;font-size:11px} |
| .help-body code{background:#f0f0f0;padding:1px 4px;border-radius:3px;font-size:12px} |
| .help-body pre{background:#f8f8f8;border:1px solid #e0e0e0;border-radius:6px;padding:10px 12px; |
| overflow-x:auto;font-size:11px;line-height:1.5;margin:8px 0 12px;white-space:pre} |
| .help-body .check-list{list-style:none;padding-left:0} |
| .help-body .check-list li{padding-left:22px;position:relative;margin-bottom:4px} |
| .help-body .check-list li::before{content:"☐";position:absolute;left:0;color:#888} |
| .help-body .warn{color:#b45309;font-weight:600} |
| |
| |
| .acc-badge{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:6px; |
| font-size:12px;font-weight:600;background:#e8f5e9;color:#2e7d32;cursor:pointer; |
| border:1px solid #c8e6c9;transition:all .15s;white-space:nowrap} |
| .acc-badge:hover{background:#c8e6c9;border-color:#a5d6a7} |
| .acc-badge.no-model{background:#f5f5f5;color:#999;border-color:#ddd;cursor:default} |
| .sparkline-popup{position:absolute;top:100%;right:0;margin-top:6px;background:#fff; |
| border:1px solid #ddd;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12); |
| padding:16px;z-index:200;min-width:320px;display:none} |
| .sparkline-popup.open{display:block} |
| .sparkline-popup h4{font-size:13px;font-weight:600;margin:0 0 10px;color:#333} |
| .sparkline-popup svg{display:block} |
| .acc-wrapper{position:relative} |
| </style> |
| </head> |
| <body> |
|
|
| <div class="login-overlay" id="login-overlay" style="display:none"> |
| <div class="login-box"> |
| <h2 data-i18n="app_title">Spreadsheet Labeler</h2> |
| <p data-i18n="login_prompt">Enter the password to start labeling</p> |
| <input type="password" id="login-password" data-i18n-placeholder="login_placeholder" placeholder="Password" autofocus> |
| <button id="login-btn" data-i18n="login_btn">Log in</button> |
| <div class="login-error" id="login-error" data-i18n="login_error">Wrong password. Try again.</div> |
| </div> |
| </div> |
|
|
| <div class="topbar"> |
| <h1 data-i18n="app_title">Spreadsheet Labeler</h1> |
| <button id="review-btn" title="Review all labeled sheets">review</button> |
| <button id="rag-label-btn" title="Label table structure for RAG eval only (not training)">RAG label</button> |
| <div class="review-nav" id="rag-label-nav"> |
| <button id="rag-label-prev-btn">←</button> |
| <input id="rag-label-input" type="number" min="1" style="width:40px;text-align:center;font-size:11px;border:1px solid #ccc;border-radius:3px;padding:1px 2px"> |
| <span id="rag-label-counter"></span> |
| <button id="rag-label-next-btn">→</button> |
| <button id="exit-rag-label">×</button> |
| </div> |
| <div class="review-nav" id="review-nav"> |
| <button id="review-prev-btn">←</button> |
| <input id="review-input" type="number" min="1" style="width:40px;text-align:center;font-size:11px;border:1px solid #ccc;border-radius:3px;padding:1px 2px"> |
| <span id="review-counter"></span> |
| <button id="review-next-btn">→</button> |
| <button id="exit-review">×</button> |
| </div> |
| <span class="progress" id="progress" data-i18n="loading">Loading...</span> |
| <span class="sheet-info" id="sheet-info"></span> |
| <input type="text" id="username-input" data-i18n-placeholder="username_placeholder" placeholder="Your name" value=""> |
| <div class="acc-wrapper" id="acc-wrapper" style="display:none"> |
| <span class="acc-badge" id="acc-badge"></span> |
| <div class="sparkline-popup" id="sparkline-popup"> |
| <h4 id="sparkline-title"></h4> |
| <svg id="sparkline-svg" width="280" height="100"></svg> |
| </div> |
| </div> |
| <div class="lang-toggle" id="lang-toggle"> |
| <button data-lang="en" class="active">EN</button> |
| <button data-lang="pl">PL</button> |
| </div> |
| <button id="help-btn" title="Labeling guide / Instrukcja">?</button> |
| <div class="prev-wrapper" id="prev-wrapper"> |
| <button id="prev-btn" data-i18n="prev_btn">← PREV</button> |
| <div class="history-popup" id="history-popup"></div> |
| </div> |
| <button id="clear-all-btn" data-i18n="clear_all_btn">CLEAR ALL</button> |
| <button id="skip-btn" disabled data-i18n="skip_btn">SKIP</button> |
| <button id="save-btn" disabled data-i18n="save_btn">SAVE & NEXT</button> |
| </div> |
|
|
| <div class="help-overlay" id="help-overlay"> |
| <div class="help-panel"> |
| <div class="help-header"> |
| <h2 id="help-title">Labeling Guide</h2> |
| <button class="help-lang-btn active" data-lang="en">EN</button> |
| <button class="help-lang-btn" data-lang="pl">PL</button> |
| <button class="help-close" id="help-close">×</button> |
| </div> |
| <div class="help-body" id="help-body"></div> |
| </div> |
| </div> |
|
|
| <div class="selector-bar" id="table-bar"> |
| <span class="bar-label">Table:</span> |
| </div> |
|
|
| <div class="selector-bar" id="level-bar"> |
| <span class="bar-label">Level:</span> |
| </div> |
|
|
| <div class="legend" id="legend-bar"></div> |
|
|
| <div class="hint" id="hint"></div> |
|
|
| <div class="table-wrap" id="table-wrap" style="position:relative"> |
| <p style="padding:40px;color:#888" data-i18n="loading_sheet">Loading first sheet...</p> |
| </div> |
|
|
| <div class="link-banner" id="link-banner"> |
| <span class="link-text" id="link-banner-text"></span> |
| <button class="link-cancel" id="link-cancel">Esc</button> |
| </div> |
|
|
| <script> |
| (function(){ |
| |
| /* ── i18n ───────────────────────────────────────────────────────────── */ |
| const I18N = { |
| en: { |
| app_title: "Spreadsheet Labeler", |
| login_prompt: "Enter the password to start labeling", |
| login_placeholder: "Password", |
| login_btn: "Log in", |
| login_error: "Wrong password. Try again.", |
| loading: "Loading\u2026", |
| loading_sheet: "Loading first sheet\u2026", |
| username_placeholder: "Your name", |
| save_btn: "SAVE & NEXT", |
| prev_btn: "\u2190 PREV", |
| clear_all_btn: "CLEAR ALL", |
| clear_all_confirm: "Remove all labels from this sheet? This can be undone with Ctrl+Z.", |
| history_title: "Recently labeled sheets", |
| history_empty: "No labeled sheets yet.", |
| history_cells: "cells", |
| saving: "Saving\u2026", |
| done: "Done!", |
| all_done: "All sheets have been labeled!", |
| waiting_others: "No sheets available right now \u2014 other labelers are working on the remaining ones.", |
| labelled_progress: "labelled", |
| table_label: "Table:", |
| t0_sheet: "T0 sheet", |
| add_table: "Add table", |
| level_label: "Level:", |
| add_level: "Add level", |
| lbl_value: "Value", |
| lbl_col_hdr: "ColHdr", |
| lbl_row_hdr: "RowHdr", |
| lbl_aggregation: "Aggregation", |
| lbl_header: "Header", |
| lbl_metadata: "Metadata", |
| lbl_comment: "Comment", |
| lbl_empty: "Empty", |
| lbl_junk: "Junk", |
| lbl_clear: "Clear", |
| link_banner_text: "Click the cell this comment refers to (or press Esc to cancel)", |
| hint: 'Click to select \u00b7 <kbd>Shift</kbd>+click for range \u00b7 <kbd>Ctrl/Cmd</kbd>+click to toggle \u00b7 <kbd>R</kbd>=RowHdr \u00b7 <kbd>C</kbd>=ColHdr (at active level) \u00b7 <kbd>H</kbd>=Header \u00b7 <kbd>N</kbd>=Comment \u00b7 <kbd>J</kbd>=Junk \u00b7 <kbd>T</kbd>+digits = table \u00b7 <kbd>L</kbd>+digits = level \u00b7 <kbd>Enter</kbd> = Save & Next', |
| error_prefix: "Error: ", |
| save_error: "Save error: ", |
| relogin_hint: "Log in again (refresh the page and enter the password).", |
| help_title: "Labeling Guide", |
| ai_accuracy: "AI v{v}: {n}%", |
| ai_no_model: "No model", |
| accuracy_title: "Prediction accuracy over time", |
| ai_assisted: "AI-assisted", |
| t0_warning: "T0 is active \u2014 data cells on T0 won't produce chunks. Switch to T1+ first?", |
| skip_btn: "SKIP", |
| skip_confirm: "Permanently skip this sheet? It will be excluded from all training, evaluation, and RAG.", |
| review_done: "Review complete! All labeled sheets reviewed.", |
| review_save_btn: "SAVE & NEXT \u25B6", |
| rag_label_save_btn: "SAVE (RAG only) \u25B6", |
| rag_label_done: "RAG structure labeling complete. Saved under data/rag_eval_labeled/ (never used for training).", |
| }, |
| pl: { |
| app_title: "Labelowanie arkuszy", |
| login_prompt: "Wpisz has\u0142o, aby rozpocz\u0105\u0107 labelowanie", |
| login_placeholder: "Has\u0142o", |
| login_btn: "Zaloguj si\u0119", |
| login_error: "B\u0142\u0119dne has\u0142o. Spr\u00f3buj ponownie.", |
| loading: "\u0141adowanie\u2026", |
| loading_sheet: "\u0141adowanie pierwszego arkusza\u2026", |
| username_placeholder: "Twoje imi\u0119", |
| save_btn: "ZAPISZ I DALEJ", |
| prev_btn: "\u2190 WRÓĆ", |
| clear_all_btn: "WYCZYŚĆ WSZYSTKO", |
| clear_all_confirm: "Usunąć wszystkie etykiety z tego arkusza? Można cofnąć przez Ctrl+Z.", |
| history_title: "Ostatnio oznaczone arkusze", |
| history_empty: "Brak oznaczonych arkuszy.", |
| history_cells: "komórek", |
| saving: "Zapisywanie\u2026", |
| done: "Gotowe!", |
| all_done: "Wszystkie arkusze zosta\u0142y oznaczone!", |
| waiting_others: "Brak dost\u0119pnych arkuszy \u2014 pozosta\u0142e s\u0105 w trakcie labelowania przez innych.", |
| labelled_progress: "oznaczonych", |
| table_label: "Tabela:", |
| t0_sheet: "T0 arkusz", |
| add_table: "Dodaj tabel\u0119", |
| level_label: "Poziom:", |
| add_level: "Dodaj poziom", |
| lbl_value: "Warto\u015b\u0107", |
| lbl_col_hdr: "NagKol", |
| lbl_row_hdr: "NagWier", |
| lbl_aggregation: "Agregacja", |
| lbl_header: "Nag\u0142\u00f3wek", |
| lbl_metadata: "Metadane", |
| lbl_comment: "Komentarz", |
| lbl_empty: "Pusta", |
| lbl_junk: "Śmieć", |
| lbl_clear: "Wyczy\u015b\u0107", |
| link_banner_text: "Kliknij kom\u00f3rk\u0119, do kt\u00f3rej odnosi si\u0119 ten komentarz (lub naci\u015bnij Esc, aby anulowa\u0107)", |
| hint: 'Kliknij, aby zaznaczy\u0107 \u00b7 <kbd>Shift</kbd>+klik = zakres \u00b7 <kbd>Ctrl/Cmd</kbd>+klik = prze\u0142\u0105cz \u00b7 <kbd>R</kbd>=NagWier \u00b7 <kbd>C</kbd>=NagKol (na aktywnym poziomie) \u00b7 <kbd>H</kbd>=Nag\u0142\u00f3wek \u00b7 <kbd>N</kbd>=Komentarz \u00b7 <kbd>J</kbd>=Śmieć \u00b7 <kbd>T</kbd>+cyfry = tabela \u00b7 <kbd>L</kbd>+cyfry = poziom \u00b7 <kbd>Enter</kbd> = Zapisz i dalej', |
| error_prefix: "B\u0142\u0105d: ", |
| save_error: "B\u0142\u0105d zapisu: ", |
| relogin_hint: "Zaloguj si\u0119 ponownie (od\u015bwie\u017c stron\u0119 i wpisz has\u0142o).", |
| help_title: "Instrukcja labelowania", |
| ai_accuracy: "SI v{v}: {n}%", |
| ai_no_model: "Brak modelu", |
| accuracy_title: "Dokładność predykcji w czasie", |
| ai_assisted: "Wspomagane SI", |
| t0_warning: "T0 jest aktywne \u2014 dane na T0 nie wygeneruj\u0105 chunk\u00f3w. Prze\u0142\u0105czy\u0107 na T1+?", |
| skip_btn: "POMI\u0143", |
| skip_confirm: "Trwale pomin\u0105\u0107 ten arkusz? Zostanie wykluczony z treningu, ewaluacji i RAG.", |
| review_done: "Przegl\u0105d zako\u0144czony! Wszystkie oznaczone arkusze sprawdzone.", |
| review_save_btn: "ZAPISZ I DALEJ \u25B6", |
| rag_label_save_btn: "ZAPISZ (tylko RAG) \u25B6", |
| rag_label_done: "Zako\u0144czono etykiety struktury RAG. Pliki: data/rag_eval_labeled/ (bez treningu).", |
| } |
| }; |
| |
| let lang = localStorage.getItem("labeler_lang") || "en"; |
| |
| function t(key) { return (I18N[lang] && I18N[lang][key]) || I18N.en[key] || key; } |
| |
| function applyLang() { |
| document.documentElement.lang = lang; |
| document.querySelectorAll("[data-i18n]").forEach(el => { |
| const key = el.dataset.i18n; |
| const val = t(key); |
| if (val) el.textContent = val; |
| }); |
| document.querySelectorAll("[data-i18n-placeholder]").forEach(el => { |
| const key = el.dataset.i18nPlaceholder; |
| const val = t(key); |
| if (val) el.placeholder = val; |
| }); |
| document.getElementById("hint").innerHTML = t("hint"); |
| document.getElementById("lang-toggle").querySelectorAll("button").forEach(b => { |
| b.classList.toggle("active", b.dataset.lang === lang); |
| }); |
| } |
| |
| document.getElementById("lang-toggle").addEventListener("click", (e) => { |
| const btn = e.target.closest("button[data-lang]"); |
| if (!btn) return; |
| lang = btn.dataset.lang; |
| localStorage.setItem("labeler_lang", lang); |
| applyLang(); |
| renderTableBar(); |
| renderLevelBar(); |
| renderLegend(); |
| renderAccuracyBadge(); |
| if (currentSheet) updateProgress(lastProgress); |
| }); |
| |
| /* ── Color palettes ────────────────────────────────────────────────── */ |
| const TABLE_COLORS = [ |
| "#888","#e74c3c","#2980b9","#27ae60","#f39c12","#8e44ad", |
| "#e67e22","#1abc9c","#c0392b","#2c3e50" |
| ]; |
| |
| function colHeaderColor(level) { |
| const base = [255, 235, 156]; |
| const cycle = (level - 1) % 12; |
| const darken = cycle * 14; |
| return `rgb(${Math.max(base[0] - darken, 80)}, ${Math.max(base[1] - (darken * 1.2 | 0), 60)}, ${Math.max(base[2] - (darken * 2 | 0), 20)})`; |
| } |
| |
| function rowHeaderColor(level) { |
| const base = [244, 176, 132]; |
| const cycle = (level - 1) % 12; |
| const darken = cycle * 12; |
| return `rgb(${Math.max(base[0] - darken, 80)}, ${Math.max(base[1] - (darken * 1.3 | 0), 50)}, ${Math.max(base[2] - (darken * 1.5 | 0), 20)})`; |
| } |
| |
| /* ── State ─────────────────────────────────────────────────────────── */ |
| let currentSheet = null; |
| let cellLabels = {}; |
| let selectedCells = new Set(); |
| let lastClickedKey = null; |
| let activeTable = 1; |
| let activeLevel = 1; |
| let maxTableId = 1; |
| let maxLevel = 3; |
| let lastProgress = null; |
| let hasPredictions = false; |
| let currentModelVersion = null; |
| let accuracyHistory = []; |
| |
| /* ── Undo / Redo stacks ─────────────────────────────────────────── */ |
| const MAX_UNDO = 50; |
| let undoStack = []; |
| let redoStack = []; |
| |
| function pushUndo() { |
| undoStack.push(_snapshot()); |
| if (undoStack.length > MAX_UNDO) undoStack.shift(); |
| redoStack = []; |
| } |
| |
| function _snapshot() { return JSON.stringify({labels: cellLabels, links: commentLinks}); } |
| function _restore(s) { const o = JSON.parse(s); cellLabels = o.labels; commentLinks = o.links || {}; } |
| |
| function doUndo() { |
| if (undoStack.length === 0) return; |
| redoStack.push(_snapshot()); |
| _restore(undoStack.pop()); |
| applyLabelClasses(); |
| drawCommentLinks(); |
| } |
| |
| function doRedo() { |
| if (redoStack.length === 0) return; |
| undoStack.push(_snapshot()); |
| _restore(redoStack.pop()); |
| applyLabelClasses(); |
| drawCommentLinks(); |
| } |
| |
| function clearUndoRedo() { |
| undoStack = []; |
| redoStack = []; |
| } |
| |
| const tableBar = document.getElementById("table-bar"); |
| const levelBar = document.getElementById("level-bar"); |
| const legendBar = document.getElementById("legend-bar"); |
| const tableWrap = document.getElementById("table-wrap"); |
| const saveBtn = document.getElementById("save-btn"); |
| const skipBtn = document.getElementById("skip-btn"); |
| const progressEl = document.getElementById("progress"); |
| const sheetInfoEl = document.getElementById("sheet-info"); |
| const usernameInput = document.getElementById("username-input"); |
| |
| usernameInput.value = localStorage.getItem("labeler_username") || ""; |
| usernameInput.addEventListener("input", () => { |
| localStorage.setItem("labeler_username", usernameInput.value); |
| }); |
| |
| /* ── Comment linking state ──────────────────────────────────────────── */ |
| let commentLinks = {}; // comment cell key -> target cell key |
| let linkMode = null; // null or the comment cell key waiting for a target |
| const linkBanner = document.getElementById("link-banner"); |
| const linkBannerText = document.getElementById("link-banner-text"); |
| const linkCancelBtn = document.getElementById("link-cancel"); |
| |
| function enterLinkMode(commentKey) { |
| linkMode = commentKey; |
| linkBanner.classList.add("active"); |
| linkBannerText.textContent = t("link_banner_text"); |
| const td = tableWrap.querySelector(`td[data-key="${commentKey}"]`); |
| if (td) td.classList.add("link-pending"); |
| } |
| |
| function exitLinkMode() { |
| if (linkMode) { |
| const td = tableWrap.querySelector(`td[data-key="${linkMode}"]`); |
| if (td) td.classList.remove("link-pending"); |
| } |
| linkMode = null; |
| linkBanner.classList.remove("active"); |
| } |
| |
| function completeLinkMode(targetKey) { |
| if (!linkMode) return; |
| if (targetKey === linkMode) { exitLinkMode(); return; } |
| commentLinks[linkMode] = targetKey; |
| exitLinkMode(); |
| drawCommentLinks(); |
| applyLabelClasses(); |
| } |
| |
| linkCancelBtn.addEventListener("click", () => exitLinkMode()); |
| |
| function drawCommentLinks() { |
| tableWrap.querySelectorAll(".link-arrows").forEach(el => el.remove()); |
| const keys = Object.keys(commentLinks); |
| if (keys.length === 0) return; |
| |
| const wrapRect = tableWrap.getBoundingClientRect(); |
| const scrollLeft = tableWrap.scrollLeft; |
| const scrollTop = tableWrap.scrollTop; |
| |
| const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); |
| svg.classList.add("link-arrows"); |
| svg.style.width = tableWrap.scrollWidth + "px"; |
| svg.style.height = tableWrap.scrollHeight + "px"; |
| |
| const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); |
| const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker"); |
| marker.setAttribute("id", "arrowhead"); |
| marker.setAttribute("markerWidth", "8"); |
| marker.setAttribute("markerHeight", "6"); |
| marker.setAttribute("refX", "8"); |
| marker.setAttribute("refY", "3"); |
| marker.setAttribute("orient", "auto"); |
| const polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon"); |
| polygon.setAttribute("points", "0 0, 8 3, 0 6"); |
| polygon.setAttribute("fill", "#d97706"); |
| marker.appendChild(polygon); |
| defs.appendChild(marker); |
| svg.appendChild(defs); |
| |
| for (const [src, tgt] of Object.entries(commentLinks)) { |
| const srcTd = tableWrap.querySelector(`td[data-key="${src}"]`); |
| const tgtTd = tableWrap.querySelector(`td[data-key="${tgt}"]`); |
| if (!srcTd || !tgtTd) continue; |
| |
| const sr = srcTd.getBoundingClientRect(); |
| const tr = tgtTd.getBoundingClientRect(); |
| |
| const x1 = sr.left + sr.width / 2 - wrapRect.left + scrollLeft; |
| const y1 = sr.top + sr.height / 2 - wrapRect.top + scrollTop; |
| const x2 = tr.left + tr.width / 2 - wrapRect.left + scrollLeft; |
| const y2 = tr.top + tr.height / 2 - wrapRect.top + scrollTop; |
| |
| const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); |
| line.setAttribute("x1", x1); |
| line.setAttribute("y1", y1); |
| line.setAttribute("x2", x2); |
| line.setAttribute("y2", y2); |
| line.setAttribute("stroke", "#d97706"); |
| line.setAttribute("stroke-width", "2"); |
| line.setAttribute("stroke-dasharray", "4,3"); |
| line.setAttribute("marker-end", "url(#arrowhead)"); |
| svg.appendChild(line); |
| } |
| |
| tableWrap.appendChild(svg); |
| } |
| |
| /* ── Dynamic style injection ───────────────────────────────────────── */ |
| const dynamicStyle = document.createElement("style"); |
| document.head.appendChild(dynamicStyle); |
| |
| function tableColor(i) { |
| return TABLE_COLORS[i % TABLE_COLORS.length]; |
| } |
| |
| function refreshDynamicCSS() { |
| let css = ""; |
| for (let i = 0; i <= maxTableId; i++) { |
| const c = tableColor(i); |
| css += `.t${i}-active{border-color:${c}!important;background:${c}!important}\n`; |
| css += `.tid-${i}{background:${c}}\n`; |
| } |
| for (let lv = 1; lv <= maxLevel; lv++) { |
| css += `td.label-col_header_${lv}{background:${colHeaderColor(lv)}!important}\n`; |
| css += `td.label-row_header_${lv}{background:${rowHeaderColor(lv)}!important}\n`; |
| } |
| dynamicStyle.textContent = css; |
| } |
| |
| /* ── Table selector ────────────────────────────────────────────────── */ |
| function renderTableBar() { |
| tableBar.innerHTML = `<span class="bar-label">${t("table_label")}</span>`; |
| for (let i = 0; i <= maxTableId; i++) { |
| const btn = document.createElement("button"); |
| btn.className = "sel-btn"; |
| btn.dataset.tid = i; |
| btn.textContent = i === 0 ? t("t0_sheet") : `T${i}`; |
| if (i === activeTable) btn.classList.add("active", `t${i}-active`); |
| btn.addEventListener("click", () => setActiveTable(i)); |
| tableBar.appendChild(btn); |
| } |
| const addBtn = document.createElement("button"); |
| addBtn.className = "add-btn"; |
| addBtn.textContent = "+"; |
| addBtn.title = t("add_table"); |
| addBtn.addEventListener("click", () => { |
| maxTableId++; |
| renderTableBar(); |
| setActiveTable(maxTableId); |
| }); |
| tableBar.appendChild(addBtn); |
| } |
| |
| function setActiveTable(tid) { |
| if (tid > maxTableId) { |
| maxTableId = tid; |
| renderTableBar(); |
| } |
| activeTable = tid; |
| tableBar.querySelectorAll(".sel-btn").forEach(b => { |
| const t = parseInt(b.dataset.tid); |
| b.className = "sel-btn"; |
| if (t === tid) b.classList.add("active", `t${t}-active`); |
| }); |
| } |
| |
| /* ── Level selector ────────────────────────────────────────────────── */ |
| function renderLevelBar() { |
| levelBar.innerHTML = `<span class="bar-label">${t("level_label")}</span>`; |
| for (let lv = 1; lv <= maxLevel; lv++) { |
| const btn = document.createElement("button"); |
| btn.className = "sel-btn"; |
| btn.dataset.lv = lv; |
| btn.textContent = `L${lv}`; |
| btn.style.borderColor = rowHeaderColor(lv); |
| if (lv === activeLevel) { |
| btn.classList.add("active"); |
| btn.style.background = rowHeaderColor(lv); |
| btn.style.borderColor = rowHeaderColor(lv); |
| } |
| btn.addEventListener("click", () => setActiveLevel(lv)); |
| levelBar.appendChild(btn); |
| } |
| const addBtn = document.createElement("button"); |
| addBtn.className = "add-btn"; |
| addBtn.textContent = "+"; |
| addBtn.title = t("add_level"); |
| addBtn.addEventListener("click", () => { |
| maxLevel++; |
| refreshDynamicCSS(); |
| renderLevelBar(); |
| renderLegend(); |
| setActiveLevel(maxLevel); |
| }); |
| levelBar.appendChild(addBtn); |
| } |
| |
| function setActiveLevel(lv) { |
| if (lv > maxLevel) { |
| maxLevel = lv; |
| refreshDynamicCSS(); |
| renderLevelBar(); |
| } |
| activeLevel = lv; |
| renderLegend(); |
| levelBar.querySelectorAll(".sel-btn").forEach(b => { |
| const l = parseInt(b.dataset.lv); |
| b.classList.remove("active"); |
| b.style.background = "#fff"; |
| if (l === lv) { |
| b.classList.add("active"); |
| b.style.background = rowHeaderColor(l); |
| } |
| }); |
| } |
| |
| /* ── Legend ─────────────────────────────────────────────────────────── */ |
| function renderLegend() { |
| let html = ''; |
| html += `<div class="legend-item"><div class="swatch" style="background:#c6efce"></div><b>V</b> ${t("lbl_value")}</div>`; |
| html += `<div class="legend-sep"></div>`; |
| html += `<div class="legend-item"><div class="swatch" style="background:${colHeaderColor(activeLevel)}"></div><b>C</b> ${t("lbl_col_hdr")}</div>`; |
| html += `<div class="legend-item"><div class="swatch" style="background:${rowHeaderColor(activeLevel)}"></div><b>R</b> ${t("lbl_row_hdr")}</div>`; |
| html += `<div class="legend-sep"></div>`; |
| html += `<div class="legend-item"><div class="swatch" style="background:#d5a6e6"></div><b>A</b> ${t("lbl_aggregation")}</div>`; |
| html += `<div class="legend-item"><div class="swatch" style="background:#a8d8ea"></div><b>H</b> ${t("lbl_header")}</div>`; |
| html += `<div class="legend-item"><div class="swatch" style="background:#bdd7ee"></div><b>M</b> ${t("lbl_metadata")}</div>`; |
| html += `<div class="legend-item"><div class="swatch" style="background:#fde68a;border:2px dashed #d97706"></div><b>N</b> ${t("lbl_comment")}</div>`; |
| html += `<div class="legend-item"><div class="swatch" style="background:#f0f0f0"></div><b>E</b> ${t("lbl_empty")}</div>`; |
| html += `<div class="legend-item"><div class="swatch" style="background:#f8d7da;text-decoration:line-through"></div><b>J</b> ${t("lbl_junk")}</div>`; |
| html += `<div class="legend-item"><div class="swatch" style="background:#fff"></div><b>X</b> ${t("lbl_clear")}</div>`; |
| legendBar.innerHTML = html; |
| } |
| |
| /* ── Initialize UI chrome ──────────────────────────────────────────── */ |
| applyLang(); |
| refreshDynamicCSS(); |
| renderTableBar(); |
| renderLevelBar(); |
| renderLegend(); |
| |
| /* ── Accuracy tracking ─────────────────────────────────────────────── */ |
| const accWrapper = document.getElementById("acc-wrapper"); |
| const accBadge = document.getElementById("acc-badge"); |
| const sparkPopup = document.getElementById("sparkline-popup"); |
| const sparkTitle = document.getElementById("sparkline-title"); |
| const sparkSvg = document.getElementById("sparkline-svg"); |
| |
| async function fetchAccuracy() { |
| try { |
| const resp = await fetch("/api/accuracy", {headers: authHeaders()}); |
| if (!resp.ok) return; |
| accuracyHistory = await safeJson(resp); |
| renderAccuracyBadge(); |
| } catch { /* ignore */ } |
| } |
| |
| function renderAccuracyBadge() { |
| if (!accuracyHistory.length && !hasPredictions) { |
| accWrapper.style.display = "none"; |
| return; |
| } |
| accWrapper.style.display = ""; |
| if (!accuracyHistory.length) { |
| accBadge.className = "acc-badge no-model"; |
| accBadge.textContent = t("ai_no_model"); |
| return; |
| } |
| accBadge.className = "acc-badge"; |
| const recent = accuracyHistory.slice(-10); |
| const hasStrict = recent.some(e => e.accuracy_strict !== undefined); |
| const avg = hasStrict |
| ? recent.reduce((s, e) => s + (e.accuracy_strict ?? e.accuracy), 0) / recent.length |
| : recent.reduce((s, e) => s + e.accuracy, 0) / recent.length; |
| const latestVersion = currentModelVersion || (recent.length ? recent[recent.length - 1].model_version : null); |
| const vNum = latestVersion ? latestVersion.replace(/^v0*/, "") : "?"; |
| accBadge.textContent = t("ai_accuracy").replace("{v}", vNum).replace("{n}", Math.round(avg * 100)); |
| } |
| |
| accBadge.addEventListener("click", () => { |
| if (!accuracyHistory.length) return; |
| sparkTitle.textContent = t("accuracy_title"); |
| renderSparkline(); |
| sparkPopup.classList.toggle("open"); |
| }); |
| |
| document.addEventListener("click", (e) => { |
| if (!e.target.closest(".acc-wrapper")) sparkPopup.classList.remove("open"); |
| }); |
| |
| function renderSparkline() { |
| if (!accuracyHistory.length) return; |
| const W = 280, H = 100, pad = 20; |
| const data = accuracyHistory.map(e => e.accuracy_strict ?? e.accuracy); |
| const n = data.length; |
| const xStep = n > 1 ? (W - 2 * pad) / (n - 1) : 0; |
| const yMin = Math.max(0, Math.min(...data) - 0.05); |
| const yMax = Math.min(1, Math.max(...data) + 0.05); |
| const yRange = yMax - yMin || 1; |
| |
| const toX = i => pad + i * xStep; |
| const toY = v => H - pad - ((v - yMin) / yRange) * (H - 2 * pad); |
| |
| let path = `M ${toX(0)} ${toY(data[0])}`; |
| for (let i = 1; i < n; i++) path += ` L ${toX(i)} ${toY(data[i])}`; |
| |
| let dots = ""; |
| for (let i = 0; i < n; i++) { |
| dots += `<circle cx="${toX(i)}" cy="${toY(data[i])}" r="3" fill="#2563eb" />`; |
| } |
| |
| const yLabels = [yMin, (yMin + yMax) / 2, yMax]; |
| let gridLines = ""; |
| for (const v of yLabels) { |
| const y = toY(v); |
| gridLines += `<line x1="${pad}" y1="${y}" x2="${W - pad}" y2="${y}" stroke="#eee" stroke-width="1" />`; |
| gridLines += `<text x="${pad - 4}" y="${y + 3}" fill="#999" font-size="9" text-anchor="end">${Math.round(v * 100)}%</text>`; |
| } |
| |
| sparkSvg.innerHTML = `${gridLines}<path d="${path}" fill="none" stroke="#2563eb" stroke-width="2" />${dots}`; |
| } |
| |
| function prefillFromPredictions(predictions) { |
| if (!predictions || !predictions.length) { |
| hasPredictions = false; |
| return; |
| } |
| hasPredictions = true; |
| for (const p of predictions) { |
| const key = `${p.row},${p.col}`; |
| cellLabels[key] = { label: p.label, table: p.table || 1 }; |
| if (p.table > maxTableId) maxTableId = p.table; |
| const m = p.label.match(/^(?:row_header|col_header)_(\d+)$/); |
| if (m) { |
| const lv = parseInt(m[1]); |
| if (lv > maxLevel) maxLevel = lv; |
| } |
| } |
| } |
| |
| /* ── Fetch next sheet ──────────────────────────────────────────────── */ |
| async function loadNextSheet() { |
| if (ragLabelMode) return; |
| saveBtn.disabled = true; skipBtn.disabled = true; |
| try { |
| const username = usernameInput.value.trim() || "anonymous"; |
| const resp = await fetch(`/api/next_sheet?labeler=${encodeURIComponent(username)}`, { |
| headers: authHeaders(), |
| credentials: "include", |
| }); |
| const data = await safeJson(resp); |
| if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`); |
| if (data.done) { |
| const hasWork = data.progress && (data.progress.in_progress > 0 || data.progress.unlabelled > 0); |
| const msg = hasWork ? t("waiting_others") : t("all_done"); |
| tableWrap.innerHTML = `<div class="done-msg">${msg}</div>`; |
| progressEl.textContent = hasWork ? "" : t("done"); |
| if (data.progress) updateProgress(data.progress); |
| sheetInfoEl.textContent = ""; |
| return; |
| } |
| currentSheet = data.sheet; |
| cellLabels = {}; |
| commentLinks = {}; |
| exitLinkMode(); |
| selectedCells.clear(); |
| lastClickedKey = null; |
| maxTableId = 1; |
| maxLevel = 3; |
| activeLevel = 1; |
| clearUndoRedo(); |
| currentModelVersion = data.model_version || null; |
| prefillFromPredictions(data.predictions); |
| refreshDynamicCSS(); |
| renderTableBar(); |
| renderLevelBar(); |
| renderLegend(); |
| setActiveTable(1); |
| updateProgress(data.progress); |
| let infoText = `${currentSheet.spreadsheet_id} / ${currentSheet.sheet_name}`; |
| if (hasPredictions) infoText += ` — ${t("ai_assisted")}`; |
| sheetInfoEl.textContent = infoText; |
| renderTable(); |
| saveBtn.disabled = false; skipBtn.disabled = false; |
| fetchAccuracy(); |
| } catch (err) { |
| tableWrap.innerHTML = `<p style="padding:40px;color:red">${t("error_prefix")}${err.message}</p>`; |
| } |
| } |
| |
| function updateProgress(p) { |
| if (!p) return; |
| lastProgress = p; |
| const done = p.labelled || p.done || 0; |
| const total = p.total || 0; |
| const inProg = p.in_progress || 0; |
| let text = `${done} / ${total} ${t("labelled_progress")}`; |
| if (inProg > 0) text += ` (${inProg} in progress)`; |
| progressEl.textContent = text; |
| } |
| |
| /* ── Render HTML table ─────────────────────────────────────────────── */ |
| function renderTable() { |
| if (!currentSheet) return; |
| const cells = currentSheet.cells; |
| const merges = currentSheet.merges || []; |
| |
| const cellMap = {}; |
| cells.forEach(c => { cellMap[`${c.row},${c.col}`] = c; }); |
| |
| const hidden = new Set(); |
| merges.forEach(m => { |
| for (let r = m.startRow; r < m.endRow; r++) { |
| for (let c = m.startCol; c < m.endCol; c++) { |
| if (r !== m.startRow || c !== m.startCol) hidden.add(`${r},${c}`); |
| } |
| } |
| }); |
| |
| let maxRow = 0, maxCol = 0; |
| cells.forEach(c => { |
| if (c.value !== "" || c.is_bold) { |
| maxRow = Math.max(maxRow, c.row); |
| maxCol = Math.max(maxCol, c.col); |
| } |
| }); |
| merges.forEach(m => { |
| maxRow = Math.max(maxRow, m.endRow - 1); |
| maxCol = Math.max(maxCol, m.endCol - 1); |
| }); |
| maxRow = Math.min(maxRow + 2, (currentSheet.grid_size?.rows || 200) - 1); |
| maxCol = Math.min(maxCol + 2, (currentSheet.grid_size?.cols || 50) - 1); |
| |
| let html = "<table>"; |
| for (let r = 0; r <= maxRow; r++) { |
| html += "<tr>"; |
| for (let c = 0; c <= maxCol; c++) { |
| const key = `${r},${c}`; |
| if (hidden.has(key)) continue; |
| const cell = cellMap[key]; |
| const val = cell ? cell.value : ""; |
| const rowspan = cell && cell.is_merge_origin ? cell.rowspan : 1; |
| const colspan = cell && cell.is_merge_origin ? cell.colspan : 1; |
| |
| let cls = []; |
| if (cell) { |
| if (cell.is_bold) cls.push("bold-cell"); |
| if (cell.is_italic) cls.push("italic-cell"); |
| if (cell.is_underline) cls.push("underline-cell"); |
| if (cell.wrap_text) cls.push("wrap-cell"); |
| if (cell.has_border_top) cls.push("src-bt"); |
| if (cell.has_border_bottom) cls.push("src-bb"); |
| if (cell.has_border_left) cls.push("src-bl"); |
| if (cell.has_border_right) cls.push("src-br"); |
| if (cell.comment) cls.push("has-comment"); |
| } |
| const info = cellLabels[key]; |
| if (info) cls.push(`label-${info.label}`); |
| |
| let styles = []; |
| if (cell) { |
| if (cell.bg_color && !info) styles.push(`background:${cell.bg_color}`); |
| if (cell.font_color) styles.push(`color:${cell.font_color}`); |
| if (cell.font_size && cell.font_size > 11) styles.push(`font-size:${Math.min(cell.font_size, 24)}px`); |
| if (cell.h_align) styles.push(`text-align:${cell.h_align}`); |
| if (cell.v_align) styles.push(`vertical-align:${cell.v_align === "center" ? "middle" : cell.v_align}`); |
| if (cell.indent && cell.indent > 0) styles.push(`padding-left:${6 + cell.indent * 12}px`); |
| } |
| |
| html += `<td data-key="${key}"`; |
| if (rowspan > 1) html += ` rowspan="${rowspan}"`; |
| if (colspan > 1) html += ` colspan="${colspan}"`; |
| if (cls.length) html += ` class="${cls.join(" ")}"`; |
| if (styles.length) html += ` style="${styles.join(";")}"`; |
| html += ">"; |
| |
| if (info) { |
| const tc = TABLE_COLORS[info.table] || TABLE_COLORS[info.table % TABLE_COLORS.length]; |
| html += `<span class="tid" style="background:${tc}${info.table === 0 ? ';opacity:.5' : ''}">T${info.table}</span>`; |
| } |
| if (info) { |
| const tag = labelTag(info.label); |
| if (tag) html += `<span class="label-tag">${tag}</span>`; |
| } |
| |
| let display = escHtml(val); |
| if (cell && cell.number_format && val) { |
| display = `<span title="fmt: ${escHtml(cell.number_format)}">${display}</span>`; |
| } |
| html += display; |
| |
| if (cell && cell.formula) { |
| html += `<span class="formula-sub" title="${escHtml(cell.formula)}">${escHtml(cell.formula)}</span>`; |
| } |
| if (cell && cell.comment) { |
| const tip = cell.comment_author |
| ? `[${escHtml(cell.comment_author)}] ${escHtml(cell.comment)}` |
| : escHtml(cell.comment); |
| html += `<span class="comment-mark" title="${tip}"></span>`; |
| } |
| html += "</td>"; |
| } |
| html += "</tr>"; |
| } |
| html += "</table>"; |
| tableWrap.innerHTML = html; |
| } |
| |
| function escHtml(s) { |
| if (!s) return ""; |
| return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); |
| } |
| |
| function labelTag(label) { |
| if (!label) return ""; |
| const m = label.match(/^(row_header|col_header)_(\d+)$/); |
| if (m) { |
| const axis = m[1] === "row_header" ? "R" : "C"; |
| return `${axis}${m[2]}`; |
| } |
| const SHORT = {value:"VAL", aggregation:"AGG", header:"HDR", metadata:"MET", comment:"COM", empty:"---", junk:"JNK"}; |
| return SHORT[label] || ""; |
| } |
| |
| /* ── Selection ─────────────────────────────────────────────────────── */ |
| tableWrap.addEventListener("mousedown", (e) => { |
| const td = e.target.closest("td[data-key]"); |
| if (!td) return; |
| const key = td.dataset.key; |
| |
| if (linkMode) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| completeLinkMode(key); |
| return; |
| } |
| |
| if (e.shiftKey && lastClickedKey) { |
| const [r1,c1] = lastClickedKey.split(",").map(Number); |
| const [r2,c2] = key.split(",").map(Number); |
| for (let r = Math.min(r1,r2); r <= Math.max(r1,r2); r++) { |
| for (let c = Math.min(c1,c2); c <= Math.max(c1,c2); c++) { |
| selectedCells.add(`${r},${c}`); |
| } |
| } |
| } else if (e.metaKey || e.ctrlKey) { |
| if (selectedCells.has(key)) selectedCells.delete(key); |
| else selectedCells.add(key); |
| } else { |
| selectedCells.clear(); |
| selectedCells.add(key); |
| } |
| lastClickedKey = key; |
| refreshSelection(); |
| }); |
| |
| function refreshSelection() { |
| tableWrap.querySelectorAll("td.selected").forEach(td => td.classList.remove("selected")); |
| selectedCells.forEach(key => { |
| const td = tableWrap.querySelector(`td[data-key="${key}"]`); |
| if (td) td.classList.add("selected"); |
| }); |
| } |
| |
| /* ── Keyboard labeling ─────────────────────────────────────────────── */ |
| let waitingForTableNum = false; |
| let waitingForLevelNum = false; |
| let digitBuffer = ""; |
| let digitTimer = null; |
| |
| function flushTableDigits() { |
| const n = parseInt(digitBuffer); |
| digitBuffer = ""; |
| digitTimer = null; |
| if (!isNaN(n) && n >= 0) setActiveTable(n); |
| } |
| |
| function flushLevelDigits() { |
| const n = parseInt(digitBuffer); |
| digitBuffer = ""; |
| digitTimer = null; |
| if (!isNaN(n) && n >= 1) setActiveLevel(n); |
| } |
| |
| const SIMPLE_LABELS = { |
| v: "value", |
| a: "aggregation", |
| h: "header", |
| m: "metadata", |
| n: "comment", |
| e: "empty", |
| j: "junk", |
| }; |
| |
| document.addEventListener("keydown", (e) => { |
| if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return; |
| |
| // T + digits for table selection (multi-digit: T12, T25, etc.) |
| if (waitingForTableNum) { |
| if (/^\d$/.test(e.key)) { |
| e.preventDefault(); |
| digitBuffer += e.key; |
| clearTimeout(digitTimer); |
| digitTimer = setTimeout(flushTableDigits, 500); |
| return; |
| } |
| clearTimeout(digitTimer); |
| flushTableDigits(); |
| waitingForTableNum = false; |
| } |
| if (e.key === "t" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) { |
| e.preventDefault(); |
| waitingForTableNum = true; |
| waitingForLevelNum = false; |
| digitBuffer = ""; |
| clearTimeout(digitTimer); |
| return; |
| } |
| |
| // L + digits for level selection (multi-digit: L10, L12, etc.) |
| if (waitingForLevelNum) { |
| if (/^\d$/.test(e.key)) { |
| e.preventDefault(); |
| digitBuffer += e.key; |
| clearTimeout(digitTimer); |
| digitTimer = setTimeout(flushLevelDigits, 500); |
| return; |
| } |
| clearTimeout(digitTimer); |
| flushLevelDigits(); |
| waitingForLevelNum = false; |
| } |
| if (e.key === "l" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) { |
| e.preventDefault(); |
| waitingForLevelNum = true; |
| waitingForTableNum = false; |
| digitBuffer = ""; |
| clearTimeout(digitTimer); |
| return; |
| } |
| |
| if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) { |
| e.preventDefault(); |
| doUndo(); |
| return; |
| } |
| if ((e.ctrlKey || e.metaKey) && (e.key === "Z" || e.key === "y")) { |
| e.preventDefault(); |
| doRedo(); |
| return; |
| } |
| |
| if (e.key === "Enter") { |
| e.preventDefault(); |
| doSave(); |
| return; |
| } |
| |
| if (e.key === "Escape" && linkMode) { |
| e.preventDefault(); |
| exitLinkMode(); |
| return; |
| } |
| |
| const k = e.key.toLowerCase(); |
| |
| // Shift+R / Shift+C = group header (one level above active) |
| if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && (k === "r" || k === "c")) { |
| if (linkMode) return; |
| e.preventDefault(); |
| const groupLevel = activeLevel + 1; |
| if (groupLevel > maxLevel) { |
| maxLevel = groupLevel; |
| renderLevelBar(); |
| } |
| const label = k === "r" |
| ? `row_header_${groupLevel}` |
| : `col_header_${groupLevel}`; |
| |
| const DATA_LABELS = new Set(["value","aggregation","row_header","col_header"]); |
| if (activeTable === 0) { |
| if (!confirm(t("t0_warning"))) return; |
| } |
| |
| if (selectedCells.size > 0) pushUndo(); |
| selectedCells.forEach(key => { |
| cellLabels[key] = {label: label, table: activeTable}; |
| delete commentLinks[key]; |
| }); |
| applyLabelClasses(); |
| drawCommentLinks(); |
| return; |
| } |
| |
| if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return; |
| |
| if (linkMode) return; |
| |
| let label = null; |
| let clear = false; |
| |
| if (k === "x") { |
| clear = true; |
| } else if (k === "r") { |
| label = `row_header_${activeLevel}`; |
| } else if (k === "c") { |
| label = `col_header_${activeLevel}`; |
| } else if (SIMPLE_LABELS[k]) { |
| label = SIMPLE_LABELS[k]; |
| } else { |
| return; |
| } |
| |
| e.preventDefault(); |
| |
| const DATA_LABELS = new Set(["value","aggregation","row_header","col_header"]); |
| const isDataLabel = label && DATA_LABELS.has(label.replace(/_\d+$/, "")); |
| if (isDataLabel && activeTable === 0) { |
| if (!confirm(t("t0_warning"))) return; |
| } |
| |
| if (selectedCells.size > 0) pushUndo(); |
| |
| if (clear) { |
| selectedCells.forEach(key => { |
| delete cellLabels[key]; |
| delete commentLinks[key]; |
| }); |
| } else { |
| selectedCells.forEach(key => { |
| cellLabels[key] = {label: label, table: activeTable}; |
| if (label !== "comment") delete commentLinks[key]; |
| }); |
| } |
| applyLabelClasses(); |
| drawCommentLinks(); |
| |
| if (label === "comment" && selectedCells.size === 1) { |
| const commentKey = [...selectedCells][0]; |
| enterLinkMode(commentKey); |
| } |
| }); |
| |
| function applyLabelClasses() { |
| const targetSet = new Set(Object.values(commentLinks)); |
| |
| tableWrap.querySelectorAll("td[data-key]").forEach(td => { |
| const key = td.dataset.key; |
| td.className = td.className.replace(/\blabel-\S+/g, "").trim(); |
| td.querySelectorAll(".tid").forEach(d => d.remove()); |
| td.querySelectorAll(".label-tag").forEach(d => d.remove()); |
| td.querySelectorAll(".comment-target-badge").forEach(d => d.remove()); |
| |
| const cell = currentSheet && currentSheet.cells.find(c => `${c.row},${c.col}` === key); |
| if (cell && cell.comment) td.classList.add("has-comment"); |
| |
| const info = cellLabels[key]; |
| if (info) { |
| td.classList.add(`label-${info.label}`); |
| const badge = document.createElement("span"); |
| const tc = TABLE_COLORS[info.table] || TABLE_COLORS[info.table % TABLE_COLORS.length]; |
| badge.className = "tid"; |
| badge.style.background = tc; |
| if (info.table === 0) badge.style.opacity = "0.5"; |
| badge.textContent = `T${info.table}`; |
| td.prepend(badge); |
| const tag = labelTag(info.label); |
| if (tag) { |
| const tagEl = document.createElement("span"); |
| tagEl.className = "label-tag"; |
| tagEl.textContent = tag; |
| td.prepend(tagEl); |
| } |
| |
| if (info.label === "comment" && commentLinks[key]) { |
| const linkBadge = document.createElement("span"); |
| linkBadge.className = "comment-target-badge"; |
| linkBadge.textContent = "\u2192" + commentLinks[key]; |
| linkBadge.title = `linked to ${commentLinks[key]}`; |
| td.appendChild(linkBadge); |
| } |
| } |
| |
| if (targetSet.has(key)) { |
| const tgtBadge = document.createElement("span"); |
| tgtBadge.className = "comment-target-badge"; |
| tgtBadge.textContent = "\u2190COM"; |
| td.appendChild(tgtBadge); |
| } |
| }); |
| } |
| |
| /* ── Clear All ──────────────────────────────────────────────────────── */ |
| document.getElementById("clear-all-btn").addEventListener("click", () => { |
| if (!currentSheet) return; |
| if (Object.keys(cellLabels).length === 0 && Object.keys(commentLinks).length === 0) return; |
| if (!confirm(t("clear_all_confirm"))) return; |
| pushUndo(); |
| cellLabels = {}; |
| commentLinks = {}; |
| exitLinkMode(); |
| applyLabelClasses(); |
| drawCommentLinks(); |
| }); |
| |
| /* ── Previous sheet / history ──────────────────────────────────────── */ |
| const prevBtn = document.getElementById("prev-btn"); |
| const historyPopup = document.getElementById("history-popup"); |
| |
| prevBtn.addEventListener("click", async () => { |
| if (historyPopup.classList.contains("open")) { |
| historyPopup.classList.remove("open"); |
| return; |
| } |
| historyPopup.innerHTML = `<div class="history-header">${t("history_title")}</div> |
| <div class="history-empty">${t("loading")}</div>`; |
| historyPopup.classList.add("open"); |
| |
| try { |
| const resp = await fetch("/api/history", {headers: authHeaders()}); |
| const data = await safeJson(resp); |
| const sheets = data.sheets || []; |
| if (sheets.length === 0) { |
| historyPopup.innerHTML = `<div class="history-header">${t("history_title")}</div> |
| <div class="history-empty">${t("history_empty")}</div>`; |
| return; |
| } |
| let html = `<div class="history-header">${t("history_title")}</div>`; |
| for (const s of sheets) { |
| const ts = s.timestamp ? new Date(s.timestamp).toLocaleString() : ""; |
| const parts = []; |
| if (s.labeler) parts.push(s.labeler); |
| if (s.num_labeled_cells) parts.push(`${s.num_labeled_cells} ${t("history_cells")}`); |
| if (ts) parts.push(ts); |
| const meta = parts.join(" · "); |
| html += `<div class="history-item" data-dfid="${s.drive_file_id}" |
| data-sn="${escHtml(s.sheet_name)}" data-mri="${s.meta_row_index}" |
| data-lfid="${s.labels_file_id || ""}"> |
| <span class="hi-name">${escHtml(s.file_name)} / ${escHtml(s.sheet_name)}</span> |
| <span class="hi-meta">${meta}</span> |
| </div>`; |
| } |
| historyPopup.innerHTML = html; |
| |
| historyPopup.querySelectorAll(".history-item").forEach(item => { |
| item.addEventListener("click", () => { |
| historyPopup.classList.remove("open"); |
| loadPreviousSheet( |
| item.dataset.dfid, |
| item.dataset.sn, |
| parseInt(item.dataset.mri), |
| item.dataset.lfid || "", |
| ); |
| }); |
| }); |
| } catch (err) { |
| historyPopup.innerHTML = `<div class="history-empty" style="color:#dc2626">${err.message}</div>`; |
| } |
| }); |
| |
| document.addEventListener("click", (e) => { |
| if (!e.target.closest("#prev-wrapper")) historyPopup.classList.remove("open"); |
| }); |
| |
| async function loadPreviousSheet(driveFileId, sheetName, metaRowIndex, labelsFileId) { |
| saveBtn.disabled = true; skipBtn.disabled = true; |
| tableWrap.innerHTML = `<p style="padding:40px;color:#888">${t("loading")}</p>`; |
| |
| try { |
| let url = `/api/load_labeled_sheet?drive_file_id=${encodeURIComponent(driveFileId)}` |
| + `&sheet_name=${encodeURIComponent(sheetName)}` |
| + `&meta_row_index=${metaRowIndex}`; |
| if (labelsFileId) url += `&labels_file_id=${encodeURIComponent(labelsFileId)}`; |
| const resp = await fetch(url, {headers: authHeaders()}); |
| const data = await safeJson(resp); |
| if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`); |
| |
| currentSheet = data.sheet; |
| cellLabels = {}; |
| commentLinks = {}; |
| exitLinkMode(); |
| selectedCells.clear(); |
| lastClickedKey = null; |
| maxTableId = 1; |
| maxLevel = 3; |
| activeLevel = 1; |
| clearUndoRedo(); |
| |
| if (data.saved_labels) { |
| prefillFromSavedLabels(data.saved_labels); |
| } |
| |
| refreshDynamicCSS(); |
| renderTableBar(); |
| renderLevelBar(); |
| renderLegend(); |
| setActiveTable(1); |
| sheetInfoEl.textContent = `${currentSheet.spreadsheet_id} / ${currentSheet.sheet_name}`; |
| renderTable(); |
| saveBtn.disabled = false; skipBtn.disabled = false; |
| } catch (err) { |
| tableWrap.innerHTML = `<p style="padding:40px;color:red">${t("error_prefix")}${err.message}</p>`; |
| if (reviewMode) throw err; |
| } |
| } |
| |
| function prefillFromSavedLabels(labels) { |
| if (!labels || !labels.length) return; |
| for (const c of labels) { |
| const key = `${c.row},${c.col}`; |
| cellLabels[key] = { label: c.label, table: c.table || 0 }; |
| if (c.table > maxTableId) maxTableId = c.table; |
| const m = c.label.match(/^(?:row_header|col_header)_(\d+)$/); |
| if (m) { |
| const lv = parseInt(m[1]); |
| if (lv > maxLevel) maxLevel = lv; |
| } |
| if (c.label === "comment" && c.comment_target_row != null) { |
| commentLinks[key] = `${c.comment_target_row},${c.comment_target_col}`; |
| } |
| } |
| } |
| |
| /* ── Skip ─────────────────────────────────────────────────────────── */ |
| skipBtn.addEventListener("click", doSkip); |
| |
| async function doSkip() { |
| if (!currentSheet) return; |
| if (!confirm(t("skip_confirm"))) return; |
| |
| const username = usernameInput.value.trim() || "anonymous"; |
| saveBtn.disabled = true; skipBtn.disabled = true; |
| |
| try { |
| const resp = await fetch("/api/skip", { |
| method: "POST", |
| headers: jsonAuthHeaders(), |
| credentials: "include", |
| body: JSON.stringify({ |
| drive_file_id: currentSheet.drive_file_id || "", |
| meta_row_index: currentSheet.meta_row_index || 0, |
| labeled_by: username, |
| }), |
| }); |
| const data = await safeJson(resp); |
| if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`); |
| if (data.done) { |
| const hasWork = data.progress && (data.progress.in_progress > 0 || data.progress.unlabelled > 0); |
| const msg = hasWork ? t("waiting_others") : t("all_done"); |
| tableWrap.innerHTML = `<div class="done-msg">${msg}</div>`; |
| progressEl.textContent = hasWork ? "" : t("done"); |
| if (data.progress) updateProgress(data.progress); |
| sheetInfoEl.textContent = ""; |
| return; |
| } |
| currentSheet = data.sheet; |
| cellLabels = {}; |
| commentLinks = {}; |
| exitLinkMode(); |
| selectedCells.clear(); |
| lastClickedKey = null; |
| maxTableId = 1; |
| maxLevel = 3; |
| activeLevel = 1; |
| clearUndoRedo(); |
| currentModelVersion = data.model_version || null; |
| prefillFromPredictions(data.predictions); |
| refreshDynamicCSS(); |
| renderTableBar(); |
| renderLevelBar(); |
| renderLegend(); |
| setActiveTable(1); |
| updateProgress(data.progress); |
| let infoText = `${currentSheet.spreadsheet_id} / ${currentSheet.sheet_name}`; |
| if (hasPredictions) infoText += ` — ${t("ai_assisted")}`; |
| sheetInfoEl.textContent = infoText; |
| renderTable(); |
| saveBtn.disabled = false; skipBtn.disabled = false; |
| } catch (err) { |
| alert(t("error_prefix") + err.message); |
| saveBtn.disabled = false; skipBtn.disabled = false; |
| } |
| } |
| |
| /* ── Save & Next ───────────────────────────────────────────────────── */ |
| saveBtn.addEventListener("click", doSave); |
| |
| async function doSave() { |
| if (!currentSheet) return; |
| if (ragLabelMode) { await doRagLabelSave(); return; } |
| if (reviewMode) { await doReviewSave(); return; } |
| |
| const nonEmptyCells = currentSheet.cells.filter(c => c.value !== ""); |
| const labeledCount = Object.keys(cellLabels).length; |
| const coverage = nonEmptyCells.length > 0 ? labeledCount / nonEmptyCells.length : 1; |
| if (coverage < 0.5) { |
| const pct = Math.round(coverage * 100); |
| if (!confirm(`Only ${pct}% of non-empty cells are labeled. Save anyway?`)) { |
| return; |
| } |
| } |
| |
| const username = usernameInput.value.trim() || "anonymous"; |
| saveBtn.disabled = true; skipBtn.disabled = true; |
| saveBtn.textContent = t("saving"); |
| |
| const cellMap = {}; |
| currentSheet.cells.forEach(c => { cellMap[`${c.row},${c.col}`] = c; }); |
| |
| const labeledCells = []; |
| for (const [key, info] of Object.entries(cellLabels)) { |
| const [r, c] = key.split(",").map(Number); |
| const orig = cellMap[key]; |
| const entry = { |
| row: r, col: c, |
| value: orig ? orig.value : "", |
| label: info.label, |
| table: info.table |
| }; |
| if (info.label === "comment" && commentLinks[key]) { |
| const [tr, tc] = commentLinks[key].split(",").map(Number); |
| entry.comment_target_row = tr; |
| entry.comment_target_col = tc; |
| } |
| labeledCells.push(entry); |
| } |
| |
| currentSheet.cells.forEach(cell => { |
| const key = `${cell.row},${cell.col}`; |
| if (!cellLabels[key] && cell.value !== "") { |
| labeledCells.push({row: cell.row, col: cell.col, value: cell.value, label: "unlabeled", table: 0}); |
| } |
| }); |
| |
| try { |
| const resp = await fetch("/api/save_and_next", { |
| method: "POST", |
| headers: jsonAuthHeaders(), |
| credentials: "include", |
| body: JSON.stringify({ |
| spreadsheet_id: currentSheet.spreadsheet_id, |
| sheet_name: currentSheet.sheet_name, |
| labeled_by: username, |
| drive_file_id: currentSheet.drive_file_id || "", |
| meta_row_index: currentSheet.meta_row_index || 0, |
| cells: labeledCells, |
| }), |
| }); |
| const data = await safeJson(resp); |
| if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`); |
| if (data.done) { |
| const hasWork = data.progress && (data.progress.in_progress > 0 || data.progress.unlabelled > 0); |
| const msg = hasWork ? t("waiting_others") : t("all_done"); |
| tableWrap.innerHTML = `<div class="done-msg">${msg}</div>`; |
| progressEl.textContent = hasWork ? "" : t("done"); |
| if (data.progress) updateProgress(data.progress); |
| sheetInfoEl.textContent = ""; |
| saveBtn.textContent = t("save_btn"); |
| return; |
| } |
| currentSheet = data.sheet; |
| cellLabels = {}; |
| commentLinks = {}; |
| exitLinkMode(); |
| selectedCells.clear(); |
| lastClickedKey = null; |
| maxTableId = 1; |
| maxLevel = 3; |
| activeLevel = 1; |
| clearUndoRedo(); |
| currentModelVersion = data.model_version || null; |
| prefillFromPredictions(data.predictions); |
| refreshDynamicCSS(); |
| renderTableBar(); |
| renderLevelBar(); |
| renderLegend(); |
| setActiveTable(1); |
| updateProgress(data.progress); |
| let infoText2 = `${currentSheet.spreadsheet_id} / ${currentSheet.sheet_name}`; |
| if (hasPredictions) infoText2 += ` — ${t("ai_assisted")}`; |
| sheetInfoEl.textContent = infoText2; |
| renderTable(); |
| saveBtn.disabled = false; skipBtn.disabled = false; |
| saveBtn.textContent = t("save_btn"); |
| fetchAccuracy(); |
| } catch (err) { |
| alert(t("save_error") + err.message); |
| saveBtn.disabled = false; skipBtn.disabled = false; |
| saveBtn.textContent = t("save_btn"); |
| } |
| } |
| |
| /* ── RAG eval structure labeling (never training data) ─────────────── */ |
| let ragLabelMode = false; |
| let ragLabelList = []; |
| let ragLabelIndex = 0; |
| const ragLabelBtn = document.getElementById("rag-label-btn"); |
| const ragLabelNav = document.getElementById("rag-label-nav"); |
| const ragLabelCounter = document.getElementById("rag-label-counter"); |
| const ragLabelInput = document.getElementById("rag-label-input"); |
| const ragLabelPrevBtn = document.getElementById("rag-label-prev-btn"); |
| const ragLabelNextBtn = document.getElementById("rag-label-next-btn"); |
| const exitRagLabelBtn = document.getElementById("exit-rag-label"); |
| const prevWrapperEl = document.getElementById("prev-wrapper"); |
| |
| function updateRagLabelUI() { |
| ragLabelInput.value = ragLabelIndex + 1; |
| ragLabelInput.max = ragLabelList.length; |
| ragLabelCounter.textContent = `/ ${ragLabelList.length}`; |
| ragLabelPrevBtn.disabled = ragLabelIndex <= 0; |
| ragLabelNextBtn.disabled = ragLabelIndex >= ragLabelList.length - 1; |
| } |
| |
| ragLabelInput.addEventListener("change", () => { |
| const n = parseInt(ragLabelInput.value); |
| if (n >= 1 && n <= ragLabelList.length) loadRagLabelSheet(n - 1); |
| else ragLabelInput.value = ragLabelIndex + 1; |
| }); |
| ragLabelInput.addEventListener("keydown", (e) => { |
| if (e.key === "Enter") { e.preventDefault(); ragLabelInput.blur(); ragLabelInput.dispatchEvent(new Event("change")); } |
| }); |
| |
| async function loadRagLabelSheet(idx) { |
| if (idx < 0 || idx >= ragLabelList.length) return; |
| ragLabelIndex = idx; |
| updateRagLabelUI(); |
| const s = ragLabelList[idx]; |
| saveBtn.disabled = true; skipBtn.disabled = true; |
| tableWrap.innerHTML = `<p style="padding:40px;color:#888">${t("loading")}</p>`; |
| try { |
| const url = `/api/rag_label/sheet?sheet_id=${encodeURIComponent(s.sheet_id)}`; |
| const resp = await fetch(url, {headers: authHeaders()}); |
| const data = await safeJson(resp); |
| if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`); |
| |
| currentSheet = data.sheet; |
| if (currentSheet.rag_sheet_id == null) currentSheet.rag_sheet_id = s.sheet_id; |
| cellLabels = {}; |
| commentLinks = {}; |
| exitLinkMode(); |
| selectedCells.clear(); |
| lastClickedKey = null; |
| maxTableId = 1; |
| maxLevel = 3; |
| activeLevel = 1; |
| clearUndoRedo(); |
| currentModelVersion = data.model_version || null; |
| if (data.saved_labels && data.saved_labels.length) { |
| prefillFromSavedLabels(data.saved_labels); |
| } else { |
| prefillFromPredictions(data.predictions); |
| } |
| refreshDynamicCSS(); |
| renderTableBar(); |
| renderLevelBar(); |
| renderLegend(); |
| setActiveTable(1); |
| progressEl.textContent = `RAG structure ${ragLabelIndex + 1}/${ragLabelList.length} (not training)`; |
| let infoText = `[RAG] ${currentSheet.spreadsheet_id} / ${currentSheet.sheet_name}`; |
| if (hasPredictions) infoText += ` — ${t("ai_assisted")}`; |
| sheetInfoEl.textContent = infoText; |
| renderTable(); |
| saveBtn.disabled = false; |
| skipBtn.disabled = true; |
| saveBtn.textContent = t("rag_label_save_btn") || "SAVE (RAG only) \u25B6"; |
| } catch (err) { |
| tableWrap.innerHTML = `<p style="padding:40px;color:red">${t("error_prefix")}${err.message}</p>`; |
| } |
| } |
| |
| function enterRagLabelMode(sheets, nQueriesTotal) { |
| if (reviewMode) exitReviewMode(); |
| ragLabelList = sheets; |
| ragLabelIndex = 0; |
| ragLabelMode = true; |
| ragLabelBtn.classList.add("active"); |
| ragLabelNav.classList.add("visible"); |
| skipBtn.style.display = "none"; |
| if (prevWrapperEl) prevWrapperEl.style.display = "none"; |
| const nq = nQueriesTotal != null ? nQueriesTotal : sheets.reduce((a, s) => a + (s.qa_count || 0), 0); |
| progressEl.textContent = `RAG structure: ${sheets.length} sheet(s), ${nq} Q&A — not training`; |
| updateRagLabelUI(); |
| loadRagLabelSheet(0); |
| } |
| |
| function exitRagLabelMode(reloadNext = true) { |
| ragLabelMode = false; |
| ragLabelList = []; |
| ragLabelIndex = 0; |
| ragLabelBtn.classList.remove("active"); |
| ragLabelNav.classList.remove("visible"); |
| skipBtn.style.display = ""; |
| if (prevWrapperEl) prevWrapperEl.style.display = ""; |
| saveBtn.textContent = t("save_btn"); |
| if (reloadNext) loadNextSheet(); |
| } |
| |
| ragLabelBtn.addEventListener("click", async () => { |
| if (ragLabelMode) { exitRagLabelMode(true); return; } |
| if (reviewMode) exitReviewMode(); |
| ragLabelBtn.textContent = "..."; |
| try { |
| const resp = await fetch("/api/rag_label/sheets", {headers: authHeaders()}); |
| const data = await safeJson(resp); |
| if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`); |
| const sheets = data.sheets || []; |
| const d = data.diagnostics || {}; |
| if (sheets.length === 0) { |
| let msg = "No RAG sheets could be listed.\n\n"; |
| if (d.dataset_exists === false && !d.drive_rag_folder_configured) { |
| msg += "The file is missing on this server:\n" + (d.dataset_path || "data/rag_eval_dataset.json") |
| + "\n\nOn Hugging Face Spaces, the data/ folder is usually not in git. Either upload rag_eval_dataset.json into data/, " |
| + "or use OAuth and set DRIVE_ROOT_FOLDER_ID (creates 'rag_eval' next to 'data' and 'labels'), " |
| + "or DRIVE_RAG_EVAL_FOLDER_ID, or DRIVE_RAG_EVAL_PARENT_FOLDER_ID, or DRIVE_LABELS_FOLDER_ID (sibling of 'labels')."; |
| } else if (d.dataset_exists === false && d.drive_rag_folder_configured) { |
| msg += "Local mirror missing and no rag_eval_dataset.json on Drive in the resolved 'rag_eval' folder. " |
| + "Upload rag_eval_dataset.json there, or add a Q&A from /rag-eval to create it."; |
| } else if ((d.raw_query_count || 0) === 0) { |
| msg += "No Q&A rows yet. Add questions from the RAG eval UI (e.g. /rag-eval) for sheets in metadata with status rag_eval; the dataset file on Drive is OK."; |
| } else { |
| msg += "Found " + d.raw_query_count + " queries, but none could be matched to a sheet. " |
| + "Each query needs sheet_id or both drive_file_id and sheet_name."; |
| if ((d.queries_skipped_no_sheet_key || 0) > 0) { |
| msg += "\n(" + d.queries_skipped_no_sheet_key + " queries skipped for missing ids.)"; |
| } |
| } |
| alert(msg); |
| return; |
| } |
| enterRagLabelMode(sheets, data.n_queries); |
| } catch (err) { |
| alert("RAG label error: " + err.message); |
| } finally { |
| ragLabelBtn.textContent = "RAG label"; |
| } |
| }); |
| |
| ragLabelPrevBtn.addEventListener("click", () => loadRagLabelSheet(ragLabelIndex - 1)); |
| ragLabelNextBtn.addEventListener("click", () => loadRagLabelSheet(ragLabelIndex + 1)); |
| exitRagLabelBtn.addEventListener("click", () => exitRagLabelMode(true)); |
| |
| async function doRagLabelSave() { |
| if (!currentSheet || !currentSheet.rag_sheet_id) return; |
| const username = usernameInput.value.trim() || "anonymous"; |
| saveBtn.disabled = true; |
| saveBtn.textContent = t("saving"); |
| |
| const cellMap = {}; |
| currentSheet.cells.forEach(c => { cellMap[`${c.row},${c.col}`] = c; }); |
| |
| const labeledCells = []; |
| for (const [key, info] of Object.entries(cellLabels)) { |
| const [r, c] = key.split(",").map(Number); |
| const orig = cellMap[key]; |
| const entry = { row: r, col: c, value: orig ? orig.value : "", label: info.label, table: info.table }; |
| if (info.label === "comment" && commentLinks[key]) { |
| const [tr, tc] = commentLinks[key].split(",").map(Number); |
| entry.comment_target_row = tr; |
| entry.comment_target_col = tc; |
| } |
| labeledCells.push(entry); |
| } |
| currentSheet.cells.forEach(cell => { |
| const key = `${cell.row},${cell.col}`; |
| if (!cellLabels[key] && cell.value !== "") { |
| labeledCells.push({row: cell.row, col: cell.col, value: cell.value, label: "unlabeled", table: 0}); |
| } |
| }); |
| |
| try { |
| const resp = await fetch("/api/rag_label/save", { |
| method: "POST", |
| headers: jsonAuthHeaders(), |
| credentials: "include", |
| body: JSON.stringify({ |
| sheet_id: currentSheet.rag_sheet_id, |
| spreadsheet_id: currentSheet.spreadsheet_id, |
| sheet_name: currentSheet.sheet_name, |
| labeled_by: username, |
| drive_file_id: currentSheet.drive_file_id || "", |
| cells: labeledCells, |
| }), |
| }); |
| const data = await safeJson(resp); |
| if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`); |
| |
| if (ragLabelIndex < ragLabelList.length - 1) { |
| await loadRagLabelSheet(ragLabelIndex + 1); |
| } else { |
| tableWrap.innerHTML = `<div class="done-msg">${t("rag_label_done") || "RAG structure labeling complete. Labels are in data/rag_eval_labeled/ (never used for training)."}</div>`; |
| sheetInfoEl.textContent = ""; |
| exitRagLabelMode(false); |
| progressEl.textContent = ""; |
| loadNextSheet(); |
| } |
| } catch (err) { |
| let msg = t("save_error") + err.message; |
| if (String(err.message || "").includes("Not authenticated")) { |
| msg += " " + (t("relogin_hint") || "Log in again (refresh the page and enter the password)."); |
| authToken = ""; |
| localStorage.removeItem("labeler_token"); |
| if (loginOverlay) loginOverlay.style.display = "flex"; |
| } |
| alert(msg); |
| } finally { |
| saveBtn.disabled = false; |
| saveBtn.textContent = t("rag_label_save_btn") || "SAVE (RAG only) \u25B6"; |
| } |
| } |
| |
| /* ── Review mode ──────────────────────────────────────────────────── */ |
| let reviewMode = false; |
| let reviewList = []; |
| let reviewIndex = 0; |
| const reviewBtn = document.getElementById("review-btn"); |
| const reviewNav = document.getElementById("review-nav"); |
| const reviewCounter = document.getElementById("review-counter"); |
| const reviewInput = document.getElementById("review-input"); |
| const reviewPrevBtn = document.getElementById("review-prev-btn"); |
| const reviewNextBtn = document.getElementById("review-next-btn"); |
| const exitReviewBtn = document.getElementById("exit-review"); |
| |
| function updateReviewUI() { |
| reviewInput.value = reviewIndex + 1; |
| reviewInput.max = reviewList.length; |
| reviewCounter.textContent = `/ ${reviewList.length}`; |
| reviewPrevBtn.disabled = reviewIndex <= 0; |
| reviewNextBtn.disabled = reviewIndex >= reviewList.length - 1; |
| } |
| |
| reviewInput.addEventListener("change", () => { |
| const n = parseInt(reviewInput.value); |
| if (n >= 1 && n <= reviewList.length) loadReviewSheet(n - 1); |
| else reviewInput.value = reviewIndex + 1; |
| }); |
| reviewInput.addEventListener("keydown", (e) => { |
| if (e.key === "Enter") { e.preventDefault(); reviewInput.blur(); reviewInput.dispatchEvent(new Event("change")); } |
| }); |
| |
| async function loadReviewSheet(idx, skipFailures = true) { |
| if (idx < 0 || idx >= reviewList.length) return; |
| reviewIndex = idx; |
| updateReviewUI(); |
| const s = reviewList[idx]; |
| try { |
| await loadPreviousSheet(s.drive_file_id, s.sheet_name, s.meta_row_index, s.labels_file_id || ""); |
| saveBtn.textContent = t("review_save_btn") || "SAVE & NEXT \u25B6"; |
| } catch (err) { |
| if (skipFailures && idx < reviewList.length - 1) { |
| console.warn(`Review: skipping ${s.sheet_name} (${err.message}), trying next...`); |
| await loadReviewSheet(idx + 1, true); |
| } |
| } |
| } |
| |
| function enterReviewMode(sheets) { |
| reviewList = sheets; |
| reviewIndex = 0; |
| reviewMode = true; |
| reviewBtn.classList.add("active"); |
| reviewNav.classList.add("visible"); |
| skipBtn.style.display = "none"; |
| updateReviewUI(); |
| loadReviewSheet(0); |
| } |
| |
| function exitReviewMode() { |
| reviewMode = false; |
| reviewList = []; |
| reviewIndex = 0; |
| reviewBtn.classList.remove("active"); |
| reviewNav.classList.remove("visible"); |
| skipBtn.style.display = ""; |
| saveBtn.textContent = t("save_btn"); |
| loadNextSheet(); |
| } |
| |
| reviewBtn.addEventListener("click", async () => { |
| if (reviewMode) { exitReviewMode(); return; } |
| if (ragLabelMode) exitRagLabelMode(false); |
| reviewBtn.textContent = "..."; |
| try { |
| const resp = await fetch("/api/review_list", {headers: authHeaders()}); |
| const data = await safeJson(resp); |
| if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`); |
| const sheets = data.sheets || []; |
| if (sheets.length === 0) { alert("No labeled sheets to review."); return; } |
| enterReviewMode(sheets); |
| } catch (err) { |
| alert("Review error: " + err.message); |
| } finally { |
| reviewBtn.textContent = "review"; |
| } |
| }); |
| |
| reviewPrevBtn.addEventListener("click", () => loadReviewSheet(reviewIndex - 1)); |
| reviewNextBtn.addEventListener("click", () => loadReviewSheet(reviewIndex + 1)); |
| exitReviewBtn.addEventListener("click", exitReviewMode); |
| |
| async function doReviewSave() { |
| if (!currentSheet) return; |
| const username = usernameInput.value.trim() || "anonymous"; |
| saveBtn.disabled = true; |
| saveBtn.textContent = t("saving"); |
| |
| const cellMap = {}; |
| currentSheet.cells.forEach(c => { cellMap[`${c.row},${c.col}`] = c; }); |
| |
| const labeledCells = []; |
| for (const [key, info] of Object.entries(cellLabels)) { |
| const [r, c] = key.split(",").map(Number); |
| const orig = cellMap[key]; |
| const entry = { row: r, col: c, value: orig ? orig.value : "", label: info.label, table: info.table }; |
| if (info.label === "comment" && commentLinks[key]) { |
| const [tr, tc] = commentLinks[key].split(",").map(Number); |
| entry.comment_target_row = tr; |
| entry.comment_target_col = tc; |
| } |
| labeledCells.push(entry); |
| } |
| currentSheet.cells.forEach(cell => { |
| const key = `${cell.row},${cell.col}`; |
| if (!cellLabels[key] && cell.value !== "") { |
| labeledCells.push({row: cell.row, col: cell.col, value: cell.value, label: "unlabeled", table: 0}); |
| } |
| }); |
| |
| try { |
| const resp = await fetch("/api/save_review", { |
| method: "POST", |
| headers: jsonAuthHeaders(), |
| credentials: "include", |
| body: JSON.stringify({ |
| spreadsheet_id: currentSheet.spreadsheet_id, |
| sheet_name: currentSheet.sheet_name, |
| labeled_by: username, |
| drive_file_id: currentSheet.drive_file_id || "", |
| meta_row_index: currentSheet.meta_row_index || 0, |
| cells: labeledCells, |
| }), |
| }); |
| const data = await safeJson(resp); |
| if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`); |
| |
| if (reviewIndex < reviewList.length - 1) { |
| await loadReviewSheet(reviewIndex + 1); |
| } else { |
| tableWrap.innerHTML = `<div class="done-msg">${t("review_done")}</div>`; |
| sheetInfoEl.textContent = ""; |
| saveBtn.disabled = true; |
| } |
| } catch (err) { |
| alert(t("save_error") + err.message); |
| } finally { |
| saveBtn.disabled = false; |
| saveBtn.textContent = t("review_save_btn") || "SAVE & NEXT \u25B6"; |
| } |
| } |
| |
| /* ── Auth ───────────────────────────────────────────────────────────── */ |
| const loginOverlay = document.getElementById("login-overlay"); |
| const loginBtn = document.getElementById("login-btn"); |
| const loginPwd = document.getElementById("login-password"); |
| const loginErr = document.getElementById("login-error"); |
| |
| let authToken = localStorage.getItem("labeler_token") || ""; |
| |
| /** Only Authorization — never send ``Content-Type: application/json`` on GET (breaks some proxies / mobile). */ |
| function authHeaders() { |
| const h = {}; |
| if (authToken) h["Authorization"] = `Bearer ${authToken}`; |
| return h; |
| } |
| |
| function jsonAuthHeaders() { |
| const h = authHeaders(); |
| h["Content-Type"] = "application/json"; |
| return h; |
| } |
| |
| async function safeJson(resp) { |
| const text = await resp.text(); |
| try { return JSON.parse(text); } |
| catch { throw new Error(text.startsWith("<") ? "Server returned HTML instead of JSON — the app may be restarting. Try again in a minute." : text.slice(0, 200)); } |
| } |
| |
| async function checkAuth() { |
| let mode = { password_required: true }; |
| try { |
| const rm = await fetch("/api/auth/mode"); |
| if (rm.ok) { |
| const ct = rm.headers.get("content-type") || ""; |
| if (ct.includes("application/json")) mode = await rm.json(); |
| } |
| } catch { /* ignore */ } |
| |
| if (!mode.password_required) { |
| loginOverlay.style.display = "none"; |
| loadNextSheet(); |
| return; |
| } |
| |
| try { |
| const r = await fetch("/api/auth/check", {headers: authHeaders(), credentials: "include"}); |
| const ct = r.headers.get("content-type") || ""; |
| if (!r.ok || !ct.includes("application/json")) { |
| authToken = ""; |
| localStorage.removeItem("labeler_token"); |
| loginOverlay.style.display = "flex"; |
| return; |
| } |
| const d = await r.json(); |
| if (d.authenticated) { |
| loginOverlay.style.display = "none"; |
| loadNextSheet(); |
| } else { |
| authToken = ""; |
| localStorage.removeItem("labeler_token"); |
| loginOverlay.style.display = "flex"; |
| } |
| } catch (e) { |
| console.warn("auth/check failed", e); |
| authToken = ""; |
| localStorage.removeItem("labeler_token"); |
| loginOverlay.style.display = "flex"; |
| } |
| } |
| |
| async function doLogin() { |
| loginErr.style.display = "none"; |
| try { |
| const r = await fetch("/api/auth", { |
| method: "POST", |
| headers: {"Content-Type": "application/json"}, |
| credentials: "include", |
| body: JSON.stringify({password: loginPwd.value}), |
| }); |
| if (r.ok) { |
| const d = await r.json(); |
| if (d.token) { |
| authToken = d.token; |
| localStorage.setItem("labeler_token", authToken); |
| } |
| loginOverlay.style.display = "none"; |
| loadNextSheet(); |
| } else { |
| loginErr.style.display = "block"; |
| } |
| } catch { |
| loginErr.style.display = "block"; |
| } |
| } |
| |
| loginBtn.addEventListener("click", doLogin); |
| loginPwd.addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); }); |
| |
| /* ── Help panel ──────────────────────────────────────────────────────── */ |
| const helpOverlay = document.getElementById("help-overlay"); |
| const helpBody = document.getElementById("help-body"); |
| const helpTitle = document.getElementById("help-title"); |
| let helpLang = "en"; |
| |
| const HELP = {}; |
| HELP.en = ` |
| <h3>What you are doing</h3> |
| <p>You are labeling cells in spreadsheets so that a machine-learning pipeline can learn their structure. |
| Your labels are used for: <strong>(1)</strong> training a graph neural network that predicts cell roles, |
| <strong>(2)</strong> extracting text chunks an LLM can search over.</p> |
| <p class="warn">A mislabeled header means every value under it loses its meaning.</p> |
| |
| <h3>Quick reference</h3> |
| <table class="ref-table"> |
| <tr><th>Key</th><th>Label</th><th>Use for</th></tr> |
| <tr><td><kbd>V</kbd></td><td>value</td><td>Any data cell — numeric or text (measurement, name, code, status)</td></tr> |
| <tr><td><kbd>R</kbd></td><td>row_header</td><td>Row label on the left side (at active level)</td></tr> |
| <tr><td><kbd>Shift</kbd>+<kbd>R</kbd></td><td>row_header</td><td>Same, one level above active (group header shortcut)</td></tr> |
| <tr><td><kbd>C</kbd></td><td>col_header</td><td>Column label at the top (at active level)</td></tr> |
| <tr><td><kbd>Shift</kbd>+<kbd>C</kbd></td><td>col_header</td><td>Same, one level above active</td></tr> |
| <tr><td><kbd>A</kbd></td><td>aggregation</td><td>Computed cell (SUM, AVERAGE, formula)</td></tr> |
| <tr><td><kbd>H</kbd></td><td>header</td><td>Table title, section heading — gives context to the data below</td></tr> |
| <tr><td><kbd>M</kbd></td><td>metadata</td><td>Footnotes, source notes, dates, disclaimers — not a title</td></tr> |
| <tr><td><kbd>N</kbd></td><td>comment</td><td>Inline footnote/annotation referring to another cell</td></tr> |
| <tr><td><kbd>E</kbd></td><td>empty</td><td>Intentionally blank spacer cell</td></tr> |
| <tr><td><kbd>J</kbd></td><td>junk</td><td>Useless/erroneous content to discard (e.g. “Unnamed 1”)</td></tr> |
| <tr><td><kbd>X</kbd></td><td><em>(clear)</em></td><td>Remove a label</td></tr> |
| </table> |
| |
| <h3>Selection</h3> |
| <ul> |
| <li><strong>Click</strong> a cell to select it.</li> |
| <li><kbd>Shift</kbd>+click — range select from last click.</li> |
| <li><kbd>Ctrl/Cmd</kbd>+click — toggle individual cells.</li> |
| <li>Then press a label key to apply to entire selection.</li> |
| </ul> |
| |
| <h3>Tables</h3> |
| <p>Each sheet can have multiple <strong>tables</strong> (separate grids). Assign cells to the correct table.</p> |
| <ul> |
| <li><strong>T0</strong> = sheet-level (titles, notes for the whole sheet).</li> |
| <li><strong>T1, T2, …</strong> = individual tables.</li> |
| <li>Set active table: click in the <strong>Table</strong> bar or <kbd>T</kbd> + digits (e.g. <kbd>T</kbd><kbd>1</kbd><kbd>2</kbd> for T12).</li> |
| <li>Click <strong>[+]</strong> to add more tables (no limit).</li> |
| </ul> |
| <p class="warn">T0 cells do NOT produce chunks. Only T1+ cells generate data. Never put value/header cells in T0.</p> |
| |
| <h3>Header levels</h3> |
| <p>Headers can have multiple hierarchy levels. The <strong>Level</strong> bar controls which level <kbd>R</kbd> and <kbd>C</kbd> assign.</p> |
| <ul> |
| <li><strong>L1</strong> = innermost (most specific, closest to data). Start here.</li> |
| <li><strong>L2</strong> = broader grouping that contains L1.</li> |
| <li><strong>L3, L4, …</strong> = even broader.</li> |
| <li>Set level: click in the <strong>Level</strong> bar or <kbd>L</kbd> + digits (e.g. L12).</li> |
| </ul> |
| |
| <h4>Row headers (R) — multi-level (horizontal)</h4> |
| <p>Multi-level row headers occupy <strong>separate columns</strong>, broadest on the left (highest level number):</p> |
| <pre> Region (R3) | Country (R2) | City (R1) | Sales (V) |
| Europe | Poland | Warsaw | 100 |
| | | Kraków | 80 |
| | Germany | Berlin | 120</pre> |
| <p>System scans <strong>leftward</strong> → chunk: <code>Row: Europe > Poland > Warsaw</code></p> |
| |
| <h4>Row headers — vertical (group rows above data)</h4> |
| <p>Common in financial reports / SAP exports: <strong>group header on its own row</strong> above detail rows, often with aggregation values:</p> |
| <pre> Q1 Q2 |
| Revenue | 50000 | 60000 | ← Shift+R (R2), data = A |
| Products | 30000 | 35000 | ← R (R1), data = V |
| Services | 20000 | 25000 | ← R (R1), data = V |
| Expenses | 40000 | 45000 | ← Shift+R (R2), data = A |
| Salaries | 25000 | 28000 | ← R (R1), data = V |
| Rent | 15000 | 17000 | ← R (R1), data = V</pre> |
| <p><strong>Workflow:</strong> label detail row headers with <kbd>R</kbd>, group headers with <kbd>Shift+R</kbd> (one level up, no switching needed). Group row data → <kbd>A</kbd> (aggregation, even without formulas).</p> |
| <p>System scans <strong>leftward + upward</strong> → chunk: <code>Row: Revenue > Products</code></p> |
| |
| <h4>Column headers (C) — multi-level</h4> |
| <p>Multi-level column headers are stacked in <strong>separate rows</strong>, broadest on top (highest level number):</p> |
| <pre> 2024 Revenue (C3, merged) |
| Q1 | Q2 (C2) |
| Jan|Feb | Mar|Apr (C1, closest to data) |
| 100|120 | 130|140 (values)</pre> |
| <p>System scans <strong>upward</strong> → chunk: <code>Column: 2024 Revenue > Q1 > Jan</code></p> |
| |
| <h3>Label details</h3> |
| |
| <h4>value (V)</h4> |
| <p>Any data cell inside the grid — numeric (amounts, quantities) or text (names, codes, statuses). Each value cell produces a chunk with full header path.</p> |
| |
| <h4>aggregation (A)</h4> |
| <p>Computed from other cells (SUM, AVERAGE, etc.). System extracts the formula automatically. Do <strong>not</strong> label as value.</p> |
| <p><strong>Aggregation vs. header levels:</strong> Rows stacked vertically in the same column (Salaries, Benefits, Total) are NOT header levels. |
| "Salaries" and "Benefits" are row_header L1. "Total" is row_header for an aggregation row. The formula captures the relationship.</p> |
| |
| <h4>header (H)</h4> |
| <p>Table title or section heading that gives context to the data below. Examples: “Consolidated Balance Sheet”, “Q3 2024 Revenue”, “(In millions)”.</p> |
| <ul> |
| <li><strong>T0 + H</strong> = sheet-level header (appears in ALL chunks as context).</li> |
| <li><strong>T1+ H</strong> = table-level header (appears only in that table's chunks).</li> |
| </ul> |
| <p><strong>Header vs. metadata:</strong> If the cell is a title/heading that names or describes the data, use <code>header</code>. If it's a footnote, disclaimer, source note, or other annotation, use <code>metadata</code>.</p> |
| |
| <h4>metadata (M)</h4> |
| <p>Footnotes, source notes, dates, disclaimers, URLs — supplementary information that is NOT a title or heading.</p> |
| <ul> |
| <li><strong>T0 + M</strong> = sheet-level metadata (may appear in chunks as context).</li> |
| <li><strong>T1+ M</strong> = table-level metadata.</li> |
| </ul> |
| |
| <h4>comment (N) — Inline footnote</h4> |
| <p>A cell containing a note/annotation referring to another cell — written in a regular cell instead of using Excel's built-in comments. |
| Examples: <code>*estimated</code>, <code>see note 3</code>, <code>revised Q3</code>.</p> |
| <ol> |
| <li>Select the cell with the comment text, press <kbd>N</kbd>.</li> |
| <li>A banner appears: click the <strong>target cell</strong> the comment refers to.</li> |
| <li>A dashed orange arrow connects the two cells.</li> |
| <li>Press <kbd>Esc</kbd> to cancel linking.</li> |
| </ol> |
| <p>The comment text appears in the target cell's chunk as <code>Note: ...</code>.</p> |
| <p><strong>Comment vs. metadata:</strong> Use <code>metadata</code> for broad context (titles, dates). Use <code>comment</code> for annotations tied to a <em>specific cell</em>.</p> |
| <p><strong>Excel built-in comments</strong> (orange triangle) are extracted automatically — no labeling needed.</p> |
| |
| <h4>empty (E)</h4> |
| <p>Structurally meaningful blank cells (spacers between tables). Unlabeled non-empty cells are saved as "unlabeled".</p> |
| |
| <h4>junk (J)</h4> |
| <p>Cells containing useless or erroneous content that should be <strong>discarded</strong> when building chunks. These are cells that obviously ended up in the spreadsheet by mistake or carry no meaningful information.</p> |
| <p>Examples: auto-generated placeholder headers like <code>Unnamed: 0</code>, <code>Unnamed 1</code>, <code>Column1</code>; corrupted or garbled text; test/debug data left behind; duplicated artifact cells.</p> |
| <p>Junk cells are <strong>completely ignored</strong> during chunk extraction — they produce no chunks, are not used as headers, and do not appear in any output. The model also learns to recognize them so they can be filtered automatically.</p> |
| <p><strong>Junk vs. empty:</strong> Use <code>empty</code> for intentionally blank spacer cells. Use <code>junk</code> for cells that <em>have content</em> but that content is meaningless or erroneous.</p> |
| |
| <h3>Multiple tables on one sheet</h3> |
| <ol> |
| <li>Identify distinct data grids. Each grid with own headers = separate table.</li> |
| <li>Sheet-level content (overall title, date, footnotes) → <strong>T0</strong>.</li> |
| <li>Switch to <strong>T1</strong>, label first table's cells.</li> |
| <li>Switch to <strong>T2</strong>, label second table's cells.</li> |
| <li>Add more with <strong>[+]</strong> as needed.</li> |
| </ol> |
| |
| <h3>Common mistakes</h3> |
| <ol> |
| <li><strong>Forgetting to switch tables</strong> — headers bleed across tables.</li> |
| <li><strong>Labeling aggregation as value</strong> — chunk loses formula info.</li> |
| <li><strong>Wrong header level</strong> — most specific = L1, broader groupings = higher numbers.</li> |
| <li><strong>Confusing aggregation with header levels</strong> — vertical stacking in same column = aggregation, NOT levels.</li> |
| <li><strong>Putting data in T0</strong> — no chunk is produced.</li> |
| <li><strong>Not labeling empty spacers</strong> — mark separating rows/cols as empty.</li> |
| <li><strong>Labeling footnotes as metadata</strong> — cell-specific annotations should be <code>comment</code> (N), not <code>metadata</code>.</li> |
| <li><strong>Forgetting to link a comment</strong> — always click the target cell after pressing N, or the note won't attach to any chunk.</li> |
| </ol> |
| |
| <h3>Self-check before saving</h3> |
| <ul class="check-list"> |
| <li>Every data cell has a label (value or aggregation).</li> |
| <li>Every header cell has the correct label and level.</li> |
| <li>Tables are separated by different table IDs (check colored dots).</li> |
| <li>Sheet-level titles/notes are on T0; table titles on their table's ID.</li> |
| <li>Aggregation cells (bold totals, formulas) are labeled A, not V.</li> |
| <li>Hierarchy: level 1 = most specific, higher numbers = broader groupings.</li> |
| <li>Inline footnotes labeled as comment (N), linked to target (orange arrow visible).</li> |
| </ul> |
| |
| <h3>All keyboard shortcuts</h3> |
| <table class="ref-table"> |
| <tr><th>Shortcut</th><th>Action</th></tr> |
| <tr><td><kbd>V</kbd></td><td>Label as value</td></tr> |
| <tr><td><kbd>R</kbd></td><td>Label as row_header (at active level)</td></tr> |
| <tr><td><kbd>Shift</kbd>+<kbd>R</kbd></td><td>Label as row_header one level above active (group header)</td></tr> |
| <tr><td><kbd>C</kbd></td><td>Label as col_header (at active level)</td></tr> |
| <tr><td><kbd>Shift</kbd>+<kbd>C</kbd></td><td>Label as col_header one level above active</td></tr> |
| <tr><td><kbd>A</kbd></td><td>Label as aggregation</td></tr> |
| <tr><td><kbd>H</kbd></td><td>Label as header (table title / section heading)</td></tr> |
| <tr><td><kbd>M</kbd></td><td>Label as metadata (footnotes, notes, disclaimers)</td></tr> |
| <tr><td><kbd>N</kbd></td><td>Label as comment (then click target cell)</td></tr> |
| <tr><td><kbd>E</kbd></td><td>Label as empty</td></tr> |
| <tr><td><kbd>J</kbd></td><td>Label as junk (discarded from chunks)</td></tr> |
| <tr><td><kbd>X</kbd></td><td>Clear label</td></tr> |
| <tr><td><kbd>T</kbd> + digits</td><td>Set active table (e.g. T12)</td></tr> |
| <tr><td><kbd>L</kbd> + digits</td><td>Set active level (e.g. L5)</td></tr> |
| <tr><td><kbd>Esc</kbd></td><td>Cancel comment linking</td></tr> |
| <tr><td><kbd>Enter</kbd></td><td>Save & load next sheet</td></tr> |
| <tr><td><kbd>Shift</kbd>+click</td><td>Range select</td></tr> |
| <tr><td><kbd>Ctrl/Cmd</kbd>+click</td><td>Toggle select</td></tr> |
| </table> |
| `; |
| |
| HELP.pl = ` |
| <h3>Co robisz i dlaczego</h3> |
| <p>Oznaczasz komórki w arkuszach kalkulacyjnych, aby system uczenia maszynowego mógł automatycznie rozumieć ich strukturę. |
| Twoje etykiety służą do: <strong>(1)</strong> trenowania sieci neuronowej przewidującej role komórek, |
| <strong>(2)</strong> wyodrębniania fragmentów tekstu (chunków), po których LLM może wyszukiwać.</p> |
| <p class="warn">Źle oznaczony nagłówek oznacza, że każda wartość pod nim traci swoje znaczenie.</p> |
| |
| <h3>Szybka ściąga</h3> |
| <table class="ref-table"> |
| <tr><th>Klawisz</th><th>Etykieta</th><th>Do czego</th></tr> |
| <tr><td><kbd>V</kbd></td><td>value</td><td>Komórka z danymi — liczbowymi lub tekstowymi (pomiar, imię, kod, status)</td></tr> |
| <tr><td><kbd>R</kbd></td><td>row_header</td><td>Nagłówek wiersza po lewej stronie (na aktywnym poziomie)</td></tr> |
| <tr><td><kbd>Shift</kbd>+<kbd>R</kbd></td><td>row_header</td><td>Jak wyżej, ale jeden poziom powyżej aktywnego (skrót dla nagłówka grupy)</td></tr> |
| <tr><td><kbd>C</kbd></td><td>col_header</td><td>Nagłówek kolumny na górze (na aktywnym poziomie)</td></tr> |
| <tr><td><kbd>Shift</kbd>+<kbd>C</kbd></td><td>col_header</td><td>Jak wyżej, ale jeden poziom powyżej aktywnego</td></tr> |
| <tr><td><kbd>A</kbd></td><td>aggregation</td><td>Komórka obliczeniowa (SUMA, ŚREDNIA, formuła)</td></tr> |
| <tr><td><kbd>H</kbd></td><td>header</td><td>Tytuł tabeli, nagłówek sekcji — kontekst dla danych poniżej</td></tr> |
| <tr><td><kbd>M</kbd></td><td>metadata</td><td>Przypisy, noty źródłowe, daty, zastrzeżenia — nie tytuł</td></tr> |
| <tr><td><kbd>N</kbd></td><td>comment</td><td>Komentarz/przypis w komórce odnoszący się do innej komórki</td></tr> |
| <tr><td><kbd>E</kbd></td><td>empty</td><td>Celowo pusta komórka (odstęp strukturalny)</td></tr> |
| <tr><td><kbd>J</kbd></td><td>junk</td><td>Bezużyteczna/błędna treść do odrzucenia (np. „Unnamed 1”)</td></tr> |
| <tr><td><kbd>X</kbd></td><td><em>(wyczyść)</em></td><td>Usuń etykietę</td></tr> |
| </table> |
| |
| <h3>Zaznaczanie</h3> |
| <ul> |
| <li><strong>Kliknij</strong> komórkę, aby ją zaznaczyć.</li> |
| <li><kbd>Shift</kbd>+klik — zaznacz prostokątny zakres.</li> |
| <li><kbd>Ctrl/Cmd</kbd>+klik — dodaj/usuń pojedyncze komórki.</li> |
| <li>Naciśnij klawisz etykiety, aby zastosować do zaznaczenia.</li> |
| </ul> |
| |
| <h3>Tabele</h3> |
| <p>Każdy arkusz może zawierać wiele <strong>tabel</strong> (oddzielnych siatek). Przypisz komórki do właściwej tabeli.</p> |
| <ul> |
| <li><strong>T0</strong> = poziom arkusza (tytuły, notatki dla całego arkusza).</li> |
| <li><strong>T1, T2, …</strong> = poszczególne tabele.</li> |
| <li>Ustaw aktywną tabelę: kliknij w pasku <strong>Table</strong> lub <kbd>T</kbd> + cyfry (np. <kbd>T</kbd><kbd>1</kbd><kbd>2</kbd> dla T12).</li> |
| <li>Kliknij <strong>[+]</strong>, aby dodać więcej (bez limitu).</li> |
| </ul> |
| <p class="warn">Komórki T0 NIE generują chunków. Tylko T1+ generuje dane. Nigdy nie umieszczaj value/header w T0.</p> |
| |
| <h3>Poziomy nagłówków</h3> |
| <p>Nagłówki mogą mieć wiele poziomów hierarchii. Pasek <strong>Level</strong> kontroluje, który poziom przypisują <kbd>R</kbd> i <kbd>C</kbd>.</p> |
| <ul> |
| <li><strong>L1</strong> = najbardziej szczegółowy (najbliższy danych). Zacznij tutaj.</li> |
| <li><strong>L2</strong> = szersze grupowanie obejmujące L1.</li> |
| <li><strong>L3, L4, …</strong> = jeszcze szersze grupowania.</li> |
| <li>Ustaw poziom: kliknij w pasku <strong>Level</strong> lub <kbd>L</kbd> + cyfry (np. L12).</li> |
| </ul> |
| |
| <h4>Nagłówki wierszy (R) — wielopoziomowe (poziome)</h4> |
| <p>Wielopoziomowe nagłówki wierszy zajmują <strong>osobne kolumny</strong>, najszerszy po lewej (najwyższy numer poziomu):</p> |
| <pre> Region (R3) | Kraj (R2) | Miasto (R1) | Sprzedaż (V) |
| Europa | Polska | Warszawa | 100 |
| | | Kraków | 80 |
| | Niemcy | Berlin | 120</pre> |
| <p>System skanuje <strong>w lewo</strong> → chunk: <code>Row: Europa > Polska > Warszawa</code></p> |
| |
| <h4>Nagłówki wierszy — pionowe (wiersze grupujące nad danymi)</h4> |
| <p>Częste w raportach finansowych / eksportach z SAP: <strong>nagłówek grupy na osobnym wierszu</strong> nad wierszami szczegółowymi, często z wartościami agregacji:</p> |
| <pre> Q1 Q2 |
| Przychody | 50000 | 60000 | ← Shift+R (R2), dane = A |
| Produkty | 30000 | 35000 | ← R (R1), dane = V |
| Usługi | 20000 | 25000 | ← R (R1), dane = V |
| Koszty | 40000 | 45000 | ← Shift+R (R2), dane = A |
| Pensje | 25000 | 28000 | ← R (R1), dane = V |
| Czynsz | 15000 | 17000 | ← R (R1), dane = V</pre> |
| <p><strong>Workflow:</strong> oznacz nagłówki szczegółowe klawiszem <kbd>R</kbd>, nagłówki grup klawiszem <kbd>Shift</kbd>+<kbd>R</kbd> (jeden poziom wyżej, bez przełączania). Dane w wierszu grupy → <kbd>A</kbd> (agregacja, nawet bez formuł).</p> |
| <p>System skanuje <strong>w lewo + w górę</strong> → chunk: <code>Row: Przychody > Produkty</code></p> |
| |
| <h4>Nagłówki kolumn (C) — wielopoziomowe</h4> |
| <p>Wielopoziomowe nagłówki kolumn to <strong>osobne wiersze</strong>, najszerszy na górze (najwyższy numer poziomu):</p> |
| <pre> Przychody 2024 (C3, scalona) |
| Q1 | Q2 (C2) |
| Sty|Lut | Mar|Kwi (C1, najbliższy danych) |
| 100|120 | 130|140 (wartości)</pre> |
| <p>System skanuje <strong>w górę</strong> → chunk: <code>Column: Przychody 2024 > Q1 > Sty</code></p> |
| |
| <h3>Szczegóły etykiet</h3> |
| |
| <h4>value (V) — Wartość</h4> |
| <p>Dowolna komórka z danymi wewnątrz siatki — liczbowa (kwoty, ilości) lub tekstowa (imiona, kody, statusy). Każda komórka value = jeden chunk z pełną ścieżką nagłówków.</p> |
| |
| <h4>aggregation (A) — Agregacja</h4> |
| <p>Obliczona z innych komórek (SUMA, ŚREDNIA itp.). System wyodrębnia formułę automatycznie. <strong>Nie</strong> oznaczaj jako value.</p> |
| <p><strong>Agregacja vs. poziomy nagłówków:</strong> Wiersze ułożone pionowo w tej samej kolumnie (Wynagrodzenia, Świadczenia, Razem) to NIE poziomy nagłówków. |
| „Wynagrodzenia” i „Świadczenia” to row_header L1. „Razem” to row_header wiersza agregacji. Formuła wychwytuje relację.</p> |
| |
| <h4>header (H) — Nagłówek</h4> |
| <p>Tytuł tabeli lub nagłówek sekcji dający kontekst danym poniżej. Przykłady: „Bilans skonsolidowany”, „Przychody Q3 2024”, „(w milionach)”.</p> |
| <ul> |
| <li><strong>T0 + H</strong> = nagłówek arkusza (pojawia się we WSZYSTKICH chunkach jako kontekst).</li> |
| <li><strong>T1+ H</strong> = nagłówek tabeli (tylko w chunkach tej tabeli).</li> |
| </ul> |
| <p><strong>Nagłówek vs. metadane:</strong> Jeśli komórka jest tytułem/nagłówkiem opisującym dane, użyj <code>header</code>. Jeśli to przypis, zastrzeżenie, nota źródłowa lub inna adnotacja, użyj <code>metadata</code>.</p> |
| |
| <h4>metadata (M) — Metadane</h4> |
| <p>Przypisy, noty źródłowe, daty, zastrzeżenia, URLe — informacje uzupełniające, które NIE są tytułem ani nagłówkiem.</p> |
| <ul> |
| <li><strong>T0 + M</strong> = metadane arkusza (mogą pojawić się w chunkach jako kontekst).</li> |
| <li><strong>T1+ M</strong> = metadane tabeli.</li> |
| </ul> |
| |
| <h4>comment (N) — Komentarz / przypis</h4> |
| <p>Komórka z adnotacją/przypisem odnoszącym się do innej komórki — wpisana w zwykłą komórkę zamiast wbudowanego komentarza Excela. |
| Przykłady: <code>*dane szacunkowe</code>, <code>patrz nota 3</code>, <code>skorygowano</code>.</p> |
| <ol> |
| <li>Zaznacz komórkę z komentarzem, naciśnij <kbd>N</kbd>.</li> |
| <li>Pojawi się banner: kliknij <strong>komórkę docelową</strong>, do której odnosi się komentarz.</li> |
| <li>Przerywana pomarańczowa strzałka połączy dwie komórki.</li> |
| <li>Naciśnij <kbd>Esc</kbd>, aby anulować linkowanie.</li> |
| </ol> |
| <p>Tekst komentarza trafia do chunka docelowej komórki jako <code>Note: ...</code>.</p> |
| <p><strong>Komentarz vs. metadane:</strong> Używaj <code>metadata</code> dla ogólnego kontekstu (tytuły, daty). Używaj <code>comment</code> dla adnotacji do <em>konkretnej komórki</em>.</p> |
| <p><strong>Wbudowane komentarze Excela</strong> (pomarańczowy trójkąt) są wyodrębniane automatycznie — nie wymagają labelowania.</p> |
| |
| <h4>empty (E) — Pusta</h4> |
| <p>Strukturalnie istotne puste komórki (separatory między tabelami). Niezaetykietowane niepuste komórki zapisywane jako „unlabeled”.</p> |
| |
| <h4>junk (J) — Śmieć</h4> |
| <p>Komórki z bezużyteczną lub błędną treścią, które powinny być <strong>odrzucone</strong> przy budowaniu chunków. Chodzi o komórki, które w oczywisty sposób znalazły się w arkuszu przez pomyłkę lub nie niosą żadnej znaczącej informacji.</p> |
| <p>Przykłady: automatycznie generowane nazwy kolumn jak <code>Unnamed: 0</code>, <code>Unnamed 1</code>, <code>Column1</code>; uszkodzony lub zniekształcony tekst; dane testowe/debugowe pozostawione przez pomyłkę; zduplikowane artefakty.</p> |
| <p>Komórki junk są <strong>całkowicie ignorowane</strong> podczas ekstrakcji chunków — nie generują chunków, nie są używane jako nagłówki i nie pojawiają się w żadnym wyniku. Model również uczy się je rozpoznawać, aby móc je filtrować automatycznie.</p> |
| <p><strong>Junk vs. empty:</strong> Używaj <code>empty</code> dla celowo pustych komórek-separatorów. Używaj <code>junk</code> dla komórek, które <em>mają treść</em>, ale ta treść jest bezsensowna lub błędna.</p> |
| |
| <h3>Wiele tabel na jednym arkuszu</h3> |
| <ol> |
| <li>Zidentyfikuj oddzielne siatki danych. Każda siatka z własnymi nagłówkami = osobna tabela.</li> |
| <li>Treści na poziomie arkusza (ogólny tytuł, data, przypisy) → <strong>T0</strong>.</li> |
| <li>Przełącz na <strong>T1</strong>, oznacz komórki pierwszej tabeli.</li> |
| <li>Przełącz na <strong>T2</strong>, oznacz komórki drugiej tabeli.</li> |
| <li>Dodaj więcej klikając <strong>[+]</strong>.</li> |
| </ol> |
| |
| <h3>Najczęstsze błędy</h3> |
| <ol> |
| <li><strong>Zapomnienie o przełączeniu tabeli</strong> — nagłówki przenikają między tabelami.</li> |
| <li><strong>Oznaczanie agregacji jako value</strong> — chunk traci informację o formule.</li> |
| <li><strong>Błędny poziom nagłówka</strong> — najbardziej szczegółowy = L1, szersze grupowania = wyższy numer.</li> |
| <li><strong>Mylenie agregacji z poziomami nagłówków</strong> — pionowe ułożenie w tej samej kolumnie = agregacja, NIE poziomy.</li> |
| <li><strong>Umieszczanie danych w T0</strong> — nie powstanie żaden chunk.</li> |
| <li><strong>Brak oznaczania pustych separatorów</strong> — oznacz oddzielające wiersze/kolumny jako empty.</li> |
| <li><strong>Mylenie header z metadata</strong> — tytuły i nagłówki sekcji to <code>header</code> (H), przypisy i noty to <code>metadata</code> (M). Adnotacje do konkretnej komórki to <code>comment</code> (N).</li> |
| <li><strong>Brak linkowania komentarza</strong> — po naciśnięciu N kliknij docelową komórkę, inaczej notatka nie trafi do żadnego chunka.</li> |
| </ol> |
| |
| <h3>Kontrola przed zapisem</h3> |
| <ul class="check-list"> |
| <li>Każda komórka z danymi ma etykietę (value lub aggregation).</li> |
| <li>Każda komórka nagłówka ma prawidłową etykietę i poziom.</li> |
| <li>Tabele mają różne ID (sprawdź kolorowe kropki).</li> |
| <li>Tytuły/notatki arkusza na T0; tytuły tabel na ID ich tabeli.</li> |
| <li>Komórki agregacji (pogrubione sumy, formuły) oznaczone A, nie V.</li> |
| <li>Hierarchia: poziom 1 = najbardziej szczegółowy (najbliższy danych), wyższe = szersze grupowania.</li> |
| <li>Przypisy w komórkach oznaczone jako comment (N) i zlinkowane z celem (widoczna pomarańczowa strzałka).</li> |
| </ul> |
| |
| <h3>Skróty klawiszowe</h3> |
| <table class="ref-table"> |
| <tr><th>Skrót</th><th>Akcja</th></tr> |
| <tr><td><kbd>V</kbd></td><td>Oznacz jako value (wartość)</td></tr> |
| <tr><td><kbd>R</kbd></td><td>Oznacz jako row_header (aktywny poziom)</td></tr> |
| <tr><td><kbd>Shift</kbd>+<kbd>R</kbd></td><td>Oznacz jako row_header jeden poziom wyżej (nagłówek grupy)</td></tr> |
| <tr><td><kbd>C</kbd></td><td>Oznacz jako col_header (aktywny poziom)</td></tr> |
| <tr><td><kbd>Shift</kbd>+<kbd>C</kbd></td><td>Oznacz jako col_header jeden poziom wyżej</td></tr> |
| <tr><td><kbd>A</kbd></td><td>Oznacz jako aggregation (agregacja)</td></tr> |
| <tr><td><kbd>H</kbd></td><td>Oznacz jako header (tytuł tabeli / nagłówek sekcji)</td></tr> |
| <tr><td><kbd>M</kbd></td><td>Oznacz jako metadata (przypisy, notatki, zastrzeżenia)</td></tr> |
| <tr><td><kbd>N</kbd></td><td>Oznacz jako comment (komentarz, potem kliknij cel)</td></tr> |
| <tr><td><kbd>E</kbd></td><td>Oznacz jako empty (pusta)</td></tr> |
| <tr><td><kbd>J</kbd></td><td>Oznacz jako junk (śmieć, odrzucone z chunków)</td></tr> |
| <tr><td><kbd>X</kbd></td><td>Wyczyść etykietę</td></tr> |
| <tr><td><kbd>T</kbd> + cyfry</td><td>Ustaw aktywną tabelę (np. T12)</td></tr> |
| <tr><td><kbd>L</kbd> + cyfry</td><td>Ustaw aktywny poziom (np. L5)</td></tr> |
| <tr><td><kbd>Esc</kbd></td><td>Anuluj linkowanie komentarza</td></tr> |
| <tr><td><kbd>Enter</kbd></td><td>Zapisz i wczytaj następny</td></tr> |
| <tr><td><kbd>Shift</kbd>+klik</td><td>Zaznaczanie zakresu</td></tr> |
| <tr><td><kbd>Ctrl/Cmd</kbd>+klik</td><td>Przełączanie zaznaczenia</td></tr> |
| </table> |
| `; |
| |
| function showHelp(helpL) { |
| helpLang = helpL; |
| helpBody.innerHTML = HELP[helpL]; |
| helpTitle.textContent = helpL === "pl" ? I18N.pl.help_title : I18N.en.help_title; |
| helpOverlay.querySelectorAll(".help-lang-btn").forEach(b => { |
| b.classList.toggle("active", b.dataset.lang === helpL); |
| }); |
| helpOverlay.classList.add("open"); |
| } |
| |
| document.getElementById("help-btn").addEventListener("click", () => showHelp(lang)); |
| document.getElementById("help-close").addEventListener("click", () => helpOverlay.classList.remove("open")); |
| helpOverlay.addEventListener("click", (e) => { |
| if (e.target === helpOverlay) helpOverlay.classList.remove("open"); |
| }); |
| helpOverlay.querySelectorAll(".help-lang-btn").forEach(b => { |
| b.addEventListener("click", () => showHelp(b.dataset.lang)); |
| }); |
| |
| /* ── Init ───────────────────────────────────────────────────────────── */ |
| checkAuth(); |
| })(); |
| </script> |
| </body> |
| </html> |
|
|