Reorganize workspace and archive skill artifacts

This commit is contained in:
zhuyongxin
2026-05-20 11:39:30 +08:00
parent 0e0275d46a
commit f45122dafb
83 changed files with 9733 additions and 15 deletions
+436
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
</script>
</body>
</html>
HTMLEOF
# ---- Phase 4: Done ----
echo "[4/4] Done!"
echo " Output: $OUTPUT"
echo " Entries: $count"
echo " Open with: start knowledge-index.html"