2022-09-10 16:29:57 +00:00
package website
import (
"context"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
2022-09-17 21:21:58 +00:00
"strconv"
"strings"
2022-09-10 16:29:57 +00:00
"time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/parsing"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
)
func EducationIndex ( c * RequestContext ) ResponseData {
type indexData struct {
templates . BaseData
2022-10-27 05:20:59 +00:00
Courses [ ] templates . EduCourse
2022-09-10 16:29:57 +00:00
NewArticleUrl string
2022-09-20 01:26:43 +00:00
RerenderUrl string
2022-09-10 16:29:57 +00:00
}
2022-10-27 05:20:59 +00:00
// TODO: Someday this can be dynamic again? Maybe? Or not? Who knows??
// articles, err := fetchEduArticles(c, c.Conn, models.EduArticleTypeArticle, c.CurrentUser)
// if err != nil {
// panic(err)
// }
// var tmplArticles []templates.EduArticle
// for _, article := range articles {
// tmplArticles = append(tmplArticles, templates.EducationArticleToTemplate(&article))
// }
2022-09-10 16:29:57 +00:00
2022-10-27 05:20:59 +00:00
article := func ( slug string ) templates . EduArticle {
if article , err := fetchEduArticle ( c , c . Conn , slug , models . EduArticleTypeArticle , c . CurrentUser ) ; err == nil {
return templates . EducationArticleToTemplate ( article )
} else if errors . Is ( err , db . NotFound ) {
return templates . EduArticle {
Title : "<UNKNOWN ARTICLE>" ,
}
} else {
panic ( err )
}
2022-09-10 16:29:57 +00:00
}
tmpl := indexData {
2022-10-27 05:20:59 +00:00
BaseData : getBaseData ( c , "Handmade Education" , nil ) ,
Courses : [ ] templates . EduCourse {
{
Name : "Compilers" ,
Slug : "compilers" ,
Articles : [ ] templates . EduArticle {
article ( "compilers" ) ,
{
Title : "Baby's first language theory" ,
Description : "State machines, abstract datatypes, type theory..." ,
} ,
} ,
} ,
{
Name : "Networking" ,
Slug : "networking" ,
Articles : [ ] templates . EduArticle {
2022-10-31 21:01:42 +00:00
article ( "http" ) ,
2022-10-27 05:20:59 +00:00
{
Title : "Internet infrastructure" ,
Description : "How does the internet actually work? How does your ISP know where to send your data? What happens to the internet if physical communication breaks down?" ,
} ,
} ,
} ,
{
Name : "Time" ,
Slug : "time" ,
Articles : [ ] templates . EduArticle {
article ( "time" ) ,
article ( "ntp" ) ,
} ,
} ,
} ,
2022-09-10 16:29:57 +00:00
NewArticleUrl : hmnurl . BuildEducationArticleNew ( ) ,
2022-09-20 01:26:43 +00:00
RerenderUrl : hmnurl . BuildEducationRerender ( ) ,
2022-09-10 16:29:57 +00:00
}
var res ResponseData
res . MustWriteTemplate ( "education_index.html" , tmpl , c . Perf )
return res
}
func EducationGlossary ( c * RequestContext ) ResponseData {
type glossaryData struct {
templates . BaseData
}
tmpl := glossaryData {
BaseData : getBaseData ( c , "Handmade Education" , nil ) ,
}
var res ResponseData
res . MustWriteTemplate ( "education_glossary.html" , tmpl , c . Perf )
return res
}
func EducationArticle ( c * RequestContext ) ResponseData {
type articleData struct {
templates . BaseData
Article templates . EduArticle
2022-09-17 21:21:58 +00:00
TOC [ ] TOCEntry
2022-09-10 16:29:57 +00:00
EditUrl string
DeleteUrl string
}
article , err := fetchEduArticle ( c , c . Conn , c . PathParams [ "slug" ] , models . EduArticleTypeArticle , c . CurrentUser )
if errors . Is ( err , db . NotFound ) {
return FourOhFour ( c )
} else if err != nil {
return c . ErrorResponse ( http . StatusInternalServerError , err )
}
tmpl := articleData {
BaseData : getBaseData ( c , article . Title , nil ) ,
Article : templates . EducationArticleToTemplate ( article ) ,
EditUrl : hmnurl . BuildEducationArticleEdit ( article . Slug ) ,
DeleteUrl : hmnurl . BuildEducationArticleDelete ( article . Slug ) ,
}
tmpl . OpenGraphItems = append ( tmpl . OpenGraphItems ,
templates . OpenGraphItem { Property : "og:description" , Value : string ( article . Description ) } ,
)
tmpl . Breadcrumbs = [ ] templates . Breadcrumb {
{ Name : "Education" , Url : hmnurl . BuildEducationIndex ( ) } ,
{ Name : article . Title , Url : hmnurl . BuildEducationArticle ( article . Slug ) } ,
}
2022-11-03 03:40:44 +00:00
// Remove editor's notes, generate TOC, etc.
canSeeNotes := c . CurrentUser != nil && c . CurrentUser . CanAuthorEducation ( )
html , tocEntries := generateTOC ( string ( tmpl . Article . Content ) , canSeeNotes )
2022-09-17 21:21:58 +00:00
tmpl . Article . Content = template . HTML ( html )
tmpl . TOC = tocEntries
2022-09-10 16:29:57 +00:00
var res ResponseData
res . MustWriteTemplate ( "education_article.html" , tmpl , c . Perf )
return res
}
func EducationArticleNew ( c * RequestContext ) ResponseData {
type adminData struct {
editorData
Article map [ string ] interface { }
}
tmpl := adminData {
editorData : getEditorDataForEduArticle ( c . UrlContext , c . CurrentUser , getBaseData ( c , "New Education Article" , nil ) , nil ) ,
}
tmpl . editorData . SubmitUrl = hmnurl . BuildEducationArticleNew ( )
var res ResponseData
res . MustWriteTemplate ( "editor.html" , tmpl , c . Perf )
return res
}
func EducationArticleNewSubmit ( c * RequestContext ) ResponseData {
form , err := c . GetFormValues ( )
if err != nil {
return c . ErrorResponse ( http . StatusBadRequest , err )
}
art , ver := getEduArticleFromForm ( form )
dupe := 0 < db . MustQueryOneScalar [ int ] ( c , c . Conn ,
`
SELECT COUNT ( * ) FROM education_article
WHERE slug = $ 1
` ,
art . Slug ,
)
if dupe {
return c . RejectRequest ( "A resource already exists with that slug." )
}
createEduArticle ( c , art , ver )
res := c . Redirect ( eduArticleURL ( & art ) , http . StatusSeeOther )
res . AddFutureNotice ( "success" , "Created new education article." )
return res
}
func EducationArticleEdit ( c * RequestContext ) ResponseData {
type adminData struct {
editorData
Article templates . EduArticle
}
article , err := fetchEduArticle ( c , c . Conn , c . PathParams [ "slug" ] , 0 , c . CurrentUser )
if errors . Is ( err , db . NotFound ) {
return FourOhFour ( c )
} else if err != nil {
panic ( err )
}
tmpl := adminData {
editorData : getEditorDataForEduArticle ( c . UrlContext , c . CurrentUser , getBaseData ( c , "Edit Education Article" , nil ) , article ) ,
Article : templates . EducationArticleToTemplate ( article ) ,
}
tmpl . editorData . SubmitUrl = hmnurl . BuildEducationArticleEdit ( c . PathParams [ "slug" ] )
var res ResponseData
res . MustWriteTemplate ( "editor.html" , tmpl , c . Perf )
return res
}
func EducationArticleEditSubmit ( c * RequestContext ) ResponseData {
form , err := c . GetFormValues ( )
if err != nil {
return c . ErrorResponse ( http . StatusBadRequest , err )
}
_ , err = fetchEduArticle ( c , c . Conn , c . PathParams [ "slug" ] , 0 , c . CurrentUser )
if errors . Is ( err , db . NotFound ) {
return FourOhFour ( c )
} else if err != nil {
panic ( err )
}
art , ver := getEduArticleFromForm ( form )
updateEduArticle ( c , c . PathParams [ "slug" ] , art , ver )
res := c . Redirect ( eduArticleURL ( & art ) , http . StatusSeeOther )
res . AddFutureNotice ( "success" , "Edited education article." )
return res
}
func EducationArticleDelete ( c * RequestContext ) ResponseData {
article , err := fetchEduArticle ( c , c . Conn , c . PathParams [ "slug" ] , 0 , c . CurrentUser )
if errors . Is ( err , db . NotFound ) {
return FourOhFour ( c )
} else if err != nil {
panic ( err )
}
type deleteData struct {
templates . BaseData
Article templates . EduArticle
SubmitUrl string
}
baseData := getBaseData ( c , fmt . Sprintf ( "Deleting \"%s\"" , article . Title ) , nil )
var res ResponseData
res . MustWriteTemplate ( "education_article_delete.html" , deleteData {
BaseData : baseData ,
Article : templates . EducationArticleToTemplate ( article ) ,
SubmitUrl : hmnurl . BuildEducationArticleDelete ( article . Slug ) ,
} , c . Perf )
return res
}
func EducationArticleDeleteSubmit ( c * RequestContext ) ResponseData {
_ , err := c . Conn . Exec ( c , ` DELETE FROM education_article WHERE slug = $1 ` , c . PathParams [ "slug" ] )
if err != nil {
panic ( err )
}
res := c . Redirect ( hmnurl . BuildEducationIndex ( ) , http . StatusSeeOther )
res . AddFutureNotice ( "success" , "Article deleted." )
return res
}
2022-09-20 01:26:43 +00:00
func EducationRerender ( c * RequestContext ) ResponseData {
everything := utils . Must1 ( fetchEduArticles ( c , c . Conn , 0 , c . CurrentUser ) )
for _ , thing := range everything {
newHTML := parsing . ParseMarkdown ( thing . CurrentVersion . ContentRaw , parsing . EducationRealMarkdown )
utils . Must1 ( c . Conn . Exec ( c ,
`
UPDATE education_article_version
SET content_html = $ 2
WHERE id = $ 1
` ,
thing . CurrentVersionID ,
newHTML ,
) )
}
res := c . Redirect ( hmnurl . BuildEducationIndex ( ) , http . StatusSeeOther )
res . AddFutureNotice ( "success" , "Rerendered all education content." )
return res
}
2022-09-10 16:29:57 +00:00
func fetchEduArticles (
ctx context . Context ,
dbConn db . ConnOrTx ,
t models . EduArticleType ,
currentUser * models . User ,
) ( [ ] models . EduArticle , error ) {
type eduArticleResult struct {
Article models . EduArticle ` db:"a" `
CurrentVersion models . EduArticleVersion ` db:"v" `
}
var qb db . QueryBuilder
qb . Add ( `
SELECT $ columns
FROM
education_article AS a
JOIN education_article_version AS v ON a . current_version = v . id
WHERE
TRUE
` )
if t != 0 {
qb . Add ( ` AND a.type = $? ` , t )
}
if currentUser == nil || ! currentUser . CanSeeUnpublishedEducationContent ( ) {
qb . Add ( ` AND NOT a.published ` )
}
articles , err := db . Query [ eduArticleResult ] ( ctx , dbConn , qb . String ( ) , qb . Args ( ) ... )
if err != nil {
return nil , err
}
var res [ ] models . EduArticle
for _ , article := range articles {
ver := article . CurrentVersion
article . Article . CurrentVersion = & ver
res = append ( res , article . Article )
}
return res , nil
}
func fetchEduArticle (
ctx context . Context ,
dbConn db . ConnOrTx ,
slug string ,
t models . EduArticleType ,
currentUser * models . User ,
) ( * models . EduArticle , error ) {
type eduArticleResult struct {
Article models . EduArticle ` db:"a" `
CurrentVersion models . EduArticleVersion ` db:"v" `
}
var qb db . QueryBuilder
qb . Add (
`
SELECT $ columns
FROM
education_article AS a
JOIN education_article_version AS v ON a . current_version = v . id
WHERE
a . slug = $ ?
` ,
slug ,
)
if t != 0 {
qb . Add ( ` AND a.type = $? ` , t )
}
if currentUser == nil || ! currentUser . CanSeeUnpublishedEducationContent ( ) {
qb . Add ( ` AND NOT a.published ` )
}
res , err := db . QueryOne [ eduArticleResult ] ( ctx , dbConn , qb . String ( ) , qb . Args ( ) ... )
if err != nil {
return nil , err
}
res . Article . CurrentVersion = & res . CurrentVersion
return & res . Article , nil
}
func getEditorDataForEduArticle (
urlContext * hmnurl . UrlContext ,
currentUser * models . User ,
baseData templates . BaseData ,
article * models . EduArticle ,
) editorData {
result := editorData {
BaseData : baseData ,
SubmitLabel : "Submit" ,
CanEditPostTitle : true ,
ShowEduOptions : true ,
PreviewClass : "edu-article" ,
2022-09-14 21:44:27 +00:00
TextEditor : templates . TextEditor {
ParserName : "parseMarkdownEdu" ,
MaxFileSize : AssetMaxSize ( currentUser ) ,
UploadUrl : urlContext . BuildAssetUpload ( ) ,
} ,
2022-09-10 16:29:57 +00:00
}
if article != nil {
result . PostTitle = article . Title
result . EditInitialContents = article . CurrentVersion . ContentRaw
}
return result
}
func getEduArticleFromForm ( form url . Values ) ( art models . EduArticle , ver models . EduArticleVersion ) {
art . Title = form . Get ( "title" )
art . Slug = form . Get ( "slug" )
art . Description = form . Get ( "description" )
switch form . Get ( "type" ) {
case "article" :
art . Type = models . EduArticleTypeArticle
case "glossary" :
art . Type = models . EduArticleTypeGlossary
default :
panic ( fmt . Errorf ( "unknown education article type: %s" , form . Get ( "type" ) ) )
}
art . Published = form . Get ( "published" ) != ""
ver . ContentRaw = form . Get ( "body" )
ver . ContentHTML = parsing . ParseMarkdown ( ver . ContentRaw , parsing . EducationRealMarkdown )
return
}
func createEduArticle ( c * RequestContext , art models . EduArticle , ver models . EduArticleVersion ) {
tx := utils . Must1 ( c . Conn . Begin ( c ) )
defer tx . Rollback ( c )
{
articleID := db . MustQueryOneScalar [ int ] ( c , tx ,
`
INSERT INTO education_article ( title , slug , description , published , type , current_version )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , - 1 )
RETURNING id
` ,
art . Title , art . Slug , art . Description , art . Published , art . Type ,
)
versionID := db . MustQueryOneScalar [ int ] ( c , tx ,
`
INSERT INTO education_article_version ( article_id , date , editor_id , content_raw , content_html )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 )
RETURNING id
` ,
articleID , time . Now ( ) , c . CurrentUser . ID , ver . ContentRaw , ver . ContentHTML ,
)
tx . Exec ( c ,
` UPDATE education_article SET current_version = $1 WHERE id = $2 ` ,
versionID , articleID ,
)
}
utils . Must ( tx . Commit ( c ) )
}
func updateEduArticle ( c * RequestContext , slug string , art models . EduArticle , ver models . EduArticleVersion ) {
tx := utils . Must1 ( c . Conn . Begin ( c ) )
defer tx . Rollback ( c )
{
articleID := db . MustQueryOneScalar [ int ] ( c , tx ,
` SELECT id FROM education_article WHERE slug = $1 ` ,
slug ,
)
versionID := db . MustQueryOneScalar [ int ] ( c , tx ,
`
INSERT INTO education_article_version ( article_id , date , editor_id , content_raw , content_html )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 )
RETURNING id
` ,
articleID , time . Now ( ) , c . CurrentUser . ID , ver . ContentRaw , ver . ContentHTML ,
)
tx . Exec ( c ,
`
UPDATE education_article
SET
title = $ 1 , slug = $ 2 , description = $ 3 , published = $ 4 , type = $ 5 ,
current_version = $ 6
WHERE
id = $ 7
` ,
art . Title , art . Slug , art . Description , art . Published , art . Type ,
versionID ,
articleID ,
)
}
utils . Must ( tx . Commit ( c ) )
}
func eduArticleURL ( a * models . EduArticle ) string {
switch a . Type {
case models . EduArticleTypeArticle :
return hmnurl . BuildEducationArticle ( a . Slug )
case models . EduArticleTypeGlossary :
return hmnurl . BuildEducationGlossary ( a . Slug )
default :
panic ( "unknown education article type" )
}
}
2022-09-17 21:21:58 +00:00
var reHeading = regexp . MustCompile ( ` <h([1-6])>(.*?)</h[1-6]> ` )
var reNotSimple = regexp . MustCompile ( ` [^a-zA-Z0-9-_]+ ` )
2022-11-03 03:40:44 +00:00
var reEduEditorsNote = regexp . MustCompile ( ` (?s)<span\s*class="note".*?>.*?</span> ` )
var reEduEditorsNoteTmp = regexp . MustCompile ( ` <<<NOTE(\d+)>>> ` )
2022-09-17 21:21:58 +00:00
type TOCEntry struct {
Text string
ID string
Level int
}
2022-11-03 03:40:44 +00:00
func generateTOC ( html string , canSeeNotes bool ) ( string , [ ] TOCEntry ) {
var notes [ ] string
replacinated := reEduEditorsNote . ReplaceAllStringFunc ( html , func ( s string ) string {
i := len ( notes )
notes = append ( notes , s )
if canSeeNotes {
return fmt . Sprintf ( "<<<NOTE%d>>>" , i )
} else {
return ""
}
} )
2022-09-17 21:21:58 +00:00
var entries [ ] TOCEntry
2022-11-03 03:40:44 +00:00
replacinated = reHeading . ReplaceAllStringFunc ( replacinated , func ( s string ) string {
2022-09-17 21:21:58 +00:00
m := reHeading . FindStringSubmatch ( s )
level := m [ 1 ]
content := m [ 2 ]
id := strings . ToLower ( reNotSimple . ReplaceAllLiteralString ( content , "-" ) )
entries = append ( entries , TOCEntry {
Text : content ,
ID : id ,
Level : utils . Must1 ( strconv . Atoi ( level ) ) ,
} )
return fmt . Sprintf ( ` <h%s id="%s">%s</h%s> ` , level , id , content , level )
} )
2022-11-03 03:40:44 +00:00
replacinated = reEduEditorsNoteTmp . ReplaceAllStringFunc ( replacinated , func ( s string ) string {
m := reEduEditorsNoteTmp . FindStringSubmatch ( s )
i , _ := strconv . Atoi ( m [ 1 ] )
return notes [ i ]
} )
2022-09-17 21:21:58 +00:00
return replacinated , entries
}