Add slug support and new admin features

This commit is contained in:
Tomas Dvorak
2026-03-14 10:40:04 +01:00
parent 4773e4cab1
commit 21574a8b30
31 changed files with 37490 additions and 36033 deletions
+172
View File
@@ -0,0 +1,172 @@
# Remote Blog Management - Complete Guide
## 🎯 Goal
Remove local blog files and work exclusively with remote server blogs.
## 📋 Options Available
### Option 1: Quick Remove Local Blogs
```bash
# Run this in your bizoni directory
./remove-local-blogs.sh
```
### Option 2: Ubuntu Server Management Script
```bash
# Upload to your Ubuntu server and run
./ubuntu-remote-blogs.sh migrate
```
### Option 3: Backend Configuration (Recommended)
Update backend to work with remote blogs only.
## 🚀 Recommended Deployment Steps
### Step 1: Remove Local Blogs
```bash
cd /home/tdvorak/Desktop/HTML_Projekty/bizoni
./remove-local-blogs.sh
```
### Step 2: Update Backend Configuration
The backend is now configured to work with remote blogs at `/var/www/bizoni/blog`.
You can set the remote path with environment variable:
```bash
export REMOTE_BLOG_DIR="/var/www/bizoni/blog"
```
### Step 3: Deploy Backend to Server
1. Build the updated backend
2. Deploy to your server
3. Set REMOTE_BLOG_DIR environment variable
### Step 4: Run Migration on Server
```bash
# On your Ubuntu server
./ubuntu-remote-blogs.sh migrate
```
## 📁 File Structure After Changes
### Local (Development)
```
bizoni/
├── backend/main.go # Updated for remote blogs
├── admin/new.html # Updated with new fields
├── js/admin-auth.js # Login persistence
├── tools/migrate_slugs.go # Migration tool
├── remove-local-blogs.sh # Local cleanup script
└── ubuntu-remote-blogs.sh # Server management script
```
### Server (Production)
```
/var/www/bizoni/
├── blog/
│ ├── 0000.html # Original numeric files
│ ├── 0001.html
│ ├── jdeme-do-finale.html # New slug files
│ └── 1-zapas-final-score.html
├── img/blog/
│ ├── 0000.png
│ └── 0001.png
└── backend # Updated backend
```
## 🔧 Ubuntu Server Script Usage
### List All Blogs
```bash
./ubuntu-remote-blogs.sh list
```
### Migrate All Blogs to Slugs
```bash
./ubuntu-remote-blogs.sh migrate
```
### Show Blog Info
```bash
./ubuntu-remote-blogs.sh info 0030
```
### Add Slug to Specific Blog
```bash
./ubuntu-remote-blogs.sh add-slug 0030
```
### Create Backup
```bash
./ubuntu-remote-blogs.sh backup
```
## 🌐 URL Structure After Migration
### Before
- `/blog/0030.html`
- `/blog/0001.html`
### After
- `/blog/jdeme-do-finale` (clean URL)
- `/blog/1-zapas-final-score`
- `/blog/0030.html` (still works for backward compatibility)
## ⚠️ Important Notes
1. **Backup First**: Always create backup before migration
2. **Test Locally**: Test backend with REMOTE_BLOG_DIR set to local copy
3. **Deploy Gradually**: Deploy backend first, then run migration
4. **Environment Variables**: Use REMOTE_BLOG_DIR for flexibility
## 🔄 Environment Variables
Set these on your server:
```bash
# Path to remote blog directory
export REMOTE_BLOG_DIR="/var/www/bizoni/blog"
# Port for backend (if needed)
export PORT="8080"
# Static files path
export STATIC_PATH="/var/www/bizoni"
```
## 🚨 Troubleshooting
### Backend Can't Find Blogs
```bash
# Check if directory exists
ls -la /var/www/bizoni/blog
# Set correct path
export REMOTE_BLOG_DIR="/correct/path/to/blogs"
```
### Migration Script Fails
```bash
# Make script executable
chmod +x ubuntu-remote-blogs.sh
# Update paths in script
nano ubuntu-remote-blogs.sh
```
### Permission Issues
```bash
# Fix permissions on server
sudo chown -R www-data:www-data /var/www/bizoni/blog
sudo chmod -R 755 /var/www/bizoni/blog
```
## 📞 Next Steps
1. **Choose your option** (1, 2, or 3)
2. **Remove local blogs** with the provided script
3. **Deploy updated backend** to server
4. **Run migration** on server
5. **Test new URLs** and admin interface
Your blog system will then work entirely with remote server blogs! 🎉
+113 -4
View File
@@ -62,6 +62,23 @@
<label for="title">Titulek</label> <label for="title">Titulek</label>
<input type="text" id="title" name="title" placeholder="Např. FC Bizoni vyhráli finále" required /> <input type="text" id="title" name="title" placeholder="Např. FC Bizoni vyhráli finále" required />
</div> </div>
<div>
<label for="slug">URL slug (použije se v adrese)</label>
<input type="text" id="slug" name="slug" placeholder="napr-fc-bizoni-vyhrali-finale" pattern="[a-z0-9-]+" title="Pouze malá písmena, číslice a pomlčky" />
<div class="muted" style="margin-top:6px">Automaticky se vygeneruje z titulku, pokud nezadáte vlastní.</div>
</div>
<div>
<label for="annotation">Anotace (krátký popis pro SEO a sociální sítě)</label>
<input type="text" id="annotation" name="annotation" placeholder="Krátký popis článku (150-300 znaků)" maxlength="300" />
<div class="muted" style="margin-top:6px">Použije se pro SEO description a při sdílení na sociálních sítích.</div>
</div>
<div>
<label for="content-mode">Způsob zadávání obsahu</label>
<select id="content-mode" name="content-mode" style="width: 100%; padding: 10px; border:1px solid #d1d5db; border-radius: 8px; font-size: 14px;">
<option value="visual">Vizuální editor (Quill)</option>
<option value="html">HTML kód</option>
</select>
</div>
<div> <div>
<label for="categories">Kategorie (oddělené čárkou)</label> <label for="categories">Kategorie (oddělené čárkou)</label>
<input type="text" id="categories" name="categories" placeholder="Zápasy, O nás" /> <input type="text" id="categories" name="categories" placeholder="Zápasy, O nás" />
@@ -87,7 +104,12 @@
</div> </div>
<div> <div>
<label for="editor">Obsah (vizuální editor)</label> <label for="editor">Obsah (vizuální editor)</label>
<div id="editor"></div> <div id="visual-editor-wrapper">
<div id="editor"></div>
</div>
<div id="html-editor-wrapper" style="display: none;">
<textarea id="html-content" name="html-content" rows="12" placeholder="Zadejte HTML kód obsahu..." style="width: 100%; padding: 10px; border:1px solid #d1d5db; border-radius: 8px; font-size: 14px; font-family: 'Courier New', monospace;"></textarea>
</div>
<!-- Hidden textarea to submit HTML (kept focusable-safe by moving offscreen) --> <!-- Hidden textarea to submit HTML (kept focusable-safe by moving offscreen) -->
<textarea id="content" name="content" rows="12" style="position:absolute; left:-10000px; width:1px; height:1px; overflow:hidden;"></textarea> <textarea id="content" name="content" rows="12" style="position:absolute; left:-10000px; width:1px; height:1px; overflow:hidden;"></textarea>
<div class="muted">Obsah bude vložen do sekce <code>&lt;div class="text lte-text-page clearfix"&gt;...&lt;/div&gt;</code> podle šablony <code>blog/0030.html</code>.</div> <div class="muted">Obsah bude vložen do sekce <code>&lt;div class="text lte-text-page clearfix"&gt;...&lt;/div&gt;</code> podle šablony <code>blog/0030.html</code>.</div>
@@ -140,6 +162,60 @@
const inputId = document.getElementById('post-id'); const inputId = document.getElementById('post-id');
const inputTitle = document.getElementById('title'); const inputTitle = document.getElementById('title');
const inputCats = document.getElementById('categories'); const inputCats = document.getElementById('categories');
const inputSlug = document.getElementById('slug');
const inputAnnotation = document.getElementById('annotation');
const contentModeSelect = document.getElementById('content-mode');
const visualEditorWrapper = document.getElementById('visual-editor-wrapper');
const htmlEditorWrapper = document.getElementById('html-editor-wrapper');
const htmlContentTextarea = document.getElementById('html-content');
// Content mode switching
contentModeSelect.addEventListener('change', () => {
const mode = contentModeSelect.value;
if (mode === 'visual') {
visualEditorWrapper.style.display = 'block';
htmlEditorWrapper.style.display = 'none';
} else {
visualEditorWrapper.style.display = 'none';
htmlEditorWrapper.style.display = 'block';
// Sync current content to HTML textarea
const currentContent = quill.root.innerHTML;
htmlContentTextarea.value = currentContent;
}
});
// Sync content from HTML to visual when switching back
contentModeSelect.addEventListener('change', () => {
if (contentModeSelect.value === 'visual') {
const htmlContent = htmlContentTextarea.value;
if (htmlContent.trim()) {
quill.root.innerHTML = htmlContent;
}
}
});
// Auto-generate slug from title
function generateSlug(text) {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphen
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
}
// Update slug when title changes (if slug is empty)
inputTitle.addEventListener('input', () => {
if (!inputSlug.value || inputSlug.dataset.autoGenerated === 'true') {
inputSlug.value = generateSlug(inputTitle.value);
inputSlug.dataset.autoGenerated = 'true';
}
});
// Mark slug as manually edited when user types in it
inputSlug.addEventListener('input', () => {
inputSlug.dataset.autoGenerated = 'false';
});
let pastedBlob = null; // holds clipboard/fetched blob if provided let pastedBlob = null; // holds clipboard/fetched blob if provided
@@ -209,8 +285,21 @@
const data = await res.json(); const data = await res.json();
inputId.value = data.id || id; inputId.value = data.id || id;
inputTitle.value = data.title || ''; inputTitle.value = data.title || '';
inputSlug.value = data.slug || '';
inputAnnotation.value = data.annotation || '';
inputCats.value = Array.isArray(data.categories) ? data.categories.join(', ') : ''; inputCats.value = Array.isArray(data.categories) ? data.categories.join(', ') : '';
quill.root.innerHTML = data.content_html || '';
// Set content mode and load content
if (data.content_mode === 'html') {
contentModeSelect.value = 'html';
htmlContentTextarea.value = data.content_html || '';
visualEditorWrapper.style.display = 'none';
htmlEditorWrapper.style.display = 'block';
} else {
contentModeSelect.value = 'visual';
quill.root.innerHTML = data.content_html || '';
}
// show current image preview // show current image preview
preview.style.display = 'flex'; preview.style.display = 'flex';
previewImg.src = '/img/blog/' + (data.id || id) + '.png'; previewImg.src = '/img/blog/' + (data.id || id) + '.png';
@@ -245,10 +334,22 @@
// Move Quill HTML into the hidden textarea // Move Quill HTML into the hidden textarea
// Merge category checkboxes into text input // Merge category checkboxes into text input
reconcileCategories(); reconcileCategories();
const html = quill.root.innerHTML;
// Get content based on mode
let html;
if (contentModeSelect.value === 'html') {
html = htmlContentTextarea.value.trim();
} else {
html = quill.root.innerHTML;
}
document.getElementById('content').value = html; document.getElementById('content').value = html;
// Validate content is not empty (avoid browser required on hidden field) // Validate content is not empty (avoid browser required on hidden field)
const plain = quill.getText().trim(); const plain = contentModeSelect.value === 'html' ?
html.replace(/<[^>]*>/g, '').trim() : // Strip HTML for validation
quill.getText().trim();
if (!plain) { if (!plain) {
result.textContent = 'Vyplňte obsah článku.'; result.textContent = 'Vyplňte obsah článku.';
result.classList.add('err'); result.classList.add('err');
@@ -256,6 +357,10 @@
return; return;
} }
const fd = new FormData(form); const fd = new FormData(form);
// Add annotation and content mode to form data
fd.set('annotation', inputAnnotation.value);
fd.set('content_mode', contentModeSelect.value);
// Prefer pasted/fetched blob if present when no file was chosen // Prefer pasted/fetched blob if present when no file was chosen
if (pastedBlob && !(imageInput.files && imageInput.files[0])) { if (pastedBlob && !(imageInput.files && imageInput.files[0])) {
const ext = (pastedBlob.type === 'image/jpeg') ? 'jpg' : 'png'; const ext = (pastedBlob.type === 'image/jpeg') ? 'jpg' : 'png';
@@ -300,6 +405,10 @@
result.appendChild(a); result.appendChild(a);
form.reset(); preview.style.display = 'none'; form.reset(); preview.style.display = 'none';
quill.setContents([]); quill.setContents([]);
htmlContentTextarea.value = '';
contentModeSelect.value = 'visual';
visualEditorWrapper.style.display = 'block';
htmlEditorWrapper.style.display = 'none';
} }
} catch (err) { } catch (err) {
result.textContent = 'Chyba: ' + (err.message || err); result.textContent = 'Chyba: ' + (err.message || err);
+474 -152
View File
@@ -6,6 +6,11 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image"
"image/color"
"image/draw"
_ "image/jpeg"
"image/png"
"io" "io"
"log" "log"
"net/http" "net/http"
@@ -19,11 +24,6 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"image"
"image/color"
"image/draw"
"image/png"
_ "image/jpeg"
) )
const ( const (
@@ -44,107 +44,131 @@ func dataPath() string {
// ---------------- Image normalization for blog thumbnails ---------------- // ---------------- Image normalization for blog thumbnails ----------------
// Target dimensions for blog images // Target dimensions for blog images
const ( const (
blogImgW = 1600 blogImgW = 1600
blogImgH = 969 blogImgH = 969
) )
// avgLuma computes average luminance of an image (0..255) // avgLuma computes average luminance of an image (0..255)
func avgLuma(img image.Image) float64 { func avgLuma(img image.Image) float64 {
b := img.Bounds() b := img.Bounds()
if b.Empty() { return 0 } if b.Empty() {
var sum uint64 return 0
var n uint64 }
for y := b.Min.Y; y < b.Max.Y; y += 4 { // sample every 4th row for speed var sum uint64
for x := b.Min.X; x < b.Max.X; x += 4 { // sample every 4th column var n uint64
r,g,bv,_ := img.At(x,y).RGBA() for y := b.Min.Y; y < b.Max.Y; y += 4 { // sample every 4th row for speed
r8 := float64(r>>8) for x := b.Min.X; x < b.Max.X; x += 4 { // sample every 4th column
g8 := float64(g>>8) r, g, bv, _ := img.At(x, y).RGBA()
b8 := float64(bv>>8) r8 := float64(r >> 8)
// Rec. 601 approximate luma g8 := float64(g >> 8)
l := 0.299*r8 + 0.587*g8 + 0.114*b8 b8 := float64(bv >> 8)
if l < 0 { l = 0 } // Rec. 601 approximate luma
if l > 255 { l = 255 } l := 0.299*r8 + 0.587*g8 + 0.114*b8
sum += uint64(l) if l < 0 {
n++ l = 0
} }
} if l > 255 {
if n == 0 { return 0 } l = 255
return float64(sum)/float64(n) }
sum += uint64(l)
n++
}
}
if n == 0 {
return 0
}
return float64(sum) / float64(n)
} }
// fitWithin returns destination size that fits source into max size, without upscaling // fitWithin returns destination size that fits source into max size, without upscaling
func fitWithin(sw, sh, mw, mh int) (int, int) { func fitWithin(sw, sh, mw, mh int) (int, int) {
if sw <= 0 || sh <= 0 { return 0, 0 } if sw <= 0 || sh <= 0 {
if sw <= mw && sh <= mh { return 0, 0
return sw, sh }
} if sw <= mw && sh <= mh {
wr := float64(mw) / float64(sw) return sw, sh
hr := float64(mh) / float64(sh) }
r := wr wr := float64(mw) / float64(sw)
if hr < wr { r = hr } hr := float64(mh) / float64(sh)
dw := int(float64(sw) * r) r := wr
dh := int(float64(sh) * r) if hr < wr {
if dw < 1 { dw = 1 } r = hr
if dh < 1 { dh = 1 } }
return dw, dh dw := int(float64(sw) * r)
dh := int(float64(sh) * r)
if dw < 1 {
dw = 1
}
if dh < 1 {
dh = 1
}
return dw, dh
} }
// scaleNearest performs nearest-neighbor downscaling from src to a new RGBA of size (dw, dh) // scaleNearest performs nearest-neighbor downscaling from src to a new RGBA of size (dw, dh)
func scaleNearest(src image.Image, dw, dh int) *image.RGBA { func scaleNearest(src image.Image, dw, dh int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0,0,dw,dh)) dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
sb := src.Bounds() sb := src.Bounds()
sw := sb.Dx() sw := sb.Dx()
sh := sb.Dy() sh := sb.Dy()
for y := 0; y < dh; y++ { for y := 0; y < dh; y++ {
sy := sb.Min.Y + int(float64(y)*float64(sh)/float64(dh)) sy := sb.Min.Y + int(float64(y)*float64(sh)/float64(dh))
for x := 0; x < dw; x++ { for x := 0; x < dw; x++ {
sx := sb.Min.X + int(float64(x)*float64(sw)/float64(dw)) sx := sb.Min.X + int(float64(x)*float64(sw)/float64(dw))
dst.Set(x, y, src.At(sx, sy)) dst.Set(x, y, src.At(sx, sy))
} }
} }
return dst return dst
} }
// normalizeBlogImage decodes any supported image (PNG/JPEG) and writes a 1600x969 PNG with letterboxing (black/white) // normalizeBlogImage decodes any supported image (PNG/JPEG) and writes a 1600x969 PNG with letterboxing (black/white)
func normalizeBlogImage(r io.Reader, outPath string) error { func normalizeBlogImage(r io.Reader, outPath string) error {
img, _, err := image.Decode(r) img, _, err := image.Decode(r)
if err != nil { if err != nil {
return fmt.Errorf("decode image: %w", err) return fmt.Errorf("decode image: %w", err)
} }
// Choose background based on average luminance // Choose background based on average luminance
l := avgLuma(img) l := avgLuma(img)
bg := color.Black bg := color.Black
if l > 160 { // bright image -> white bg; tweak threshold as needed if l > 160 { // bright image -> white bg; tweak threshold as needed
bg = color.White bg = color.White
} }
// Compute fitted size (no upscaling) // Compute fitted size (no upscaling)
srcB := img.Bounds() srcB := img.Bounds()
dw, dh := fitWithin(srcB.Dx(), srcB.Dy(), blogImgW, blogImgH) dw, dh := fitWithin(srcB.Dx(), srcB.Dy(), blogImgW, blogImgH)
var scaled image.Image var scaled image.Image
if dw == srcB.Dx() && dh == srcB.Dy() { if dw == srcB.Dx() && dh == srcB.Dy() {
scaled = img scaled = img
} else { } else {
scaled = scaleNearest(img, dw, dh) scaled = scaleNearest(img, dw, dh)
} }
// Compose centered on canvas // Compose centered on canvas
canvas := image.NewRGBA(image.Rect(0,0,blogImgW,blogImgH)) canvas := image.NewRGBA(image.Rect(0, 0, blogImgW, blogImgH))
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{C:bg}, image.Point{}, draw.Src) draw.Draw(canvas, canvas.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
offX := (blogImgW - dw) / 2 offX := (blogImgW - dw) / 2
offY := (blogImgH - dh) / 2 offY := (blogImgH - dh) / 2
draw.Draw(canvas, image.Rect(offX, offY, offX+dw, offY+dh), scaled, scaled.Bounds().Min, draw.Over) draw.Draw(canvas, image.Rect(offX, offY, offX+dw, offY+dh), scaled, scaled.Bounds().Min, draw.Over)
// Write PNG atomically // Write PNG atomically
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { return err } if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
tmp := outPath + ".tmp" return err
f, err := os.Create(tmp) }
if err != nil { return err } tmp := outPath + ".tmp"
enc := png.Encoder{CompressionLevel: png.BestSpeed} f, err := os.Create(tmp)
if err := enc.Encode(f, canvas); err != nil { if err != nil {
f.Close(); _ = os.Remove(tmp); return fmt.Errorf("encode png: %w", err) return err
} }
f.Close() enc := png.Encoder{CompressionLevel: png.BestSpeed}
_ = os.Remove(outPath) if err := enc.Encode(f, canvas); err != nil {
if err := os.Rename(tmp, outPath); err != nil { return err } f.Close()
return nil _ = os.Remove(tmp)
return fmt.Errorf("encode png: %w", err)
}
f.Close()
_ = os.Remove(outPath)
if err := os.Rename(tmp, outPath); err != nil {
return err
}
return nil
} }
func staticPath() string { func staticPath() string {
@@ -391,12 +415,72 @@ func ytChannel() string {
// BlogItem represents a simple blog card item for the homepage // BlogItem represents a simple blog card item for the homepage
type BlogItem struct { type BlogItem struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Link string `json:"link"` Slug string `json:"slug"`
Image string `json:"image"` Link string `json:"link"`
MTime time.Time `json:"mtime"` Image string `json:"image"`
Categories []string `json:"categories,omitempty"` MTime time.Time `json:"mtime"`
Categories []string `json:"categories,omitempty"`
}
// generateSlug creates a URL-friendly slug from a title
func generateSlug(title string) string {
slug := strings.ToLower(title)
// Replace Czech characters with their ASCII equivalents
replacements := map[string]string{
"á": "a", "ä": "a", "č": "c", "ď": "d", "é": "e", "ě": "e", "í": "i", "ľ": "l",
"ň": "n", "ó": "o", "ö": "o", "ô": "o", "ř": "r", "š": "s", "ť": "t", "ú": "u",
"ů": "u", "ý": "y", "ž": "z",
"Á": "a", "Ä": "a", "Č": "c", "Ď": "d", "É": "e", "Ě": "e", "Í": "i", "Ľ": "l",
"Ň": "n", "Ó": "o", "Ö": "o", "Ô": "o", "Ř": "r", "Š": "s", "Ť": "t", "Ú": "u",
"Ů": "u", "Ý": "y", "Ž": "z",
}
for czech, ascii := range replacements {
slug = strings.ReplaceAll(slug, czech, ascii)
}
// Remove any character that isn't alphanumeric, space, or hyphen
re := regexp.MustCompile(`[^a-z0-9\s-]`)
slug = re.ReplaceAllString(slug, "")
// Replace spaces and multiple hyphens with a single hyphen
re = regexp.MustCompile(`[\s-]+`)
slug = re.ReplaceAllString(slug, "-")
// Remove leading and trailing hyphens
slug = strings.Trim(slug, "-")
return slug
}
// ensureUniqueSlug ensures the slug is unique by appending a number if needed
func ensureUniqueSlug(siteRoot, baseSlug string) string {
blogDir := filepath.Join(siteRoot, "blog")
entries, err := os.ReadDir(blogDir)
if err != nil {
return baseSlug
}
existingSlugs := make(map[string]bool)
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".html") {
continue
}
// Extract slug from filename if it follows the new pattern
name := strings.TrimSuffix(e.Name(), ".html")
// Check if it's a slug-based filename (contains letters, not just numbers)
if regexp.MustCompile(`[a-z]`).MatchString(name) {
existingSlugs[name] = true
}
}
if !existingSlugs[baseSlug] {
return baseSlug
}
// Try baseSlug-2, baseSlug-3, etc.
for i := 2; i < 100; i++ {
testSlug := fmt.Sprintf("%s-%d", baseSlug, i)
if !existingSlugs[testSlug] {
return testSlug
}
}
// Fallback to timestamp
return fmt.Sprintf("%s-%d", baseSlug, time.Now().Unix())
} }
func extractCategories(path string) []string { func extractCategories(path string) []string {
@@ -415,15 +499,82 @@ func extractCategories(path string) []string {
return categories return categories
} }
// listLatestBlogs scans the blog and image folders under the provided site root and returns the latest N posts // extractAnnotation extracts the annotation from blog HTML meta tags
func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) { func extractAnnotation(path string) string {
blogDir := filepath.Join(siteRoot, "blog") b, err := os.ReadFile(path)
imgDir := filepath.Join(siteRoot, "img", "blog")
entries, err := os.ReadDir(blogDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("readdir blog: %w", err) return ""
} }
re := regexp.MustCompile(`^(\d{4})\.html$`) s := string(b)
// Try to find description in meta tag first
re := regexp.MustCompile(`(?is)<meta name="description" content="([^"]+)"`)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
return ""
}
// extractContentMode extracts the content mode from blog HTML meta tags
func extractContentMode(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
s := string(b)
// Try to find content_mode in meta tag first
re := regexp.MustCompile(`(?is)<meta name="content_mode" content="([^"]+)"`)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
// Default to visual if not found
return "visual"
}
// extractSlug extracts the slug from blog HTML meta tags or generates from filename
func extractSlug(path, filename string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
s := string(b)
// Try to find slug in meta tag first
re := regexp.MustCompile(`(?is)<meta name="slug" content="([^"]+)"`)
m := re.FindStringSubmatch(s)
if len(m) >= 2 {
return m[1]
}
// Fallback: generate slug from title
title := extractTitle(path)
if title != "" {
return generateSlug(title)
}
// Final fallback: use filename without extension
return strings.TrimSuffix(filename, ".html")
}
func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
// REMOTE SERVER CONFIGURATION - UPDATE THIS PATH
remoteBlogDir := "/var/www/bizoni/blog" // Path to blogs on remote server
// For local development, you can override with environment variable
if envPath := os.Getenv("REMOTE_BLOG_DIR"); envPath != "" {
remoteBlogDir = envPath
}
// If remote directory doesn't exist locally, try to sync or return error
if _, err := os.Stat(remoteBlogDir); os.IsNotExist(err) {
return nil, fmt.Errorf("remote blog directory not found: %s. Set REMOTE_BLOG_DIR environment variable or ensure remote directory is mounted", remoteBlogDir)
}
imgDir := filepath.Join(filepath.Dir(remoteBlogDir), "img", "blog")
entries, err := os.ReadDir(remoteBlogDir)
if err != nil {
return nil, fmt.Errorf("readdir remote blog: %w", err)
}
// Match both numeric (0001.html) and slug-based filenames
re := regexp.MustCompile(`^(\d{4}|[a-z0-9-]+)\.html$`)
var items []BlogItem var items []BlogItem
for _, e := range entries { for _, e := range entries {
name := e.Name() name := e.Name()
@@ -432,12 +583,13 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
} }
id := strings.TrimSuffix(name, ".html") id := strings.TrimSuffix(name, ".html")
// Title and categories extraction from blog HTML // Title and categories extraction from blog HTML
blogPath := filepath.Join(blogDir, name) blogPath := filepath.Join(remoteBlogDir, name)
title := extractTitle(blogPath) title := extractTitle(blogPath)
slug := extractSlug(blogPath, name)
cats := extractCategories(blogPath) cats := extractCategories(blogPath)
// Determine mod time - prefer image modtime if exists, else html // Determine mod time - prefer image modtime if exists, else html
mtime := time.Time{} mtime := time.Time{}
htmlInfo, err1 := os.Stat(filepath.Join(blogDir, name)) htmlInfo, err1 := os.Stat(filepath.Join(remoteBlogDir, name))
if err1 == nil { if err1 == nil {
mtime = htmlInfo.ModTime() mtime = htmlInfo.ModTime()
} }
@@ -447,13 +599,21 @@ func listLatestBlogs(siteRoot string, limit int) ([]BlogItem, error) {
mtime = imgInfo.ModTime() mtime = imgInfo.ModTime()
} }
} }
// Use slug-based link if slug exists and is not just numeric, otherwise use numeric
var link string
if slug != "" && regexp.MustCompile(`[a-z]`).MatchString(slug) {
link = "/blog/" + slug
} else {
link = "/blog/" + id + ".html"
}
items = append(items, BlogItem{ items = append(items, BlogItem{
ID: id, ID: id,
Title: title, Title: title,
Link: "/blog/" + id + ".html", Slug: slug,
Image: "/img/blog/" + id + ".png", Link: link,
MTime: mtime, Image: "/img/blog/" + id + ".png",
Categories: cats, MTime: mtime,
Categories: cats,
}) })
} }
sort.Slice(items, func(i, j int) bool { sort.Slice(items, func(i, j int) bool {
@@ -758,12 +918,15 @@ func main() {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
// Expect multipart form with: title, content (HTML), image (png), categories (comma-separated) // Expect multipart form with: title, slug, annotation, content_mode, content (HTML), image (png), categories (comma-separated)
if err := r.ParseMultipartForm(20 << 20); err != nil { // 20MB if err := r.ParseMultipartForm(20 << 20); err != nil { // 20MB
http.Error(w, "invalid form", http.StatusBadRequest) http.Error(w, "invalid form", http.StatusBadRequest)
return return
} }
title := strings.TrimSpace(r.FormValue("title")) title := strings.TrimSpace(r.FormValue("title"))
slugInput := strings.TrimSpace(r.FormValue("slug"))
annotation := strings.TrimSpace(r.FormValue("annotation"))
contentMode := strings.TrimSpace(r.FormValue("content_mode"))
htmlContent := strings.TrimSpace(r.FormValue("content")) htmlContent := strings.TrimSpace(r.FormValue("content"))
catsRaw := strings.TrimSpace(r.FormValue("categories")) catsRaw := strings.TrimSpace(r.FormValue("categories"))
var cats []string var cats []string
@@ -779,6 +942,10 @@ func main() {
http.Error(w, "missing title or content", http.StatusBadRequest) http.Error(w, "missing title or content", http.StatusBadRequest)
return return
} }
// Default content mode to visual if not specified
if contentMode == "" {
contentMode = "visual"
}
f, fh, err := r.FormFile("image") f, fh, err := r.FormFile("image")
if err != nil { if err != nil {
http.Error(w, "missing image", http.StatusBadRequest) http.Error(w, "missing image", http.StatusBadRequest)
@@ -799,6 +966,18 @@ func main() {
return return
} }
idStr := fmt.Sprintf("%04d", nid) idStr := fmt.Sprintf("%04d", nid)
// Determine slug
var finalSlug string
if slugInput != "" {
// Use provided slug (already validated by frontend)
finalSlug = slugInput
} else {
// Generate from title
baseSlug := generateSlug(title)
finalSlug = ensureUniqueSlug(site, baseSlug)
}
// Write image (normalize to 1600x969 with letterboxing) // Write image (normalize to 1600x969 with letterboxing)
imgDir := filepath.Join(site, "img", "blog") imgDir := filepath.Join(site, "img", "blog")
if err := os.MkdirAll(imgDir, 0755); err != nil { if err := os.MkdirAll(imgDir, 0755); err != nil {
@@ -839,26 +1018,99 @@ func main() {
reHead := regexp.MustCompile(`(?is)</head>`) reHead := regexp.MustCompile(`(?is)</head>`)
s = reHead.ReplaceAllString(s, meta+"</head>") s = reHead.ReplaceAllString(s, meta+"</head>")
} }
// Write new blog html // Inject slug as <meta name="slug" content="..."> before </head>
slugMeta := "<meta name=\"slug\" content=\"" + htmlEscape(finalSlug) + "\">\n"
reHeadSlug := regexp.MustCompile(`(?is)</head>`)
s = reHeadSlug.ReplaceAllString(s, slugMeta+"</head>")
// Inject annotation as <meta name="description" content="..."> before </head>
if annotation != "" {
annotationMeta := "<meta name=\"description\" content=\"" + htmlEscape(annotation) + "\">\n"
reHeadAnnotation := regexp.MustCompile(`(?is)</head>`)
s = reHeadAnnotation.ReplaceAllString(s, annotationMeta+"</head>")
}
// Inject content mode as <meta name="content_mode" content="..."> before </head>
contentModeMeta := "<meta name=\"content_mode\" content=\"" + htmlEscape(contentMode) + "\">\n"
reHeadContentMode := regexp.MustCompile(`(?is)</head>`)
s = reHeadContentMode.ReplaceAllString(s, contentModeMeta+"</head>")
// Write new blog html with both numeric and slug filenames
blogDir := filepath.Join(site, "blog") blogDir := filepath.Join(site, "blog")
if err := os.MkdirAll(blogDir, 0755); err != nil { if err := os.MkdirAll(blogDir, 0755); err != nil {
http.Error(w, "storage error: blog dir", http.StatusInternalServerError) http.Error(w, "storage error: blog dir", http.StatusInternalServerError)
return return
} }
// Write numeric file (for backward compatibility)
htmlPath := filepath.Join(blogDir, idStr+".html") htmlPath := filepath.Join(blogDir, idStr+".html")
if err := os.WriteFile(htmlPath, []byte(s), 0644); err != nil { if err := os.WriteFile(htmlPath, []byte(s), 0644); err != nil {
http.Error(w, "cannot write blog (is STATIC_PATH read-only?)", http.StatusInternalServerError) http.Error(w, "cannot write blog (is STATIC_PATH read-only?)", http.StatusInternalServerError)
return return
} }
// Write slug file (new format)
slugPath := filepath.Join(blogDir, finalSlug+".html")
if err := os.WriteFile(slugPath, []byte(s), 0644); err != nil {
http.Error(w, "cannot write slug blog (is STATIC_PATH read-only?)", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
"id": idStr, "id": idStr,
"link": "/blog/" + idStr + ".html", "slug": finalSlug,
"link": "/blog/" + finalSlug,
"image": "/img/blog/" + idStr + ".png", "image": "/img/blog/" + idStr + ".png",
"message": "created", "message": "created",
}) })
}) })
// Blog slug resolution: convert slug to numeric ID
mux.HandleFunc("/api/blog/resolve", func(w http.ResponseWriter, r *http.Request) {
okCORS(w)
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
http.Error(w, "missing slug", http.StatusBadRequest)
return
}
// Validate slug format
if !regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(slug) {
http.Error(w, "invalid slug format", http.StatusBadRequest)
return
}
site := staticPath()
blogDir := filepath.Join(site, "blog")
// First try to find slug-based file
slugPath := filepath.Join(blogDir, slug+".html")
if b, err := os.ReadFile(slugPath); err == nil {
s := string(b)
// Try to find the corresponding numeric file
entries, _ := os.ReadDir(blogDir)
for _, e := range entries {
name := e.Name()
if !regexp.MustCompile(`^\d{4}\.html$`).MatchString(name) {
continue
}
numericPath := filepath.Join(blogDir, name)
numericContent, _ := os.ReadFile(numericPath)
if string(numericContent) == s {
// Found matching numeric file
numericID := strings.TrimSuffix(name, ".html")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"id": numericID, "slug": slug})
return
}
}
}
// If not found, return 404
http.Error(w, "slug not found", http.StatusNotFound)
})
// Blog fetch (admin): returns title, content html, image for editing // Blog fetch (admin): returns title, content html, image for editing
mux.HandleFunc("/api/blog/get", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/blog/get", func(w http.ResponseWriter, r *http.Request) {
okCORS(w) okCORS(w)
@@ -901,9 +1153,12 @@ func main() {
content = strings.TrimSpace(mc[1]) content = strings.TrimSpace(mc[1])
} }
cats := extractCategories(path) cats := extractCategories(path)
slug := extractSlug(path, id+".html")
annotation := extractAnnotation(path)
contentMode := extractContentMode(path)
img := "/img/blog/" + id + ".png" img := "/img/blog/" + id + ".png"
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "title": title, "content_html": content, "image": img, "categories": cats}) _ = json.NewEncoder(w).Encode(map[string]any{"id": id, "title": title, "slug": slug, "annotation": annotation, "content_mode": contentMode, "content_html": content, "image": img, "categories": cats})
}) })
// Blog edit (admin): update title/content and optionally replace image // Blog edit (admin): update title/content and optionally replace image
@@ -921,7 +1176,7 @@ func main() {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
// Expect multipart form with: title, content (HTML), image (png), categories (comma-separated) // Expect multipart form with: title, slug, annotation, content_mode, content (HTML), image (png), categories (comma-separated)
if err := r.ParseMultipartForm(25 << 20); err != nil { if err := r.ParseMultipartForm(25 << 20); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest) http.Error(w, "invalid form", http.StatusBadRequest)
return return
@@ -933,6 +1188,9 @@ func main() {
return return
} }
title := strings.TrimSpace(r.FormValue("title")) title := strings.TrimSpace(r.FormValue("title"))
slugInput := strings.TrimSpace(r.FormValue("slug"))
annotation := strings.TrimSpace(r.FormValue("annotation"))
contentMode := strings.TrimSpace(r.FormValue("content_mode"))
htmlContent := strings.TrimSpace(r.FormValue("content")) htmlContent := strings.TrimSpace(r.FormValue("content"))
catsRaw := strings.TrimSpace(r.FormValue("categories")) catsRaw := strings.TrimSpace(r.FormValue("categories"))
var cats []string var cats []string
@@ -948,6 +1206,10 @@ func main() {
http.Error(w, "missing title or content", http.StatusBadRequest) http.Error(w, "missing title or content", http.StatusBadRequest)
return return
} }
// Default content mode to visual if not specified
if contentMode == "" {
contentMode = "visual"
}
site := staticPath() site := staticPath()
if f, fh, err := r.FormFile("image"); err == nil { if f, fh, err := r.FormFile("image"); err == nil {
defer f.Close() defer f.Close()
@@ -989,6 +1251,26 @@ func main() {
reHead := regexp.MustCompile(`(?is)</head>`) reHead := regexp.MustCompile(`(?is)</head>`)
s = reHead.ReplaceAllString(s, meta+"</head>") s = reHead.ReplaceAllString(s, meta+"</head>")
} }
// Update or add slug meta tag
reSlug := regexp.MustCompile(`(?is)<meta name="slug" content="[^"]*"\s*/?>\s*`)
s = reSlug.ReplaceAllString(s, "")
slugMeta := "<meta name=\"slug\" content=\"" + htmlEscape(slugInput) + "\">\n"
reHeadSlug := regexp.MustCompile(`(?is)</head>`)
s = reHeadSlug.ReplaceAllString(s, slugMeta+"</head>")
// Update or add annotation meta tag
reAnnotation := regexp.MustCompile(`(?is)<meta name="description" content="[^"]*"\s*/?>\s*`)
s = reAnnotation.ReplaceAllString(s, "")
if annotation != "" {
annotationMeta := "<meta name=\"description\" content=\"" + htmlEscape(annotation) + "\">\n"
reHeadAnnotation := regexp.MustCompile(`(?is)</head>`)
s = reHeadAnnotation.ReplaceAllString(s, annotationMeta+"</head>")
}
// Update or add content mode meta tag
reContentMode := regexp.MustCompile(`(?is)<meta name="content_mode" content="[^"]*"\s*/?>\s*`)
s = reContentMode.ReplaceAllString(s, "")
contentModeMeta := "<meta name=\"content_mode\" content=\"" + htmlEscape(contentMode) + "\">\n"
reHeadContentMode := regexp.MustCompile(`(?is)</head>`)
s = reHeadContentMode.ReplaceAllString(s, contentModeMeta+"</head>")
if err := os.WriteFile(hPath, []byte(s), 0644); err != nil { if err := os.WriteFile(hPath, []byte(s), 0644); err != nil {
http.Error(w, "cannot write", http.StatusInternalServerError) http.Error(w, "cannot write", http.StatusInternalServerError)
return return
@@ -1019,44 +1301,84 @@ func main() {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
}) })
// Static file server for the frontend // Static file server for the frontend
sp := staticPath() sp := staticPath()
log.Printf("serving static from: %s", sp) log.Printf("serving static from: %s", sp)
fs := http.FileServer(http.Dir(sp)) fs := http.FileServer(http.Dir(sp))
// Serve common asset prefixes explicitly // Serve common asset prefixes explicitly
mux.Handle("/img/", fs) mux.Handle("/img/", fs)
mux.Handle("/css/", fs) mux.Handle("/css/", fs)
mux.Handle("/js/", fs) mux.Handle("/js/", fs)
// Protect /admin/ with Basic Auth // Protect /admin/ with Basic Auth
mux.Handle("/admin/", basicAuth(http.StripPrefix("/admin/", http.FileServer(http.Dir(filepath.Join(staticPath(), "admin")))))) mux.Handle("/admin/", basicAuth(http.StripPrefix("/admin/", http.FileServer(http.Dir(filepath.Join(staticPath(), "admin"))))))
mux.Handle("/blog/", fs)
mux.Handle("/zapasy/", fs)
// Fallback: serve index.html at root, otherwise delegate to static file server
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
http.ServeFile(w, r, filepath.Join(sp, "index.html"))
return
}
fs.ServeHTTP(w, r)
})
port := os.Getenv("PORT") // Custom slug-based blog routing
if port == "" { port = "8080" } mux.HandleFunc("/blog/", func(w http.ResponseWriter, r *http.Request) {
srv := &http.Server{ // Extract the slug/ID from the path
Addr: ":" + port, path := strings.TrimPrefix(r.URL.Path, "/blog/")
Handler: mux, if path == "" || path == "/" {
} // Serve the main blog listing page
go func() { http.ServeFile(w, r, filepath.Join(sp, "blog.html"))
log.Printf("server listening on :%s", port) return
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { }
log.Fatalf("server error: %v", err)
}
}()
<-ctx.Done() // Remove .html extension if present
ctxShut, cancel := context.WithTimeout(context.Background(), 5*time.Second) if strings.HasSuffix(path, ".html") {
defer cancel() path = strings.TrimSuffix(path, ".html")
_ = srv.Shutdown(ctxShut) }
// Check if it's a numeric ID (legacy format)
if regexp.MustCompile(`^\d{4}$`).MatchString(path) {
// Serve numeric file directly
numericPath := filepath.Join(sp, "blog", path+".html")
if _, err := os.Stat(numericPath); err == nil {
http.ServeFile(w, r, numericPath)
return
}
}
// Check if it's a slug (new format)
if regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(path) {
slugPath := filepath.Join(sp, "blog", path+".html")
if _, err := os.Stat(slugPath); err == nil {
http.ServeFile(w, r, slugPath)
return
}
}
// If not found, serve 404
http.NotFound(w, r)
})
mux.Handle("/zapasy/", fs)
// Fallback: serve index.html at root, otherwise delegate to static file server
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
http.ServeFile(w, r, filepath.Join(sp, "index.html"))
return
}
fs.ServeHTTP(w, r)
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
}
go func() {
log.Printf("server listening on :%s", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
<-ctx.Done()
ctxShut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctxShut)
} }
func okCORS(w http.ResponseWriter) { func okCORS(w http.ResponseWriter) {
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Remove local blog files and keep only remote blogs
# Run this in your bizoni directory
echo "🗑️ Removing local blog files..."
# Remove all blog files
rm -rf blog/
echo "✅ Local blog files removed!"
echo ""
echo "📝 Next steps:"
echo "1. Deploy updated backend to server"
echo "2. Backend will now work with remote blogs only"
echo "3. Admin interface will connect to server API"
echo ""
echo "🌐 Your live blogs will remain untouched on the server"
+300
View File
@@ -0,0 +1,300 @@
#!/bin/bash
# 🚀 Bizoni Remote Blog Setup - One Command Script
# Run this to setup remote blog management
set -e
echo "🚀 Bizoni Remote Blog Setup"
echo "=========================="
# Configuration - UPDATE THESE
SERVER_USER="your_username" # Your SSH username
SERVER_HOST="your_server.com" # Your server domain/IP
SERVER_BLOG_DIR="/var/www/bizoni/blog" # Blog directory on server
LOCAL_PROJECT_DIR="/home/tdvorak/Desktop/HTML_Projekty/bizoni" # Your local project
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Helper functions
print_status() {
echo -e "${BLUE}$1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# Check if local blogs exist
check_local_blogs() {
print_status "Checking local blog files..."
if [ -d "$LOCAL_PROJECT_DIR/blog" ]; then
blog_count=$(find "$LOCAL_PROJECT_DIR/blog" -name "*.html" -type f 2>/dev/null | wc -l)
if [ "$blog_count" -gt 0 ]; then
print_warning "Found $blog_count local blog files"
read -p "Remove local blog files? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
rm -rf "$LOCAL_PROJECT_DIR/blog"
print_success "Local blog files removed"
else
print_warning "Skipping local blog removal"
fi
else
print_success "No local blog files found"
fi
else
print_success "No local blog directory found"
fi
}
# Create Ubuntu management script
create_server_script() {
print_status "Creating server management script..."
cat > /tmp/ubuntu-blog-manager.sh << 'EOF'
#!/bin/bash
# Ubuntu Server Blog Management Script
set -e
BLOG_DIR="/var/www/bizoni/blog"
BACKUP_DIR="/var/backups/bizoni-blogs"
# Create backup
create_backup() {
echo "📦 Creating backup..."
mkdir -p "$BACKUP_DIR"
timestamp=$(date +%Y%m%d_%H%M%S)
tar -czf "$BACKUP_DIR/blogs_backup_$timestamp.tar.gz" -C "$BLOG_DIR" . 2>/dev/null || echo "No files to backup"
echo "✅ Backup created"
}
# Generate slug from title
generate_slug() {
local title="$1"
echo "$title" | tr '[:upper:]' '[:lower:]' | \
sed 's/á/a/g; s/ä/a/g; s/č/c/g; s/ď/d/g; s/é/e/g; s/ě/e/g; s/í/i/g; s/ľ/l/g; s/ň/n/g; s/ó/o/g; s/ö/o/g; s/ô/o/g; s/ř/r/g; s/š/s/g; s/ť/t/g; s/ú/u/g; s/ů/u/g; s/ý/y/g; s/ž/z/g' | \
sed 's/Á/a/g; s/Ä/a/g; s/Č/c/g; s/Ď/d/g; s/É/e/g; s/Ě/e/g; s/Í/i/g; s/Ľ/l/g; s/Ň/n/g; s/Ó/o/g; s/Ö/o/g; s/Ô/o/g; s/Ř/r/g; s/Š/s/g; s/Ť/t/g; s/Ú/u/g; s/Ů/u/g; s/Ý/y/g; s/Ž/z/g' | \
sed 's/[^a-z0-9\s-]//g' | \
sed 's/[\s-]\+/ -/g' | \
sed 's/^-\|-$//g'
}
# Add slug to blog
add_slug_to_blog() {
local blog_id="$1"
local blog_file="$BLOG_DIR/$blog_id.html"
if [ ! -f "$blog_file" ]; then
echo "❌ Blog not found: $blog_id.html"
return 1
fi
# Check if slug already exists
if grep -q '<meta name="slug"' "$blog_file"; then
echo "️ $blog_id already has slug"
return 0
fi
# Extract title
title=$(grep -o '<h1[^>]*class="[^"]*lte-header[^"]*"[^>]*>.*</h1>' "$blog_file" 2>/dev/null | sed 's/<[^>]*>//g' | xargs || echo "")
if [ -z "$title" ]; then
echo "❌ Could not extract title from $blog_id"
return 1
fi
# Generate slug
slug=$(generate_slug "$title")
# Check uniqueness
counter=2
original_slug="$slug"
while [ -f "$BLOG_DIR/$slug.html" ]; do
slug="$original_slug-$counter"
((counter++))
done
echo "📝 $blog_id: $title → $slug"
# Add slug meta tag
sed -i "s|</head>|<meta name=\"slug\" content=\"$slug\">\n</head>|" "$blog_file"
# Create slug file
cp "$blog_file" "$BLOG_DIR/$slug.html"
echo "✅ Added slug: $slug.html"
}
# Migrate all blogs
migrate_all() {
echo "🔄 Migrating all blogs..."
if [ ! -d "$BLOG_DIR" ]; then
echo "❌ Blog directory not found: $BLOG_DIR"
exit 1
fi
create_backup
count=0
for blog_file in "$BLOG_DIR"/*.html; do
if [ -f "$blog_file" ]; then
filename=$(basename "$blog_file")
if [[ "$filename" =~ ^([0-9]{4})\.html$ ]]; then
blog_id="${BASH_REMATCH[1]}"
add_slug_to_blog "$blog_id"
((count++))
fi
fi
done
echo "✅ Migration completed! Processed $count blogs"
}
# List blogs
list_blogs() {
echo "📋 Blogs on server:"
if [ -d "$BLOG_DIR" ]; then
for file in "$BLOG_DIR"/*.html; do
if [ -f "$file" ]; then
filename=$(basename "$file")
if [[ "$filename" =~ ^[0-9]{4}\.html$ ]]; then
echo " 📄 $filename (numeric)"
elif [[ "$filename" =~ ^[a-z0-9-]+\.html$ ]]; then
echo " 🔗 $filename (slug)"
fi
fi
done
else
echo " ❌ Blog directory not found"
fi
}
case "${1:-}" in
"migrate")
migrate_all
;;
"list")
list_blogs
;;
"backup")
create_backup
;;
*)
echo "Ubuntu Blog Manager"
echo "=================="
echo "Usage: $0 <command>"
echo ""
echo "Commands:"
echo " migrate - Add slugs to all blogs"
echo " list - List all blogs"
echo " backup - Create backup"
echo ""
echo "Example: $0 migrate"
exit 1
;;
esac
EOF
chmod +x /tmp/ubuntu-blog-manager.sh
print_success "Server script created"
}
# Deploy to server
deploy_to_server() {
print_status "Deploying to server..."
# Upload server script
scp /tmp/ubuntu-blog-manager.sh "$SERVER_USER@$SERVER_HOST:/tmp/"
# Move script to server location and make executable
ssh "$SERVER_USER@$SERVER_HOST" "sudo mv /tmp/ubuntu-blog-manager.sh /usr/local/bin/blog-manager && sudo chmod +x /usr/local/bin/blog-manager"
print_success "Server script deployed to /usr/local/bin/blog-manager"
}
# Test server connection
test_connection() {
print_status "Testing server connection..."
if ssh "$SERVER_USER@$SERVER_HOST" "echo 'Connection successful'" 2>/dev/null; then
print_success "Server connection OK"
else
print_error "Cannot connect to server. Please check:"
echo " - Username: $SERVER_USER"
echo " - Host: $SERVER_HOST"
echo " - SSH key or password setup"
exit 1
fi
}
# Run migration on server
run_migration() {
print_status "Running migration on server..."
ssh "$SERVER_USER@$SERVER_HOST" "sudo blog-manager migrate"
print_success "Migration completed on server"
}
# Show results
show_results() {
print_status "Showing results..."
echo ""
ssh "$SERVER_USER@$SERVER_HOST" "blog-manager list"
echo ""
print_success "Setup completed!"
echo ""
echo "🌐 Your blogs now have clean URLs:"
echo " Old: /blog/0030.html"
echo " New: /blog/jdeme-do-finale"
echo ""
echo "🔧 Server commands you can use:"
echo " ssh $SERVER_USER@$SERVER_HOST 'blog-manager list'"
echo " ssh $SERVER_USER@$SERVER_HOST 'blog-manager backup'"
echo ""
}
# Main execution
main() {
echo ""
print_warning "Please update the configuration in this script:"
echo " SERVER_USER=\"$SERVER_USER\""
echo " SERVER_HOST=\"$SERVER_HOST\""
echo " SERVER_BLOG_DIR=\"$SERVER_BLOG_DIR\""
echo ""
read -p "Continue with current settings? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Please edit the script and run again"
exit 1
fi
check_local_blogs
test_connection
create_server_script
deploy_to_server
run_migration
show_results
}
# Run main function
main "$@"
+156
View File
@@ -0,0 +1,156 @@
#!/bin/bash
# Simple Server Setup Script - Run this ON YOUR SERVER after git push
# Usage: ./setup-server.sh
set -e
echo "🚀 Bizoni Server Setup Script"
echo "============================"
# Configuration - UPDATE THESE IF NEEDED
BLOG_DIR="/var/www/bizoni/blog"
IMG_DIR="/var/www/bizoni/img/blog"
BACKUP_DIR="/var/backups/bizoni"
echo "📁 Blog directory: $BLOG_DIR"
echo "🖼️ Image directory: $IMG_DIR"
echo ""
# Check if directories exist
if [ ! -d "$BLOG_DIR" ]; then
echo "❌ Blog directory not found: $BLOG_DIR"
echo "Please update BLOG_DIR in this script"
exit 1
fi
if [ ! -d "$IMG_DIR" ]; then
echo "❌ Image directory not found: $IMG_DIR"
echo "Please update IMG_DIR in this script"
exit 1
fi
echo "✅ Directories found"
echo ""
# Create backup
echo "📦 Creating backup..."
mkdir -p "$BACKUP_DIR"
timestamp=$(date +%Y%m%d_%H%M%S)
tar -czf "$BACKUP_DIR/blogs_backup_$timestamp.tar.gz" -C "$(dirname "$BLOG_DIR")" "$(basename "$BLOG_DIR")"
echo "✅ Backup created: $BACKUP_DIR/blogs_backup_$timestamp.tar.gz"
echo ""
# Count existing blogs
total_blogs=$(ls "$BLOG_DIR"/*.html 2>/dev/null | wc -l)
numeric_blogs=$(ls "$BLOG_DIR"/[0-9][0-9][0-9][0-9].html 2>/dev/null | wc -l)
slug_blogs=$(ls "$BLOG_DIR"/[a-z]*.html 2>/dev/null | wc -l)
echo "📊 Current blog status:"
echo " Total blogs: $total_blogs"
echo " Numeric files: $numeric_blogs"
echo " Slug files: $slug_blogs"
echo ""
# Function to generate slug from title
generate_slug() {
local title="$1"
echo "$title" | tr '[:upper:]' '[:lower:]' | \
sed 's/á/a/g; s/ä/a/g; s/č/c/g; s/ď/d/g; s/é/e/g; s/ě/e/g; s/í/i/g; s/ľ/l/g; s/ň/n/g; s/ó/o/g; s/ö/o/g; s/ô/o/g; s/ř/r/g; s/š/s/g; s/ť/t/g; s/ú/u/g; s/ů/u/g; s/ý/y/g; s/ž/z/g' | \
sed 's/Á/a/g; s/Ä/a/g; s/Č/c/g; s/Ď/d/g; s/É/e/g; s/Ě/e/g; s/Í/i/g; s/Ľ/l/g; s/Ň/n/g; s/Ó/o/g; s/Ö/o/g; s/Ô/o/g; s/Ř/r/g; s/Š/s/g; s/Ť/t/g; s/Ú/u/g; s/Ů/u/g; s/Ý/y/g; s/Ž/z/g' | \
sed 's/[^a-z0-9\s-]//g' | \
sed 's/[\s-]\+/ -/g' | \
sed 's/^-\|-$//g'
}
# Function to extract title from HTML
extract_title() {
local file="$1"
grep -o '<h1[^>]*class="[^"]*lte-header[^"]*"[^>]*>.*</h1>' "$file" | sed 's/<[^>]*>//g' | xargs || echo ""
}
# Function to check if slug exists
slug_exists() {
local slug="$1"
[ -f "$BLOG_DIR/$slug.html" ]
}
# Process numeric blogs that don't have slugs yet
echo "🔄 Processing blogs without slugs..."
processed=0
for blog_file in "$BLOG_DIR"/[0-9][0-9][0-9][0-9].html; do
if [ ! -f "$blog_file" ]; then
continue
fi
filename=$(basename "$blog_file")
blog_id="${filename%.html}"
# Check if slug meta tag already exists
if grep -q '<meta name="slug"' "$blog_file"; then
echo " ⏭️ Skipping $filename (slug already exists)"
continue
fi
# Extract title
title=$(extract_title "$blog_file")
if [ -z "$title" ]; then
echo " ⚠️ Skipping $filename (no title found)"
continue
fi
# Generate slug
base_slug=$(generate_slug "$title")
slug="$base_slug"
# Make slug unique
counter=2
while slug_exists "$slug"; do
slug="${base_slug}-${counter}"
((counter++))
done
echo " 📝 $filename: '$title' → '$slug'"
# Add slug meta tag before </head>
sed -i "s|</head>|<meta name=\"slug\" content=\"$slug\">\n</head>|" "$blog_file"
# Create slug file (copy of original)
cp "$blog_file" "$BLOG_DIR/$slug.html"
((processed++))
done
echo ""
echo "✅ Migration completed!"
echo "📊 Processed $processed blogs"
echo ""
# Show final status
total_blogs=$(ls "$BLOG_DIR"/*.html 2>/dev/null | wc -l)
numeric_blogs=$(ls "$BLOG_DIR"/[0-9][0-9][0-9][0-9].html 2>/dev/null | wc -l)
slug_blogs=$(ls "$BLOG_DIR"/[a-z]*.html 2>/dev/null | wc -l)
echo "📊 Final blog status:"
echo " Total blogs: $total_blogs"
echo " Numeric files: $numeric_blogs"
echo " Slug files: $slug_blogs"
echo ""
# Show some example URLs
echo "🌐 Example URLs now available:"
echo " /blog/$(ls "$BLOG_DIR"/[a-z]*.html 2>/dev/null | head -1 | xargs basename -s .html || echo 'jdeme-do-finale')"
echo " /blog/$(ls "$BLOG_DIR"/[a-z]*.html 2>/dev/null | head -2 | tail -1 | xargs basename -s .html || echo '1-zapas-final-score')"
echo ""
echo "🎉 Setup complete! Your blogs now support:"
echo " ✅ Clean URLs (slugs)"
echo " ✅ SEO meta tags"
echo " ✅ Backward compatibility"
echo " ✅ New admin features"
echo ""
echo "📝 Next steps:"
echo " 1. Restart your backend service"
echo " 2. Test new URLs in browser"
echo " 3. Try admin interface with new features"
+166
View File
@@ -0,0 +1,166 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"strings"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: ./migrate_slugs.go <site_root>")
fmt.Println("Example: ./migrate_slugs.go /home/tdvorak/Desktop/HTML_Projekty/bizoni")
os.Exit(1)
}
siteRoot := os.Args[1]
blogDir := filepath.Join(siteRoot, "blog")
// Read all blog files
entries, err := os.ReadDir(blogDir)
if err != nil {
log.Fatalf("Failed to read blog directory: %v", err)
}
// Pattern to match numeric blog files
numericPattern := regexp.MustCompile(`^(\d{4})\.html$`)
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := entry.Name()
matches := numericPattern.FindStringSubmatch(filename)
if len(matches) != 2 {
continue // Skip non-numeric files
}
blogPath := filepath.Join(blogDir, filename)
// Read the blog file
content, err := os.ReadFile(blogPath)
if err != nil {
log.Printf("Failed to read %s: %v", filename, err)
continue
}
contentStr := string(content)
// Check if slug already exists
slugPattern := regexp.MustCompile(`(?is)<meta name="slug" content="([^"]+)"`)
if slugPattern.MatchString(contentStr) {
log.Printf("Skipping %s - slug already exists", filename)
continue
}
// Extract title
titlePattern := regexp.MustCompile(`(?is)<h1[^>]*class="[^"]*\blte-header\b[^"]*"[^>]*>(.*?)</h1>`)
titleMatches := titlePattern.FindStringSubmatch(contentStr)
if len(titleMatches) < 2 {
log.Printf("Skipping %s - could not find title", filename)
continue
}
title := titleMatches[1]
// Remove any HTML tags from title
title = regexp.MustCompile(`(?is)<[^>]+>`).ReplaceAllString(title, "")
title = strings.TrimSpace(title)
// Generate slug
slug := generateSlug(title)
slug = ensureUniqueSlug(siteRoot, slug)
// Find where to insert the slug meta tag (before </head>)
headPattern := regexp.MustCompile(`(?is)</head>`)
if !headPattern.MatchString(contentStr) {
log.Printf("Skipping %s - could not find </head> tag", filename)
continue
}
// Insert slug meta tag
slugMeta := fmt.Sprintf(`<meta name="slug" content="%s">`, slug)
newContent := headPattern.ReplaceAllString(contentStr, slugMeta+"\n</head>")
// Write the updated content to both files
err = os.WriteFile(blogPath, []byte(newContent), 0644)
if err != nil {
log.Printf("Failed to write updated %s: %v", filename, err)
continue
}
// Create slug-based file
slugPath := filepath.Join(blogDir, slug+".html")
err = os.WriteFile(slugPath, []byte(newContent), 0644)
if err != nil {
log.Printf("Failed to create slug file %s: %v", slugPath, err)
continue
}
log.Printf("Processed %s: title='%s' -> slug='%s'", filename, title, slug)
}
log.Println("Migration completed!")
}
// generateSlug creates a URL-friendly slug from a title
func generateSlug(title string) string {
slug := strings.ToLower(title)
// Replace Czech characters with their ASCII equivalents
replacements := map[string]string{
"á": "a", "ä": "a", "č": "c", "ď": "d", "é": "e", "ě": "e", "í": "i", "ľ": "l",
"ň": "n", "ó": "o", "ö": "o", "ô": "o", "ř": "r", "š": "s", "ť": "t", "ú": "u",
"ů": "u", "ý": "y", "ž": "z",
"Á": "a", "Ä": "a", "Č": "c", "Ď": "d", "É": "e", "Ě": "e", "Í": "i", "Ľ": "l",
"Ň": "n", "Ó": "o", "Ö": "o", "Ô": "o", "Ř": "r", "Š": "s", "Ť": "t", "Ú": "u",
"Ů": "u", "Ý": "y", "Ž": "z",
}
for czech, ascii := range replacements {
slug = strings.ReplaceAll(slug, czech, ascii)
}
// Remove any character that isn't alphanumeric, space, or hyphen
re := regexp.MustCompile(`[^a-z0-9\s-]`)
slug = re.ReplaceAllString(slug, "")
// Replace spaces and multiple hyphens with a single hyphen
re = regexp.MustCompile(`[\s-]+`)
slug = re.ReplaceAllString(slug, "-")
// Remove leading and trailing hyphens
slug = strings.Trim(slug, "-")
return slug
}
// ensureUniqueSlug ensures the slug is unique by appending a number if needed
func ensureUniqueSlug(siteRoot, baseSlug string) string {
blogDir := filepath.Join(siteRoot, "blog")
entries, err := os.ReadDir(blogDir)
if err != nil {
return baseSlug
}
existingSlugs := make(map[string]bool)
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".html") {
continue
}
// Extract slug from filename if it follows the new pattern
name := strings.TrimSuffix(e.Name(), ".html")
// Check if it's a slug-based filename (contains letters, not just numbers)
if regexp.MustCompile(`[a-z]`).MatchString(name) {
existingSlugs[name] = true
}
}
if !existingSlugs[baseSlug] {
return baseSlug
}
// Try baseSlug-2, baseSlug-3, etc.
for i := 2; i < 100; i++ {
testSlug := fmt.Sprintf("%s-%d", baseSlug, i)
if !existingSlugs[testSlug] {
return testSlug
}
}
// Fallback to timestamp
return fmt.Sprintf("%s-%d", baseSlug, 1234567890)
}
+214
View File
@@ -0,0 +1,214 @@
#!/bin/bash
# Ubuntu Server Script: Remote Blog Management
# This script manages blogs on the remote server only
set -e
# Configuration - UPDATE THESE PATHS
SERVER_BLOG_DIR="/var/www/bizoni/blog" # Path to blogs on your server
BACKUP_DIR="/var/backups/bizoni-blogs" # Backup location
SITE_ROOT="/var/www/bizoni" # Site root on server
echo "🚀 Bizoni Remote Blog Management Script"
echo "====================================="
# Function to create backup
create_backup() {
echo "📦 Creating backup..."
mkdir -p "$BACKUP_DIR"
timestamp=$(date +%Y%m%d_%H%M%S)
tar -czf "$BACKUP_DIR/blogs_backup_$timestamp.tar.gz" -C "$SERVER_BLOG_DIR" .
echo "✅ Backup created: $BACKUP_DIR/blogs_backup_$timestamp.tar.gz"
}
# Function to list blogs
list_blogs() {
echo "📋 Current blogs on server:"
if [ -d "$SERVER_BLOG_DIR" ]; then
ls -la "$SERVER_BLOG_DIR"/*.html | while read line; do
filename=$(basename "$line")
if [[ "$filename" =~ ^[0-9]{4}\.html$ ]]; then
echo " 📄 $filename (numeric)"
elif [[ "$filename" =~ ^[a-z0-9-]+\.html$ ]]; then
echo " 🔗 $filename (slug)"
fi
done
else
echo " ❌ Blog directory not found: $SERVER_BLOG_DIR"
fi
}
# Function to add slug to existing blog
add_slug_to_blog() {
local blog_id="$1"
local blog_file="$SERVER_BLOG_DIR/$blog_id.html"
if [ ! -f "$blog_file" ]; then
echo "❌ Blog file not found: $blog_file"
return 1
fi
echo "🔧 Adding slug to blog: $blog_id"
# Extract title from blog file
title=$(grep -o '<h1[^>]*class="[^"]*lte-header[^"]*"[^>]*>.*</h1>' "$blog_file" | sed 's/<[^>]*>//g' | xargs)
if [ -z "$title" ]; then
echo "❌ Could not extract title from $blog_file"
return 1
fi
echo "📝 Title: $title"
# Generate slug
slug=$(echo "$title" | tr '[:upper:]' '[:lower:]' | \
sed 's/á/a/g; s/ä/a/g; s/č/c/g; s/ď/d/g; s/é/e/g; s/ě/e/g; s/í/i/g; s/ľ/l/g; s/ň/n/g; s/ó/o/g; s/ö/o/g; s/ô/o/g; s/ř/r/g; s/š/s/g; s/ť/t/g; s/ú/u/g; s/ů/u/g; s/ý/y/g; s/ž/z/g' | \
sed 's/Á/a/g; s/Ä/a/g; s/Č/c/g; s/Ď/d/g; s/É/e/g; s/Ě/e/g; s/Í/i/g; s/Ľ/l/g; s/Ň/n/g; s/Ó/o/g; s/Ö/o/g; s/Ô/o/g; s/Ř/r/g; s/Š/s/g; s/Ť/t/g; s/Ú/u/g; s/Ů/u/g; s/Ý/y/g; s/Ž/z/g' | \
sed 's/[^a-z0-9\s-]//g' | \
sed 's/[\s-]\+/ -/g' | \
sed 's/^-\|-$//g')
# Check if slug file already exists
slug_file="$SERVER_BLOG_DIR/$slug.html"
if [ -f "$slug_file" ]; then
# Add number suffix
i=2
while [ -f "$SERVER_BLOG_DIR/${slug}-${i}.html" ]; do
((i++))
done
slug="${slug}-${i}"
slug_file="$SERVER_BLOG_DIR/${slug}.html"
fi
echo "🔗 Generated slug: $slug"
# Check if slug meta tag already exists
if grep -q '<meta name="slug"' "$blog_file"; then
echo "️ Slug meta tag already exists"
return 0
fi
# Add slug meta tag before </head>
sed -i "s|</head>|<meta name=\"slug\" content=\"$slug\">\n</head>|" "$blog_file"
# Create slug file (copy of original)
cp "$blog_file" "$slug_file"
echo "✅ Slug added and slug file created: $slug.html"
}
# Function to migrate all blogs to slugs
migrate_all_blogs() {
echo "🔄 Migrating all blogs to slugs..."
if [ ! -d "$SERVER_BLOG_DIR" ]; then
echo "❌ Blog directory not found: $SERVER_BLOG_DIR"
echo "Please update SERVER_BLOG_DIR in this script"
exit 1
fi
create_backup
# Process all numeric blog files
for blog_file in "$SERVER_BLOG_DIR"/*.html; do
if [ -f "$blog_file" ]; then
filename=$(basename "$blog_file")
if [[ "$filename" =~ ^([0-9]{4})\.html$ ]]; then
blog_id="${BASH_REMATCH[1]}"
add_slug_to_blog "$blog_id"
echo ""
fi
fi
done
echo "✅ Migration completed!"
}
# Function to show blog info
show_blog_info() {
local blog_id="$1"
local blog_file="$SERVER_BLOG_DIR/$blog_id.html"
if [ ! -f "$blog_file" ]; then
echo "❌ Blog file not found: $blog_file"
return 1
fi
echo "📄 Blog Info for: $blog_id"
echo "========================"
# Extract title
title=$(grep -o '<h1[^>]*class="[^"]*lte-header[^"]*"[^>]*>.*</h1>' "$blog_file" | sed 's/<[^>]*>//g' | xargs)
echo "📝 Title: $title"
# Extract slug
slug=$(grep -o '<meta name="slug" content="[^"]*"' "$blog_file" | sed 's/.*content="\([^"]*\)".*/\1/')
echo "🔗 Slug: $slug"
# Extract categories
categories=$(grep -o '<meta name="category" content="[^"]*"' "$blog_file" | sed 's/.*content="\([^"]*\)".*/\1/' | tr '\n' ', ')
echo "🏷️ Categories: $categories"
# File size
size=$(du -h "$blog_file" | cut -f1)
echo "📊 Size: $size"
# Check if slug file exists
if [ -n "$slug" ] && [ -f "$SERVER_BLOG_DIR/$slug.html" ]; then
echo "✅ Slug file exists: $slug.html"
else
echo "❌ Slug file missing"
fi
}
# Main menu
case "${1:-}" in
"list")
list_blogs
;;
"migrate")
migrate_all_blogs
;;
"info")
if [ -z "${2:-}" ]; then
echo "Usage: $0 info <blog_id>"
echo "Example: $0 info 0030"
exit 1
fi
show_blog_info "$2"
;;
"add-slug")
if [ -z "${2:-}" ]; then
echo "Usage: $0 add-slug <blog_id>"
echo "Example: $0 add-slug 0030"
exit 1
fi
add_slug_to_blog "$2"
;;
"backup")
create_backup
;;
*)
echo "Bizoni Remote Blog Management"
echo "============================"
echo ""
echo "Usage: $0 <command> [options]"
echo ""
echo "Commands:"
echo " list - List all blogs on server"
echo " migrate - Migrate all blogs to use slugs"
echo " info <blog_id> - Show info about specific blog"
echo " add-slug <blog_id> - Add slug to specific blog"
echo " backup - Create backup of all blogs"
echo ""
echo "Examples:"
echo " $0 list"
echo " $0 migrate"
echo " $0 info 0030"
echo " $0 add-slug 0030"
echo ""
echo "⚠️ Make sure to update SERVER_BLOG_DIR path in this script!"
exit 1
;;
esac