this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: separate presentation from business logic in service layer

Remove HTML generation from ContentService and handlers, moving all
presentation logic into templates. DisplayItem is now a pure data struct
with EmbedType enum to let templates decide how to render content.

Key changes:
- Service layer returns structured data instead of template.HTML
- Templates use conditionals to dispatch based on EmbedType
- Added template functions (irclinkURL, truncate, safeHTML, safeURL)
- New templates for link_posted and no_search_results pages
- Navigation and embed styles moved from inline to CSS classes
- Updated tests to verify data fields instead of parsing HTML

+740 -567
+91
internal/assets/css/screen.css
··· 1103 1103 display: none !important; 1104 1104 } 1105 1105 1106 + /* Navigation Links */ 1107 + .nav-link { 1108 + text-decoration: none; 1109 + color: var(--text-primary); 1110 + display: inline-flex; 1111 + align-items: center; 1112 + padding: var(--space-2); 1113 + border-radius: var(--radius-sm); 1114 + transition: all 0.15s ease; 1115 + } 1116 + 1117 + .nav-link:hover { 1118 + background: var(--bg-surface); 1119 + color: var(--accent-primary); 1120 + } 1121 + 1122 + .nav-icon { 1123 + font-size: 36px; 1124 + vertical-align: middle; 1125 + } 1126 + 1127 + /* Search Results - No Results */ 1128 + .search-fail-title { 1129 + color: var(--text-primary); 1130 + font-weight: var(--font-weight-bold); 1131 + } 1132 + 1133 + /* Imgur Media Link */ 1134 + .imgur-media-link { 1135 + display: inline-block; 1136 + position: relative; 1137 + } 1138 + 1139 + /* Imgur Video */ 1140 + .imgur-video { 1141 + width: 100%; 1142 + height: auto; 1143 + display: block; 1144 + max-width: 800px; 1145 + } 1146 + 1147 + /* Imgur Error Fallback */ 1148 + .imgur-error-fallback { 1149 + display: none; 1150 + } 1151 + 1152 + /* Flickr Image */ 1153 + .flickr-image { 1154 + max-width: 500px; 1155 + height: auto; 1156 + display: block; 1157 + } 1158 + 1159 + /* Direct Image */ 1160 + .direct-image { 1161 + max-width: 800px; 1162 + max-height: 600px; 1163 + height: auto; 1164 + display: block; 1165 + } 1166 + 1167 + /* Link Posted Page */ 1168 + .link-posted-page { 1169 + display: flex; 1170 + justify-content: center; 1171 + align-items: center; 1172 + min-height: 100vh; 1173 + background: var(--bg-canvas); 1174 + } 1175 + 1176 + .link-posted-message { 1177 + font-size: var(--font-size-md); 1178 + color: var(--text-secondary); 1179 + font-family: var(--font-sans); 1180 + text-align: center; 1181 + padding: var(--space-8); 1182 + background: var(--card-bg); 1183 + border-radius: var(--radius-lg); 1184 + box-shadow: var(--shadow-md); 1185 + max-width: 500px; 1186 + } 1187 + 1188 + .link-posted-message b { 1189 + color: var(--text-primary); 1190 + } 1191 + 1192 + .duplicate-notice { 1193 + color: #f59e0b; 1194 + font-style: italic; 1195 + } 1196 +
+156 -190
internal/handler/handlers.go
··· 6 6 "html/template" 7 7 "log/slog" 8 8 "net/http" 9 + "sort" 9 10 "strconv" 10 11 "sync" 11 12 "time" ··· 45 46 } 46 47 } 47 48 48 - // Index Page Data structure for the main template 49 + // IndexPageData is the data structure for the main template 49 50 type IndexPageData struct { 50 - PageTitle string 51 - Hot template.HTML 52 - Container template.HTML 53 - NavP template.HTML 54 - NavN template.HTML 55 - GitCommit string // Placeholder 56 - GitCommitURL string // Placeholder 57 - // For XML 51 + PageTitle string 52 + Hot template.HTML 53 + Container template.HTML 54 + NavP template.HTML 55 + NavN template.HTML 56 + GitCommit string 57 + GitCommitURL string 58 58 BaseURL template.HTML 59 59 Poster string 60 60 FilterType string 61 61 IsFallbackContent bool 62 62 } 63 63 64 - // Helper to fetch and render Hot Shit links 65 - func (h *Handler) getHotHTML(ctx context.Context) template.HTML { 64 + // NavigationData holds pagination navigation info 65 + type NavigationData struct { 66 + PrevPage int 67 + NextPage int 68 + CurrentPage int 69 + HasPrev bool 70 + HasNext bool 71 + PosterParam string 72 + } 73 + 74 + // HotLinkItem is a simplified struct for hot links display 75 + type HotLinkItem struct { 76 + ID int 77 + Title string 78 + BaseURL string 79 + } 80 + 81 + // getHotLinks returns hot link data for templates 82 + func (h *Handler) getHotLinks(ctx context.Context) []HotLinkItem { 66 83 topLinks, err := h.Store.GetTopIRCLinks(ctx, 12, 6, 5) 67 84 if err != nil { 68 85 slog.Error("Failed to get top links", "error", err) 86 + return nil 87 + } 88 + 89 + items := make([]HotLinkItem, 0, len(topLinks)) 90 + for _, l := range topLinks { 91 + items = append(items, HotLinkItem{ 92 + ID: l.ID, 93 + Title: l.Title, 94 + BaseURL: h.Config.BaseURL, 95 + }) 96 + } 97 + return items 98 + } 99 + 100 + // getHotHTML renders hot links to HTML (for backwards compatibility during transition) 101 + func (h *Handler) getHotHTML(ctx context.Context) template.HTML { 102 + hotLinks := h.getHotLinks(ctx) 103 + if len(hotLinks) == 0 { 69 104 return "" 70 105 } 106 + 71 107 hotHTML := "" 72 - for _, l := range topLinks { 73 - if len(l.Title) > 30 { 74 - l.Title = l.Title[:30] + "..." 75 - } 76 - content := fmt.Sprintf(`<a href="%s/irclink/?%d" target="_blank">%s</a>`, h.Config.BaseURL, l.ID, l.Title) 77 - data := map[string]interface{}{ 78 - "Content": template.HTML(content), 79 - } 80 - s, _ := h.Renderer.RenderToString("tumble_item_top5.html", data) 108 + for _, link := range hotLinks { 109 + s, _ := h.Renderer.RenderToString("tumble_item_top5.html", link) 81 110 hotHTML += s 82 111 } 83 112 return template.HTML(hotHTML) 84 113 } 85 114 115 + // DateGroup represents a group of items for a single date 116 + type DateGroup struct { 117 + FullDate string 118 + DateRawDay string 119 + DateDay string 120 + DateMonth string 121 + DateYear string 122 + Items []service.DisplayItem 123 + } 124 + 86 125 func (h *Handler) Index(w http.ResponseWriter, r *http.Request) { 87 126 ctx := r.Context() 88 127 ··· 106 145 } 107 146 } 108 147 109 - // Date interval logic: 110 - // Perl: start_days = i * 6, end_days = (i - 1) * 6 148 + // Date interval logic 111 149 startDays := i * 6 112 150 endDays := (i - 1) * 6 113 151 ··· 119 157 var quotes []data.Quote 120 158 121 159 poster := params.Get("poster") 122 - filterType := params.Get("type") // "links", "quotes", or empty/all 160 + filterType := params.Get("type") 123 161 isFallback := false 124 162 125 163 if poster != "" { 126 164 // Filtered View: Only links/quotes by 'poster' 127 - // Pagination for poster view is 30 items 128 165 limit := 30 129 166 offset := (i - 1) * 30 130 167 131 168 timelineItems, err := h.Store.GetUserTimeline(ctx, poster, filterType, limit, offset) 132 169 if err != nil { 133 - errIrc = err // Propagate error 170 + errIrc = err 134 171 } else { 135 - // Unpack timeline items into respective slices 136 172 for _, item := range timelineItems { 137 173 if item.Type == "link" { 138 174 ircLinks = append(ircLinks, data.IRCLink{ ··· 162 198 } 163 199 164 200 if errIrc != nil || errImg != nil || errQuote != nil { 165 - // Consolidate errors for logging? 166 - // Just picking one for now as example or joining them 167 201 err := fmt.Errorf("irc: %v, img: %v, quote: %v", errIrc, errImg, errQuote) 168 202 h.ServerError(w, r, err) 169 203 return 170 204 } 171 205 172 - // Check for empty state on front page (standard view, page 1) 206 + // Check for empty state on front page 173 207 if poster == "" && i == 1 && len(ircLinks) == 0 && len(images) == 0 && len(quotes) == 0 { 174 208 slog.Info("No recent content found, fetching global timeline fallback") 175 209 fallbackItems, err := h.Store.GetGlobalTimeline(ctx, 20, 0) ··· 197 231 ID: item.ID, 198 232 Timestamp: item.Timestamp, 199 233 Title: item.Title, 200 - Link: item.URL, // In GetGlobalTimeline, we mapped URL to URL, but Image struct has Link and URL. 201 - // Looking at mysql select: 'image' as type... url ... 202 - // In Image struct: Link is usually the click-through, URL is the src. 203 - // Let's re-verify image struct usage. 204 - // Image struct: Link string `json:"link"`, URL string `json:"url"` 205 - // In GetRecentImages: Scan(&i.Link, &i.URL...) 206 - // In GetGlobalTimeline: SELECT ... url ... 207 - // We might be missing the 'link' field in global timeline for images if we just select one 'url' column. 208 - // TimelineItem has 'URL'. 209 - // For now, let's map URL to URL and assume Link is same or empty? 210 - // Revisiting GetGlobalTimeline query: 211 - // SELECT 'image', ..., url, ... 212 - // It seems we only selected URL. We might want to fix GetGlobalTimeline to include Link if essential. 213 - // Assuming URL is the main thing for display. 214 - URL: item.URL, 215 - MD5Sum: item.MD5Sum, 234 + Link: item.URL, 235 + URL: item.URL, 236 + MD5Sum: item.MD5Sum, 216 237 }) 217 238 } 218 239 } ··· 221 242 } 222 243 } 223 244 224 - type ProcessedItem struct { 225 - Timestamp string // for sorting 226 - HTML string 227 - DateRawDay string 228 - DateDay string 229 - DateMonth string 230 - DateYear string 231 - FullDate string // YYYYMMDD for comparison 232 - } 245 + // Process all items into DisplayItems 246 + var allItems []service.DisplayItem 233 247 234 - processedItems := []ProcessedItem{} 235 - 236 - // Process IRCLinks 237 248 for _, item := range ircLinks { 238 - d := h.Service.ProcessIRCLink(item) 239 - tmplName := "tumble_item_ircLink.html" 240 - if dtype == "rss" || dtype == "xml" { 241 - tmplName = "tumble_item_ircLink.xml" 242 - } 243 - 244 - html, err := h.Renderer.RenderToString(tmplName, d) 245 - if err == nil { 246 - processedItems = append(processedItems, ProcessedItem{ 247 - Timestamp: item.Timestamp.Format("20060102150405"), // Sortable string 248 - HTML: html, 249 - DateRawDay: d.DateRawDay, 250 - DateDay: d.DateDay, 251 - DateMonth: d.DateMonth, 252 - DateYear: d.DateYear, 253 - FullDate: item.Timestamp.Format("20060102"), 254 - }) 255 - } else { 256 - slog.Debug("Render Error for Link", "id", item.ID, "error", err) 257 - } 249 + allItems = append(allItems, h.Service.ProcessIRCLink(item)) 258 250 } 259 - 260 - // Process Images 261 251 for _, item := range images { 262 - d := h.Service.ProcessImage(item) 263 - tmplName := "tumble_item_image.html" 264 - if dtype == "rss" || dtype == "xml" { 265 - tmplName = "tumble_item_image.xml" 266 - } 267 - 268 - html, err := h.Renderer.RenderToString(tmplName, d) 269 - if err == nil { 270 - processedItems = append(processedItems, ProcessedItem{ 271 - Timestamp: item.Timestamp.Format("20060102150405"), 272 - HTML: html, 273 - DateRawDay: d.DateRawDay, 274 - DateDay: d.DateDay, 275 - DateMonth: d.DateMonth, 276 - DateYear: d.DateYear, 277 - FullDate: item.Timestamp.Format("20060102"), 278 - }) 279 - } 252 + allItems = append(allItems, h.Service.ProcessImage(item)) 280 253 } 281 - 282 - // Process Quotes 283 254 for _, item := range quotes { 284 - d := h.Service.ProcessQuote(item) 285 - tmplName := "tumble_item_quote.html" 286 - if dtype == "rss" || dtype == "xml" { 287 - tmplName = "tumble_item_quote.xml" 288 - } 289 - 290 - html, err := h.Renderer.RenderToString(tmplName, d) 291 - if err == nil { 292 - processedItems = append(processedItems, ProcessedItem{ 293 - Timestamp: item.Timestamp.Format("20060102150405"), 294 - HTML: html, 295 - DateRawDay: d.DateRawDay, 296 - DateDay: d.DateDay, 297 - DateMonth: d.DateMonth, 298 - DateYear: d.DateYear, 299 - FullDate: item.Timestamp.Format("20060102"), 300 - }) 301 - } 255 + allItems = append(allItems, h.Service.ProcessQuote(item)) 302 256 } 303 257 304 - // Sort items (descending) 305 - for j := 0; j < len(processedItems); j++ { 306 - for k := j + 1; k < len(processedItems); k++ { 307 - if processedItems[j].Timestamp < processedItems[k].Timestamp { 308 - processedItems[j], processedItems[k] = processedItems[k], processedItems[j] 309 - } 310 - } 311 - } 258 + // Sort items by timestamp descending 259 + sort.Slice(allItems, func(i, j int) bool { 260 + return allItems[i].Timestamp.After(allItems[j].Timestamp) 261 + }) 312 262 313 - // Generate Container HTML 263 + // Render items to HTML (still using RenderToString for now, but templates handle the logic) 314 264 containerHTML := "" 315 265 lastDate := "" 316 266 sectionOpen := false 317 267 318 - for _, p := range processedItems { 268 + for _, item := range allItems { 269 + // Select template based on item type and output format 270 + var tmplName string 271 + switch item.Type { 272 + case "ircLink": 273 + tmplName = "tumble_item_ircLink.html" 274 + if dtype == "rss" || dtype == "xml" { 275 + tmplName = "tumble_item_ircLink.xml" 276 + } 277 + case "image": 278 + tmplName = "tumble_item_image.html" 279 + if dtype == "rss" || dtype == "xml" { 280 + tmplName = "tumble_item_image.xml" 281 + } 282 + case "quote": 283 + tmplName = "tumble_item_quote.html" 284 + if dtype == "rss" || dtype == "xml" { 285 + tmplName = "tumble_item_quote.xml" 286 + } 287 + } 288 + 289 + // For HTML output, handle date grouping 319 290 if dtype != "rss" && dtype != "xml" { 320 - if p.FullDate != lastDate { 321 - // Close previous section if open 291 + if item.FullDate != lastDate { 322 292 if sectionOpen { 323 293 containerHTML += "</div>" 324 294 } 325 - // Open new date section 326 - containerHTML += fmt.Sprintf(`<div class="date-section" data-date="%s">`, p.FullDate) 295 + containerHTML += fmt.Sprintf(`<div class="date-section" data-date="%s">`, item.FullDate) 327 296 sectionOpen = true 328 297 329 - // Date Changed, Render Date Template 330 298 dateData := map[string]string{ 331 - "Date": p.DateRawDay, 332 - "Day": p.DateDay, 333 - "Month": p.DateMonth, 334 - "Year": p.DateYear, 299 + "Date": item.DateRawDay, 300 + "Day": item.DateDay, 301 + "Month": item.DateMonth, 302 + "Year": item.DateYear, 335 303 } 336 304 dateHTML, err := h.Renderer.RenderToString("tumble_date.html", dateData) 337 305 if err == nil { 338 306 containerHTML += dateHTML 339 307 } 340 - lastDate = p.FullDate 308 + lastDate = item.FullDate 341 309 } 342 310 } 343 - containerHTML += p.HTML 311 + 312 + html, err := h.Renderer.RenderToString(tmplName, item) 313 + if err != nil { 314 + slog.Debug("Render Error", "type", item.Type, "id", item.ID, "error", err) 315 + continue 316 + } 317 + containerHTML += html 344 318 } 345 319 346 - // Close final section if open 347 320 if sectionOpen { 348 321 containerHTML += "</div>" 349 322 } ··· 354 327 hotHTML = h.getHotHTML(ctx) 355 328 } 356 329 357 - // Navigation 358 - navP := "" 359 - navN := "" 360 - posterParam := "" 361 - if poster != "" { 362 - posterParam = fmt.Sprintf("&poster=%s", poster) 363 - if filterType != "" { 364 - posterParam += fmt.Sprintf("&type=%s", filterType) 365 - } 366 - } 367 - 368 - if iParam != "" || i > 1 { 369 - navP = fmt.Sprintf(`<a href="?i=%d%s" style="text-decoration:none;"><span class="material-symbols-rounded" style="font-size: 36px; vertical-align: middle;">chevron_left</span></a>`, i+1, posterParam) 370 - navN = fmt.Sprintf(` &nbsp;<a href="?i=%d%s" style="text-decoration:none;"><span class="material-symbols-rounded" style="font-size: 36px; vertical-align: middle;">chevron_right</span></a>`, i-1, posterParam) 371 - } else { 372 - navP = fmt.Sprintf(`<a href="?i=2%s" style="text-decoration:none;"><span class="material-symbols-rounded" style="font-size: 36px; vertical-align: middle;">chevron_left</span></a>`, posterParam) 373 - } 374 - if i == 1 { 375 - navN = "" 376 - } 330 + // Navigation - using template.HTML for now, will move to template later 331 + nav := h.buildNavigation(i, poster, filterType) 377 332 378 333 // View Data 379 334 pageTitle := "" ··· 385 340 PageTitle: pageTitle, 386 341 Container: template.HTML(containerHTML), 387 342 Hot: hotHTML, 388 - NavP: template.HTML(navP), 389 - NavN: template.HTML(navN), 343 + NavP: template.HTML(nav.prevHTML), 344 + NavN: template.HTML(nav.nextHTML), 390 345 BaseURL: template.HTML(h.Config.BaseURL), 391 346 Poster: poster, 392 347 FilterType: filterType, ··· 408 363 } 409 364 } 410 365 366 + type navResult struct { 367 + prevHTML string 368 + nextHTML string 369 + } 370 + 371 + func (h *Handler) buildNavigation(page int, poster, filterType string) navResult { 372 + posterParam := "" 373 + if poster != "" { 374 + posterParam = fmt.Sprintf("&poster=%s", poster) 375 + if filterType != "" { 376 + posterParam += fmt.Sprintf("&type=%s", filterType) 377 + } 378 + } 379 + 380 + var prevHTML, nextHTML string 381 + 382 + if page > 1 { 383 + prevHTML = fmt.Sprintf(`<a href="?i=%d%s" class="nav-link"><span class="material-symbols-rounded nav-icon">chevron_left</span></a>`, page+1, posterParam) 384 + nextHTML = fmt.Sprintf(`<a href="?i=%d%s" class="nav-link"><span class="material-symbols-rounded nav-icon">chevron_right</span></a>`, page-1, posterParam) 385 + } else { 386 + prevHTML = fmt.Sprintf(`<a href="?i=2%s" class="nav-link"><span class="material-symbols-rounded nav-icon">chevron_left</span></a>`, posterParam) 387 + nextHTML = "" 388 + } 389 + 390 + return navResult{prevHTML: prevHTML, nextHTML: nextHTML} 391 + } 392 + 411 393 func (h *Handler) ButtonHandler(w http.ResponseWriter, r *http.Request) { 412 394 user := r.FormValue("user") 413 - // If user is empty, template will show the landing page (form) 414 - // If user is present, template will show the bookmarklets 415 395 data := map[string]interface{}{ 416 396 "User": user, 417 397 "BaseURL": h.Config.BaseURL, ··· 430 410 query := r.URL.Query().Get("search") 431 411 432 412 if query == "" { 433 - // Perl behaviour: returns unless string? 434 413 return 435 414 } 436 415 437 - // Perform Search 438 416 links, err := h.Store.SearchIRCLinks(ctx, query) 439 417 if err != nil { 440 418 h.ServerError(w, r, err) ··· 449 427 containerHTML += s 450 428 } 451 429 } else { 452 - // No results template (tumble_item_text) 453 - msg := fmt.Sprintf(` 454 - <font color="#000">Your search-fu is weak.</font><br /><br /> 455 - Your search for '%s' did not return any results. Perhaps the following tips can help aid you on your quest: 456 - <ul> 457 - <li>Searches must be done using four or more characters.<br /><br /> 458 - <li>MySQL fulltext-searching is the magic behind this. Stop blaming scott.<br /><br /> 459 - <li>Try not to be such a fucking idiot. 460 - </ul>`, query) 461 - 430 + // Use the new no_search_results template 462 431 data := map[string]interface{}{ 463 - "Content": template.HTML(msg), 432 + "Query": query, 464 433 } 465 - containerHTML, _ = h.Renderer.RenderToString("tumble_item_text.html", data) 434 + containerHTML, _ = h.Renderer.RenderToString("no_search_results.html", data) 466 435 } 467 436 468 - // Hot links 469 437 hotHTML := h.getHotHTML(ctx) 470 438 471 439 viewData := IndexPageData{ 472 - PageTitle: fmt.Sprintf(" &gt; %s", query), 473 - Container: template.HTML(containerHTML), 474 - Hot: hotHTML, 475 - BaseURL: template.HTML(h.Config.BaseURL), 440 + PageTitle: fmt.Sprintf(" &gt; %s", query), 441 + Container: template.HTML(containerHTML), 442 + Hot: hotHTML, 443 + BaseURL: template.HTML(h.Config.BaseURL), 444 + GitCommit: version.CommitHash, 445 + GitCommitURL: fmt.Sprintf("https://github.com/websages/tumble/commit/%s", version.CommitHash), 476 446 } 477 447 478 448 w.Header().Set("Content-Type", "text/html; charset=UTF-8") ··· 482 452 func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) { 483 453 ctx := r.Context() 484 454 485 - // View mode: "users" (default) or "links" 486 455 view := r.URL.Query().Get("view") 487 456 if view == "" { 488 457 view = "users" 489 458 } 490 459 491 - // Pagination 492 460 page := 1 493 461 pageParam := r.URL.Query().Get("page") 494 462 if pageParam != "" { ··· 500 468 limit := 50 501 469 offset := (page - 1) * limit 502 470 503 - // Navigation 504 471 nextPage := page + 1 505 472 prevPage := page - 1 506 473 if prevPage < 1 { ··· 510 477 var data map[string]interface{} 511 478 512 479 if view == "links" { 513 - // Link popularity view 514 480 links, err := h.Store.GetLinksByPopularity(ctx, limit, offset) 515 481 if err != nil { 516 482 h.ServerError(w, r, err) 517 483 return 518 484 } 519 485 520 - // Prepare View Data with Ranks 521 486 type LinkViewItem struct { 522 487 Rank int 523 488 ID int ··· 527 492 Clicks int 528 493 Timestamp time.Time 529 494 ContentType string 495 + BaseURL string 530 496 } 531 497 532 498 var linksView []LinkViewItem ··· 540 506 Clicks: link.Clicks, 541 507 Timestamp: link.Timestamp, 542 508 ContentType: link.ContentType, 509 + BaseURL: h.Config.BaseURL, 543 510 }) 544 511 } 545 512 ··· 556 523 "HasNext": hasNext, 557 524 "View": view, 558 525 "Hot": h.getHotHTML(ctx), 526 + "BaseURL": h.Config.BaseURL, 559 527 } 560 528 } else { 561 - // User stats view (original) 562 - // Sorting 563 529 sortBy := r.URL.Query().Get("sort") 564 530 if sortBy == "" { 565 531 sortBy = "links" ··· 571 537 return 572 538 } 573 539 574 - // Prepare View Data with Ranks 575 540 type StatViewItem struct { 576 541 Rank int 577 542 User string ··· 603 568 "Sort": sortBy, 604 569 "View": view, 605 570 "Hot": h.getHotHTML(ctx), 571 + "BaseURL": h.Config.BaseURL, 606 572 } 607 573 } 608 574
+14 -20
internal/handler/irclink.go
··· 15 15 16 16 // LinkSubmissionResponse represents the response when a link is submitted 17 17 type LinkSubmissionResponse struct { 18 - LinkID int `json:"link_id"` 19 - IsDuplicate bool `json:"is_duplicate"` 18 + LinkID int `json:"link_id"` 19 + IsDuplicate bool `json:"is_duplicate"` 20 20 PreviousSubmissions []PreviousSubmission `json:"previous_submissions,omitempty"` 21 21 } 22 22 ··· 162 162 return 163 163 } 164 164 165 - // HTML Redirect Page 165 + // HTML Redirect Page - use template 166 166 w.Header().Set("Content-Type", "text/html") 167 - duplicateMessage := "" 167 + templateData := map[string]interface{}{ 168 + "RedirectURL": url, 169 + "IsDuplicate": isDuplicate, 170 + } 168 171 if isDuplicate { 169 - duplicateMessage = fmt.Sprintf(`<br /><br /><font color="#ff9900"><i>Note: This link was previously posted by <b>%s</b> on %s</i></font>`, 170 - existingLinks[0].User, 171 - existingLinks[0].Timestamp.Format("2006-01-02 15:04:05")) 172 + templateData["PreviousUser"] = existingLinks[0].User 173 + templateData["PreviousTimestamp"] = existingLinks[0].Timestamp.Format("2006-01-02 15:04:05") 172 174 } 173 - fmt.Fprintf(w, `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/><html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"> 174 - <head> 175 - <title>tumblefish link posted</title> 176 - <META HTTP-EQUIV="Refresh" 177 - CONTENT="5; URL=%s"> 178 - </head> 179 - <body> 180 - <font size="14px" color="#aaa" face="Helvetica, Arial, sand-serif"> 181 - <b>Your link has been posted!</b>%s<br /><br /> 182 - Redirecting back to <b>%s</b> in 5 seconds... 183 - </font> 184 - </body> 185 - </html>`, url, duplicateMessage, url) 175 + if err := h.Renderer.Render(w, "link_posted.html", templateData); err != nil { 176 + log.Printf("Error rendering link_posted template: %v", err) 177 + // Fallback to simple response 178 + fmt.Fprintf(w, "Link posted! Redirecting...") 179 + } 186 180 return 187 181 } 188 182
+125 -171
internal/service/content.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 - "html/template" 8 7 "io/ioutil" 9 8 "net/http" 10 9 "regexp" ··· 20 19 Store data.Store 21 20 } 22 21 22 + // EmbedType identifies the type of embed for template rendering 23 + type EmbedType string 24 + 25 + const ( 26 + EmbedTypeGeneric EmbedType = "generic" 27 + EmbedTypeImage EmbedType = "image" 28 + EmbedTypeTwitter EmbedType = "twitter" 29 + EmbedTypeImgurGallery EmbedType = "imgur_gallery" 30 + EmbedTypeImgurSingle EmbedType = "imgur_single" 31 + EmbedTypeFlickr EmbedType = "flickr" 32 + EmbedTypeQuote EmbedType = "quote" 33 + ) 34 + 35 + // DisplayItem is a data-only struct for template rendering. 36 + // Templates are responsible for generating HTML based on these fields. 23 37 type DisplayItem struct { 24 - ID int `json:"id"` 25 - Type string `json:"type"` 26 - Timestamp time.Time `json:"timestamp"` 27 - FormattedDate string `json:"formatted_date"` 28 - User string `json:"user"` 29 - Author string `json:"author"` 30 - Title string `json:"title"` 31 - URL string `json:"url"` 32 - Clicks int `json:"clicks"` 33 - Content template.HTML `json:"content"` 34 - Description string `json:"description,omitempty"` 35 - ContentType string `json:"content_type"` 36 - Quote string `json:"quote,omitempty"` 37 - BaseURL string `json:"base_url"` 38 + ID int `json:"id"` 39 + Type string `json:"type"` // "ircLink", "image", "quote" 40 + Timestamp time.Time `json:"timestamp"` 41 + FormattedDate string `json:"formatted_date"` 42 + User string `json:"user"` 43 + Author string `json:"author"` 44 + Title string `json:"title"` 45 + URL string `json:"url"` 46 + Clicks int `json:"clicks"` 47 + Description string `json:"description,omitempty"` 48 + ContentType string `json:"content_type"` // MIME type from HTTP response 49 + BaseURL string `json:"base_url"` 50 + 51 + // Embed rendering metadata (templates use these to decide how to render) 52 + EmbedType EmbedType `json:"embed_type"` 53 + EmbedURL string `json:"embed_url,omitempty"` // URL for embed (e.g., Twitter embed URL) 54 + ThumbnailURL string `json:"thumbnail_url,omitempty"` // For gallery cards 55 + MediaURL string `json:"media_url,omitempty"` // Direct media URL (image/video src) 56 + MediaType string `json:"media_type,omitempty"` // "video", "image" 57 + PhotoPageURL string `json:"photo_page_url,omitempty"` 58 + IsAnimated bool `json:"is_animated,omitempty"` // mp4, gifv, gif 59 + IsBroken bool `json:"is_broken,omitempty"` // 404 detection 60 + IsNSFW bool `json:"is_nsfw,omitempty"` // NSFW flag 61 + 62 + // Display text (truncated title for display) 63 + DisplayTitle string `json:"display_title,omitempty"` 64 + 65 + // Quote-specific fields 66 + Quote string `json:"quote,omitempty"` 38 67 39 68 // Date components for grouping 40 69 DateDay string `json:"date_day"` // e.g. "Mon" 41 70 DateMonth string `json:"date_month"` // e.g. "Jan" 42 71 DateRawDay string `json:"date_raw_day"` // e.g. "01" 43 - DateYear string `json:"date_year"` // e.g. "2026" 44 - SuppressOG bool `json:"suppress_og"` 72 + DateYear string `json:"date_year"` // e.g. "2006" 73 + FullDate string `json:"full_date"` // e.g. "20060102" for grouping 74 + 75 + // OG preview control 76 + SuppressOG bool `json:"suppress_og"` 45 77 } 46 78 47 79 func NewContentService(cfg *config.Config, store data.Store) *ContentService { ··· 59 91 Clicks: item.Clicks, 60 92 ContentType: item.ContentType, 61 93 BaseURL: s.Config.BaseURL, 94 + EmbedType: EmbedTypeGeneric, // Default 62 95 } 63 96 s.formatDate(&d) 64 97 65 - linkFiller := item.Title 66 - if len(item.Title) > 40 { 67 - if strings.HasPrefix(item.Title, "http://") { 68 - linkFiller = item.Title[:40] + "..." 69 - } 98 + // Set display title (truncated if needed) 99 + d.DisplayTitle = item.Title 100 + if len(item.Title) > 40 && strings.HasPrefix(item.Title, "http://") { 101 + d.DisplayTitle = item.Title[:40] + "..." 70 102 } 71 103 72 - // Image check 73 - if strings.Contains(item.ContentType, "image") && !strings.Contains(item.User, "nsfw") && !strings.Contains(item.User, "otd") { 74 - // Add onerror handler to replace broken images with text 75 - linkFiller = fmt.Sprintf(`<img src="%s" onerror="this.parentNode.innerHTML='<span class=\'http-error-badge\'>404</span> <span class=\'missing-link\'>%s</span>'; this.parentNode.classList.add('missing-link');" />`, item.URL, item.URL) 76 - } 104 + // Check for NSFW content 105 + d.IsNSFW = strings.Contains(item.User, "nsfw") || strings.Contains(item.User, "otd") 77 106 78 - isYoutube := false 107 + // IMPORTANT: Check for site-specific handlers BEFORE generic content type checks 108 + // This ensures Flickr, Imgur, etc. are handled correctly even when content-type is image/* 79 109 80 110 // Twitter / X 81 - isTwitter := false 82 111 if strings.Contains(item.URL, "twitter.com") || strings.Contains(item.URL, "x.com") { 83 - // Extract ID to ensure it looks like a tweet URL 84 112 re := regexp.MustCompile(`(?:twitter\.com|x\.com)\/.*\/status\/([0-9]+)`) 85 113 matches := re.FindStringSubmatch(item.URL) 86 114 if len(matches) > 1 { 87 - // standard embed code 88 - // Force twitter.com domain for embed compatibility as widgets.js might not support x.com fully yet 89 - embedURL := strings.Replace(item.URL, "x.com", "twitter.com", 1) 90 - embed := fmt.Sprintf(`<blockquote class="twitter-tweet"><a href="%s" target="_blank">%s</a></blockquote><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`, embedURL, item.Title) 91 - d.Content = template.HTML(embed) 92 - isTwitter = true 115 + // Force twitter.com domain for embed compatibility 116 + d.EmbedType = EmbedTypeTwitter 117 + d.EmbedURL = strings.Replace(item.URL, "x.com", "twitter.com", 1) 93 118 d.SuppressOG = false 119 + return d 94 120 } 95 121 } 96 122 97 - // Imgur 98 - isImgur := false 99 - if strings.Contains(item.URL, "imgur.com") { 100 - baseURL := s.Config.BaseURL 123 + // Flickr - check before generic image handler 124 + if strings.Contains(item.URL, "flickr.com") || strings.Contains(item.URL, "staticflickr.com") { 125 + // Case 1: Static Flickr image URL (farm*.staticflickr.com) 126 + re := regexp.MustCompile(`\/([0-9]+)_[0-9a-z]+`) 127 + matches := re.FindStringSubmatch(item.URL) 128 + if len(matches) > 1 { 129 + photoID := matches[1] 130 + d.EmbedType = EmbedTypeFlickr 131 + d.MediaURL = item.URL 132 + d.PhotoPageURL = "https://www.flickr.com/photo.gne?id=" + photoID 133 + d.MediaType = "image" 134 + d.SuppressOG = true 135 + return d 136 + } 101 137 138 + // Case 2: Flickr photo page URL 139 + photoPageRe := regexp.MustCompile(`flickr\.com/photos/[^/]+/(\d+)`) 140 + photoMatches := photoPageRe.FindStringSubmatch(item.URL) 141 + if len(photoMatches) > 1 { 142 + imgURL := s.fetchFlickrImageURL(item.URL) 143 + if imgURL != "" { 144 + d.EmbedType = EmbedTypeFlickr 145 + d.MediaURL = imgURL 146 + d.MediaType = "image" 147 + d.SuppressOG = true 148 + return d 149 + } 150 + } 151 + } 152 + 153 + // Imgur - check before generic image handler 154 + if strings.Contains(item.URL, "imgur.com") { 102 155 // Gallery Check 103 156 if strings.Contains(item.URL, "/gallery/") || strings.Contains(item.URL, "/a/") { 104 - // Extract gallery ID 105 157 re := regexp.MustCompile(`imgur\.com/(?:gallery|a)/([a-zA-Z0-9]+)`) 106 158 matches := re.FindStringSubmatch(item.URL) 107 159 108 160 if len(matches) > 1 { 109 - baseURL := s.Config.BaseURL 110 - 111 - // Try to get thumbnail from LinkPreview (OpenGraph og:image) 112 - // Only render as gallery if we have a valid preview with an image 161 + // Try to get thumbnail from LinkPreview 113 162 thumbnailURL := "" 114 163 if s.Store != nil { 115 164 if preview, err := s.Store.GetLinkPreview(context.TODO(), item.URL); err == nil && preview != nil { 116 165 var meta map[string]string 117 166 if err := json.Unmarshal(preview.Data, &meta); err == nil { 118 - // Extract only the og:image, not description or other text 119 167 if imgURL, ok := meta["image"]; ok && imgURL != "" { 120 168 thumbnailURL = imgURL 121 169 } ··· 123 171 } 124 172 } 125 173 126 - // Only render gallery card if we have a valid thumbnail 127 - // Otherwise, show 404 error for deleted/unavailable galleries 128 - if thumbnailURL != "" { 129 - // Build gallery card routing through IRC link handler 130 - // Detect and hide Imgur placeholder to maintain zero-tolerance requirement 131 - embed := fmt.Sprintf( 132 - `<span class="imgur-gallery-card"> 133 - <a href="%s/irclink/?%d" target="_blank"> 134 - <span class="gallery-image-container"> 135 - <img src="%s" 136 - onload="if(this.naturalWidth===161 && this.naturalHeight===81){this.style.display='none'; this.nextElementSibling.style.display='block';}" 137 - onerror="this.style.display='none'; this.nextElementSibling.style.display='block';" /> 138 - <span class="gallery-image-placeholder">📸</span> 139 - </span> 140 - <span class="gallery-card-content"> 141 - <span class="gallery-card-title">Imgur Gallery</span> 142 - <span class="gallery-card-subtitle">%s</span> 143 - </span> 144 - </a> 145 - </span>`, 146 - baseURL, item.ID, thumbnailURL, item.Title) 147 - 148 - d.Content = template.HTML(embed) 149 - isImgur = true 150 - d.SuppressOG = true 151 - } else { 152 - // No valid preview - gallery is likely deleted/unavailable 153 - // Render as 404 error with gray link, same as other broken images 154 - d.Content = template.HTML(fmt.Sprintf( 155 - `<a href="%s/irclink/?%d" target="_blank"><span class='http-error-badge'>404</span> <span class='missing-link'>%s</span></a>`, 156 - baseURL, item.ID, item.URL)) 157 - isImgur = true 158 - d.SuppressOG = true 159 - } 174 + d.EmbedType = EmbedTypeImgurGallery 175 + d.ThumbnailURL = thumbnailURL 176 + d.IsBroken = thumbnailURL == "" 177 + d.SuppressOG = true 178 + return d 160 179 } 161 180 } else { 162 181 // Single Image / Video Detection ··· 165 184 if len(matches) > 1 { 166 185 id := matches[1] 167 186 ext := matches[2] 168 - // Default to .mp4 for animations (Imgur's preferred animated format) 187 + // Default to .mp4 for animations 169 188 if ext == "" { 170 189 ext = "mp4" 171 190 } 172 - imgURL := fmt.Sprintf("https://i.imgur.com/%s.%s", id, ext) 173 191 174 - var mediaTag string 175 - // Render as video tag for animated formats (.mp4, .gifv, .gif) 176 - // Render as image tag for static formats (.jpg, .jpeg, .png) 177 - if ext == "mp4" || ext == "gifv" || ext == "gif" { 178 - mediaTag = fmt.Sprintf( 179 - `<video autoplay loop muted playsinline style="width: 100%%; height: auto; display: block;"> 180 - <source src="%s" type="video/mp4" /> 181 - </video>`, imgURL) 182 - } else { 183 - // Static image with error detection 184 - mediaTag = fmt.Sprintf( 185 - `<img src="%s" class="imgur-image" 186 - onload="if(this.naturalWidth===161 && this.naturalHeight===81){this.style.display='none'; this.nextElementSibling.style.display='inline';}" 187 - onerror="this.style.display='none'; this.nextElementSibling.style.display='inline';" /> 188 - <span style="display: none;"> 189 - <span class='http-error-badge'>404</span> 190 - <span class='missing-link'>%s</span> 191 - </span>`, imgURL, item.URL) 192 + d.EmbedType = EmbedTypeImgurSingle 193 + d.MediaURL = "https://i.imgur.com/" + id + "." + ext 194 + d.IsAnimated = ext == "mp4" || ext == "gifv" || ext == "gif" 195 + d.MediaType = "image" 196 + if d.IsAnimated { 197 + d.MediaType = "video" 192 198 } 193 - 194 - // Wrap in IRC link handler anchor for click tracking 195 - embed := fmt.Sprintf( 196 - `<a href="%s/irclink/?%d" target="_blank" style="display: inline-block; position: relative;"> 197 - %s 198 - </a>`, 199 - baseURL, item.ID, mediaTag) 200 - 201 - d.Content = template.HTML(embed) 202 - isImgur = true 203 199 d.SuppressOG = true 204 - } 205 - } 206 - } 207 - 208 - // Flickr 209 - isFlickr := false 210 - if strings.Contains(item.URL, "flickr.com") { 211 - baseURL := s.Config.BaseURL 212 - imgURL := "" 213 - 214 - // Case 1: Static Flickr image URL (farm*.staticflickr.com) 215 - // Example: http://farm3.staticflickr.com/2362/2362225867_0a3b0b7e05.jpg 216 - re := regexp.MustCompile(`\/([0-9]+)_[0-9a-z]+`) 217 - matches := re.FindStringSubmatch(item.URL) 218 - if len(matches) > 1 { 219 - photoID := matches[1] 220 - photoPage := fmt.Sprintf("https://www.flickr.com/photo.gne?id=%s", photoID) 221 - // Use standard image tag but linked to photo page 222 - embed := fmt.Sprintf(`<a href="%s" target="_blank"><img src="%s" alt="%s" /></a>`, photoPage, item.URL, item.Title) 223 - d.Content = template.HTML(embed) 224 - isFlickr = true 225 - d.SuppressOG = true 226 - } else { 227 - // Case 2: Flickr photo page URL (www.flickr.com/photos/...) 228 - // Example: https://www.flickr.com/photos/cwage/402950834/ 229 - photoPageRe := regexp.MustCompile(`flickr\.com/photos/[^/]+/(\d+)`) 230 - photoMatches := photoPageRe.FindStringSubmatch(item.URL) 231 - 232 - if len(photoMatches) > 1 { 233 - // Fetch image URL directly from Flickr's OEmbed API 234 - imgURL = s.fetchFlickrImageURL(item.URL) 235 - 236 - // If we have an image URL, render inline 237 - if imgURL != "" { 238 - embed := fmt.Sprintf( 239 - `<a href="%s/irclink/?%d" target="_blank"><img src="%s" style="max-width: 500px;" /></a>`, 240 - baseURL, item.ID, imgURL) 241 - d.Content = template.HTML(embed) 242 - isFlickr = true 243 - d.SuppressOG = true 244 - } 200 + return d 245 201 } 246 202 } 247 203 } 248 204 249 - // YouTube logic removed: Handled client-side by OGPreview for "click to play" behavior 250 - // and to correctly handle unavailable videos (404s). 251 - 252 - if !isYoutube && !isTwitter && !isImgur && !isFlickr { 253 - baseURL := s.Config.BaseURL 254 - content := fmt.Sprintf(`<a href="%s/irclink/?%d" target="_blank">%s</a>`, baseURL, item.ID, linkFiller) 255 - d.Content = template.HTML(content) 205 + // Generic image content type check (after all site-specific handlers) 206 + // This handles direct image URLs that aren't from special sites 207 + if strings.Contains(item.ContentType, "image") && !d.IsNSFW { 208 + d.EmbedType = EmbedTypeImage 209 + d.MediaURL = item.URL 210 + d.MediaType = "image" 211 + return d 256 212 } 257 213 214 + // Default: generic link 215 + d.EmbedType = EmbedTypeGeneric 258 216 return d 259 217 } 260 218 ··· 266 224 Title: item.Title, 267 225 URL: item.URL, 268 226 BaseURL: s.Config.BaseURL, 227 + EmbedType: EmbedTypeImage, 228 + MediaURL: item.URL, 229 + MediaType: "image", 269 230 } 270 231 s.formatDate(&d) 271 232 272 233 // Flickr Logic for Images 273 234 if strings.Contains(item.URL, "flickr.com") { 274 - // Attempt to extract photo ID from URL 275 235 re := regexp.MustCompile(`\/([0-9]+)_[0-9a-z]+`) 276 236 matches := re.FindStringSubmatch(item.URL) 277 237 if len(matches) > 1 { 278 238 photoID := matches[1] 279 - photoPage := fmt.Sprintf("https://www.flickr.com/photo.gne?id=%s", photoID) 280 - // Linked Thumbnail 281 - d.Content = template.HTML(fmt.Sprintf(`<a href="%s" target="_blank"><img src="%s" alt="image" /></a>`, photoPage, item.URL)) 282 - return d 239 + d.EmbedType = EmbedTypeFlickr 240 + d.PhotoPageURL = "https://www.flickr.com/photo.gne?id=" + photoID 283 241 } 284 242 } 285 243 286 - d.Content = template.HTML(fmt.Sprintf(`<img src="%s" alt="image" />`, item.URL)) 287 244 return d 288 245 } 289 246 ··· 292 249 ID: item.ID, 293 250 Type: "quote", 294 251 Timestamp: item.Timestamp, 295 - Author: item.Author, // Quote author field 252 + Author: item.Author, 296 253 Quote: item.Quote, 297 254 BaseURL: s.Config.BaseURL, 255 + EmbedType: EmbedTypeQuote, 298 256 } 299 257 s.formatDate(&d) 300 - // For quotes, content is text + author 301 - d.Content = template.HTML(fmt.Sprintf(`"%s" --%s`, item.Quote, item.Author)) 302 - d.Description = item.Quote // For separate usage 258 + d.Description = item.Quote // For RSS usage 303 259 return d 304 260 } 305 261 306 262 func (s *ContentService) formatDate(d *DisplayItem) { 307 - // Replicate Perl's timezone and formatting logic if needed. 308 - // Perl: "Sun, 04 Jan 2026 15:04:05 +0000" 309 - // Go's time.Time is already aware. we just format it. 310 263 d.FormattedDate = d.Timestamp.Format("Mon, 02 Jan 2006 15:04:05 -0700") 311 264 312 265 // Date components for grouping ··· 314 267 d.DateMonth = d.Timestamp.Format("Jan") 315 268 d.DateRawDay = d.Timestamp.Format("02") 316 269 d.DateYear = d.Timestamp.Format("2006") 270 + d.FullDate = d.Timestamp.Format("20060102") // For date grouping comparisons 317 271 } 318 272 319 273 // FetchOEmbed (Optional helper, untranslated for now due to API changes)
+171 -167
internal/service/content_test.go
··· 1 1 package service 2 2 3 3 import ( 4 - "fmt" 5 - "strings" 6 4 "testing" 7 5 "time" 8 6 ··· 15 13 svc := NewContentService(cfg, nil) 16 14 17 15 tests := []struct { 18 - name string 19 - item data.IRCLink 20 - wantURL string 21 - wantType string // "flickr" or "default" (internal link) 16 + name string 17 + item data.IRCLink 18 + wantEmbedType EmbedType 19 + wantPhotoPage string 20 + wantMediaURL string 22 21 }{ 23 22 { 24 23 name: "Flickr Static URL", ··· 30 29 User: "photog", 31 30 Timestamp: time.Now(), 32 31 }, 33 - wantURL: "https://www.flickr.com/photo.gne?id=2362225867", 34 - wantType: "flickr", 32 + wantEmbedType: EmbedTypeFlickr, 33 + wantPhotoPage: "https://www.flickr.com/photo.gne?id=2362225867", 34 + wantMediaURL: "http://farm3.staticflickr.com/2362/2362225867_0a3b0b7e05.jpg", 35 35 }, 36 36 { 37 - name: "Normal Image", 37 + name: "Normal Image (direct link, not Flickr)", 38 38 item: data.IRCLink{ 39 39 ID: 2, 40 40 Title: "Just an Image", ··· 43 43 User: "user", 44 44 Timestamp: time.Now(), 45 45 }, 46 - wantURL: "http://tumble.test/irclink/?2", 47 - wantType: "default", 46 + wantEmbedType: EmbedTypeImage, 47 + wantMediaURL: "http://example.com/image.jpg", 48 48 }, 49 49 } 50 50 51 51 for _, tt := range tests { 52 52 t.Run(tt.name, func(t *testing.T) { 53 53 got := svc.ProcessIRCLink(tt.item) 54 - html := string(got.Content) 55 54 56 - if tt.wantType == "flickr" { 57 - if !strings.Contains(html, tt.wantURL) { 58 - t.Errorf("ProcessIRCLink() html = %v, want to contain %v", html, tt.wantURL) 59 - } 55 + if got.EmbedType != tt.wantEmbedType { 56 + t.Errorf("ProcessIRCLink() EmbedType = %v, want %v", got.EmbedType, tt.wantEmbedType) 57 + } 58 + 59 + if tt.wantPhotoPage != "" && got.PhotoPageURL != tt.wantPhotoPage { 60 + t.Errorf("ProcessIRCLink() PhotoPageURL = %v, want %v", got.PhotoPageURL, tt.wantPhotoPage) 61 + } 60 62 61 - // Verify it's an anchor tag 62 - if !strings.HasPrefix(html, "<a href=") { 63 - t.Errorf("ProcessIRCLink() html should start with anchor tag, got %v", html) 64 - } 65 - // Verify target blank 66 - if !strings.Contains(html, `target="_blank"`) { 67 - t.Errorf("ProcessIRCLink() html should contain target=_blank, got %v", html) 68 - } 69 - } else { 70 - if !strings.Contains(html, tt.wantURL) { 71 - t.Errorf("ProcessIRCLink() html = %v, want to contain %v", html, tt.wantURL) 72 - } 73 - // Verify target blank for default links too 74 - if !strings.Contains(html, `target="_blank"`) { 75 - t.Errorf("ProcessIRCLink() html should contain target=_blank, got %v", html) 76 - } 63 + if tt.wantMediaURL != "" && got.MediaURL != tt.wantMediaURL { 64 + t.Errorf("ProcessIRCLink() MediaURL = %v, want %v", got.MediaURL, tt.wantMediaURL) 77 65 } 78 66 }) 79 67 } ··· 84 72 svc := NewContentService(cfg, nil) 85 73 86 74 tests := []struct { 87 - name string 88 - item data.Image 89 - wantURL string 90 - wantType string // "flickr" or "default" 75 + name string 76 + item data.Image 77 + wantEmbedType EmbedType 78 + wantPhotoPage string 79 + wantMediaURL string 91 80 }{ 92 81 { 93 82 name: "Flickr Static URL", ··· 97 86 URL: "http://farm3.staticflickr.com/2362/2362225867_0a3b0b7e05.jpg", 98 87 Timestamp: time.Now(), 99 88 }, 100 - wantURL: "https://www.flickr.com/photo.gne?id=2362225867", 101 - wantType: "flickr", 89 + wantEmbedType: EmbedTypeFlickr, 90 + wantPhotoPage: "https://www.flickr.com/photo.gne?id=2362225867", 91 + wantMediaURL: "http://farm3.staticflickr.com/2362/2362225867_0a3b0b7e05.jpg", 102 92 }, 103 93 { 104 94 name: "Normal Image", ··· 108 98 URL: "http://example.com/image.jpg", 109 99 Timestamp: time.Now(), 110 100 }, 111 - wantURL: "http://example.com/image.jpg", 112 - wantType: "default", 101 + wantEmbedType: EmbedTypeImage, 102 + wantMediaURL: "http://example.com/image.jpg", 113 103 }, 114 104 } 115 105 116 106 for _, tt := range tests { 117 107 t.Run(tt.name, func(t *testing.T) { 118 108 got := svc.ProcessImage(tt.item) 119 - html := string(got.Content) 109 + 110 + if got.EmbedType != tt.wantEmbedType { 111 + t.Errorf("ProcessImage() EmbedType = %v, want %v", got.EmbedType, tt.wantEmbedType) 112 + } 113 + 114 + if tt.wantPhotoPage != "" && got.PhotoPageURL != tt.wantPhotoPage { 115 + t.Errorf("ProcessImage() PhotoPageURL = %v, want %v", got.PhotoPageURL, tt.wantPhotoPage) 116 + } 120 117 121 - if tt.wantType == "flickr" { 122 - if !strings.Contains(html, tt.wantURL) { 123 - t.Errorf("ProcessImage() html = %v, want to contain %v", html, tt.wantURL) 124 - } 125 - // Verify it's an anchor tag 126 - if !strings.HasPrefix(html, "<a href=") { 127 - t.Errorf("ProcessImage() html should start with anchor tag, got %v", html) 128 - } 129 - // Verify target blank 130 - if !strings.Contains(html, `target="_blank"`) { 131 - t.Errorf("ProcessImage() html should contain target=_blank, got %v", html) 132 - } 133 - } else { 134 - if !strings.Contains(html, tt.wantURL) { 135 - t.Errorf("ProcessImage() html = %v, want to contain %v", html, tt.wantURL) 136 - } 137 - // Verify default is just img tag 138 - if !strings.HasPrefix(html, "<img src=") { 139 - t.Errorf("ProcessImage() html should start with img tag, got %v", html) 140 - } 118 + if got.MediaURL != tt.wantMediaURL { 119 + t.Errorf("ProcessImage() MediaURL = %v, want %v", got.MediaURL, tt.wantMediaURL) 141 120 } 142 121 }) 143 122 } ··· 148 127 svc := NewContentService(cfg, nil) 149 128 150 129 tests := []struct { 151 - name string 152 - item data.IRCLink 153 - wantType string // "single", "gallery" 154 - wantIRCLinkHandler bool // Should route through /irclink/? 155 - wantImgurCDN bool // Should use i.imgur.com 156 - wantErrorHandler bool // Should have onerror handler 157 - wantSuppressOG bool 158 - wantGalleryCard bool 130 + name string 131 + item data.IRCLink 132 + wantEmbedType EmbedType 133 + wantMediaURL string 134 + wantIsAnimated bool 135 + wantSuppressOG bool 136 + wantIsBroken bool 159 137 }{ 160 138 { 161 - name: "Single Imgur Image - Standard URL", 139 + name: "Single Imgur Image - Standard URL (defaults to mp4)", 162 140 item: data.IRCLink{ 163 141 ID: 42, 164 142 Title: "Cool Picture", 165 - URL: "https://imgur.com/abc123", 143 + URL: "https://imgur.com/abc1234", 166 144 ContentType: "text/html", 167 145 User: "testuser", 168 146 Timestamp: time.Now(), 169 147 }, 170 - wantType: "single", 171 - wantIRCLinkHandler: true, 172 - wantImgurCDN: true, 173 - wantErrorHandler: false, // Extensionless URLs default to .mp4 video (no error handler) 174 - wantSuppressOG: true, 148 + wantEmbedType: EmbedTypeImgurSingle, 149 + wantMediaURL: "https://i.imgur.com/abc1234.mp4", 150 + wantIsAnimated: true, 151 + wantSuppressOG: true, 175 152 }, 176 153 { 177 - name: "Single Imgur Image - With Extension", 154 + name: "Single Imgur Image - With JPG Extension", 178 155 item: data.IRCLink{ 179 156 ID: 43, 180 157 Title: "Another Picture", 181 - URL: "https://imgur.com/xyz789.jpg", 158 + URL: "https://imgur.com/xyz78901.jpg", 182 159 ContentType: "image/jpeg", 183 160 User: "testuser", 184 161 Timestamp: time.Now(), 185 162 }, 186 - wantType: "single", 187 - wantIRCLinkHandler: true, 188 - wantImgurCDN: true, 189 - wantErrorHandler: true, 190 - wantSuppressOG: true, 191 - }, 192 - { 193 - name: "Single Imgur Image - Direct i.imgur.com", 194 - item: data.IRCLink{ 195 - ID: 44, 196 - Title: "Direct CDN", 197 - URL: "https://i.imgur.com/def456.png", 198 - ContentType: "image/png", 199 - User: "testuser", 200 - Timestamp: time.Now(), 201 - }, 202 - wantType: "single", 203 - wantIRCLinkHandler: true, 204 - wantImgurCDN: true, 205 - wantErrorHandler: true, 206 - wantSuppressOG: true, 163 + wantEmbedType: EmbedTypeImgurSingle, 164 + wantMediaURL: "https://i.imgur.com/xyz78901.jpg", 165 + wantIsAnimated: false, 166 + wantSuppressOG: true, 207 167 }, 208 168 { 209 - name: "Imgur Gallery - /gallery/ URL (no preview)", 169 + name: "Imgur Gallery - /gallery/ URL (no store, so broken)", 210 170 item: data.IRCLink{ 211 171 ID: 45, 212 172 Title: "Gallery Title", ··· 215 175 User: "testuser", 216 176 Timestamp: time.Now(), 217 177 }, 218 - wantType: "gallery", 219 - wantIRCLinkHandler: true, 220 - wantSuppressOG: true, 221 - wantGalleryCard: false, // No LinkPreview, so should show 404 error 178 + wantEmbedType: EmbedTypeImgurGallery, 179 + wantSuppressOG: true, 180 + wantIsBroken: true, // No store, so no thumbnail 222 181 }, 223 182 { 224 - name: "Imgur Gallery - /a/ URL (no preview)", 183 + name: "Imgur Album - /a/ URL (no store, so broken)", 225 184 item: data.IRCLink{ 226 185 ID: 46, 227 186 Title: "Album Title", ··· 230 189 User: "testuser", 231 190 Timestamp: time.Now(), 232 191 }, 233 - wantType: "gallery", 234 - wantIRCLinkHandler: true, 235 - wantSuppressOG: true, 236 - wantGalleryCard: false, // No LinkPreview, so should show 404 error 192 + wantEmbedType: EmbedTypeImgurGallery, 193 + wantSuppressOG: true, 194 + wantIsBroken: true, // No store, so no thumbnail 237 195 }, 238 196 } 239 197 240 198 for _, tt := range tests { 241 199 t.Run(tt.name, func(t *testing.T) { 242 200 got := svc.ProcessIRCLink(tt.item) 243 - html := string(got.Content) 244 201 245 - // Verify SuppressOG 202 + if got.EmbedType != tt.wantEmbedType { 203 + t.Errorf("ProcessIRCLink() EmbedType = %v, want %v", got.EmbedType, tt.wantEmbedType) 204 + } 205 + 246 206 if got.SuppressOG != tt.wantSuppressOG { 247 207 t.Errorf("ProcessIRCLink() SuppressOG = %v, want %v", got.SuppressOG, tt.wantSuppressOG) 248 208 } 249 209 250 - // CRITICAL: Verify IRC link handler routing (click tracking) 251 - if tt.wantIRCLinkHandler { 252 - expectedIRCLink := fmt.Sprintf("%s/irclink/?%d", cfg.BaseURL, tt.item.ID) 253 - if !strings.Contains(html, expectedIRCLink) { 254 - t.Errorf("ProcessIRCLink() html should contain IRC link handler %v, got %v", expectedIRCLink, html) 255 - } 210 + if tt.wantMediaURL != "" && got.MediaURL != tt.wantMediaURL { 211 + t.Errorf("ProcessIRCLink() MediaURL = %v, want %v", got.MediaURL, tt.wantMediaURL) 212 + } 256 213 257 - // CRITICAL: Ensure we NEVER link directly to imgur.com (bypassing click tracking) 258 - // Gallery cards should not have direct imgur.com links 259 - if strings.Contains(html, `href="https://imgur.com`) || strings.Contains(html, `href="http://imgur.com`) { 260 - t.Errorf("ProcessIRCLink() html should NOT contain direct imgur.com links, got %v", html) 261 - } 214 + if got.IsAnimated != tt.wantIsAnimated { 215 + t.Errorf("ProcessIRCLink() IsAnimated = %v, want %v", got.IsAnimated, tt.wantIsAnimated) 262 216 } 263 217 264 - // Verify Imgur CDN usage for single images 265 - if tt.wantImgurCDN { 266 - if !strings.Contains(html, "i.imgur.com") { 267 - t.Errorf("ProcessIRCLink() html should contain i.imgur.com CDN URL") 268 - } 218 + if got.IsBroken != tt.wantIsBroken { 219 + t.Errorf("ProcessIRCLink() IsBroken = %v, want %v", got.IsBroken, tt.wantIsBroken) 269 220 } 270 221 271 - // Verify error handler for single images 272 - if tt.wantErrorHandler { 273 - if !strings.Contains(html, "onerror=") { 274 - t.Errorf("ProcessIRCLink() html should contain onerror handler") 275 - } 276 - // Verify it uses the standard error pattern 277 - if !strings.Contains(html, "http-error-badge") { 278 - t.Errorf("ProcessIRCLink() html should use http-error-badge class for errors") 279 - } 280 - if !strings.Contains(html, "missing-link") { 281 - t.Errorf("ProcessIRCLink() html should use missing-link class for errors") 282 - } 222 + // Verify BaseURL is always set correctly 223 + if got.BaseURL != cfg.BaseURL { 224 + t.Errorf("ProcessIRCLink() BaseURL = %v, want %v", got.BaseURL, cfg.BaseURL) 283 225 } 226 + }) 227 + } 228 + } 284 229 285 - // Verify gallery card structure (only if we expect a card) 286 - if tt.wantGalleryCard { 287 - if !strings.Contains(html, "imgur-gallery-card") { 288 - t.Errorf("ProcessIRCLink() html should contain imgur-gallery-card class") 289 - } 290 - if !strings.Contains(html, "Imgur Gallery") { 291 - t.Errorf("ProcessIRCLink() html should contain 'Imgur Gallery' text") 292 - } 293 - // Verify gallery has preview image attempt 294 - if !strings.Contains(html, "<img src=") { 295 - t.Errorf("ProcessIRCLink() gallery should attempt to show preview image") 296 - } 297 - } 230 + func TestProcessIRCLink_Twitter(t *testing.T) { 231 + cfg := &config.Config{BaseURL: "http://tumble.test"} 232 + svc := NewContentService(cfg, nil) 233 + 234 + tests := []struct { 235 + name string 236 + item data.IRCLink 237 + wantEmbedType EmbedType 238 + wantEmbedURL string 239 + }{ 240 + { 241 + name: "Twitter.com URL", 242 + item: data.IRCLink{ 243 + ID: 1, 244 + Title: "A Tweet", 245 + URL: "https://twitter.com/user/status/1234567890", 246 + ContentType: "text/html", 247 + User: "poster", 248 + Timestamp: time.Now(), 249 + }, 250 + wantEmbedType: EmbedTypeTwitter, 251 + wantEmbedURL: "https://twitter.com/user/status/1234567890", 252 + }, 253 + { 254 + name: "X.com URL (should convert to twitter.com for embed)", 255 + item: data.IRCLink{ 256 + ID: 2, 257 + Title: "A Tweet", 258 + URL: "https://x.com/user/status/1234567890", 259 + ContentType: "text/html", 260 + User: "poster", 261 + Timestamp: time.Now(), 262 + }, 263 + wantEmbedType: EmbedTypeTwitter, 264 + wantEmbedURL: "https://twitter.com/user/status/1234567890", 265 + }, 266 + { 267 + name: "Non-tweet Twitter URL", 268 + item: data.IRCLink{ 269 + ID: 3, 270 + Title: "Twitter Profile", 271 + URL: "https://twitter.com/someuser", 272 + ContentType: "text/html", 273 + User: "poster", 274 + Timestamp: time.Now(), 275 + }, 276 + wantEmbedType: EmbedTypeGeneric, // Not a tweet, so generic 277 + }, 278 + } 298 279 299 - // Verify galleries WITHOUT previews show error badge (not gallery card) 300 - if tt.wantType == "gallery" && !tt.wantGalleryCard { 301 - if !strings.Contains(html, "http-error-badge") { 302 - t.Errorf("ProcessIRCLink() gallery without preview should show http-error-badge, got: %v", html) 303 - } 304 - if !strings.Contains(html, "missing-link") { 305 - t.Errorf("ProcessIRCLink() gallery without preview should show missing-link class") 306 - } 307 - // Should NOT contain gallery card elements 308 - if strings.Contains(html, "imgur-gallery-card") { 309 - t.Errorf("ProcessIRCLink() gallery without preview should NOT show gallery card") 310 - } 280 + for _, tt := range tests { 281 + t.Run(tt.name, func(t *testing.T) { 282 + got := svc.ProcessIRCLink(tt.item) 283 + 284 + if got.EmbedType != tt.wantEmbedType { 285 + t.Errorf("ProcessIRCLink() EmbedType = %v, want %v", got.EmbedType, tt.wantEmbedType) 311 286 } 312 287 313 - // Verify target="_blank" for all Imgur links 314 - if !strings.Contains(html, `target="_blank"`) { 315 - t.Errorf("ProcessIRCLink() html should open in new tab with target=_blank") 288 + if tt.wantEmbedURL != "" && got.EmbedURL != tt.wantEmbedURL { 289 + t.Errorf("ProcessIRCLink() EmbedURL = %v, want %v", got.EmbedURL, tt.wantEmbedURL) 316 290 } 317 291 }) 318 292 } 319 293 } 294 + 295 + func TestProcessQuote(t *testing.T) { 296 + cfg := &config.Config{BaseURL: "http://tumble.test"} 297 + svc := NewContentService(cfg, nil) 298 + 299 + item := data.Quote{ 300 + ID: 1, 301 + Author: "Someone Famous", 302 + Quote: "This is a great quote", 303 + Timestamp: time.Now(), 304 + } 305 + 306 + got := svc.ProcessQuote(item) 307 + 308 + if got.EmbedType != EmbedTypeQuote { 309 + t.Errorf("ProcessQuote() EmbedType = %v, want %v", got.EmbedType, EmbedTypeQuote) 310 + } 311 + 312 + if got.Quote != item.Quote { 313 + t.Errorf("ProcessQuote() Quote = %v, want %v", got.Quote, item.Quote) 314 + } 315 + 316 + if got.Author != item.Author { 317 + t.Errorf("ProcessQuote() Author = %v, want %v", got.Author, item.Author) 318 + } 319 + 320 + if got.Description != item.Quote { 321 + t.Errorf("ProcessQuote() Description = %v, want %v", got.Description, item.Quote) 322 + } 323 + }
+41 -4
internal/templates/renderer.go
··· 3 3 import ( 4 4 "bytes" 5 5 "embed" 6 + "fmt" 6 7 "html/template" 7 8 "io" 8 9 "strings" ··· 20 21 xmlTmpls *texttemplate.Template 21 22 } 22 23 24 + // templateFuncs provides helper functions for templates 25 + var templateFuncs = template.FuncMap{ 26 + // irclinkURL builds a click-tracking URL for IRC links 27 + "irclinkURL": func(baseURL string, id int) string { 28 + return fmt.Sprintf("%s/irclink/?%d", baseURL, id) 29 + }, 30 + // truncate shortens a string to max characters with ellipsis 31 + "truncate": func(s string, max int) string { 32 + if len(s) > max { 33 + return s[:max] + "..." 34 + } 35 + return s 36 + }, 37 + // safeHTML marks a string as safe HTML (use sparingly) 38 + "safeHTML": func(s string) template.HTML { 39 + return template.HTML(s) 40 + }, 41 + // safeURL marks a string as a safe URL 42 + "safeURL": func(s string) template.URL { 43 + return template.URL(s) 44 + }, 45 + } 46 + 47 + // textTemplateFuncs is the equivalent for text/template (XML) 48 + var textTemplateFuncs = texttemplate.FuncMap{ 49 + "irclinkURL": func(baseURL string, id int) string { 50 + return fmt.Sprintf("%s/irclink/?%d", baseURL, id) 51 + }, 52 + "truncate": func(s string, max int) string { 53 + if len(s) > max { 54 + return s[:max] + "..." 55 + } 56 + return s 57 + }, 58 + } 59 + 23 60 func NewRenderer(cfg *config.Config) (*Renderer, error) { 24 61 r := &Renderer{cfg: cfg} 25 62 ··· 39 76 if r.cfg.Mode == "development" { 40 77 // Parse from local filesystem for reload 41 78 // Assuming running from project root 42 - r.htmlTmpls, err = template.ParseGlob("internal/templates/views/*.html") 79 + r.htmlTmpls, err = template.New("").Funcs(templateFuncs).ParseGlob("internal/templates/views/*.html") 43 80 if err != nil { 44 81 return err 45 82 } 46 - r.xmlTmpls, err = texttemplate.ParseGlob("internal/templates/views/*.xml") 83 + r.xmlTmpls, err = texttemplate.New("").Funcs(textTemplateFuncs).ParseGlob("internal/templates/views/*.xml") 47 84 if err != nil { 48 85 return err 49 86 } 50 87 } else { 51 88 // Use embedded FS for production 52 - r.htmlTmpls, err = template.ParseFS(viewsFS, "views/*.html") 89 + r.htmlTmpls, err = template.New("").Funcs(templateFuncs).ParseFS(viewsFS, "views/*.html") 53 90 if err != nil { 54 91 return err 55 92 } 56 - r.xmlTmpls, err = texttemplate.ParseFS(viewsFS, "views/*.xml") 93 + r.xmlTmpls, err = texttemplate.New("").Funcs(textTemplateFuncs).ParseFS(viewsFS, "views/*.xml") 57 94 if err != nil { 58 95 return err 59 96 }
+11
internal/templates/views/no_search_results.html
··· 1 + <div class="item"> 2 + <span class="text"> 3 + <span class="search-fail-title">Your search-fu is weak.</span><br /><br /> 4 + Your search for '{{.Query}}' did not return any results. Perhaps the following tips can help aid you on your quest: 5 + <ul> 6 + <li>Searches must be done using four or more characters.</li> 7 + <li>MySQL fulltext-searching is the magic behind this. Stop blaming scott.</li> 8 + <li>Try not to be such a fucking idiot.</li> 9 + </ul> 10 + </span> 11 + </div>
+7 -1
internal/templates/views/tumble_item_image.html
··· 1 1 <div class="item"> 2 - {{.Content}} 2 + {{if eq .EmbedType "flickr"}} 3 + {{/* Flickr image with link to photo page */}} 4 + <a href="{{.PhotoPageURL}}" target="_blank"><img src="{{.MediaURL}}" alt="image" /></a> 5 + {{else}} 6 + {{/* Standard image */}} 7 + <img src="{{.MediaURL}}" alt="image" /> 8 + {{end}} 3 9 </div>
+2 -2
internal/templates/views/tumble_item_image.xml
··· 2 2 <title>{{.Title}}</title> 3 3 <link>{{.URL}}</link> 4 4 <guid isPermaLink="false">tumble-image-{{.ID}}</guid> 5 - <description><![CDATA[{{.Content}}]]></description> 6 - <pubDate>{{.Timestamp}}</pubDate> 5 + <description><![CDATA[<img src="{{.MediaURL}}" alt="{{.Title}}" />]]></description> 6 + <pubDate>{{.FormattedDate}}</pubDate> 7 7 </item>
+60 -2
internal/templates/views/tumble_item_ircLink.html
··· 1 1 <div class="item" data-url="{{.URL}}" data-content-type="{{.ContentType}}" data-irc-link-id="{{.ID}}"> 2 2 <div class="link-card-wrapper"> 3 3 <div class="link-header"> 4 - <span class="link">{{.Content}}</span> 4 + <span class="link"> 5 + {{if eq .EmbedType "twitter"}} 6 + {{/* Twitter embed */}} 7 + <blockquote class="twitter-tweet"><a href="{{.EmbedURL}}" target="_blank">{{.Title}}</a></blockquote><script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 8 + {{else if eq .EmbedType "imgur_gallery"}} 9 + {{/* Imgur gallery card */}} 10 + {{if .IsBroken}} 11 + <a href="{{irclinkURL .BaseURL .ID}}" target="_blank"><span class="http-error-badge">404</span> <span class="missing-link">{{.URL}}</span></a> 12 + {{else}} 13 + <span class="imgur-gallery-card"> 14 + <a href="{{irclinkURL .BaseURL .ID}}" target="_blank"> 15 + <span class="gallery-image-container"> 16 + <img src="{{.ThumbnailURL}}" 17 + onload="if(this.naturalWidth===161 && this.naturalHeight===81){this.style.display='none'; this.nextElementSibling.style.display='block';}" 18 + onerror="this.style.display='none'; this.nextElementSibling.style.display='block';" /> 19 + <span class="gallery-image-placeholder">📸</span> 20 + </span> 21 + <span class="gallery-card-content"> 22 + <span class="gallery-card-title">Imgur Gallery</span> 23 + <span class="gallery-card-subtitle">{{.Title}}</span> 24 + </span> 25 + </a> 26 + </span> 27 + {{end}} 28 + {{else if eq .EmbedType "imgur_single"}} 29 + {{/* Imgur single image/video */}} 30 + <a href="{{irclinkURL .BaseURL .ID}}" target="_blank" class="imgur-media-link"> 31 + {{if .IsAnimated}} 32 + <video autoplay loop muted playsinline class="imgur-video"> 33 + <source src="{{.MediaURL}}" type="video/mp4" /> 34 + </video> 35 + {{else}} 36 + <img src="{{.MediaURL}}" class="imgur-image" 37 + onload="if(this.naturalWidth===161 && this.naturalHeight===81){this.style.display='none'; this.nextElementSibling.style.display='inline';}" 38 + onerror="this.style.display='none'; this.nextElementSibling.style.display='inline';" /> 39 + <span class="imgur-error-fallback"> 40 + <span class="http-error-badge">404</span> 41 + <span class="missing-link">{{.URL}}</span> 42 + </span> 43 + {{end}} 44 + </a> 45 + {{else if eq .EmbedType "flickr"}} 46 + {{/* Flickr image */}} 47 + {{if .PhotoPageURL}} 48 + <a href="{{.PhotoPageURL}}" target="_blank"><img src="{{.MediaURL}}" alt="{{.Title}}" class="flickr-image" /></a> 49 + {{else}} 50 + <a href="{{irclinkURL .BaseURL .ID}}" target="_blank"><img src="{{.MediaURL}}" alt="{{.Title}}" class="flickr-image" /></a> 51 + {{end}} 52 + {{else if eq .EmbedType "image"}} 53 + {{/* Direct image link */}} 54 + <a href="{{irclinkURL .BaseURL .ID}}" target="_blank"> 55 + <img src="{{.URL}}" class="direct-image" 56 + onerror="this.parentNode.innerHTML='<span class=\'http-error-badge\'>404</span> <span class=\'missing-link\'>{{.URL}}</span>'; this.parentNode.classList.add('missing-link');" /> 57 + </a> 58 + {{else}} 59 + {{/* Generic link */}} 60 + <a href="{{irclinkURL .BaseURL .ID}}" target="_blank">{{if .DisplayTitle}}{{.DisplayTitle}}{{else}}{{.Title}}{{end}}</a> 61 + {{end}} 62 + </span> 5 63 <span class="author"><a href="/?poster={{.User}}" data-tooltip="{{.FormattedDate}}">{{.User}}</a></span> 6 64 </div> 7 - {{ if not .SuppressOG }}<div class="og-preview" id="og-preview-{{.ID}}"></div>{{ end }} 65 + {{if not .SuppressOG}}<div class="og-preview" id="og-preview-{{.ID}}"></div>{{end}} 8 66 </div> 9 67 </div>
+3 -3
internal/templates/views/tumble_item_ircLink.xml
··· 1 1 <item> 2 2 <title>{{.Title}}</title> 3 - <link>{{.BaseURL}}/irclink/?{{.ID}}</link> 3 + <link>{{irclinkURL .BaseURL .ID}}</link> 4 4 <guid isPermaLink="false">tumble-{{.ID}}</guid> 5 - <description><![CDATA[{{.Content}}]]></description> 6 - <pubDate>{{.Timestamp}}</pubDate> 5 + <description><![CDATA[{{.Title}}]]></description> 6 + <pubDate>{{.FormattedDate}}</pubDate> 7 7 </item>
+2 -2
internal/templates/views/tumble_item_quote.xml
··· 2 2 <title>{{.Quote}}</title> 3 3 <link>{{.BaseURL}}/</link> 4 4 <guid isPermaLink="false">tumble-quote-{{.ID}}</guid> 5 - <description>{{.Description}}</description> 6 - <pubDate>{{.Timestamp}}</pubDate> 5 + <description>"{{.Quote}}" --{{.Author}}</description> 6 + <pubDate>{{.FormattedDate}}</pubDate> 7 7 </item>
+1 -1
internal/templates/views/tumble_item_top5.html
··· 1 - <div class="sm"><div class="link">{{.Content}}</div></div> 1 + <div class="sm"><div class="link"><a href="{{irclinkURL .BaseURL .ID}}" target="_blank">{{truncate .Title 30}}</a></div></div>