Mastodon-archive-tool/generate_archive.py

612 lines
No EOL
29 KiB
Python

import zipfile
import json
import os
import re
from datetime import datetime
from urllib.parse import urlparse
def get_danish_date(timestamp):
dt = datetime.fromtimestamp(timestamp)
return format_danish_date(dt)
def format_danish_date(dt):
months_da = ['januar', 'februar', 'marts', 'april', 'maj', 'juni',
'juli', 'august', 'september', 'oktober', 'november', 'december']
return f"{dt.day}. {months_da[dt.month - 1]} {dt.year}"
def get_safe_id(mastodon_id_url):
if not mastodon_id_url:
return "unknown"
return mastodon_id_url.rstrip('/').split('/')[-1]
def generate_html_archive():
zip_filename = "archive.zip"
html_filename = "index.html"
media_dir = "media"
if not os.path.exists(zip_filename):
print(f"Fejl: Kunne ikke finde '{zip_filename}' i den nuværende mappe.")
return
if not os.path.exists(media_dir):
os.makedirs(media_dir)
try:
zip_mtime = os.path.getmtime(zip_filename)
archive_date = get_danish_date(zip_mtime)
with zipfile.ZipFile(zip_filename, 'r') as archive:
zip_file_map = {os.path.basename(f): f for f in archive.namelist() if not f.endswith('/')}
# 1. Hent profiloplysninger
with archive.open('actor.json') as f:
actor = json.load(f)
# 2. Hent indlæg
with archive.open('outbox.json') as f:
outbox = json.load(f)
# 3. Udtræk profilbillede
avatar_src = ""
if 'icon' in actor and 'url' in actor['icon']:
avatar_filename_in_zip = actor['icon']['url']
try:
ext = avatar_filename_in_zip.split('.')[-1].lower()
if ext not in ['png', 'jpg', 'jpeg', 'gif']:
ext = 'png'
avatar_src = f"avatar.{ext}"
with archive.open(avatar_filename_in_zip) as f_in:
with open(avatar_src, 'wb') as f_out:
f_out.write(f_in.read())
except KeyError:
pass
# Udled brugerdata
display_name = actor.get('name', 'Ukendt')
username = actor.get('preferredUsername', 'ukendt')
profile_url = actor.get('url', '')
parsed_url = urlparse(profile_url)
full_handle = f"@{username}@{parsed_url.netloc}"
bio = actor.get('summary', '')
# Find profilens oprettelsesdato
profile_published = actor.get('published', '')
profile_created_str = ""
if profile_published:
try:
dt = datetime.fromisoformat(profile_published.replace('Z', '+00:00'))
profile_created_str = f"Oprettet: {format_danish_date(dt)}"
except:
profile_created_str = f"Oprettet: {profile_published}"
# Klargør data
raw_posts = []
all_years = set()
tag_counts = {}
year_counts = {}
# Tællere til indhold og typer
content_counts = {'media': 0, 'links': 0, 'tags': 0, 'no_tags': 0}
ordered_items = outbox.get('orderedItems', [])
for item in ordered_items:
if item.get('type') == 'Create':
obj = item.get('object', {})
if isinstance(obj, dict):
raw_posts.append(obj)
posts = []
post_count = 0
reply_count = 0
quote_count = 0
for obj in raw_posts:
obj_id = obj.get('id', '')
safe_id = get_safe_id(obj_id)
raw_content = obj.get('content', '')
clean_text = re.sub(r'<[^>]+>', '', raw_content).strip()
is_quote = clean_text.startswith('RE:')
is_reply = obj.get('inReplyTo') is not None
if is_quote:
quote_count += 1
post_type_label = "Citat"
post_type_class = "citat"
elif is_reply:
reply_count += 1
post_type_label = "Svar"
post_type_class = "svar"
else:
post_count += 1
post_type_label = "Post"
post_type_class = "post"
# Parse dato
published_str = obj.get('published', '')
post_year = "Ukendt"
try:
dt = datetime.fromisoformat(published_str.replace('Z', '+00:00'))
formatted_date = dt.strftime('%d. %b %Y kl. %H:%M')
post_year = str(dt.year)
all_years.add(post_year)
year_counts[post_year] = year_counts.get(post_year, 0) + 1
except:
formatted_date = published_str
# Parse tags
post_tags = []
tags_data = obj.get('tag', [])
for t in tags_data:
if isinstance(t, dict) and t.get('type') == 'Hashtag':
tag_name = t.get('name', '').lower()
if tag_name:
post_tags.append(tag_name)
tag_counts[tag_name] = tag_counts.get(tag_name, 0) + 1
tags_attr = ",".join(post_tags)
# Tjek om der er eksterne links
has_links_bool = "false"
a_tags = re.findall(r'<a\s+[^>]*>', raw_content)
for a in a_tags:
class_match = re.search(r'class="([^"]*)"', a)
if class_match:
classes = class_match.group(1).split()
if 'mention' not in classes and 'hashtag' not in classes:
has_links_bool = "true"
break
else:
has_links_bool = "true"
break
# Håndter medier
media_html = ""
attachments = obj.get('attachment', [])
for att in attachments:
att_url = att.get('url', '')
if att_url:
filename = os.path.basename(urlparse(att_url).path)
if filename in zip_file_map:
zip_path = zip_file_map[filename]
local_media_path = os.path.join(media_dir, filename)
try:
with archive.open(zip_path) as f_in, open(local_media_path, 'wb') as f_out:
f_out.write(f_in.read())
media_type = att.get('mediaType', '')
if media_type.startswith('image/'):
media_html += f'<img src="{media_dir}/{filename}" alt="Vedhæftet billede" class="post-media-item">'
elif media_type.startswith('video/'):
media_html += f'<video controls src="{media_dir}/{filename}" class="post-media-item"></video>'
except Exception as e:
print(f"Kunne ikke udpakke medie {filename}: {e}")
if media_html:
media_html = f'<div class="post-media-container">{media_html}</div>'
# Sæt boolske attributter til indholdsfiltrering
has_media_bool = "true" if media_html else "false"
has_tags_bool = "true" if post_tags else "false"
# Opdater tællere for indhold
if has_media_bool == "true": content_counts['media'] += 1
if has_links_bool == "true": content_counts['links'] += 1
if has_tags_bool == "true": content_counts['tags'] += 1
else: content_counts['no_tags'] += 1
posts.append({
'safe_id': safe_id,
'content': raw_content,
'media_html': media_html,
'date': formatted_date,
'url': obj.get('url', profile_url),
'raw_date': published_str,
'year': post_year,
'tags_attr': tags_attr,
'type_label': post_type_label,
'type_class': post_type_class,
'has_media': has_media_bool,
'has_links': has_links_bool,
'has_tags': has_tags_bool
})
posts.sort(key=lambda x: x['raw_date'], reverse=True)
sorted_years = sorted(list(all_years), reverse=True)
sorted_tags = sorted(tag_counts.items(), key=lambda item: (-item[1], item[0]))
total_posts = len(posts)
# Byg HTML til år-knapper
years_html = f'<button class="filter-btn active year-btn" data-year="all" onclick="setYearFilter(\'all\', this)">Alle år ({total_posts})</button>\n'
for year in sorted_years:
if year != "Ukendt":
count = year_counts.get(year, 0)
years_html += f'<button class="filter-btn year-btn" data-year="{year}" onclick="setYearFilter(\'{year}\', this)">{year} ({count})</button>\n'
# Byg HTML til tag-knapper
tags_html = ""
if sorted_tags:
tags_html += '<div class="filter-row">'
tags_html += '<span class="filter-label">Tags:</span>'
tags_html += '<div class="filter-buttons">'
tags_html += f'<button class="filter-btn active tag-btn" data-tag="all" onclick="setTagFilter(\'all\', this)">Alle tags ({total_posts})</button>\n'
for tag, count in sorted_tags:
display_tag = tag if tag.startswith('#') else f"#{tag}"
tags_html += f'<button class="filter-btn tag-btn" data-tag="{tag}" onclick="setTagFilter(\'{tag}\', this)">{display_tag} ({count})</button>\n'
tags_html += '</div></div>'
# --- Byg HTML ---
html_content = f"""<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mastodon Arkiv - {display_name}</title>
<style>
:root {{
--primary: #6364ff;
--primary-hover: #563acc;
--bg: #f5f8fa;
--card-bg: #ffffff;
--text-main: #282c37;
--text-muted: #9baec8;
--border: #d9e1e8;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg); color: var(--text-main); line-height: 1.6;
margin: 0; padding: 20px; scroll-behavior: smooth;
}}
.page-container {{ max-width: 1100px; margin: 0 auto; }}
.mastodon-title-container {{
display: flex; align-items: center; justify-content: center;
gap: 15px; margin-bottom: 30px;
}}
.mastodon-title-container h1 {{ color: var(--text-main); margin: 0; font-size: 2.5em; }}
.mastodon-logo {{ width: 45px; height: 45px; color: var(--primary); }}
.layout-wrapper {{ display: flex; gap: 30px; align-items: flex-start; }}
.main-content {{ flex: 1; min-width: 0; }}
.sidebar {{
width: 320px; flex-shrink: 0; position: sticky;
top: 20px; transition: top 0.1s;
}}
.profile-card {{
background: var(--card-bg); border-radius: 10px; padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-bottom: 20px;
display: flex; gap: 20px; align-items: flex-start;
}}
.profile-pic {{
width: 100px; height: 100px; border-radius: 50%;
object-fit: cover; background-color: var(--border); flex-shrink: 0;
}}
.profile-info {{ flex: 1; display: flex; flex-direction: column; justify-content: center; }}
.profile-info h2 {{ margin: 0 0 5px 0; font-size: 1.5em; }}
.profile-handle {{ color: var(--text-muted); font-weight: bold; margin-bottom: 5px; }}
.profile-created {{ font-size: 0.85em; color: var(--text-muted); margin-bottom: 10px; }}
.profile-bio {{ margin-bottom: 10px; }}
.profile-stats {{ display: flex; gap: 15px; font-size: 0.9em; flex-wrap: wrap; }}
.profile-stats span {{ background: var(--bg); padding: 5px 10px; border-radius: 5px; border: 1px solid var(--border); }}
.moved-notice {{
background-color: #f0efff; border-left: 4px solid var(--primary);
padding: 20px; border-radius: 4px; margin-bottom: 30px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}}
.moved-notice p {{ margin: 0 0 10px 0; }}
.moved-notice a {{ color: var(--primary); font-weight: bold; text-decoration: none; }}
.moved-notice a:hover {{ text-decoration: underline; }}
.archive-date {{ font-size: 0.85em; color: #828ca0; margin-top: 15px !important; border-top: 1px solid #dcdbe8; padding-top: 10px; }}
.controls-container {{
background: var(--card-bg); padding: 20px; border-radius: 10px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex; flex-direction: column; gap: 20px;
}}
.search-container {{ display: flex; flex-direction: column; gap: 8px; }}
.search-container label {{ font-weight: bold; font-size: 0.9em; color: var(--text-muted); }}
.search-input-group {{ display: flex; gap: 8px; }}
#searchInput {{
flex-grow: 1; padding: 10px; border: 1px solid var(--border);
border-radius: 5px; font-size: 1em; box-sizing: border-box;
}}
#searchInput:focus {{ outline: none; border-color: var(--primary); }}
#searchBtn {{
padding: 0 15px; background-color: var(--primary); color: white;
border: none; border-radius: 5px; cursor: pointer; font-weight: bold;
transition: background-color 0.2s; font-size: 0.9em;
}}
#searchBtn:hover {{ background-color: var(--primary-hover); }}
.result-count {{ font-size: 0.85em; color: var(--text-muted); font-weight: 500; }}
.filter-row {{ display: flex; flex-direction: column; gap: 8px; }}
.filter-label {{ font-weight: bold; font-size: 0.9em; color: var(--text-muted); }}
.filter-buttons {{ display: flex; gap: 6px; flex-wrap: wrap; }}
.filter-btn {{
padding: 6px 12px; background-color: var(--bg); color: var(--text-main);
border: 1px solid var(--border); border-radius: 5px; cursor: pointer;
font-size: 0.9em; transition: all 0.2s;
}}
.filter-btn.active {{ background-color: var(--primary); color: white; border-color: var(--primary); }}
.filter-btn:hover:not(.active) {{ background-color: #e2eaf0; }}
.sidebar-footer {{
margin-top: 10px; padding-top: 15px; border-top: 1px solid var(--border);
text-align: center; font-size: 0.85em; color: var(--text-muted);
}}
.sidebar-footer a {{ color: var(--primary); text-decoration: none; font-weight: 600; }}
.sidebar-footer a:hover {{ text-decoration: underline; }}
.mastodon-post {{
background: var(--card-bg); border-radius: 10px; padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 15px; scroll-margin-top: 20px;
}}
.post-header {{ display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }}
.post-mini-pic {{ width: 40px; height: 40px; border-radius: 50%; object-fit: cover; }}
.post-author {{ font-weight: bold; }}
.post-content {{ margin-bottom: 15px; word-break: break-word; }}
.post-content p {{ margin-top: 0; }}
.post-media-container {{ margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 10px; }}
.post-media-item {{ max-width: 100%; max-height: 500px; border-radius: 8px; border: 1px solid var(--border); }}
.post-meta {{
font-size: 0.85em; color: var(--text-muted); display: flex;
justify-content: space-between; align-items: center;
border-top: 1px solid var(--border); padding-top: 10px;
}}
.post-meta-right {{ display: flex; align-items: center; gap: 15px; flex-wrap: wrap; justify-content: flex-end; }}
.type-badge {{ background: var(--bg); padding: 3px 8px; border-radius: 4px; border: 1px solid var(--border); font-weight: bold; color: var(--text-main); }}
.type-badge.Svar {{ background-color: #f0efff; color: var(--primary-hover); }}
.type-badge.Citat {{ background-color: #fff4e5; color: #d97706; border-color: #fde68a; }}
.post-meta a {{ color: var(--primary); text-decoration: none; }}
.post-meta a:hover {{ text-decoration: underline; }}
@media (max-width: 850px) {{
.layout-wrapper {{ flex-direction: column; }}
.sidebar {{ width: 100%; position: static; order: -1; }}
.profile-card {{ flex-direction: column; align-items: center; text-align: center; }}
.profile-stats {{ justify-content: center; }}
}}
</style>
</head>
<body>
<div class="page-container">
<div class="mastodon-title-container">
<svg class="mastodon-logo" viewBox="0 0 216.4144 232.00976" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M211.80734 139.0875c-3.18125 16.36625-28.4925 34.2775-57.5625 37.74875-15.15875 1.80875-30.08375 3.47125-45.99875 2.74125-26.0275-1.1925-46.565-6.2125-46.565-6.2125 0 2.53375.15625 4.94625.46875 7.2025 3.38375 25.68625 25.47 27.225 46.39125 27.9425 21.11625.7225 39.91875-5.20625 39.91875-5.20625l.8675 19.09s-14.77 7.93125-41.08125 9.39c-14.50875.7975-32.52375-.365-53.50625-5.91875C9.23234 213.82 1.40609 165.31125.20859 116.09125c-.365-14.61375-.14-28.39375-.14-39.91875 0-50.33 32.97625-65.0825 32.97625-65.0825C49.67234 3.45375 78.20359.2425 107.86484 0h.72875c29.66125.2425 58.21125 3.45375 74.8375 11.09 0 0 32.975 14.7525 32.975 65.0825 0 0 .41375 37.13375-4.59875 62.915"/>
<path fill="#fff" d="M177.50984 80.077v60.94125h-24.14375v-59.15c0-12.46875-5.24625-18.7975-15.74-18.7975-11.6025 0-17.4175 7.5075-17.4175 22.3525v32.37625H96.20734V85.42325c0-14.845-5.81625-22.3525-17.41875-22.3525-10.49375 0-15.74 6.32875-15.74 18.7975v59.15H38.90484V80.077c0-12.455 3.17125-22.3525 9.54125-29.675 6.56875-7.5225 15.17125-11.28875 25.80625-11.28875 12.355 0 21.71125 4.74875 27.8975 14.2475l6.01375 10.08125 6.015-10.08125c6.185-9.49875 15.54125-14.2475 27.8975-14.2475 10.635 0 19.2375 3.76625 25.80625 11.28875 6.36875 7.3225 9.54 17.22 9.54 29.675"/>
</svg>
<h1>Mastodon Arkiv</h1>
</div>
<div class="layout-wrapper">
<main class="main-content">
<div class="profile-card">
<img src="{avatar_src}" alt="Profilbillede" class="profile-pic">
<div class="profile-info">
<h2>{display_name}</h2>
<div class="profile-handle">{full_handle}</div>
{f'<div class="profile-created">{profile_created_str}</div>' if profile_created_str else ''}
<div class="profile-bio">{bio}</div>
<div class="profile-stats">
<span><strong>{post_count}</strong> Posts</span>
<span><strong>{reply_count}</strong> Svar</span>
<span><strong>{quote_count}</strong> Citater</span>
</div>
</div>
</div>
<div class="moved-notice">
<p><strong>Kontoen er flyttet!</strong></p>
<p>Min nye Mastodon instans og profil kan findes her: <a href="https://andersen.one/@aphandersen" target="_blank">@aphandersen@andersen.one</a></p>
<p class="archive-date">Dette historiske Mastodon-arkiv blev genereret den {archive_date}.</p>
</div>
<div id="postsList">
"""
for post in posts:
html_content += f"""
<div class="mastodon-post"
data-type="{post['type_class']}"
data-year="{post['year']}"
data-tags="{post['tags_attr']}"
data-has-media="{post['has_media']}"
data-has-links="{post['has_links']}"
data-has-tags="{post['has_tags']}"
id="post-{post['safe_id']}">
<div class="post-header">
<img src="{avatar_src}" alt="Mini profilbillede" class="post-mini-pic">
<div class="post-author">{display_name}</div>
</div>
<div class="post-content">
{post['content']}
</div>
{post['media_html']}
<div class="post-meta">
<span class="post-date">{post['date']}</span>
<div class="post-meta-right">
<span class="type-badge {post['type_label']}">{post['type_label']}</span>
<a href="{post['url']}" target="_blank">Original URL ↗</a>
</div>
</div>
</div>
"""
html_content += f"""
</div>
</main>
<aside class="sidebar" id="filterSidebar">
<div class="controls-container">
<div class="search-container">
<label for="searchInput">Søg:</label>
<div class="search-input-group">
<input type="text" id="searchInput" placeholder="Søg i indlæg...">
<button id="searchBtn" onclick="applyFilters()">Søg</button>
</div>
<div id="resultCount" class="result-count"></div>
</div>
<div class="filter-row">
<span class="filter-label">Type:</span>
<div class="filter-buttons">
<button class="filter-btn active type-btn" data-filter="all" onclick="setTypeFilter('all', this)">Vis alle ({total_posts})</button>
<button class="filter-btn type-btn" data-filter="post" onclick="setTypeFilter('post', this)">Kun Posts ({post_count})</button>
<button class="filter-btn type-btn" data-filter="svar" onclick="setTypeFilter('svar', this)">Kun Svar ({reply_count})</button>
<button class="filter-btn type-btn" data-filter="citat" onclick="setTypeFilter('citat', this)">Kun Citater ({quote_count})</button>
</div>
</div>
<div class="filter-row">
<span class="filter-label">Indhold:</span>
<div class="filter-buttons">
<button class="filter-btn active content-btn" data-content="all" onclick="setContentFilter('all', this)">Alt ({total_posts})</button>
<button class="filter-btn content-btn" data-content="media" onclick="setContentFilter('media', this)">Med billeder ({content_counts['media']})</button>
<button class="filter-btn content-btn" data-content="links" onclick="setContentFilter('links', this)">Med links ({content_counts['links']})</button>
<button class="filter-btn content-btn" data-content="tags" onclick="setContentFilter('tags', this)">Med tags ({content_counts['tags']})</button>
<button class="filter-btn content-btn" data-content="no_tags" onclick="setContentFilter('no_tags', this)">Uden tags ({content_counts['no_tags']})</button>
</div>
</div>
<div class="filter-row">
<span class="filter-label">År:</span>
<div class="filter-buttons">
{years_html}
</div>
</div>
{tags_html}
<div class="sidebar-footer">
&copy; <a href="https://aandersen.eu" target="_blank">Andreas Andersen</a>
</div>
</div>
</aside>
</div>
</div>
<script>
let currentFilterType = 'all';
let currentFilterContent = 'all';
let currentFilterYear = 'all';
let currentFilterTag = 'all';
// Gør så Enter-tasten i søgefeltet også udfører en søgning
document.getElementById("searchInput").addEventListener("keyup", function(event) {{
if (event.key === "Enter") {{
applyFilters();
}}
}});
function setTypeFilter(type, btnElement) {{
currentFilterType = type;
document.querySelectorAll('.type-btn').forEach(btn => btn.classList.remove('active'));
btnElement.classList.add('active');
applyFilters();
}}
function setContentFilter(content, btnElement) {{
currentFilterContent = content;
document.querySelectorAll('.content-btn').forEach(btn => btn.classList.remove('active'));
btnElement.classList.add('active');
applyFilters();
}}
function setYearFilter(year, btnElement) {{
currentFilterYear = year;
document.querySelectorAll('.year-btn').forEach(btn => btn.classList.remove('active'));
btnElement.classList.add('active');
applyFilters();
}}
function setTagFilter(tag, btnElement) {{
currentFilterTag = tag;
document.querySelectorAll('.tag-btn').forEach(btn => btn.classList.remove('active'));
btnElement.classList.add('active');
applyFilters();
}}
function applyFilters() {{
const query = document.getElementById('searchInput').value.toLowerCase();
const posts = document.querySelectorAll('.mastodon-post');
let visibleCount = 0;
const totalCount = posts.length;
posts.forEach(post => {{
const content = post.querySelector('.post-content').innerText.toLowerCase();
const postType = post.getAttribute('data-type');
const postYear = post.getAttribute('data-year');
const postTags = post.getAttribute('data-tags') ? post.getAttribute('data-tags').split(',') : [];
const hasMedia = post.getAttribute('data-has-media') === 'true';
const hasLinks = post.getAttribute('data-has-links') === 'true';
const hasTagsBool = post.getAttribute('data-has-tags') === 'true';
const matchesSearch = content.includes(query);
const matchesType = (currentFilterType === 'all') || (currentFilterType === postType);
const matchesYear = (currentFilterYear === 'all') || (currentFilterYear === postYear);
const matchesTag = (currentFilterTag === 'all') || postTags.includes(currentFilterTag);
let matchesContent = true;
if (currentFilterContent === 'media') matchesContent = hasMedia;
if (currentFilterContent === 'links') matchesContent = hasLinks;
if (currentFilterContent === 'tags') matchesContent = hasTagsBool;
if (currentFilterContent === 'no_tags') matchesContent = !hasTagsBool;
if (matchesSearch && matchesType && matchesContent && matchesYear && matchesTag) {{
post.style.display = 'block';
visibleCount++;
}} else {{
post.style.display = 'none';
}}
}});
document.getElementById('resultCount').innerText = `Viser ${{visibleCount}} af ${{totalCount}} indlæg`;
setTimeout(updateSidebarSticky, 50);
}}
function updateSidebarSticky() {{
const sidebar = document.getElementById('filterSidebar');
if (!sidebar || window.innerWidth <= 850) return;
const windowHeight = window.innerHeight;
const sidebarHeight = sidebar.offsetHeight;
if (sidebarHeight > windowHeight - 40) {{
const offset = windowHeight - sidebarHeight - 20;
sidebar.style.top = offset + 'px';
}} else {{
sidebar.style.top = '20px';
}}
}}
window.addEventListener('load', () => {{
applyFilters();
updateSidebarSticky();
}});
window.addEventListener('resize', updateSidebarSticky);
</script>
</body>
</html>
"""
with open(html_filename, 'w', encoding='utf-8') as f:
f.write(html_content)
print(f"Succes! {html_filename} er nu blevet genereret.")
except Exception as e:
print(f"Der opstod en fejl under behandlingen af arkivet: {e}")
if __name__ == "__main__":
generate_html_archive()