; ============================================================================
; 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