Reorganize workspace and archive skill artifacts
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
KNOWLEDGE_ROOT="$PROJECT_ROOT/knowledge/entries"
|
||||
OUTPUT="$PROJECT_ROOT/knowledge-index.html"
|
||||
|
||||
if [ ! -d "$KNOWLEDGE_ROOT" ]; then
|
||||
KNOWLEDGE_ROOT="$PROJECT_ROOT"
|
||||
fi
|
||||
|
||||
# ---- Phase 1: Scan directories ----
|
||||
echo "[1/4] Scanning knowledge directories..."
|
||||
dirs=()
|
||||
while IFS= read -r d; do
|
||||
dirs+=("${d%/}")
|
||||
done < <(ls -d "$KNOWLEDGE_ROOT"/knowledge_*/ 2>/dev/null || true)
|
||||
|
||||
if [ ${#dirs[@]} -eq 0 ]; then
|
||||
echo " No knowledge_*/ directories found in $KNOWLEDGE_ROOT"
|
||||
echo " Generated empty index (will show 'no entries' message)."
|
||||
fi
|
||||
n=${#dirs[@]}; echo " Found $n knowledge director$([ "$n" -ne 1 ] && echo 'ies' || echo 'y')."
|
||||
|
||||
# ---- Phase 2: Extract metadata ----
|
||||
echo "[2/4] Extracting metadata..."
|
||||
|
||||
entries_js=""
|
||||
count=0
|
||||
for dir in "${dirs[@]}"; do
|
||||
dirname=$(basename "$dir")
|
||||
|
||||
# Parse directory name: knowledge_YYYYMMDD_Slug
|
||||
date_part=$(echo "$dirname" | sed -E 's/^knowledge_([0-9]{8})_.*/\1/')
|
||||
slug=$(echo "$dirname" | sed -E 's/^knowledge_[0-9]{8}_//')
|
||||
|
||||
if [ -z "$date_part" ] || [ "$date_part" = "$dirname" ]; then
|
||||
echo " WARNING: '$dirname' doesn't match knowledge_YYYYMMDD_Slug pattern, skipping..."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Find .md file
|
||||
md_file="$dir/${dirname}.md"
|
||||
if [ ! -f "$md_file" ]; then
|
||||
# Try to find any .md file in the directory
|
||||
md_file=$(ls "$dir"/*.md 2>/dev/null | head -1)
|
||||
if [ -z "$md_file" ]; then
|
||||
echo " WARNING: No .md file found in '$dirname', skipping..."
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extract YAML frontmatter (between first and second ---)
|
||||
yaml=$(sed -n '/^---$/,/^---$/p' "$md_file" | sed '1d;$d' 2>/dev/null || echo "")
|
||||
|
||||
# Extract title
|
||||
title=$(echo "$yaml" | grep -i "^title:" | head -1 | sed 's/^[Tt]itle: *//' | sed 's/^"//;s/"$//;s/^'"'"'//;s/'"'"'$//' || echo "")
|
||||
|
||||
# Extract author
|
||||
author=$(echo "$yaml" | grep -i "^author:" | head -1 | sed 's/^[Aa]uthor: *//' | sed 's/^"//;s/"$//' || echo "")
|
||||
|
||||
# Extract tags: handle both ["a","b"] and [a,b] formats
|
||||
tags_raw=$(echo "$yaml" | grep -i "^tags:" | head -1 | sed 's/^[Tt]ags: *//' || echo "")
|
||||
# Normalize: remove brackets, split by comma, strip quotes/spaces
|
||||
tags_csv=$(echo "$tags_raw" | sed 's/^\[//;s/\]$//' | sed 's/"//g' | sed "s/'//g" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -v '^$' | paste -sd ',' - 2>/dev/null || echo "")
|
||||
|
||||
# Format tags as JS array
|
||||
if [ -n "$tags_csv" ]; then
|
||||
tags_js=""
|
||||
IFS=',' read -ra TAG_ARR <<< "$tags_csv"
|
||||
for t in "${TAG_ARR[@]}"; do
|
||||
t_trimmed=$(echo "$t" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
if [ -n "$t_trimmed" ]; then
|
||||
[ -n "$tags_js" ] && tags_js+=", "
|
||||
# Escape single quotes in tag
|
||||
t_escaped=$(echo "$t_trimmed" | sed "s/'/\\\\'/g")
|
||||
tags_js+="'$t_escaped'"
|
||||
fi
|
||||
done
|
||||
else
|
||||
tags_js=""
|
||||
fi
|
||||
|
||||
# Fallback title
|
||||
if [ -z "$title" ]; then
|
||||
title=$(echo "$slug" | sed 's/_/ /g')
|
||||
fi
|
||||
[ -z "$author" ] && author="unknown"
|
||||
|
||||
# Extract body text (everything after the second ---)
|
||||
body=$(awk 'BEGIN{found=0} /^---$/{found++; next} found>=2{print}' "$md_file" 2>/dev/null || echo "")
|
||||
|
||||
# Escape for JS string: backslash, single quote, newline
|
||||
title_esc=$(echo "$title" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g")
|
||||
author_esc=$(echo "$author" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g")
|
||||
body_esc=$(echo "$body" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g" | tr '\n' ' ' | sed 's/ */ /g')
|
||||
slug_esc=$(echo "$slug" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g")
|
||||
rel_dir=$(realpath --relative-to="$PROJECT_ROOT" "$dir" 2>/dev/null || echo "$dirname")
|
||||
rel_dir=${rel_dir//\\//}
|
||||
rel_dir_esc=$(echo "$rel_dir" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g")
|
||||
|
||||
# Build JS entry
|
||||
entries_js+=" {"
|
||||
entries_js+=" slug: '$slug_esc',"
|
||||
entries_js+=" path: '$rel_dir_esc',"
|
||||
entries_js+=" date: '$date_part',"
|
||||
entries_js+=" title: '$title_esc',"
|
||||
entries_js+=" author: '$author_esc',"
|
||||
entries_js+=" tags: [$tags_js],"
|
||||
entries_js+=" body: '$body_esc'"
|
||||
entries_js+=" },"
|
||||
entries_js+=$'\n'
|
||||
|
||||
count=$((count + 1))
|
||||
echo " ✓ $dirname"
|
||||
done
|
||||
|
||||
# ---- Phase 3: Generate HTML ----
|
||||
echo "[3/4] Generating knowledge-index.html..."
|
||||
entries_js=$(echo "$entries_js" | sed '$ s/,$//')
|
||||
|
||||
# Write HTML header (before __DATA_PLACEHOLDER__)
|
||||
cat > "$OUTPUT" << 'HTMLEOF'
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>知识库索引 — github-learn</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans SC",sans-serif;background:#f8fafc;color:#1e293b;line-height:1.7}
|
||||
.wrapper{max-width:900px;margin:0 auto;padding:0 20px}
|
||||
header{background:linear-gradient(135deg,#0f172a 0%,#1e3a5f 100%);color:#f1f5f9;padding:48px 0 40px;margin-bottom:40px}
|
||||
header h1{font-size:1.6rem;font-weight:700;margin-bottom:6px}
|
||||
header .sub{font-size:.85rem;color:#94a3b8}
|
||||
.search-bar{margin-bottom:12px}
|
||||
.search-bar input{width:100%;padding:12px 16px;border:2px solid #e2e8f0;border-radius:8px;font-size:.95rem;outline:none;transition:border-color .2s;font-family:inherit}
|
||||
.search-bar input:focus{border-color:#3b82f6}
|
||||
.filter-row{display:flex;align-items:center;gap:10px;margin-bottom:24px;flex-wrap:wrap}
|
||||
.filter-row label{font-size:.85rem;color:#64748b;font-weight:600}
|
||||
.filter-row select{padding:8px 12px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#1e293b;font-size:.88rem;font-family:inherit;outline:none}
|
||||
.filter-row select:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12)}
|
||||
.clear-filters{padding:8px 12px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#475569;font-size:.88rem;font-family:inherit;cursor:pointer;transition:all .15s}
|
||||
.clear-filters:hover{background:#f1f5f9;border-color:#94a3b8;color:#1e293b}
|
||||
.clear-filters:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.12)}
|
||||
.no-results{text-align:center;padding:40px 20px;color:#94a3b8;display:none}
|
||||
.no-results.visible{display:block}
|
||||
.tag-cloud{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:32px}
|
||||
.tag-badge{background:#eff6ff;color:#1d4ed8;border:1px solid #bfdbfe;padding:4px 12px;border-radius:16px;font-size:.8rem;cursor:pointer;user-select:none;transition:all .15s}
|
||||
.tag-badge:hover{background:#dbeafe;border-color:#93c5fd}
|
||||
.tag-badge.active{background:#1d4ed8;color:#fff;border-color:#1d4ed8}
|
||||
.tag-badge .count{font-size:.7rem;opacity:.7;margin-left:2px}
|
||||
.entries{margin-bottom:48px}
|
||||
.entry-card{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:22px 24px;margin-bottom:16px;transition:box-shadow .15s}
|
||||
.entry-card:hover{box-shadow:0 2px 12px rgba(0,0,0,.06)}
|
||||
.entry-card.hidden{display:none}
|
||||
.entry-header{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:8px;margin-bottom:8px}
|
||||
.entry-title{font-size:1.15rem;font-weight:700;color:#0f172a}
|
||||
.entry-date{font-size:.8rem;color:#94a3b8}
|
||||
.entry-meta{font-size:.8rem;color:#64748b;margin-bottom:10px}
|
||||
.entry-tags{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px}
|
||||
.entry-tags .mini-tag{background:#f1f5f9;color:#475569;padding:2px 8px;border-radius:10px;font-size:.73rem;cursor:pointer;transition:all .12s}
|
||||
.entry-tags .mini-tag:hover{background:#e2e8f0}
|
||||
.entry-tags .mini-tag.match{background:#fef3c7;color:#92400e}
|
||||
.entry-body{font-size:.88rem;color:#475569;line-height:1.65;max-height:100px;overflow:hidden;position:relative}
|
||||
.entry-body::after{content:'';position:absolute;bottom:0;left:0;right:0;height:30px;background:linear-gradient(transparent,#fff)}
|
||||
.related-section{margin-top:12px;padding-top:10px;border-top:1px dashed #e2e8f0;font-size:.8rem}
|
||||
.related-section .related-label{color:#94a3b8;margin-bottom:4px}
|
||||
.related-section a{color:#3b82f6;text-decoration:none;margin-right:8px;cursor:pointer}
|
||||
.related-section a:hover{text-decoration:underline}
|
||||
mark{background:#fde68a;color:#1e293b;padding:0 2px;border-radius:2px}
|
||||
footer{text-align:center;padding:24px 0;border-top:1px solid #e2e8f0;color:#94a3b8;font-size:.8rem;margin-top:20px}
|
||||
@media(max-width:640px){header h1{font-size:1.3rem}.entry-card{padding:16px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="wrapper">
|
||||
<h1>知识库索引</h1>
|
||||
<div class="sub">github-learn · 共 <span id="entry-count">0</span> 条知识条目</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrapper" id="content-area">
|
||||
|
||||
<div class="search-bar">
|
||||
<input type="text" id="search-input" placeholder="搜索标题、标签或正文..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<label for="year-filter">年份</label>
|
||||
<select id="year-filter"><option value="">全部年份</option></select>
|
||||
<button type="button" class="clear-filters" id="clear-filters">清空筛选</button>
|
||||
</div>
|
||||
|
||||
<div class="no-results" id="no-results">没有找到匹配的条目</div>
|
||||
|
||||
<div class="tag-cloud" id="tag-cloud"></div>
|
||||
|
||||
<div class="entries" id="entries"></div>
|
||||
|
||||
<footer>
|
||||
由 scripts/update-knowledge-index.sh 生成 · <span id="gen-time"></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ENTRIES = [
|
||||
HTMLEOF
|
||||
|
||||
# Inject data
|
||||
echo "$entries_js" >> "$OUTPUT"
|
||||
|
||||
# Write HTML footer (after __DATA_PLACEHOLDER__)
|
||||
cat >> "$OUTPUT" << 'HTMLEOF'
|
||||
];
|
||||
|
||||
// ---- Init ----
|
||||
(function(){
|
||||
document.getElementById('entry-count').textContent = ENTRIES.length;
|
||||
document.getElementById('gen-time').textContent = new Date().toLocaleString('zh-CN');
|
||||
|
||||
if(ENTRIES.length === 0){
|
||||
document.getElementById('entries').innerHTML = '<div class="no-results visible">暂无知识条目。<br>运行 /knowledge-absorber 学习新内容后,执行 bash scripts/update-knowledge-index.sh 重建索引。</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
buildTagCloud();
|
||||
buildYearFilter();
|
||||
renderEntries(ENTRIES);
|
||||
bindSearch();
|
||||
bindYearFilter();
|
||||
bindClearFilters();
|
||||
})();
|
||||
|
||||
// ---- Tag Cloud ----
|
||||
function buildTagCloud(){
|
||||
const map = {};
|
||||
ENTRIES.forEach(e => {
|
||||
(e.tags||[]).forEach(t => {
|
||||
if(!t) return;
|
||||
map[t] = (map[t]||0)+1;
|
||||
});
|
||||
});
|
||||
const sorted = Object.entries(map).sort((a,b)=>b[1]-a[1]);
|
||||
const cloud = document.getElementById('tag-cloud');
|
||||
cloud.innerHTML = sorted.map(([tag,count]) =>
|
||||
'<span class="tag-badge" data-tag="'+escHtml(tag)+'">'+escHtml(tag)+'<span class="count">('+count+')</span></span>'
|
||||
).join('');
|
||||
|
||||
cloud.querySelectorAll('.tag-badge').forEach(b => {
|
||||
b.addEventListener('click', function(){
|
||||
this.classList.toggle('active');
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildYearFilter(){
|
||||
const years = Array.from(new Set(ENTRIES.map(e => (e.date||'').slice(0,4)).filter(y => /^\d{4}$/.test(y)))).sort((a,b)=>b.localeCompare(a));
|
||||
const select = document.getElementById('year-filter');
|
||||
if(!select) return;
|
||||
select.innerHTML = '<option value="">全部年份</option>' + years.map(y => '<option value="'+escHtml(y)+'">'+escHtml(y)+'</option>').join('');
|
||||
}
|
||||
|
||||
function bindYearFilter(){
|
||||
const select = document.getElementById('year-filter');
|
||||
if(select) select.addEventListener('change', applyFilters);
|
||||
}
|
||||
|
||||
function bindClearFilters(){
|
||||
const button = document.getElementById('clear-filters');
|
||||
if(button) button.addEventListener('click', clearFilters);
|
||||
}
|
||||
|
||||
function clearFilters(){
|
||||
const input = document.getElementById('search-input');
|
||||
const yearFilter = document.getElementById('year-filter');
|
||||
if(input) input.value = '';
|
||||
if(yearFilter) yearFilter.value = '';
|
||||
document.querySelectorAll('.tag-badge.active').forEach(b => b.classList.remove('active'));
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// ---- Render ----
|
||||
function renderEntries(entries){
|
||||
const container = document.getElementById('entries');
|
||||
if(entries.length === 0){
|
||||
document.getElementById('no-results').classList.add('visible');
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('no-results').classList.remove('visible');
|
||||
|
||||
container.innerHTML = entries.map(e => {
|
||||
const dateStr = e.date ? e.date.slice(0,4)+'-'+e.date.slice(4,6)+'-'+e.date.slice(6,8) : '';
|
||||
const tagsHtml = (e.tags||[]).filter(Boolean).map(t =>
|
||||
'<span class="mini-tag" data-tag="'+escHtml(t)+'">'+escHtml(t)+'</span>'
|
||||
).join('');
|
||||
const related = findRelated(e);
|
||||
const relatedHtml = related.length > 0 ?
|
||||
'<div class="related-section"><div class="related-label">相关内容:</div>'+
|
||||
related.map(r => '<a onclick="scrollToEntry(\''+escHtml(r.slug)+'\')">'+escHtml(r.title)+'</a>').join('')+
|
||||
'</div>' : '';
|
||||
const bodyPreview = (e.body||'').slice(0,300);
|
||||
|
||||
const entryHref = (e.path || ('knowledge_'+e.date+'_'+e.slug)) + '/'+(e.path || ('knowledge_'+e.date+'_'+e.slug)).split('/').pop()+'.html';
|
||||
|
||||
return '<div class="entry-card" data-slug="'+escHtml(e.slug)+'" data-title="'+escHtml(e.title)+'" data-tags="'+escHtml((e.tags||[]).join(','))+'">'+
|
||||
'<div class="entry-header">'+
|
||||
'<a class="entry-title" href="'+escHtml(entryHref)+'">'+escHtml(e.title)+'</a>'+
|
||||
'<span class="entry-date">'+dateStr+'</span>'+
|
||||
'</div>'+
|
||||
'<div class="entry-meta">作者:'+escHtml(e.author||'unknown')+'</div>'+
|
||||
(tagsHtml ? '<div class="entry-tags">'+tagsHtml+'</div>' : '')+
|
||||
'<div class="entry-body">'+escHtml(bodyPreview)+(e.body&&e.body.length>300?' ...':'')+'</div>'+
|
||||
relatedHtml+
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
// Bind mini tag clicks
|
||||
container.querySelectorAll('.mini-tag').forEach(mt => {
|
||||
mt.addEventListener('click', function(e){
|
||||
e.stopPropagation();
|
||||
const tag = this.dataset.tag;
|
||||
const badge = document.querySelector('.tag-badge[data-tag="'+escHtml(tag)+'"]');
|
||||
if(badge) badge.click();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Search ----
|
||||
function bindSearch(){
|
||||
const input = document.getElementById('search-input');
|
||||
let debounceTimer;
|
||||
input.addEventListener('input', function(){
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(applyFilters, 150);
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters(){
|
||||
const term = (document.getElementById('search-input').value||'').toLowerCase().trim();
|
||||
const selectedYear = (document.getElementById('year-filter')?.value||'').trim();
|
||||
|
||||
// Active tags
|
||||
const activeTags = new Set();
|
||||
document.querySelectorAll('.tag-badge.active').forEach(b => activeTags.add(b.dataset.tag));
|
||||
|
||||
let filtered = ENTRIES;
|
||||
|
||||
// Year filter
|
||||
if(selectedYear){
|
||||
filtered = filtered.filter(e => (e.date||'').slice(0,4) === selectedYear);
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if(activeTags.size > 0){
|
||||
filtered = filtered.filter(e => (e.tags||[]).some(t => activeTags.has(t)));
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if(term.length > 0){
|
||||
const words = term.split(/\s+/);
|
||||
filtered = filtered.filter(e => {
|
||||
const haystack = ((e.title||'')+' '+((e.tags||[]).join(' '))+' '+(e.body||'')).toLowerCase();
|
||||
return words.every(w => haystack.includes(w));
|
||||
});
|
||||
}
|
||||
|
||||
renderEntries(filtered);
|
||||
|
||||
// Highlight in filtered results
|
||||
if(term.length > 0){
|
||||
highlightMatches(term);
|
||||
}
|
||||
|
||||
// Highlight matching tags
|
||||
highlightMatchingTags(term, activeTags);
|
||||
}
|
||||
|
||||
function highlightMatches(term){
|
||||
const words = term.split(/\s+/).filter(Boolean);
|
||||
document.querySelectorAll('.entry-title,.entry-body').forEach(el => {
|
||||
let html = el.textContent;
|
||||
words.forEach(w => {
|
||||
const re = new RegExp('('+w.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+')','gi');
|
||||
html = html.replace(re, '<mark>$1</mark>');
|
||||
});
|
||||
el.innerHTML = html;
|
||||
});
|
||||
}
|
||||
|
||||
function highlightMatchingTags(term, activeTags){
|
||||
document.querySelectorAll('.mini-tag').forEach(mt => {
|
||||
const tag = mt.dataset.tag||'';
|
||||
const matchesSearch = term.length > 0 && tag.toLowerCase().includes(term);
|
||||
const isActive = activeTags.has(tag);
|
||||
mt.classList.toggle('match', matchesSearch || isActive);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Related Content ----
|
||||
function findRelated(entry){
|
||||
const myTags = new Set((entry.tags||[]).filter(Boolean));
|
||||
if(myTags.size === 0) return [];
|
||||
return ENTRIES.filter(e => e.slug !== entry.slug && (e.tags||[]).some(t => myTags.has(t))).slice(0,3);
|
||||
}
|
||||
|
||||
function scrollToEntry(slug){
|
||||
const card = document.querySelector('.entry-card[data-slug="'+slug+'"]');
|
||||
if(card){
|
||||
card.scrollIntoView({behavior:'smooth',block:'center'});
|
||||
card.style.boxShadow = '0 0 0 3px #3b82f6';
|
||||
setTimeout(()=>{card.style.boxShadow='';},2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Utils ----
|
||||
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTMLEOF
|
||||
|
||||
# ---- Phase 4: Done ----
|
||||
echo "[4/4] Done!"
|
||||
echo " Output: $OUTPUT"
|
||||
echo " Entries: $count"
|
||||
echo " Open with: start knowledge-index.html"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user