SelfHost/Server/includes/data.pbi
2025-12-15 19:46:13 +01:00

1100 lines
34 KiB
Plaintext

; ============================================================================
; DataModel Module - Database and Content Management
; ============================================================================
Module DataModel
EnableExplicit
UseSQLiteDatabase()
; -- Private Structures --
Structure PublishedArticle Extends Article
DefaultName.s
EndStructure
Structure Template
language.s[General::#LanguageCount]
EndStructure
; -- Language Configuration --
; WARNING: Changing languages on an existing DB will break relationships!
Language("en") = 1
Language("fr") = 2
; -- Constants --
#DBFile = "blog.db"
#FieldSeparator = Chr(2)
#RecordSeparator = Chr(1)
#ArticlePerPage = 6
; -- Private Variables --
Global TemplateArticle.Template
Global TemplateIndex.Template
Global TemplateBrowse.Template
Global TemplateTags.Template
Global TemplateLargeCard.Template
Global TemplateSmallCard.Template
Global TemplateRSS.Template
Global TemplateRSSItem.s
; -- Private Procedure Declarations --
Declare LoadAdmin(Path.s)
Declare LoadMimeTypes()
Declare.s LoadThemeFile(FilePath.s)
Declare.s Base64ToString(Base64.s)
Declare CalculateReadingTime(Content.s)
Declare BuildArticle(Title.s, Content.s, Blurb.s, Image.s, URL.s, language.i)
Declare.s BuildCard(Title.s, Blurb.s, Image.s, Date.i, Tags.s, URL.s, Language.i, ReadingTime.i, Large.i)
Declare BuildSiteStructure()
; ========================================================================
; Initialization
; ========================================================================
Procedure InitializeDatabase()
If FileSize(#DBFile) > 0
ProcedureReturn
EndIf
PrintN("[DataModel] Creating new database...")
CreateFile(0, #DBFile)
CloseFile(0)
If OpenDatabase(0, #DBFile, "", "")
DatabaseUpdate(0, "CREATE TABLE ArticleID (id INTEGER NOT NULL UNIQUE, default_title TEXT NOT NULL, date INTEGER NOT NULL, tags INTEGER, draft INTEGER, PRIMARY KEY(id AUTOINCREMENT));")
DatabaseUpdate(0, "CREATE TABLE Articles (id INTEGER NOT NULL, language_id INTEGER NOT NULL, title TEXT, blurb TEXT, image TEXT, content TEXT, reading_time INTEGER, bluesky_post TEXT);")
DatabaseUpdate(0, "CREATE TABLE Data (id INTEGER NOT NULL UNIQUE, name TEXT NOT NULL, binary BLOB NOT NULL, PRIMARY KEY(id AUTOINCREMENT));")
DatabaseUpdate(0, "CREATE TABLE TagID (id INTEGER NOT NULL UNIQUE, default_name TEXT NOT NULL, color TEXT, PRIMARY KEY(id AUTOINCREMENT));")
DatabaseUpdate(0, "CREATE TABLE Tags (tag_id INTEGER, language_id INTEGER, name TEXT);")
CloseDatabase(0)
EndIf
EndProcedure
Procedure LoadThemeTemplates()
TemplateRSSItem = LoadThemeFile("theme/rss_item.xml")
; English templates
TemplateArticle\language[0] = LoadThemeFile("theme/article.html")
TemplateIndex\language[0] = LoadThemeFile("theme/index.html")
TemplateBrowse\language[0] = LoadThemeFile("theme/browse.html")
TemplateLargeCard\language[0] = LoadThemeFile("theme/card_large.html")
TemplateSmallCard\language[0] = LoadThemeFile("theme/card_small.html")
TemplateTags\language[0] = LoadThemeFile("theme/tag.html")
TemplateRSS\language[0] = LoadThemeFile("theme/rss.xml")
; French templates
TemplateArticle\language[1] = LoadThemeFile("theme/article.html")
TemplateIndex\language[1] = LoadThemeFile("theme/index.html")
TemplateBrowse\language[1] = LoadThemeFile("theme/browse.html")
TemplateLargeCard\language[1] = LoadThemeFile("theme/card_large.html")
TemplateSmallCard\language[1] = LoadThemeFile("theme/card_small.html")
TemplateTags\language[1] = LoadThemeFile("theme/tag.html")
TemplateRSS\language[1] = LoadThemeFile("theme/rss.xml")
EndProcedure
Procedure LoadBinariesIntoMemory()
If Not DatabaseQuery(0, "SELECT name, binary, id FROM Data")
ProcedureReturn
EndIf
While NextDatabaseRow(0)
Protected Key.s = GetDatabaseString(0, 0)
AddMapElement(Binaries(), Key)
Binaries()\size = DatabaseColumnSize(0, 1)
Binaries()\data = AllocateMemory(Binaries()\size, #PB_Memory_NoClear)
GetDatabaseBlob(0, 1, Binaries()\data, Binaries()\size)
Binaries()\mime = MIMETypes(GetExtensionPart(Key))
Binaries()\ID = GetDatabaseLong(0, 2)
Wend
FinishDatabaseQuery(0)
EndProcedure
Procedure LoadTagsIntoMemory()
If Not DatabaseQuery(0, "SELECT id, default_name, color FROM TagID ORDER BY id")
ProcedureReturn
EndIf
While NextDatabaseRow(0)
AddElement(Tags())
Tags()\ID = GetDatabaseLong(0, 0)
Tags()\DefaultName = GetDatabaseString(0, 1)
Tags()\Color = GetDatabaseString(0, 2)
Wend
FinishDatabaseQuery(0)
; Load localized names and compute binary IDs
ForEach Tags()
Tags()\BinaryID = Pow(2, ListIndex(Tags()))
ForEach Language()
SetDatabaseLong(0, 0, Tags()\ID)
SetDatabaseLong(0, 1, Language())
If DatabaseQuery(0, "SELECT name FROM Tags WHERE tag_id = ? AND language_id = ?")
NextDatabaseRow(0)
Tags()\Name[Language() - 1] = GetDatabaseString(0, 0)
FinishDatabaseQuery(0)
EndIf
Next
Next
EndProcedure
Procedure LoadArticlesIntoMemory()
If Not DatabaseQuery(0, "SELECT default_title, id, draft, tags, date FROM ArticleID")
ProcedureReturn
EndIf
While NextDatabaseRow(0)
AddMapElement(Articles(), GetDatabaseString(0, 0))
Articles()\ID = GetDatabaseLong(0, 1)
Articles()\Draft = GetDatabaseLong(0, 2)
Articles()\Tags = GetDatabaseLong(0, 3)
Articles()\Date = GetDatabaseLong(0, 4)
Wend
FinishDatabaseQuery(0)
; Build article HTML for each language
ForEach Articles()
ForEach Language()
SetDatabaseLong(0, 0, Articles()\ID)
SetDatabaseLong(0, 1, Language())
If DatabaseQuery(0, "SELECT title, content, blurb, image FROM Articles WHERE id = ? AND language_id = ?")
NextDatabaseRow(0)
Articles()\language[Language() - 1]\data = BuildArticle(GetDatabaseString(0, 0), GetDatabaseString(0, 1), GetDatabaseString(0, 2), GetDatabaseString(0, 3), MapKey(Articles()), Language() - 1)
Articles()\language[Language() - 1]\size = MemorySize(Articles()\language[Language() - 1]\data)
FinishDatabaseQuery(0)
EndIf
Next
Next
EndProcedure
Procedure OpenRuntimeDatabase()
If Not OpenDatabase(0, #DBFile, "", "")
PrintN("[Error] Could not open database.")
ProcedureReturn #False
EndIf
LoadBinariesIntoMemory()
LoadTagsIntoMemory()
LoadArticlesIntoMemory()
ProcedureReturn #True
EndProcedure
Procedure Init()
PrintN("[DataModel] Initializing...")
LoadMimeTypes()
LoadAdmin("admin")
InitializeDatabase()
LoadThemeTemplates()
OpenRuntimeDatabase()
BuildSiteStructure()
EndProcedure
; ========================================================================
; Utility Functions
; ========================================================================
Procedure CalculateReadingTime(Content.s)
Protected WordCount.i, i, InWord.b = #False
Protected Len = Len(Content), Char.s
Protected Result
; Count words (simple whitespace-based counting)
For i = 1 To Len
Char = Mid(Content, i, 1)
If Char = " " Or Char = Chr(10) Or Char = Chr(13) Or Char = Chr(9)
InWord = #False
ElseIf Not InWord
WordCount + 1
InWord = #True
EndIf
Next
Result = Round(WordCount / 250, #PB_Round_Up) ; 250 words per minute is the upper limit of average reading speed.
If Result < 1
Result = 1
EndIf
; Calculate reading time in minutes (minimum 1 minute)
ProcedureReturn Result
EndProcedure
Procedure.s MakeUUID()
Protected i, GUID.s
For i = 0 To 15
GUID + RSet(Hex(Random(255), #PB_Byte), 2, "0")
Next
ProcedureReturn GUID
EndProcedure
Procedure.s Base64ToString(Base64.s)
Protected *Data, Result.s, Len
Len = (StringByteLength(Base64) * 4) / 5
If Len
*Data = AllocateMemory(Len)
Base64Decoder(Base64, *Data, Len)
Result = PeekS(*Data, -1, #PB_UTF8)
FreeMemory(*Data)
EndIf
ProcedureReturn Result
EndProcedure
Procedure.s LoadThemeFile(FilePath.s)
Protected File, Size, *Buffer, Result.s
Size = FileSize(FilePath)
If Size <= 0
PrintN("[Error] Theme file not found or empty: " + FilePath)
ProcedureReturn ""
EndIf
File = ReadFile(#PB_Any, FilePath)
If Not File
PrintN("[Error] Could not read theme file: " + FilePath)
ProcedureReturn ""
EndIf
*Buffer = AllocateMemory(Size + 2)
ReadData(File, *Buffer, Size)
CloseFile(File)
Result = PeekS(*Buffer, Size, #PB_UTF8 | #PB_ByteLength)
FreeMemory(*Buffer)
ProcedureReturn Result
EndProcedure
Procedure LoadMimeTypes()
Protected.s Ext, Type
Restore MimeData
Read.s Ext
While Ext <> "END"
Read.s Type
MIMETypes(Ext) = Type
Read.s Ext
Wend
EndProcedure
Procedure LoadAdmin(Path.s)
Protected Size, File, FullPath.s, Dir, EntryName.s
Path = ReplaceString(Path, "\", "/")
If Right(Path, 1) <> "/"
Path + "/"
EndIf
Dir = ExamineDirectory(#PB_Any, Path, "*.*")
If Not Dir
ProcedureReturn
EndIf
While NextDirectoryEntry(Dir)
EntryName = DirectoryEntryName(Dir)
If EntryName = "." Or EntryName = ".."
Continue
EndIf
FullPath = Path + EntryName
Size = FileSize(FullPath)
If Size = -2 ; Directory
LoadAdmin(FullPath)
ElseIf FindMapElement(MIMETypes(), GetExtensionPart(EntryName))
AddMapElement(AdminBinaries(), "/" + FullPath, #PB_Map_NoElementCheck)
AdminBinaries()\size = Size
AdminBinaries()\data = AllocateMemory(Size, #PB_Memory_NoClear)
AdminBinaries()\mime = MIMETypes(GetExtensionPart(EntryName))
File = ReadFile(#PB_Any, FullPath)
If File
ReadData(File, AdminBinaries()\data, Size)
CloseFile(File)
EndIf
EndIf
Wend
FinishDirectory(Dir)
EndProcedure
; ========================================================================
; Article Operations
; ========================================================================
Procedure.s CreateArticle()
Protected Result.s, ID, Title.s, *Article, Size, Date, Counter
; Generate unique default name
Title = "new_article"
While FindMapElement(Articles(), Title)
Counter + 1
Title = "new_article_" + Str(Counter)
Wend
SetDatabaseString(0, 0, Title)
Date = Date()
SetDatabaseLong(0, 1, Date)
If Not DatabaseUpdate(0, "INSERT INTO ArticleID (default_title, date, draft) values (?, ?, 1);")
ProcedureReturn ""
EndIf
If Not DatabaseQuery(0, "SELECT id, default_title, draft, tags FROM ArticleID ORDER BY id DESC LIMIT 1")
ProcedureReturn ""
EndIf
NextDatabaseRow(0)
ID = GetDatabaseLong(0, 0)
Title = GetDatabaseString(0, 1)
Result = Str(ID) + #FieldSeparator
Result + Title + #FieldSeparator
Result + Str(GetDatabaseLong(0, 2)) + #FieldSeparator
Result + Str(GetDatabaseLong(0, 3)) + #FieldSeparator
Result + Str(Date) + #RecordSeparator
FinishDatabaseQuery(0)
AddMapElement(Articles(), Title)
Articles()\ID = ID
Articles()\Date = Date
; Create empty content for each language
ForEach Language()
Articles()\language[Language() - 1]\data = BuildArticle("", "", "", "", Title,Language() - 1)
Articles()\language[Language() - 1]\size = Size
SetDatabaseLong(0, 0, ID)
SetDatabaseLong(0, 1, Language())
SetDatabaseLong(0, 2, 1) ; Default 1 minute reading time
DatabaseUpdate(0, "INSERT INTO Articles (id, language_id, reading_time) values (?, ?, ?);")
Next
ProcedureReturn Result
EndProcedure
Procedure.s GetArticle(ID)
Protected Result.s
SetDatabaseLong(0, 0, ID)
If DatabaseQuery(0, "SELECT title, image, blurb, content FROM Articles WHERE id = ? ORDER BY language_id ASC")
While NextDatabaseRow(0)
Result + GetDatabaseString(0, 0) + #FieldSeparator
Result + GetDatabaseString(0, 1) + #FieldSeparator
Result + GetDatabaseString(0, 2) + #FieldSeparator
Result + GetDatabaseString(0, 3) + #RecordSeparator
Wend
FinishDatabaseQuery(0)
EndIf
ProcedureReturn Result
EndProcedure
Procedure.s ListArticles()
Protected Result.s
If DatabaseQuery(0, "SELECT id, default_title, draft, tags, date FROM ArticleID ORDER BY id ASC")
While NextDatabaseRow(0)
Result + Str(GetDatabaseLong(0, 0)) + #FieldSeparator
Result + GetDatabaseString(0, 1) + #FieldSeparator
Result + Str(GetDatabaseLong(0, 2)) + #FieldSeparator
Result + Str(GetDatabaseLong(0, 3)) + #FieldSeparator
Result + Str(GetDatabaseLong(0, 4)) + #RecordSeparator
Wend
FinishDatabaseQuery(0)
EndIf
ProcedureReturn Result
EndProcedure
Procedure UpdateArticle(TextBundle.s)
Protected Entry.s, Loop, Count, ID
Protected Draft, Title.s, Content.s, ReadingTime.i, Image.s, Blurb.s
Protected Rebuild.b, BlueSkyUpdate.b, BlueSkyPost.s, BlueSkyPostID.s, BlobRef.s, BlueSkyMessage.s
Count = CountString(TextBundle, #RecordSeparator)
Entry = StringField(TextBundle, 1, #RecordSeparator)
ID = Val(StringField(Entry, 1, #FieldSeparator))
Title = StringField(Entry, 2, #FieldSeparator)
Draft = Val(StringField(Entry, 3, #FieldSeparator))
; Find existing article
ForEach Articles()
If Articles()\ID = ID
Break
EndIf
Next
If Articles()\Draft = #False Or Draft <> Articles()\Draft
Rebuild = #True
EndIf
If Draft = #False And Articles()\Draft = #True
BlueSkyUpdate = #True
EndIf
; Free old content
ForEach Language()
FreeMemory(Articles()\language[Language() - 1]\data)
Next
; Handle title change (map key change)
If Title <> MapKey(Articles())
DeleteMapElement(Articles())
AddMapElement(Articles(), Title)
Articles()\ID = ID
EndIf
; Update metadata
Articles()\Draft = Draft
Articles()\Tags = Val(StringField(Entry, 4, #FieldSeparator))
Articles()\Date = Val(StringField(Entry, 5, #FieldSeparator))
; Update database
SetDatabaseString(0, 0, Title)
SetDatabaseLong(0, 1, Articles()\Draft)
SetDatabaseLong(0, 2, Articles()\Tags)
SetDatabaseLong(0, 3, Articles()\Date)
SetDatabaseLong(0, 4, ID)
DatabaseUpdate(0, "UPDATE ArticleID SET default_title = ?, draft = ?, tags = ?, date = ? WHERE id = ?")
; Update each language's content
For Loop = 2 To Count
Entry = StringField(TextBundle, Loop, #RecordSeparator)
Title = Base64ToString(StringField(Entry, 1, #FieldSeparator))
Image = Base64ToString(StringField(Entry, 2, #FieldSeparator))
Blurb = Base64ToString(StringField(Entry, 3, #FieldSeparator))
Content = Base64ToString(StringField(Entry, 4, #FieldSeparator))
ReadingTime = CalculateReadingTime(Content)
SetDatabaseString(0, 0, Title)
SetDatabaseString(0, 1, Image)
SetDatabaseString(0, 2, Blurb)
SetDatabaseString(0, 3, Content)
SetDatabaseLong(0, 4, ReadingTime)
SetDatabaseLong(0, 5, ID)
SetDatabaseLong(0, 6, Loop - 1)
DatabaseUpdate(0, "UPDATE Articles SET title = ?, image = ?, blurb = ?, content = ?, reading_time = ? WHERE id = ? AND language_id = ?")
Articles()\language[Loop - 2]\data = BuildArticle(Title, Content, Blurb, Image, MapKey(Articles()), Loop - 2)
Articles()\language[Loop - 2]\size = MemorySize(Articles()\language[Loop - 2]\data)
Next
If BlueSkyUpdate
Loop = 0
ForEach Language()
BlueSkyPost = ""
If General::BlueSky(Loop)\Enabled
SetDatabaseLong(0, 0, ID)
SetDatabaseLong(0, 1, Language())
If DatabaseQuery(0, "SELECT title, image FROM Articles WHERE id = ? AND language_id = ?")
If NextDatabaseRow(0)
Title = GetDatabaseString(0, 0)
Image = GetDatabaseString(0, 1)
BlueSkyPost = GetDatabaseString(0, 2)
EndIf
FinishDatabaseQuery(0)
EndIf
If BlueSkyPost = ""
BlobRef = ""
If Image And FindMapElement(Binaries(), Image)
BlobRef = BlueskyAPI::UploadBlob(General::@BlueSky(Loop)\Session, Binaries()\data, Binaries()\size, Binaries()\mime)
EndIf
BlueSkyMessage = General::BlueSky(Loop)\Message
BlueSkyMessage = ReplaceString(BlueSkyMessage, "SH:TITLE", Title)
BlueSkyMessage = ReplaceString(BlueSkyMessage, "SH:URL", General::#Address + "article?" + MapKey(Articles()))
BlueSkyPostID = BlueskyAPI::CreatePost(General::@BlueSky(Loop)\Session, BlueSkyMessage, MapKey(Language()), BlobRef)
If BlueSkyPostID
SetDatabaseString(0, 0, BlueskyAPI::GetPostURL(General::BlueSky(Loop)\Handle, BlueSkyPostID))
SetDatabaseLong(0, 1, ID)
SetDatabaseLong(0, 2, Language())
DatabaseUpdate(0, "UPDATE Articles SET bluesky_post = ? WHERE id = ? AND language_id = ?")
EndIf
EndIf
EndIf
Loop + 1
Next
EndIf
If Rebuild
BuildSiteStructure()
EndIf
ProcedureReturn #True
EndProcedure
Procedure.s PreviewArticle(TextBundle.s)
Protected Title.s, Content.s
Title = Base64ToString(StringField(TextBundle, 1, #FieldSeparator))
Content = Base64ToString(StringField(TextBundle, 2, #FieldSeparator))
FreeMemory(*PreviewData)
*PreviewData = BuildArticle(Title, Content, "", "", "", 0)
PreviewSize = MemorySize(*PreviewData)
PreviewUUID = MakeUUID()
ProcedureReturn PreviewUUID
EndProcedure
Procedure DeleteArticle(ID)
Protected Rebuild.b
; Delete from Articles table (content)
SetDatabaseLong(0, 0, ID)
DatabaseUpdate(0, "DELETE FROM Articles WHERE id = ?")
; Delete from ArticleID table (metadata)
SetDatabaseLong(0, 0, ID)
If Not DatabaseUpdate(0, "DELETE FROM ArticleID WHERE id = ?")
ProcedureReturn #False
EndIf
; Remove from memory cache
ForEach Articles()
If Articles()\ID = ID
If Articles()\Draft = #False
Rebuild = #True
EndIf
; Free article content memory for each language
ForEach Language()
If Articles()\language[Language() - 1]\data
FreeMemory(Articles()\language[Language() - 1]\data)
EndIf
Next
DeleteMapElement(Articles())
Break
EndIf
Next
If Rebuild
BuildSiteStructure()
EndIf
ProcedureReturn #True
EndProcedure
; ========================================================================
; File Operations
; ========================================================================
Procedure.s ListFiles()
Protected Result.s
If DatabaseQuery(0, "SELECT id, name FROM Data ORDER BY id ASC")
While NextDatabaseRow(0)
Result + Str(GetDatabaseLong(0, 0)) + #FieldSeparator
Result + GetDatabaseString(0, 1) + #RecordSeparator
Wend
FinishDatabaseQuery(0)
EndIf
ProcedureReturn Result
EndProcedure
Procedure.s NewFile(Name.s, Base64File.s)
Protected Result.s, *Data, Size
Size = StringByteLength(Base64File)
If Not Size
ProcedureReturn ""
EndIf
*Data = AllocateMemory(Size)
Size = Base64Decoder(Base64File, *Data, Size)
SetDatabaseString(0, 0, Name)
SetDatabaseBlob(0, 1, *Data, Size)
If Not DatabaseUpdate(0, "INSERT INTO Data (name, binary) values (?, ?);")
FreeMemory(*Data)
ProcedureReturn ""
EndIf
; Add to memory cache
AddMapElement(Binaries(), Name)
Binaries()\size = Size
Binaries()\data = AllocateMemory(Binaries()\size, #PB_Memory_NoClear)
CopyMemory(*Data, Binaries()\data, Binaries()\size)
Binaries()\mime = MIMETypes(GetExtensionPart(Name))
; Get the assigned ID
DatabaseQuery(0, "SELECT id, name FROM Data ORDER BY id DESC LIMIT 1")
NextDatabaseRow(0)
Binaries()\ID = GetDatabaseLong(0, 0)
Result = Str(Binaries()\ID) + #FieldSeparator + GetDatabaseString(0, 1)
FinishDatabaseQuery(0)
FreeMemory(*Data)
ProcedureReturn Result
EndProcedure
Procedure DeleteAFile(ID)
SetDatabaseLong(0, 0, ID)
If Not DatabaseUpdate(0, "DELETE FROM Data WHERE id = ?")
ProcedureReturn #False
EndIf
; Remove from memory cache
ForEach Binaries()
If Binaries()\ID = ID
FreeMemory(Binaries()\data)
DeleteMapElement(Binaries())
Break
EndIf
Next
ProcedureReturn #True
EndProcedure
; ========================================================================
; Tag Operations
; ========================================================================
Procedure.s ListTags()
Protected Result.s
If DatabaseQuery(0, "SELECT id, default_name, color FROM TagID ORDER BY id ASC")
While NextDatabaseRow(0)
Result + Str(GetDatabaseLong(0, 0)) + #FieldSeparator
Result + GetDatabaseString(0, 1) + #FieldSeparator
Result + GetDatabaseString(0, 2) + #RecordSeparator
Wend
FinishDatabaseQuery(0)
EndIf
ProcedureReturn Result
EndProcedure
Procedure.s CreateTag()
Protected Result.s, ID, DefaultName.s, Counter = 0
DefaultName = "new_tag"
ForEach Tags()
If Tags()\DefaultName = DefaultName
Counter + 1
DefaultName = "new_tag_" + Str(Counter)
ResetList(Tags()) ; Restart check with new name
EndIf
Next
SetDatabaseString(0, 0, DefaultName)
SetDatabaseString(0, 1, "#000000")
If Not DatabaseUpdate(0, "INSERT INTO TagID (default_name, color) values (?, ?);")
ProcedureReturn ""
EndIf
If Not DatabaseQuery(0, "SELECT id, default_name, color FROM TagID ORDER BY id DESC LIMIT 1")
ProcedureReturn ""
EndIf
NextDatabaseRow(0)
ID = GetDatabaseLong(0, 0)
Result = Str(ID) + #FieldSeparator
Result + GetDatabaseString(0, 1) + #FieldSeparator
Result + GetDatabaseString(0, 2) + #RecordSeparator
FinishDatabaseQuery(0)
AddElement(Tags())
Tags()\ID = ID
Tags()\DefaultName = "new_tag"
Tags()\Color = "#000000"
; Create language entries
ForEach Language()
SetDatabaseLong(0, 0, ID)
SetDatabaseLong(0, 1, Language())
SetDatabaseString(0, 2, "")
DatabaseUpdate(0, "INSERT INTO Tags (tag_id, language_id, name) values (?, ?, ?);")
Next
ProcedureReturn Result
EndProcedure
Procedure.s GetTag(ID)
Protected Result.s
; Get tag metadata
SetDatabaseLong(0, 0, ID)
If DatabaseQuery(0, "SELECT default_name, color FROM TagID WHERE id = ?")
If NextDatabaseRow(0)
Result + GetDatabaseString(0, 0) + #FieldSeparator
Result + GetDatabaseString(0, 1) + #RecordSeparator
EndIf
FinishDatabaseQuery(0)
EndIf
; Get localized names
ForEach Language()
SetDatabaseLong(0, 0, ID)
SetDatabaseLong(0, 1, Language())
If DatabaseQuery(0, "SELECT name FROM Tags WHERE tag_id = ? AND language_id = ?")
If NextDatabaseRow(0)
Result + GetDatabaseString(0, 0) + #RecordSeparator
Else
Result + #RecordSeparator
EndIf
FinishDatabaseQuery(0)
EndIf
Next
ProcedureReturn Result
EndProcedure
Procedure UpdateTag(TextBundle.s)
Protected Entry.s, Loop, Count, ID
Protected DefaultName.s, Color.s, LocalizedName.s
Count = CountString(TextBundle, #RecordSeparator)
Entry = StringField(TextBundle, 1, #RecordSeparator)
ID = Val(StringField(Entry, 1, #FieldSeparator))
DefaultName = StringField(Entry, 2, #FieldSeparator)
Color = StringField(Entry, 3, #FieldSeparator)
; Find and update local data
ForEach Tags()
If Tags()\ID = ID
Tags()\DefaultName = DefaultName
Tags()\Color = Color
Break
EndIf
Next
; Update TagID table
SetDatabaseString(0, 0, DefaultName)
SetDatabaseString(0, 1, Color)
SetDatabaseLong(0, 2, ID)
DatabaseUpdate(0, "UPDATE TagID SET default_name = ?, color = ? WHERE id = ?")
; Update localized names
For Loop = 2 To Count
Entry = StringField(TextBundle, Loop, #RecordSeparator)
LocalizedName = Base64ToString(Entry)
SetDatabaseString(0, 0, LocalizedName)
SetDatabaseLong(0, 1, ID)
SetDatabaseLong(0, 2, Loop - 1)
DatabaseUpdate(0, "UPDATE Tags SET name = ? WHERE tag_id = ? AND language_id = ?")
Tags()\Name[Loop - 2] = LocalizedName
Next
BuildSiteStructure()
ProcedureReturn #True
EndProcedure
; ========================================================================
; Miscellaneous
; ========================================================================
Procedure.s ListLanguages()
Protected Result.s
ForEach Language()
Result + MapKey(Language()) + #RecordSeparator
Next
ProcedureReturn Result
EndProcedure
; ========================================================================
; Page Building
; ========================================================================
Procedure BuildArticle(Title.s, Content.s, Blurb.s, Image.s, URL.s, language.i)
Protected *Memory, Article.s, Size
Article = ReplaceString(TemplateArticle\language[language], "SH:TITLE", Title)
Article = ReplaceString(Article, "SH:CONTENT", Content)
Article = ReplaceString(Article, "SH:BLURB", Blurb)
Article = ReplaceString(Article, "SH:IMAGE", Image)
Article = ReplaceString(Article, "SH:URL", URL)
Size = StringByteLength(Article, #PB_UTF8)
*Memory = AllocateMemory(Size)
PokeS(*Memory, Article, Size, #PB_UTF8 | #PB_String_NoZero)
ProcedureReturn *Memory
EndProcedure
Procedure.s BuildCard(Title.s, Blurb.s, Image.s, Date.i, Tags.s, URL.s, Language.i, ReadingTime.i, Large.i)
Protected Card.s, ReadingTimeText.s, FormatedDate.s
; Format reading time text
If language = 0 ; English
If ReadingTime = 1
ReadingTimeText = "1 minute read"
Else
ReadingTimeText = Str(ReadingTime) + " minutes read"
EndIf
FormatedDate = FormatDate("%mm/%dd/%yyyy", Date)
Else ; French
If ReadingTime = 1
ReadingTimeText = "1 minute de lecture"
Else
ReadingTimeText = Str(ReadingTime) + " minutes de lecture"
EndIf
FormatedDate = FormatDate("%dd/%mm/%yyyy", Date)
EndIf
If Large
Card = TemplateLargeCard\language[Language]
Else
Card = TemplateSmallCard\language[Language]
EndIf
Card = ReplaceString(Card, "SH:TITLE", Title)
Card = ReplaceString(Card, "SH:URL", URL)
Card = ReplaceString(Card, "SH:IMAGE", Image)
Card = ReplaceString(Card, "SH:BLURB", Blurb)
Card = ReplaceString(Card, "SH:DATE", FormatedDate)
Card = ReplaceString(Card, "SH:TAGS", Tags)
Card = ReplaceString(Card, "SH:READTIME", ReadingTimeText)
ProcedureReturn Card
EndProcedure
Procedure.s BuildPage(PageTemplate.s, WorkingLanguage, List PublishedArticles.PublishedArticle())
Protected i, Position, Card.s
Protected Title.s, Blurb.s, Image.s, Date.s, ReadTime, TagString.s
ResetList(PublishedArticles())
For i = 1 To #ArticlePerPage
If Not NextElement(PublishedArticles())
PageTemplate = RemoveString(PageTemplate, "SH:CARDLARGE")
PageTemplate = RemoveString(PageTemplate, "SH:CARDSMALL")
Break
EndIf
; Build the article tags links
TagString = ""
PushListPosition(Tags())
ForEach Tags()
If PublishedArticles()\Tags & Tags()\BinaryID
TagString = TagString + ~"<a href=\"tag?" + Tags()\DefaultName + ~"\" style=\"color:" + Tags()\Color + ~";\" class=\"article-tag\">" + Tags()\Name[WorkingLanguage] + "</a> "
EndIf
Next
PopListPosition(Tags())
; Fetch article content for this language
SetDatabaseLong(0, 0, PublishedArticles()\ID)
SetDatabaseLong(0, 1, Language())
If DatabaseQuery(0, "SELECT title, image, blurb, reading_time FROM Articles WHERE id = ? AND language_id = ?")
NextDatabaseRow(0)
Title = GetDatabaseString(0, 0)
Image = GetDatabaseString(0, 1)
Blurb = GetDatabaseString(0, 2)
ReadTime = GetDatabaseLong(0, 3)
FinishDatabaseQuery(0)
EndIf
Position = FindString(PageTemplate, "SH:CARD")
If Mid(PageTemplate, Position + 7, 5) = "LARGE"
Card = BuildCard(Title, Blurb, Image, PublishedArticles()\Date, TagString, PublishedArticles()\DefaultName, WorkingLanguage, ReadTime, #True)
PageTemplate = ReplaceString(PageTemplate, "SH:CARDLARGE", Card, #PB_String_CaseSensitive, 0, 1)
Else
Card = BuildCard(Title, Blurb, Image, PublishedArticles()\Date, TagString, PublishedArticles()\DefaultName, WorkingLanguage, ReadTime, #False)
PageTemplate = ReplaceString(PageTemplate, "SH:CARDSMALL", Card, #PB_String_CaseSensitive, 0, 1)
EndIf
Next
ProcedureReturn PageTemplate
EndProcedure
Procedure.s BuildRSSFeed(WorkingLanguage, List PublishedArticles.PublishedArticle())
Protected RSS.s, Items.s, Item.s
Protected Title.s, Blurb.s, PubDate.s, LangCode.s
Protected i, MaxItems = 20 ; Limit RSS to 20 most recent articles
RSS = TemplateRSS\language[WorkingLanguage]
; Build RSS items
ResetList(PublishedArticles())
For i = 1 To MaxItems
If Not NextElement(PublishedArticles())
Break
EndIf
; Fetch article content for this language
SetDatabaseLong(0, 0, PublishedArticles()\ID)
SetDatabaseLong(0, 1, WorkingLanguage + 1)
If DatabaseQuery(0, "SELECT title, blurb FROM Articles WHERE id = ? AND language_id = ?")
NextDatabaseRow(0)
Title = GetDatabaseString(0, 0)
Blurb = GetDatabaseString(0, 1)
FinishDatabaseQuery(0)
EndIf
; Format date as RFC 822 (required for RSS)
Select DayOfWeek(PublishedArticles()\Date)
Case 0
PubDate = "Sun, "
Case 1
PubDate = "Mon, "
Case 2
PubDate = "Tue, "
Case 3
PubDate = "Wed, "
Case 4
PubDate = "Thu, "
Case 5
PubDate = "Fri, "
Case 6
PubDate = "Sat, "
EndSelect
PubDate + FormatDate("%dd %mm %yyyy %hh:%ii:%ss +0000", PublishedArticles()\Date)
Item = TemplateRSSItem
Item = ReplaceString(Item, "SH:TITLE", Title)
Item = ReplaceString(Item, "SH:LINK", General::#Address + "article?" + PublishedArticles()\DefaultName)
Item = ReplaceString(Item, "SH:GUID", General::#Address + "article?" + PublishedArticles()\DefaultName)
Item = ReplaceString(Item, "SH:PUBDATE", PubDate)
Item = ReplaceString(Item, "SH:DESCRIPTION", Blurb)
Items + Item + #CRLF$
Next
RSS = ReplaceString(RSS, "SH:ITEMS", Items)
ProcedureReturn RSS
EndProcedure
Procedure BuildSiteStructure()
Protected NewList PublishedArticles.PublishedArticle(), NewList PublishedArticlesByTag.PublishedArticle()
Protected i, Page.s
Protected WorkingLanguage
; Free the existing pages
ForEach StructurePages()
For i = 1 To General::#LanguageCount
FreeMemory(StructurePages()\language[i - 1]\data)
Next
Next
ClearMap(StructurePages())
; Collect published articles
ForEach Articles()
If Articles()\Draft = #False
AddElement(PublishedArticles())
PublishedArticles()\DefaultName = MapKey(Articles())
PublishedArticles()\ID = Articles()\ID
PublishedArticles()\Tags = Articles()\Tags
PublishedArticles()\Date = Articles()\Date
EndIf
Next
; Sort by date (newest first)
SortStructuredList(PublishedArticles(), #PB_Sort_Descending, OffsetOf(PublishedArticle\Date), #PB_Integer)
; Build index page for each language
AddMapElement(StructurePages(), "index")
ForEach Language()
WorkingLanguage = Language() - 1
Page = TemplateIndex\language[WorkingLanguage]
Page = BuildPage(Page, WorkingLanguage, PublishedArticles())
StructurePages()\language[WorkingLanguage]\size = StringByteLength(Page, #PB_UTF8)
StructurePages()\language[WorkingLanguage]\data = AllocateMemory(StructurePages()\language[WorkingLanguage]\size)
PokeS(StructurePages()\language[WorkingLanguage]\data, Page, StructurePages()\language[WorkingLanguage]\size, #PB_UTF8 | #PB_String_NoZero)
Next
; TODO: Build history browsing pages
; Tag pages
ForEach Tags()
ClearList(PublishedArticlesByTag())
ForEach PublishedArticles()
If PublishedArticles()\Tags & Tags()\BinaryID
AddElement(PublishedArticlesByTag())
PublishedArticlesByTag()\DefaultName = PublishedArticles()\DefaultName
PublishedArticlesByTag()\ID = PublishedArticles()\ID
PublishedArticlesByTag()\Tags = PublishedArticles()\Tags
PublishedArticlesByTag()\Date = PublishedArticles()\Date
EndIf
Next
AddMapElement(StructurePages(), "tag?"+Tags()\DefaultName)
ForEach Language()
WorkingLanguage = Language() - 1
Page = TemplateTags\language[WorkingLanguage]
Page = ReplaceString(Page, "SH:TAG", Tags()\Name[WorkingLanguage])
Page = BuildPage(Page, WorkingLanguage, PublishedArticlesByTag())
StructurePages()\language[WorkingLanguage]\size = StringByteLength(Page, #PB_UTF8)
StructurePages()\language[WorkingLanguage]\data = AllocateMemory(StructurePages()\language[WorkingLanguage]\size)
PokeS(StructurePages()\language[WorkingLanguage]\data, Page, StructurePages()\language[WorkingLanguage]\size, #PB_UTF8 | #PB_String_NoZero)
Next
Next
;
; RSS Feed
AddMapElement(StructurePages(), "rss")
ForEach Language()
WorkingLanguage = Language() - 1
Page = BuildRSSFeed(WorkingLanguage, PublishedArticles())
StructurePages()\language[WorkingLanguage]\size = StringByteLength(Page, #PB_UTF8)
StructurePages()\language[WorkingLanguage]\data = AllocateMemory(StructurePages()\language[WorkingLanguage]\size + 2)
PokeS(StructurePages()\language[WorkingLanguage]\data, Page, StructurePages()\language[WorkingLanguage]\size, #PB_UTF8 | #PB_String_NoZero)
Next
EndProcedure
; ========================================================================
; MIME Type Data
; ========================================================================
DataSection
MimeData:
Data.s "aac", "audio/aac"
Data.s "abw", "application/x-abiword"
Data.s "apng", "image/apng"
Data.s "avi", "video/x-msvideo"
Data.s "bin", "application/octet-stream"
Data.s "bmp", "image/bmp"
Data.s "css", "text/css"
Data.s "csv", "text/csv"
Data.s "doc", "application/msword"
Data.s "gif", "image/gif"
Data.s "htm", "text/html"
Data.s "html", "text/html"
Data.s "ico", "image/x-icon"
Data.s "jpeg", "image/jpeg"
Data.s "jpg", "image/jpeg"
Data.s "js", "text/javascript"
Data.s "json", "application/json"
Data.s "mp3", "audio/mpeg"
Data.s "mp4", "video/mp4"
Data.s "mpeg", "video/mpeg"
Data.s "otf", "font/otf"
Data.s "png", "image/png"
Data.s "pdf", "application/pdf"
Data.s "php", "application/x-httpd-php"
Data.s "svg", "image/svg+xml"
Data.s "txt", "text/plain"
Data.s "wav", "audio/wav"
Data.s "webm", "video/webm"
Data.s "webp", "image/webp"
Data.s "woff", "font/woff"
Data.s "woff2", "font/woff2"
Data.s "xml", "application/xml"
Data.s "zip", "application/zip"
Data.s "END"
EndDataSection
EndModule
; IDE Options = PureBasic 6.30 beta 5 (Linux - x64)
; CursorPosition = 90
; FirstLine = 44
; Folding = FAAAA5
; EnableXP
; DPIAware