AuraGem Servers > Commit [8079e6e]

Christian Lee Seibold

Wed March 20, 2024 5:48 AM -0500

Add in Ask and Star Wars database. Lots of other changes.


 .gitignore                                |    2 +-
 .vscode/launch.json                       |   15 +++++++++++++++
 gemini/ask/ask.go                         | 1435 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 gemini/ask/db.go                          |  348 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 gemini/ask/types.go                       |  410 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 gemini/chat/chat.go                       |   13 +++++++++++--
 gemini/devlog.go                          |   16 ++++++++++------
 gemini/gemini.go                          |  156 ++++++++++++++++++-----------------------------------
 gemini/music/music.go                     |  242 +++++++++++++++++++++++++++++++++++++++++++----------
 gemini/music/music_index.gmi              |   27 +++++++++++++++++++++++++++
 gemini/music/music_index_scroll.scroll    |   27 +++++++++++++++++++++++++++
 gemini/music/radio.go                     |   12 ++++++++++++
 gemini/music/stations.go                  |   38 ++++++++++++++++++++++++++++++++++++++
 gemini/search/feedback.go                 |    7 ++++++-
 gemini/search/search.go                   |  145 +++++++++++++++++++++++++++++++++++++++++++++++++----
 gemini/starwars/queries.go                |  326 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 gemini/starwars/starwars.go               |  406 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 gemini/starwars/tables.go                 |  368 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 gemini/starwars/types.go                  |  237 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 gemini/textola/textola.go                 |    8 +++++++-
 gemini/texts/christianity/christianity.go |    6 ++++++
 gemini/texts/islam/islam.go               |    2 ++
 gemini/texts/judaism/judaism.go           |    1 +
 gemini/texts/judaism/sefaria.go           |   13 ++++++++-----
 gemini/texts/judaism/sefaria_test.go      |   30 ++++++++++++++++++++++++++++++
 gemini/utils/atom.go                      |    4 ++--
 gemini/weather.go                         |    6 ++++++
 gemini/youtube/utils.go                   |    4 ++++
 gemini/youtube/youtube.go                 |   76 ++++++++++++++++++++++++++++++++++++++++++++++++-----
 go.mod                                    |   42 ++++++++++++++++++++++--------------------
 go.sum                                    |  145 +++++++++++++++++++++++++++++++++++++----------------
 main.go                                   |   32 ++++++++++++++++++++++++++++++++

Commit Hash: 8079e6e55c830102f1b541d762083d98267c2808

Tree Hash: a18fa9317d94673e4b28f02fe59da273e1214671

Date: 2024-03-20T05:48:35-05:00

Browse Tree
Parent 1f28880
Commits
Repo Home

Changes

.gitignore

   ... | ...
     7 | data/
     8 | access.log
     9 | *.pem
    10 | *.key
    11 | *.crt
    12 | *.exe
    13 | auragem_sis
    14 | config/config.go
    15 | main
    16 | main.backup
    17 | SIS/
-   12 | 
+   12 | *.prof

.vscode/launch.json (created)

+    1 | {
+    2 |     // Use IntelliSense to learn about possible attributes.
+    3 |     // Hover to view descriptions of existing attributes.
+    4 |     // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    5 |     "version": "0.2.0",
+    6 |     "configurations": [
+    7 |         {
+    8 |             "name": "Launch Package",
+    9 |             "type": "go",
+   10 |             "request": "launch",
+   11 |             "mode": "auto",
+   12 |             "program": "${workspaceFolder}"
+   13 |         }
+   14 |     ]
+   15 | }

gemini/ask/ask.go (created)

+    1 | package ask
+    2 | 
+    3 | import (
+    4 | 	"database/sql"
+    5 | 	"errors"
+    6 | 	"fmt"
+    7 | 	"net/url"
+    8 | 	"strconv"
+    9 | 	"strings"
+   10 | 	"time"
+   11 | 	"unicode"
+   12 | 	"unicode/utf8"
+   13 | 
+   14 | 	gemini "git.sr.ht/~adnano/go-gemini"
+   15 | 	gemini2 "github.com/clseibold/go-gemini"
+   16 | 	"gitlab.com/clseibold/auragem_sis/db"
+   17 | 	sis "gitlab.com/clseibold/smallnetinformationservices"
+   18 | )
+   19 | 
+   20 | var registerNotification = `# AuraGem Ask
+   21 | 
+   22 | You have selected a certificate that has not been registered yet. Registering associates a username to your certificate and allows you to start posting. Please register here:
+   23 | 
+   24 | => /~ask/?register Register Cert
+   25 | `
+   26 | 
+   27 | func HandleAsk(s sis.ServerHandle) {
+   28 | 	conn := db.NewConn(db.AskDB)
+   29 | 	conn.SetMaxOpenConns(500)
+   30 | 	conn.SetMaxIdleConns(3)
+   31 | 	conn.SetConnMaxLifetime(time.Hour * 4)
+   32 | 
+   33 | 	s.AddRoute("/ask", func(request sis.Request) {
+   34 | 		request.Redirect("/~ask/")
+   35 | 	})
+   36 | 	s.AddRoute("/~ask", func(request sis.Request) {
+   37 | 		request.Redirect("/~ask/")
+   38 | 	})
+   39 | 	s.AddRoute("/~ask/", func(request sis.Request) {
+   40 | 		cert := request.UserCert
+   41 | 		query, err2 := request.Query()
+   42 | 		if err2 != nil {
+   43 | 			return
+   44 | 		}
+   45 | 
+   46 | 		if cert == nil {
+   47 | 			if query == "register" || query != "" {
+   48 | 				request.Redirect("Please enable a certificate.")
+   49 | 				return
+   50 | 			} else {
+   51 | 				getHomepage(request, conn)
+   52 | 			}
+   53 | 		} else {
+   54 | 			fmt.Printf("%s\n", query)
+   55 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+   56 | 			if !isRegistered {
+   57 | 				if query == "register" {
+   58 | 					request.RequestInput("Enter a username:")
+   59 | 					return
+   60 | 				} else if query != "" {
+   61 | 					// Do registration
+   62 | 					RegisterUser(request, conn, query, request.UserCertHash_Gemini())
+   63 | 				} else {
+   64 | 					request.Gemini(registerNotification)
+   65 | 					return
+   66 | 					//return getUserDashboard(c, conn, user)
+   67 | 				}
+   68 | 			} else {
+   69 | 				if query == "register" {
+   70 | 					request.Redirect("/~ask/")
+   71 | 				} else {
+   72 | 					getUserDashboard(request, conn, user)
+   73 | 				}
+   74 | 			}
+   75 | 		}
+   76 | 	})
+   77 | 
+   78 | 	s.AddRoute("/~ask/register", func(request sis.Request) {
+   79 | 		cert := request.UserCert
+   80 | 
+   81 | 		if cert == nil {
+   82 | 			request.Redirect("/~ask/?register")
+   83 | 		} else {
+   84 | 			// Check if user already exists, if so, give error.
+   85 | 			_, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+   86 | 			if isRegistered {
+   87 | 				request.TemporaryFailure("You are already registered. Be sure to select your certificate on the homepage.")
+   88 | 				return
+   89 | 			}
+   90 | 
+   91 | 			query, err2 := request.Query()
+   92 | 			if err2 != nil {
+   93 | 				return
+   94 | 			} else if query == "" {
+   95 | 				//request.RequestInput("Enter a username:")
+   96 | 				request.Redirect("/~ask/?register")
+   97 | 			} else {
+   98 | 				// Do registration
+   99 | 				RegisterUser(request, conn, query, request.UserCertHash_Gemini())
+  100 | 			}
+  101 | 		}
+  102 | 	})
+  103 | 
+  104 | 	s.AddRoute("/~ask/recent", func(request sis.Request) {
+  105 | 		var recentQuestionsBuilder strings.Builder
+  106 | 
+  107 | 		activities := getRecentActivity_Questions(conn)
+  108 | 		prevYear, prevMonth, prevDay := 0, time.Month(1), 0
+  109 | 		for _, activity := range activities {
+  110 | 			year, month, day := activity.Activity_date.Date()
+  111 | 			if prevYear != 0 && (year != prevYear || month != prevMonth || day != prevDay) {
+  112 | 				fmt.Fprintf(&recentQuestionsBuilder, "\n")
+  113 | 			}
+  114 | 
+  115 | 			if activity.Activity == "question" {
+  116 | 				fmt.Fprintf(&recentQuestionsBuilder, "=> /~ask/%d/%d %s %s > %s (%s)\n", activity.Q.TopicId, activity.Q.Id, activity.Activity_date.Format("2006-01-02"), activity.TopicTitle, activity.Q.Title, activity.User.Username)
+  117 | 			} else if activity.Activity == "answer" {
+  118 | 				fmt.Fprintf(&recentQuestionsBuilder, "=> /~ask/%d/%d/a/%d %s %s > Re %s (%s)\n", activity.Q.TopicId, activity.Q.Id, activity.AnswerId, activity.Activity_date.Format("2006-01-02"), activity.TopicTitle, activity.Q.Title, activity.User.Username)
+  119 | 			} else {
+  120 | 				fmt.Fprintf(&recentQuestionsBuilder, "'%s'\n", activity.Activity)
+  121 | 			}
+  122 | 
+  123 | 			prevYear, prevMonth, prevDay = year, month, day
+  124 | 		}
+  125 | 
+  126 | 		request.Gemini(fmt.Sprintf(`# AuraGem Ask - Recent Activity on All Topics
+  127 | 
+  128 | => /~ask/ AuraGem Ask Root
+  129 | 
+  130 | %s
+  131 | `, recentQuestionsBuilder.String()))
+  132 | 	})
+  133 | 
+  134 | 	s.AddRoute("/~ask/dailydigest", func(request sis.Request) {
+  135 | 		dates := getRecentActivity_dates(conn)
+  136 | 
+  137 | 		var builder strings.Builder
+  138 | 		fmt.Fprintf(&builder, "# AuraGem Ask Daily Digest\n\n")
+  139 | 		fmt.Fprintf(&builder, "=> /~ask/ AuraGem Ask Root\n")
+  140 | 		query := strings.ReplaceAll(url.QueryEscape("gemini://auragem.letz.dev/~ask/dailydigest"), "%", "%%")
+  141 | 		fmt.Fprintf(&builder, "=> gemini://warmedal.se/~antenna/submit?%s Update Digest on Antenna\n\n", query)
+  142 | 		prevYear, prevMonth := 0, time.Month(1)
+  143 | 		for _, date := range dates {
+  144 | 			year, month, _ := date.Date()
+  145 | 			if prevYear != 0 && (year != prevYear || month != prevMonth) {
+  146 | 				fmt.Fprintf(&builder, "\n")
+  147 | 			}
+  148 | 			fmt.Fprintf(&builder, "=> /~ask/dailydigest/%s %s\n", date.Format("2006-01-02"), date.Format("2006-01-02"))
+  149 | 
+  150 | 			prevYear, prevMonth = year, month
+  151 | 		}
+  152 | 
+  153 | 		request.Gemini(builder.String())
+  154 | 	})
+  155 | 	s.AddRoute("/~ask/dailydigest/:date", func(request sis.Request) {
+  156 | 		dateString := request.GetParam("date")
+  157 | 		date, err := time.Parse("2006-01-02", dateString)
+  158 | 		if err != nil {
+  159 | 			request.TemporaryFailure("Malformed date string.")
+  160 | 			return
+  161 | 		}
+  162 | 
+  163 | 		var builder strings.Builder
+  164 | 		activities := getRecentActivityFromDate_Questions(conn, date)
+  165 | 		for _, activity := range activities {
+  166 | 			if activity.Activity == "question" {
+  167 | 				fmt.Fprintf(&builder, "=> /~ask/%d/%d %s %s > %s (%s)\n", activity.Q.TopicId, activity.Q.Id, activity.Activity_date.Format("2006-01-02"), activity.TopicTitle, activity.Q.Title, activity.User.Username)
+  168 | 			} else if activity.Activity == "answer" {
+  169 | 				fmt.Fprintf(&builder, "=> /~ask/%d/%d/a/%d %s %s > Re %s (%s)\n", activity.Q.TopicId, activity.Q.Id, activity.AnswerId, activity.Activity_date.Format("2006-01-02"), activity.TopicTitle, activity.Q.Title, activity.User.Username)
+  170 | 			} else {
+  171 | 				fmt.Fprintf(&builder, "'%s'\n", activity.Activity)
+  172 | 			}
+  173 | 		}
+  174 | 
+  175 | 		request.Gemini(fmt.Sprintf(`# %s AuraGem Ask Activity
+  176 | 
+  177 | => /~ask/ What Is AuraGem Ask?
+  178 | => /~ask/dailydigest Daily Digest
+  179 | 
+  180 | %s
+  181 | `, date.Format("2006-01-02"), builder.String()))
+  182 | 	})
+  183 | 
+  184 | 	s.AddRoute("/~ask/myquestions", func(request sis.Request) {
+  185 | 		cert := request.UserCert
+  186 | 
+  187 | 		if cert == nil {
+  188 | 			request.Redirect("Please enable a certificate.")
+  189 | 			return
+  190 | 		} else {
+  191 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  192 | 			if isRegistered {
+  193 | 				getUserQuestionsPage(request, conn, user)
+  194 | 			} else {
+  195 | 				request.Gemini(registerNotification)
+  196 | 				return
+  197 | 			}
+  198 | 		}
+  199 | 	})
+  200 | 
+  201 | 	s.AddRoute("/~ask/:topicid", func(request sis.Request) {
+  202 | 		cert := request.UserCert
+  203 | 
+  204 | 		if cert == nil {
+  205 | 			getTopicHomepage(request, conn, (AskUser{}), false)
+  206 | 		} else {
+  207 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  208 | 			getTopicHomepage(request, conn, user, isRegistered)
+  209 | 		}
+  210 | 	})
+  211 | 
+  212 | 	s.AddUploadRoute("/~ask/:topicid/create", func(request sis.Request) {
+  213 | 		cert := request.UserCert
+  214 | 
+  215 | 		if cert == nil {
+  216 | 			getCreateQuestion(request, conn, (AskUser{}), false)
+  217 | 		} else if request.Upload {
+  218 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  219 | 			doCreateQuestion(request, conn, user, isRegistered)
+  220 | 		}
+  221 | 	})
+  222 | 	s.AddRoute("/~ask/:topicid/create", func(request sis.Request) {
+  223 | 		cert := request.UserCert
+  224 | 
+  225 | 		if cert == nil {
+  226 | 			getCreateQuestion(request, conn, (AskUser{}), false)
+  227 | 		} else {
+  228 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  229 | 			getCreateQuestion(request, conn, user, isRegistered)
+  230 | 		}
+  231 | 	})
+  232 | 
+  233 | 	s.AddRoute("/~ask/:topicid/create/title", func(request sis.Request) {
+  234 | 		cert := request.UserCert
+  235 | 		query, err2 := request.Query()
+  236 | 		if err2 != nil {
+  237 | 			return
+  238 | 		}
+  239 | 
+  240 | 		if cert == nil {
+  241 | 			getCreateQuestionTitle(request, conn, (AskUser{}), false, 0, query)
+  242 | 		} else if query == "" {
+  243 | 			request.RequestInput("Title of Question:")
+  244 | 			return
+  245 | 		} else {
+  246 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  247 | 			getCreateQuestionTitle(request, conn, user, isRegistered, 0, query)
+  248 | 		}
+  249 | 	})
+  250 | 
+  251 | 	s.AddRoute("/~ask/:topicid/create/text", func(request sis.Request) {
+  252 | 		cert := request.UserCert
+  253 | 		query, err2 := request.Query()
+  254 | 		if err2 != nil {
+  255 | 			return
+  256 | 		}
+  257 | 
+  258 | 		if cert == nil {
+  259 | 			getCreateQuestionText(request, conn, (AskUser{}), false, 0, query)
+  260 | 		} else if query == "" {
+  261 | 			request.RequestInput("Text of Question:")
+  262 | 			return
+  263 | 		} else {
+  264 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  265 | 			getCreateQuestionText(request, conn, user, isRegistered, 0, query)
+  266 | 		}
+  267 | 	})
+  268 | 
+  269 | 	s.AddRoute("/~ask/:topicid/:questionid", func(request sis.Request) {
+  270 | 		cert := request.UserCert
+  271 | 
+  272 | 		if cert == nil {
+  273 | 			getQuestionPage(request, conn, (AskUser{}), false)
+  274 | 		} else {
+  275 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  276 | 			getQuestionPage(request, conn, user, isRegistered)
+  277 | 		}
+  278 | 	})
+  279 | 
+  280 | 	s.AddRoute("/~ask/:topicid/:questionid/addtitle", func(request sis.Request) {
+  281 | 		cert := request.UserCert
+  282 | 		query, err2 := request.Query()
+  283 | 		if err2 != nil {
+  284 | 			return
+  285 | 		}
+  286 | 
+  287 | 		questionId, err := strconv.Atoi(request.GetParam("questionid"))
+  288 | 		if err != nil {
+  289 | 			return
+  290 | 		}
+  291 | 
+  292 | 		if cert == nil {
+  293 | 			getCreateQuestionTitle(request, conn, (AskUser{}), false, questionId, query)
+  294 | 		} else if query == "" {
+  295 | 			request.RequestInput("Title of Question:")
+  296 | 			return
+  297 | 		} else {
+  298 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  299 | 			getCreateQuestionTitle(request, conn, user, isRegistered, questionId, query)
+  300 | 		}
+  301 | 	})
+  302 | 
+  303 | 	s.AddRoute("/~ask/:topicid/:questionid/addtext", func(request sis.Request) {
+  304 | 		cert := request.UserCert
+  305 | 		query, err2 := request.Query()
+  306 | 		if err2 != nil {
+  307 | 			return
+  308 | 		}
+  309 | 
+  310 | 		questionId, err := strconv.Atoi(request.GetParam("questionid"))
+  311 | 		if err != nil {
+  312 | 			return
+  313 | 		}
+  314 | 
+  315 | 		if cert == nil {
+  316 | 			getCreateQuestionText(request, conn, (AskUser{}), false, questionId, query)
+  317 | 		} else if query == "" {
+  318 | 			request.RequestInput("Text of Question:")
+  319 | 			return
+  320 | 		} else {
+  321 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  322 | 			getCreateQuestionText(request, conn, user, isRegistered, questionId, query)
+  323 | 		}
+  324 | 	})
+  325 | 
+  326 | 	s.AddRoute("/~ask/:topicid/:questionid/raw", func(request sis.Request) { // TODO
+  327 | 		// Used for titan edits
+  328 | 		cert := request.UserCert
+  329 | 
+  330 | 		if cert == nil {
+  331 | 			//return getQuestionPage(c, conn, (AskUser{}), false)
+  332 | 			request.Redirect("Certificate required.")
+  333 | 			return
+  334 | 		} else {
+  335 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  336 | 			if !isRegistered {
+  337 | 				request.Gemini(registerNotification)
+  338 | 				return
+  339 | 			} else {
+  340 | 				getQuestionPage(request, conn, user, isRegistered)
+  341 | 			}
+  342 | 			//return getQuestionPage(c, conn, user, isRegistered)
+  343 | 		}
+  344 | 	})
+  345 | 
+  346 | 	s.AddUploadRoute("/~ask/:topicid/:questionid/a/create", func(request sis.Request) {
+  347 | 		cert := request.UserCert
+  348 | 
+  349 | 		if cert == nil {
+  350 | 			getCreateAnswer(request, conn, (AskUser{}), false)
+  351 | 		} else if request.Upload {
+  352 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  353 | 			doCreateAnswer(request, conn, user, isRegistered)
+  354 | 		}
+  355 | 	})
+  356 | 	s.AddRoute("/~ask/:topicid/:questionid/a/create", func(request sis.Request) {
+  357 | 		cert := request.UserCert
+  358 | 
+  359 | 		if cert == nil {
+  360 | 			getCreateAnswer(request, conn, (AskUser{}), false)
+  361 | 		} else {
+  362 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  363 | 			getCreateAnswer(request, conn, user, isRegistered)
+  364 | 		}
+  365 | 	})
+  366 | 
+  367 | 	s.AddRoute("/~ask/:topicid/:questionid/a/create/text", func(request sis.Request) {
+  368 | 		cert := request.UserCert
+  369 | 		query, err2 := request.Query()
+  370 | 		if err2 != nil {
+  371 | 			return
+  372 | 		}
+  373 | 
+  374 | 		if cert == nil {
+  375 | 			getCreateAnswerText(request, conn, (AskUser{}), false, 0, query)
+  376 | 		} else if query == "" {
+  377 | 			request.RequestInput("Text of Answer:")
+  378 | 			return
+  379 | 		} else {
+  380 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  381 | 			getCreateAnswerText(request, conn, user, isRegistered, 0, query)
+  382 | 		}
+  383 | 	})
+  384 | 
+  385 | 	s.AddRoute("/~ask/:topicid/:questionid/a/create/gemlog", func(request sis.Request) {
+  386 | 		cert := request.UserCert
+  387 | 		query, err2 := request.Query()
+  388 | 		if err2 != nil {
+  389 | 			return
+  390 | 		}
+  391 | 
+  392 | 		if cert == nil {
+  393 | 			getCreateAnswerGemlog(request, conn, (AskUser{}), false, query)
+  394 | 		} else if query == "" {
+  395 | 			request.RequestInput("Gemlog URL:")
+  396 | 			return
+  397 | 		} else {
+  398 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  399 | 			getCreateAnswerGemlog(request, conn, user, isRegistered, query)
+  400 | 		}
+  401 | 	})
+  402 | 
+  403 | 	s.AddRoute("/~ask/:topicid/:questionid/a/:answerid", func(request sis.Request) {
+  404 | 		cert := request.UserCert
+  405 | 
+  406 | 		if cert == nil {
+  407 | 			getAnswerPage(request, conn, (AskUser{}), false)
+  408 | 		} else {
+  409 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  410 | 			getAnswerPage(request, conn, user, isRegistered)
+  411 | 		}
+  412 | 	})
+  413 | 
+  414 | 	s.AddRoute("/~ask/:topicid/:questionid/a/:answerid/addtext", func(request sis.Request) {
+  415 | 		cert := request.UserCert
+  416 | 		query, err2 := request.Query()
+  417 | 		if err2 != nil {
+  418 | 			return
+  419 | 		}
+  420 | 
+  421 | 		answerId, err := strconv.Atoi(request.GetParam("answerid"))
+  422 | 		if err != nil {
+  423 | 			return
+  424 | 		}
+  425 | 
+  426 | 		if cert == nil {
+  427 | 			getCreateAnswerText(request, conn, (AskUser{}), false, answerId, query)
+  428 | 		} else if query == "" {
+  429 | 			request.RequestInput("Text of Answer:")
+  430 | 			return
+  431 | 		} else {
+  432 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  433 | 			getCreateAnswerText(request, conn, user, isRegistered, answerId, query)
+  434 | 		}
+  435 | 	})
+  436 | 
+  437 | 	s.AddRoute("/~ask/:topicid/:questionid/a/:answerid/upvote", func(request sis.Request) {
+  438 | 		cert := request.UserCert
+  439 | 		query, err2 := request.Query()
+  440 | 		if err2 != nil {
+  441 | 			return
+  442 | 		}
+  443 | 		query = strings.ToLower(query)
+  444 | 
+  445 | 		if cert == nil {
+  446 | 			request.Redirect("Please enable a certificate.")
+  447 | 			return
+  448 | 		} else {
+  449 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  450 | 			if !isRegistered {
+  451 | 				request.Gemini(registerNotification)
+  452 | 				return
+  453 | 			} else if query == "" {
+  454 | 				request.RequestInput("Upvote? [yes/no]")
+  455 | 				return
+  456 | 			} else {
+  457 | 				getUpvoteAnswer(request, conn, user, query)
+  458 | 			}
+  459 | 		}
+  460 | 	})
+  461 | 
+  462 | 	s.AddRoute("/~ask/:topicid/:questionid/a/:answerid/removeupvote", func(request sis.Request) {
+  463 | 		cert := request.UserCert
+  464 | 		query, err2 := request.Query()
+  465 | 		if err2 != nil {
+  466 | 			return
+  467 | 		}
+  468 | 		query = strings.ToLower(query)
+  469 | 
+  470 | 		if cert == nil {
+  471 | 			request.Redirect("Please enable a certificate.")
+  472 | 			return
+  473 | 		} else {
+  474 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
+  475 | 			if !isRegistered {
+  476 | 				request.Gemini(registerNotification)
+  477 | 				return
+  478 | 			} else if query == "" {
+  479 | 				request.RequestInput("Remove Upvote? [yes/no]")
+  480 | 				return
+  481 | 			} else {
+  482 | 				getRemoveUpvoteAnswer(request, conn, user, query)
+  483 | 			}
+  484 | 		}
+  485 | 	})
+  486 | }
+  487 | 
+  488 | // -- Homepages Handling --
+  489 | 
+  490 | func getHomepage(request sis.Request, conn *sql.DB) {
+  491 | 	topics := GetTopics(conn)
+  492 | 
+  493 | 	// TODO: Show user's questions
+  494 | 
+  495 | 	var builder strings.Builder
+  496 | 	for _, topic := range topics {
+  497 | 		fmt.Fprintf(&builder, "=> /~ask/%d %s\n%s\nQuestions Asked: %d\n\n", topic.Id, topic.Title, topic.Description, topic.QuestionTotal)
+  498 | 	}
+  499 | 
+  500 | 	request.Gemini(fmt.Sprintf(`# AuraGem Ask
+  501 | 
+  502 | AuraGem Ask is a Gemini-first Question and Answer service, similar to Quora and StackOverflow.
+  503 | 
+  504 | AuraGem Ask supports uploading content via Gemini or Titan. Optionally, one can submit a URL to a gemlog that answers a particular question, and the content of the gemlog will be cached in case it goes down for any reason.
+  505 | 
+  506 | You must register first before being able to post. Registering will simply associate a username to your certificate. To register, create and enable a client certificate and then click the link below to register your cert:
+  507 | 
+  508 | => /~ask/?register Register Cert
+  509 | => gemini://transjovian.org/titan About Titan
+  510 | 
+  511 | => /~ask/dailydigest Daily Digest
+  512 | => /~ask/recent Recent Activity on All Topics
+  513 | 
+  514 | ## Topics
+  515 | 
+  516 | %s
+  517 | `, builder.String()))
+  518 | }
+  519 | 
+  520 | func getUserDashboard(request sis.Request, conn *sql.DB, user AskUser) {
+  521 | 	topics := GetTopics(conn)
+  522 | 
+  523 | 	// TODO: Show user's questions
+  524 | 
+  525 | 	var builder strings.Builder
+  526 | 	for _, topic := range topics {
+  527 | 		fmt.Fprintf(&builder, "=> /~ask/%d %s\n%s\nQuestions Asked: %d\n\n", topic.Id, topic.Title, topic.Description, topic.QuestionTotal)
+  528 | 	}
+  529 | 
+  530 | 	request.Gemini(fmt.Sprintf(`# AuraGem Ask - %s
+  531 | 
+  532 | => /~ask/dailydigest Daily Digest
+  533 | => /~ask/recent Recent Activity on All Topics
+  534 | => /~ask/myquestions List of Your Questions
+  535 | 
+  536 | ## Topics
+  537 | 
+  538 | %s
+  539 | `, user.Username, builder.String()))
+  540 | }
+  541 | 
+  542 | func getUserQuestionsPage(request sis.Request, conn *sql.DB, user AskUser) {
+  543 | 	var builder strings.Builder
+  544 | 	userQuestions := GetUserQuestions(conn, user)
+  545 | 	for _, question := range userQuestions {
+  546 | 		fmt.Fprintf(&builder, "=> /~ask/%d/%d %s %s\n", question.TopicId, question.Id, question.Date_added.Format("2006-01-02"), question.Title)
+  547 | 	}
+  548 | 
+  549 | 	request.Gemini(fmt.Sprintf(`# AuraGem Ask - Your Questions
+  550 | 
+  551 | => /~ask/ AuraGem Ask Root
+  552 | 
+  553 | %s
+  554 | `, builder.String()))
+  555 | }
+  556 | 
+  557 | // TODO: Pagination for all questions
+  558 | func getTopicHomepage(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool) {
+  559 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+  560 | 	if err != nil {
+  561 | 		return
+  562 | 	}
+  563 | 	topic, topicSuccess := getTopic(conn, topicId)
+  564 | 	if !topicSuccess {
+  565 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+  566 | 		return
+  567 | 	}
+  568 | 
+  569 | 	var builder strings.Builder
+  570 | 	fmt.Fprintf(&builder, "# AuraGem Ask > %s\n\n", topic.Title)
+  571 | 
+  572 | 	if isRegistered {
+  573 | 		fmt.Fprintf(&builder, "Welcome %s\n\n", user.Username)
+  574 | 	}
+  575 | 
+  576 | 	fmt.Fprintf(&builder, "=> /~ask/ AuraGem Ask Root\n")
+  577 | 	if isRegistered {
+  578 | 		fmt.Fprintf(&builder, "=> /~ask/%d/create Create New Question\n", topic.Id)
+  579 | 	} else {
+  580 | 		fmt.Fprintf(&builder, "=> /~ask/?register Register Cert\n")
+  581 | 	}
+  582 | 
+  583 | 	// TODOShow user's questions for this topic
+  584 | 
+  585 | 	fmt.Fprintf(&builder, "\n## Recent Questions\n")
+  586 | 	questions := getQuestionsForTopic(conn, topic.Id)
+  587 | 	for _, question := range questions {
+  588 | 		fmt.Fprintf(&builder, "=> /~ask/%d/%d %s %s (%s)\n", topic.Id, question.Id, question.Date_added.Format("2006-01-02"), question.Title, question.User.Username)
+  589 | 	}
+  590 | 
+  591 | 	request.Gemini(builder.String())
+  592 | }
+  593 | 
+  594 | // -- Question Handling --
+  595 | 
+  596 | func getCreateQuestion(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool) {
+  597 | 	if !isRegistered {
+  598 | 		request.Gemini(registerNotification)
+  599 | 		return
+  600 | 	}
+  601 | 
+  602 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+  603 | 	if err != nil {
+  604 | 		return
+  605 | 	}
+  606 | 	topic, topicSuccess := getTopic(conn, topicId)
+  607 | 	if !topicSuccess {
+  608 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+  609 | 		return
+  610 | 	}
+  611 | 
+  612 | 	titanHost := "titan://auragem.letz.dev/"
+  613 | 	if request.Hostname() == "192.168.0.60" {
+  614 | 		titanHost = "titan://192.168.0.60/"
+  615 | 	} else if request.Hostname() == "auragem.ddns.net" {
+  616 | 		titanHost = "titan://auragem.ddns.net/"
+  617 | 	}
+  618 | 
+  619 | 	// /create/title?TitleHere  -> Creates the question with title and blank text -> Redirects to question's new page with questionid
+  620 | 	// /create/text?TextHere -> Creates the question with text and blank title -> Redirects to question's new page with questionId
+  621 | 	request.Gemini(fmt.Sprintf(`# AuraGem Ask > %s - Create New Question
+  622 | 
+  623 | => /~ask/%d %s Homepage
+  624 | 
+  625 | To create a new question, you can do one of the following:
+  626 | 
+  627 | 1. Upload text to this url with Titan. Use a level-1 heading ('#') for the Question Title. All other heading lines are disallowed and will be stripped. This option is suitable for long posts. Make sure your certificate/identity is selected when uploading with Titan.
+  628 | => %s/~ask/%d/create Upload with Titan
+  629 | 
+  630 | 2. Or add a Title and Text via Gemini with the links below. Note that Gemini limits these to 1024 bytes total.
+  631 | => /~ask/%d/create/title Add Title
+  632 | => /~ask/%d/create/text Add Text
+  633 | `, topic.Title, topic.Id, topic.Title, titanHost, topic.Id, topic.Id, topic.Id))
+  634 | }
+  635 | 
+  636 | func doCreateQuestion(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool) {
+  637 | 	if !isRegistered {
+  638 | 		request.TemporaryFailure("You must be registered.")
+  639 | 		return
+  640 | 	}
+  641 | 
+  642 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+  643 | 	if err != nil {
+  644 | 		return
+  645 | 	}
+  646 | 	topic, topicSuccess := getTopic(conn, topicId)
+  647 | 	if !topicSuccess {
+  648 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+  649 | 		return
+  650 | 	}
+  651 | 
+  652 | 	// Check mimetype and size
+  653 | 	if request.DataMime != "text/plain" && request.DataMime != "text/gemini" {
+  654 | 		request.TemporaryFailure("Wrong mimetype. Only text/plain and text/gemini supported.")
+  655 | 		return
+  656 | 	}
+  657 | 	if request.DataSize > 16*1024 {
+  658 | 		request.TemporaryFailure("Size too large. Max size allowed is 16 KiB.")
+  659 | 		return
+  660 | 	}
+  661 | 
+  662 | 	data, read_err := request.GetUploadData()
+  663 | 	if read_err != nil {
+  664 | 		return
+  665 | 	}
+  666 | 
+  667 | 	text := string(data)
+  668 | 	if !utf8.ValidString(text) {
+  669 | 		request.TemporaryFailure("Not a valid UTF-8 text file.")
+  670 | 		return
+  671 | 	}
+  672 | 	if ContainsCensorWords(text) {
+  673 | 		request.TemporaryFailure("Profanity or slurs were detected. Your edit is rejected.")
+  674 | 		return
+  675 | 	}
+  676 | 
+  677 | 	strippedText, title := StripGeminiText(text)
+  678 | 	question, q_err := createQuestionTitan(conn, topic.Id, title, strippedText, user)
+  679 | 	if q_err != nil {
+  680 | 		return
+  681 | 	}
+  682 | 
+  683 | 	request.Redirect(fmt.Sprintf("%s%s/~ask/%d/%d", request.Server.Scheme(), request.Hostname(), topic.Id, question.Id))
+  684 | }
+  685 | 
+  686 | func getQuestionPage(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool) {
+  687 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+  688 | 	if err != nil {
+  689 | 		return
+  690 | 	}
+  691 | 	topic, topicSuccess := getTopic(conn, topicId)
+  692 | 	if !topicSuccess {
+  693 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+  694 | 		return
+  695 | 	}
+  696 | 
+  697 | 	questionId, err := strconv.Atoi(request.GetParam("questionid"))
+  698 | 	if err != nil {
+  699 | 		return
+  700 | 	}
+  701 | 	question, questionSuccess := getQuestion(conn, topic.Id, questionId)
+  702 | 	if !questionSuccess {
+  703 | 		request.TemporaryFailure("Question Id doesn't exist.")
+  704 | 		return
+  705 | 	}
+  706 | 
+  707 | 	var builder strings.Builder
+  708 | 	if question.Title != "" {
+  709 | 		fmt.Fprintf(&builder, "# AuraGem Ask > %s: %s\n\n", topic.Title, question.Title)
+  710 | 	} else {
+  711 | 		fmt.Fprintf(&builder, "# AuraGem Ask > %s: [No Title]\n\n", topic.Title)
+  712 | 	}
+  713 | 	fmt.Fprintf(&builder, "=> /~ask/%d %s Homepage\n", topic.Id, topic.Title)
+  714 | 	if question.Title == "" && question.MemberId == user.Id { // Only display if user owns the question
+  715 | 		fmt.Fprintf(&builder, "=> /~ask/%d/%d/addtitle Add Title\n", topic.Id, questionId)
+  716 | 	} else if question.MemberId == user.Id {
+  717 | 		fmt.Fprintf(&builder, "=> /~ask/%d/%d/addtitle Edit Title\n", topic.Id, questionId)
+  718 | 	}
+  719 | 
+  720 | 	if question.Text != "" {
+  721 | 		if question.MemberId == user.Id {
+  722 | 			fmt.Fprintf(&builder, "=> /~ask/%d/%d/addtext Edit Text\n\n", topic.Id, questionId)
+  723 | 		} else {
+  724 | 			fmt.Fprintf(&builder, "\n")
+  725 | 		}
+  726 | 		fmt.Fprintf(&builder, "%s\n\n", question.Text)
+  727 | 	} else if question.MemberId == user.Id { // Only display if user owns the question
+  728 | 		fmt.Fprintf(&builder, "\n")
+  729 | 		fmt.Fprintf(&builder, "=> /~ask/%d/%d/addtext Add Text\n\n", topic.Id, questionId)
+  730 | 	} else {
+  731 | 		fmt.Fprintf(&builder, "\n")
+  732 | 		fmt.Fprintf(&builder, "[No Body Text]\n\n")
+  733 | 	}
+  734 | 
+  735 | 	fmt.Fprintf(&builder, "Asked %s UTC by %s\n\n", question.Date_added.Format("2006-01-02 15:04"), question.User.Username)
+  736 | 	fmt.Fprintf(&builder, "## Answers List\n\n")
+  737 | 
+  738 | 	if isRegistered {
+  739 | 		fmt.Fprintf(&builder, "=> /~ask/%d/%d/a/create Create New Answer\n\n", topic.Id, question.Id)
+  740 | 	} else {
+  741 | 		fmt.Fprintf(&builder, "=> /~ask/?register Register Cert\n")
+  742 | 	}
+  743 | 
+  744 | 	answers := getAnswersForQuestion(conn, question)
+  745 | 	for _, answer := range answers {
+  746 | 		// TODO: Check if gemlog answer
+  747 | 		if answer.Gemlog_url != nil {
+  748 | 			fmt.Fprintf(&builder, "=> %s %s Gemlog Answer by %s\n", GetNormalizedURL(answer.Gemlog_url), answer.Date_added.Format("2006-01-02"), answer.User.Username)
+  749 | 		} else {
+  750 | 			fmt.Fprintf(&builder, "=> /~ask/%d/%d/a/%d %s %s\n", topic.Id, question.Id, answer.Id, answer.Date_added.Format("2006-01-02"), answer.User.Username)
+  751 | 		}
+  752 | 	}
+  753 | 
+  754 | 	request.Gemini(builder.String())
+  755 | }
+  756 | 
+  757 | func getCreateQuestionTitle(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool, questionId int, title string) {
+  758 | 	if !isRegistered {
+  759 | 		request.Gemini(registerNotification)
+  760 | 		return
+  761 | 	}
+  762 | 
+  763 | 	if ContainsCensorWords(title) {
+  764 | 		request.TemporaryFailure("Profanity or slurs were detected. They are not allowed.")
+  765 | 		return
+  766 | 	}
+  767 | 
+  768 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+  769 | 	if err != nil {
+  770 | 		return
+  771 | 	}
+  772 | 	topic, topicSuccess := getTopic(conn, topicId)
+  773 | 	if !topicSuccess {
+  774 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+  775 | 		return
+  776 | 	}
+  777 | 
+  778 | 	// Make sure no newlines in title // TODO
+  779 | 	//title = strings.Fields(title)[0]
+  780 | 	title = strings.FieldsFunc(title, func(r rune) bool {
+  781 | 		return r == '\n'
+  782 | 	})[0]
+  783 | 
+  784 | 	if questionId == 0 {
+  785 | 		// Question hasn't been created yet. This is from the initial /create page. Create a new question.
+  786 | 		question, q_err := createQuestionWithTitle(conn, topic.Id, title, user)
+  787 | 		if q_err != nil {
+  788 | 			return
+  789 | 		}
+  790 | 		request.Redirect(fmt.Sprintf("/~ask/%d/%d", topic.Id, question.Id))
+  791 | 		return
+  792 | 	} else {
+  793 | 		question, questionSuccess := getQuestion(conn, topic.Id, questionId)
+  794 | 		if !questionSuccess {
+  795 | 			request.TemporaryFailure("Question Id doesn't exist.")
+  796 | 			return
+  797 | 		}
+  798 | 
+  799 | 		// Check that the current user owns this question
+  800 | 		if question.MemberId != user.Id {
+  801 | 			request.TemporaryFailure("You cannot edit this question, since you did not post it.")
+  802 | 			return
+  803 | 		}
+  804 | 
+  805 | 		var q_err error
+  806 | 		question, q_err = updateQuestionTitle(conn, question, title, user)
+  807 | 		if q_err != nil {
+  808 | 			return
+  809 | 		}
+  810 | 		request.Redirect(fmt.Sprintf("/~ask/%d/%d", topic.Id, question.Id))
+  811 | 		return
+  812 | 	}
+  813 | }
+  814 | 
+  815 | func getCreateQuestionText(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool, questionId int, text string) {
+  816 | 	if !isRegistered {
+  817 | 		request.Gemini(registerNotification)
+  818 | 		return
+  819 | 	}
+  820 | 
+  821 | 	if ContainsCensorWords(text) {
+  822 | 		request.TemporaryFailure("Profanity or slurs were detected. They are not allowed.")
+  823 | 		return
+  824 | 	}
+  825 | 
+  826 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+  827 | 	if err != nil {
+  828 | 		return
+  829 | 	}
+  830 | 	topic, topicSuccess := getTopic(conn, topicId)
+  831 | 	if !topicSuccess {
+  832 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+  833 | 		return
+  834 | 	}
+  835 | 
+  836 | 	strippedText, _ := StripGeminiText(text)
+  837 | 	if questionId == 0 {
+  838 | 		// Question hasn't been created yet. This is from the initial /create page. Create a new question.
+  839 | 		question, q_err := createQuestionWithText(conn, topic.Id, strippedText, user)
+  840 | 		if q_err != nil {
+  841 | 			return
+  842 | 		}
+  843 | 		request.Redirect(fmt.Sprintf("/~ask/%d/%d", topic.Id, question.Id))
+  844 | 		return
+  845 | 	} else {
+  846 | 		question, questionSuccess := getQuestion(conn, topic.Id, questionId)
+  847 | 		if !questionSuccess {
+  848 | 			request.TemporaryFailure("Question Id doesn't exist.")
+  849 | 			return
+  850 | 		}
+  851 | 
+  852 | 		// Check that the current user owns this question
+  853 | 		if question.MemberId != user.Id {
+  854 | 			request.TemporaryFailure("You cannot edit this question, since you did not post it.")
+  855 | 			return
+  856 | 		}
+  857 | 
+  858 | 		var q_err error
+  859 | 		question, q_err = updateQuestionText(conn, question, strippedText, user)
+  860 | 		if q_err != nil {
+  861 | 			return
+  862 | 		}
+  863 | 
+  864 | 		request.Redirect(fmt.Sprintf("/~ask/%d/%d", topic.Id, question.Id))
+  865 | 		return
+  866 | 	}
+  867 | }
+  868 | 
+  869 | // -- Answer Handling --
+  870 | 
+  871 | func getCreateAnswer(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool) {
+  872 | 	if !isRegistered {
+  873 | 		request.Gemini(registerNotification)
+  874 | 		return
+  875 | 	}
+  876 | 
+  877 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+  878 | 	if err != nil {
+  879 | 		return
+  880 | 	}
+  881 | 	topic, topicSuccess := getTopic(conn, topicId)
+  882 | 	if !topicSuccess {
+  883 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+  884 | 		return
+  885 | 	}
+  886 | 
+  887 | 	questionid, err := strconv.Atoi(request.GetParam("questionid"))
+  888 | 	if err != nil {
+  889 | 		return
+  890 | 	}
+  891 | 	question, questionSuccess := getQuestion(conn, topic.Id, questionid)
+  892 | 	if !questionSuccess {
+  893 | 		request.TemporaryFailure("Question Id doesn't exist.")
+  894 | 		return
+  895 | 	}
+  896 | 
+  897 | 	titanHost := "titan://auragem.letz.dev/"
+  898 | 	if request.Hostname() == "192.168.0.60" {
+  899 | 		titanHost = "titan://192.168.0.60/"
+  900 | 	} else if request.Hostname() == "auragem.ddns.net" {
+  901 | 		titanHost = "titan://auragem.ddns.net/"
+  902 | 	}
+  903 | 
+  904 | 	// /create/text?TextHere -> Creates the question with text and blank title -> Redirects to question's new page with questionId
+  905 | 	request.Gemini(fmt.Sprintf(`# AuraGem Ask > %s - Create New Answer
+  906 | 
+  907 | => /~ask/%d %s Homepage
+  908 | => /~ask/%d/%d Back to Question
+  909 | 
+  910 | To create a new answer, you can do one of the following:
+  911 | 
+  912 | 1. Upload text to this url with Titan. All heading lines are disallowed and will be stripped. This option is suitable for long posts. Make sure your certificate/identity is selected when uploading with Titan.
+  913 | => %s/~ask/%d/%d/a/create Upload with Titan
+  914 | 
+  915 | 2. Add Text via Gemini with the link below. Note that Gemini limits these to 1024 bytes total.
+  916 | => /~ask/%d/%d/a/create/text Add Text
+  917 | 
+  918 | 3. Or Submit a URL to a gemlog post in response to the question
+  919 | => /~ask/%d/%d/a/create/gemlog Post URL of Gemlog Response
+  920 | `, topic.Title, topic.Id, topic.Title, topic.Id, question.Id, titanHost, topic.Id, question.Id, topic.Id, question.Id, topic.Id, question.Id))
+  921 | }
+  922 | 
+  923 | func doCreateAnswer(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool) {
+  924 | 	if !isRegistered {
+  925 | 		request.TemporaryFailure("You must be registered.")
+  926 | 		return
+  927 | 	}
+  928 | 
+  929 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+  930 | 	if err != nil {
+  931 | 		return
+  932 | 	}
+  933 | 	topic, topicSuccess := getTopic(conn, topicId)
+  934 | 	if !topicSuccess {
+  935 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+  936 | 		return
+  937 | 	}
+  938 | 
+  939 | 	questionid, err2 := strconv.Atoi(request.GetParam("questionid"))
+  940 | 	if err2 != nil {
+  941 | 		return
+  942 | 	}
+  943 | 	question, questionSuccess := getQuestion(conn, topic.Id, questionid)
+  944 | 	if !questionSuccess {
+  945 | 		request.TemporaryFailure("Question Id doesn't exist.")
+  946 | 		return
+  947 | 	}
+  948 | 
+  949 | 	// Check mimetype and size
+  950 | 	if request.DataMime != "text/plain" && request.DataMime != "text/gemini" {
+  951 | 		request.TemporaryFailure("Wrong mimetype. Only text/plain and text/gemini supported.")
+  952 | 		return
+  953 | 	}
+  954 | 	if request.DataSize > 16*1024 {
+  955 | 		request.TemporaryFailure("Size too large. Max size allowed is 16 KiB.")
+  956 | 		return
+  957 | 	}
+  958 | 
+  959 | 	data, read_err := request.GetUploadData()
+  960 | 	if read_err != nil {
+  961 | 		return
+  962 | 	}
+  963 | 
+  964 | 	text := string(data)
+  965 | 	if !utf8.ValidString(text) {
+  966 | 		request.TemporaryFailure("Not a valid UTF-8 text file.")
+  967 | 		return
+  968 | 	}
+  969 | 	if ContainsCensorWords(text) {
+  970 | 		request.TemporaryFailure("Profanity or slurs were detected. Your edit is rejected.")
+  971 | 		return
+  972 | 	}
+  973 | 
+  974 | 	strippedText, _ := StripGeminiText(text)
+  975 | 	answer, a_err := createAnswerWithText(conn, question.Id, strippedText, user)
+  976 | 	if a_err != nil {
+  977 | 		return
+  978 | 	}
+  979 | 	request.Redirect(fmt.Sprintf("%s%s/~ask/%d/%d/a/%d", request.Server.Scheme(), request.Hostname(), topic.Id, question.Id, answer.Id))
+  980 | }
+  981 | 
+  982 | func getCreateAnswerText(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool, answerId int, text string) {
+  983 | 	if !isRegistered {
+  984 | 		request.Gemini(registerNotification)
+  985 | 		return
+  986 | 	}
+  987 | 
+  988 | 	if ContainsCensorWords(text) {
+  989 | 		request.TemporaryFailure("Profanity or slurs were detected. They are not allowed.")
+  990 | 		return
+  991 | 	}
+  992 | 
+  993 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+  994 | 	if err != nil {
+  995 | 		return
+  996 | 	}
+  997 | 	topic, topicSuccess := getTopic(conn, topicId)
+  998 | 	if !topicSuccess {
+  999 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+ 1000 | 		return
+ 1001 | 	}
+ 1002 | 
+ 1003 | 	questionid, err2 := strconv.Atoi(request.GetParam("questionid"))
+ 1004 | 	if err2 != nil {
+ 1005 | 		return
+ 1006 | 	}
+ 1007 | 	question, questionSuccess := getQuestion(conn, topic.Id, questionid)
+ 1008 | 	if !questionSuccess {
+ 1009 | 		request.TemporaryFailure("Question Id doesn't exist.")
+ 1010 | 		return
+ 1011 | 	}
+ 1012 | 
+ 1013 | 	strippedText, _ := StripGeminiText(text)
+ 1014 | 	if answerId == 0 {
+ 1015 | 		// Answer hasn't been created yet. This is from the initial /create page. Create a new question.
+ 1016 | 		answer, a_err := createAnswerWithText(conn, question.Id, strippedText, user)
+ 1017 | 		if a_err != nil {
+ 1018 | 			return
+ 1019 | 		}
+ 1020 | 		request.Redirect(fmt.Sprintf("/~ask/%d/%d/a/%d", topic.Id, question.Id, answer.Id))
+ 1021 | 		return
+ 1022 | 	} else {
+ 1023 | 		answer, answerSuccess := getAnswer(conn, question.Id, answerId)
+ 1024 | 		if !answerSuccess {
+ 1025 | 			request.TemporaryFailure("Answer Id doesn't exist.")
+ 1026 | 			return
+ 1027 | 		}
+ 1028 | 
+ 1029 | 		// Check that the current user owns this question
+ 1030 | 		if answer.MemberId != user.Id {
+ 1031 | 			request.TemporaryFailure("You cannot edit this answer, since you did not post it.")
+ 1032 | 			return
+ 1033 | 		}
+ 1034 | 
+ 1035 | 		var a_err error
+ 1036 | 		answer, a_err = updateAnswerText(conn, answer, strippedText, user)
+ 1037 | 		if a_err != nil {
+ 1038 | 			return
+ 1039 | 		}
+ 1040 | 
+ 1041 | 		request.Redirect(fmt.Sprintf("/~ask/%d/%d/a/%d", topic.Id, question.Id, answer.Id))
+ 1042 | 		return
+ 1043 | 	}
+ 1044 | }
+ 1045 | 
+ 1046 | func getCreateAnswerGemlog(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool, gemlogUrl string) {
+ 1047 | 	if !isRegistered {
+ 1048 | 		request.Gemini(registerNotification)
+ 1049 | 		return
+ 1050 | 	}
+ 1051 | 
+ 1052 | 	/*if ContainsCensorWords(text) {
+ 1053 | 			request.TemporaryFailure("Profanity or slurs were detected. They are not allowed.")
+ 1054 | 	return
+ 1055 | 		}*/
+ 1056 | 
+ 1057 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+ 1058 | 	if err != nil {
+ 1059 | 		return
+ 1060 | 	}
+ 1061 | 	topic, topicSuccess := getTopic(conn, topicId)
+ 1062 | 	if !topicSuccess {
+ 1063 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+ 1064 | 		return
+ 1065 | 	}
+ 1066 | 
+ 1067 | 	questionid, err2 := strconv.Atoi(request.GetParam("questionid"))
+ 1068 | 	if err2 != nil {
+ 1069 | 		return
+ 1070 | 	}
+ 1071 | 	question, questionSuccess := getQuestion(conn, topic.Id, questionid)
+ 1072 | 	if !questionSuccess {
+ 1073 | 		request.TemporaryFailure("Question Id doesn't exist.")
+ 1074 | 		return
+ 1075 | 	}
+ 1076 | 
+ 1077 | 	gemlogUrlNormalized, url_err := checkValidUrl(gemlogUrl)
+ 1078 | 	if url_err != nil {
+ 1079 | 		return
+ 1080 | 	}
+ 1081 | 
+ 1082 | 	// Get text of gemlog so we can cache it in the DB
+ 1083 | 	client := gemini2.DefaultClient
+ 1084 | 	resp, fetch_err := client.Fetch(gemlogUrlNormalized)
+ 1085 | 	if fetch_err != nil {
+ 1086 | 		request.TemporaryFailure("Failed to fetch gemlog at given url.")
+ 1087 | 		return
+ 1088 | 	} else if resp.Status == 30 || resp.Status == 31 {
+ 1089 | 		request.TemporaryFailure("Failed to fetch gemlog at given url. Links that redirect are not allowed.")
+ 1090 | 		return
+ 1091 | 	} else if resp.Status != 20 {
+ 1092 | 		request.TemporaryFailure("Failed to fetch gemlog at given url.")
+ 1093 | 		return
+ 1094 | 	}
+ 1095 | 
+ 1096 | 	_, a_err := createAnswerAsGemlog(conn, question.Id, gemlogUrlNormalized, user)
+ 1097 | 	if a_err != nil {
+ 1098 | 		return
+ 1099 | 	}
+ 1100 | 	request.Redirect(fmt.Sprintf("/~ask/%d/%d", topic.Id, question.Id))
+ 1101 | }
+ 1102 | 
+ 1103 | func getAnswerPage(request sis.Request, conn *sql.DB, user AskUser, isRegistered bool) {
+ 1104 | 	// Get Topic
+ 1105 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+ 1106 | 	if err != nil {
+ 1107 | 		return
+ 1108 | 	}
+ 1109 | 	topic, topicSuccess := getTopic(conn, topicId)
+ 1110 | 	if !topicSuccess {
+ 1111 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+ 1112 | 		return
+ 1113 | 	}
+ 1114 | 
+ 1115 | 	// Get Question
+ 1116 | 	questionId, err := strconv.Atoi(request.GetParam("questionid"))
+ 1117 | 	if err != nil {
+ 1118 | 		return
+ 1119 | 	}
+ 1120 | 	question, questionSuccess := getQuestion(conn, topic.Id, questionId)
+ 1121 | 	if !questionSuccess {
+ 1122 | 		request.TemporaryFailure("Question Id doesn't exist.")
+ 1123 | 		return
+ 1124 | 	}
+ 1125 | 
+ 1126 | 	// Get Answer
+ 1127 | 	answerId, a_err := strconv.Atoi(request.GetParam("answerid"))
+ 1128 | 	if a_err != nil {
+ 1129 | 		return
+ 1130 | 	}
+ 1131 | 	answer, answerSuccess := getAnswer(conn, question.Id, answerId)
+ 1132 | 	if !answerSuccess {
+ 1133 | 		request.TemporaryFailure("Answer Id doesn't exist.")
+ 1134 | 		return
+ 1135 | 	}
+ 1136 | 
+ 1137 | 	// Get Upvotes
+ 1138 | 	upvotes := getUpvotesWithUsers(conn, answer)
+ 1139 | 	upvotesCount := len(upvotes)
+ 1140 | 	currentUserHasUpvoted := false
+ 1141 | 	for _, upvote := range upvotes {
+ 1142 | 		if upvote.MemberId == user.Id {
+ 1143 | 			currentUserHasUpvoted = true
+ 1144 | 			break
+ 1145 | 		}
+ 1146 | 	}
+ 1147 | 
+ 1148 | 	var builder strings.Builder
+ 1149 | 	if question.Title != "" {
+ 1150 | 		fmt.Fprintf(&builder, "# AuraGem Ask > %s: Re %s\n\n", topic.Title, question.Title)
+ 1151 | 	} else {
+ 1152 | 		fmt.Fprintf(&builder, "# AuraGem Ask > %s: Re [No Title]\n\n", topic.Title)
+ 1153 | 	}
+ 1154 | 
+ 1155 | 	fmt.Fprintf(&builder, "=> /~ask/%d/%d Back to the Question\n", topic.Id, question.Id)
+ 1156 | 
+ 1157 | 	if answer.Text != "" {
+ 1158 | 		if answer.MemberId == user.Id && len(answer.Text) < 1024 { // Don't show Gemini Edit if text is under 1024 bytes
+ 1159 | 			fmt.Fprintf(&builder, "=> /~ask/%d/%d/a/%d/addtext Edit Text\n\n", topic.Id, question.Id, answer.Id)
+ 1160 | 		} else {
+ 1161 | 			fmt.Fprintf(&builder, "\n")
+ 1162 | 		}
+ 1163 | 		fmt.Fprintf(&builder, "%s\n\n", answer.Text)
+ 1164 | 	} else if answer.MemberId == user.Id && answer.Gemlog_url == nil { // Only display if user owns the question
+ 1165 | 		fmt.Fprintf(&builder, "\n")
+ 1166 | 		fmt.Fprintf(&builder, "=> /~ask/%d/%d/a/%d/addtext Add Text\n\n", topic.Id, question.Id, answer.Id)
+ 1167 | 	} else if answer.Gemlog_url != nil {
+ 1168 | 		fmt.Fprintf(&builder, "\n")
+ 1169 | 		fmt.Fprintf(&builder, "=> %s Link to Gemlog\n", GetNormalizedURL(answer.Gemlog_url))
+ 1170 | 	} else {
+ 1171 | 		fmt.Fprintf(&builder, "\n")
+ 1172 | 		fmt.Fprintf(&builder, "[No Body Text]\n\n")
+ 1173 | 	}
+ 1174 | 
+ 1175 | 	fmt.Fprintf(&builder, "Answered %s UTC by %s\n", answer.Date_added.Format("2006-01-02 15:04"), answer.User.Username)
+ 1176 | 	fmt.Fprintf(&builder, "Upvotes: %d\n\n", upvotesCount)
+ 1177 | 
+ 1178 | 	if isRegistered {
+ 1179 | 		if currentUserHasUpvoted {
+ 1180 | 			fmt.Fprintf(&builder, "=> /~ask/%d/%d/a/%d/removeupvote Remove Upvote\n", topic.Id, question.Id, answer.Id)
+ 1181 | 		} else {
+ 1182 | 			fmt.Fprintf(&builder, "=> /~ask/%d/%d/a/%d/upvote Upvote\n", topic.Id, question.Id, answer.Id)
+ 1183 | 		}
+ 1184 | 	} else {
+ 1185 | 		fmt.Fprintf(&builder, "=> /~ask/?register Register Cert\n")
+ 1186 | 	}
+ 1187 | 	/*fmt.Fprintf(&builder, "## Comments\n\n")*/
+ 1188 | 
+ 1189 | 	request.Gemini(builder.String())
+ 1190 | }
+ 1191 | 
+ 1192 | func getUpvoteAnswer(request sis.Request, conn *sql.DB, user AskUser, query string) {
+ 1193 | 	// Get Topic
+ 1194 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+ 1195 | 	if err != nil {
+ 1196 | 		return
+ 1197 | 	}
+ 1198 | 	topic, topicSuccess := getTopic(conn, topicId)
+ 1199 | 	if !topicSuccess {
+ 1200 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+ 1201 | 		return
+ 1202 | 	}
+ 1203 | 
+ 1204 | 	// Get Question
+ 1205 | 	questionId, err := strconv.Atoi(request.GetParam("questionid"))
+ 1206 | 	if err != nil {
+ 1207 | 		return
+ 1208 | 	}
+ 1209 | 	question, questionSuccess := getQuestion(conn, topic.Id, questionId)
+ 1210 | 	if !questionSuccess {
+ 1211 | 		request.TemporaryFailure("Question Id doesn't exist.")
+ 1212 | 		return
+ 1213 | 	}
+ 1214 | 
+ 1215 | 	// Get Answer
+ 1216 | 	answerId, a_err := strconv.Atoi(request.GetParam("answerid"))
+ 1217 | 	if a_err != nil {
+ 1218 | 		return
+ 1219 | 	}
+ 1220 | 	answer, answerSuccess := getAnswer(conn, question.Id, answerId)
+ 1221 | 	if !answerSuccess {
+ 1222 | 		request.TemporaryFailure("Answer Id doesn't exist.")
+ 1223 | 		return
+ 1224 | 	}
+ 1225 | 
+ 1226 | 	if query == "yes" || query == "y" {
+ 1227 | 		_, db_err := addUpvote(conn, answer, user)
+ 1228 | 		if db_err != nil {
+ 1229 | 			return
+ 1230 | 		}
+ 1231 | 	}
+ 1232 | 
+ 1233 | 	request.Redirect("/~ask/%d/%d/a/%d", topic.Id, question.Id, answer.Id)
+ 1234 | }
+ 1235 | 
+ 1236 | func getRemoveUpvoteAnswer(request sis.Request, conn *sql.DB, user AskUser, query string) {
+ 1237 | 	// Get Topic
+ 1238 | 	topicId, err := strconv.Atoi(request.GetParam("topicid"))
+ 1239 | 	if err != nil {
+ 1240 | 		return
+ 1241 | 	}
+ 1242 | 	topic, topicSuccess := getTopic(conn, topicId)
+ 1243 | 	if !topicSuccess {
+ 1244 | 		request.TemporaryFailure("Topic Id doesn't exist.")
+ 1245 | 		return
+ 1246 | 	}
+ 1247 | 
+ 1248 | 	// Get Question
+ 1249 | 	questionId, err := strconv.Atoi(request.GetParam("questionid"))
+ 1250 | 	if err != nil {
+ 1251 | 		return
+ 1252 | 	}
+ 1253 | 	question, questionSuccess := getQuestion(conn, topic.Id, questionId)
+ 1254 | 	if !questionSuccess {
+ 1255 | 		request.TemporaryFailure("Question Id doesn't exist.")
+ 1256 | 		return
+ 1257 | 	}
+ 1258 | 
+ 1259 | 	// Get Answer
+ 1260 | 	answerId, a_err := strconv.Atoi(request.GetParam("answerid"))
+ 1261 | 	if a_err != nil {
+ 1262 | 		return
+ 1263 | 	}
+ 1264 | 	answer, answerSuccess := getAnswer(conn, question.Id, answerId)
+ 1265 | 	if !answerSuccess {
+ 1266 | 		request.TemporaryFailure("Answer Id doesn't exist.")
+ 1267 | 		return
+ 1268 | 	}
+ 1269 | 
+ 1270 | 	if query == "yes" || query == "y" {
+ 1271 | 		db_err := removeUpvote(conn, answer, user)
+ 1272 | 		if db_err != nil {
+ 1273 | 			return
+ 1274 | 		}
+ 1275 | 	}
+ 1276 | 
+ 1277 | 	request.Redirect("/~ask/%d/%d/a/%d", topic.Id, question.Id, answer.Id)
+ 1278 | }
+ 1279 | 
+ 1280 | func CensorWords(str string) string {
+ 1281 | 	wordCensors := []string{"fuck", "kill", "die", "damn", "ass", "shit", "stupid", "faggot", "fag", "whore", "cock", "cunt", "motherfucker", "fucker", "asshole", "nigger", "abbie", "abe", "abie", "abid", "abeed", "ape", "armo", "nazi", "ashke-nazi", "אשכנאצי", "bamboula", "barbarian", "beaney", "beaner", "bohunk", "boerehater", "boer-hater", "burrhead", "burr-head", "chode", "chad", "penis", "vagina", "porn", "bbc", "stealthing", "bbw", "Hentai", "milf", "dilf", "tummysticks", "heeb", "hymie", "kike", "jidan", "sheeny", "shylock", "zhyd", "yid", "shyster", "smouch"}
+ 1282 | 
+ 1283 | 	var result string = str
+ 1284 | 	for _, forbiddenWord := range wordCensors {
+ 1285 | 		replacement := strings.Repeat("*", len(forbiddenWord))
+ 1286 | 		result = strings.Replace(result, forbiddenWord, replacement, -1)
+ 1287 | 	}
+ 1288 | 
+ 1289 | 	return result
+ 1290 | }
+ 1291 | 
+ 1292 | func ContainsCensorWords(str string) bool {
+ 1293 | 	wordCensors := map[string]bool{"fuck": true, "f*ck": true, "kill": true, "k*ll": true, "die": true, "damn": true, "ass": true, "*ss": true, "shit": true, "sh*t": true, "stupid": true, "faggot": true, "fag": true, "f*g": true, "whore": true, "wh*re": true, "cock": true, "c*ck": true, "cunt": true, "c*nt": true, "motherfucker": true, "fucker": true, "f*cker": true, "asshole": true, "*sshole": true, "nigger": true, "n*gger": true, "n*gg*r": true, "abbie": true, "abe": true, "abie": true, "abid": true, "abeed": true, "ape": true, "armo": true, "nazi": true, "ashke-nazi": true, "אשכנאצי": true, "bamboula": true, "barbarian": true, "beaney": true, "beaner": true, "bohunk": true, "boerehater": true, "boer-hater": true, "burrhead": true, "burr-head": true, "chode": true, "chad": true, "penis": true, "vagina": true, "porn": true, "stealthing": true, "bbw": true, "Hentai": true, "milf": true, "dilf": true, "tummysticks": true, "heeb": true, "hymie": true, "kike": true, "k*ke": true, "jidan": true, "sheeny": true, "shylock": true, "zhyd": true, "yid": true, "shyster": true, "smouch": true}
+ 1294 | 
+ 1295 | 	fields := strings.FieldsFunc(strings.ToLower(str), func(r rune) bool {
+ 1296 | 		if r == '*' {
+ 1297 | 			return false
+ 1298 | 		}
+ 1299 | 		return unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) || unicode.IsDigit(r) || !unicode.IsPrint(r)
+ 1300 | 	})
+ 1301 | 
+ 1302 | 	for _, word := range fields {
+ 1303 | 		if _, ok := wordCensors[word]; ok {
+ 1304 | 			return true
+ 1305 | 		}
+ 1306 | 	}
+ 1307 | 
+ 1308 | 	return false
+ 1309 | }
+ 1310 | 
+ 1311 | var InvalidURLString = errors.New("URL is not a valid UTF-8 string.")
+ 1312 | var URLTooLong = errors.New("URL exceeds 1024 bytes.")
+ 1313 | var InvalidURL = errors.New("URL is not valid.")
+ 1314 | var URLRelative = errors.New("URL is relative. Only absolute URLs can be added.")
+ 1315 | var URLNotGemini = errors.New("Must be a Gemini URL.")
+ 1316 | 
+ 1317 | func checkValidUrl(s string) (string, error) {
+ 1318 | 	// Make sure URL is a valid UTF-8 string
+ 1319 | 	if !utf8.ValidString(s) {
+ 1320 | 		return "", InvalidURLString
+ 1321 | 	}
+ 1322 | 	// Make sure URL doesn't exceed 1024 bytes
+ 1323 | 	if len(s) > 1024 {
+ 1324 | 		return "", URLTooLong
+ 1325 | 	}
+ 1326 | 	// Make sure URL has gemini:// scheme
+ 1327 | 	if !strings.HasPrefix(s, "gemini://") && !strings.Contains(s, "://") && !strings.HasPrefix(s, ".") && !strings.HasPrefix(s, "/") {
+ 1328 | 		s = "gemini://" + s
+ 1329 | 	}
+ 1330 | 
+ 1331 | 	// Make sure the url is parseable and that only the hostname is being added
+ 1332 | 	u, urlErr := url.Parse(s)
+ 1333 | 	if urlErr != nil { // Check if able to parse
+ 1334 | 		return "", InvalidURL
+ 1335 | 	}
+ 1336 | 	if !u.IsAbs() { // Check if Absolute URL
+ 1337 | 		return "", URLRelative
+ 1338 | 	}
+ 1339 | 	if u.Scheme != "gemini" { // Make sure scheme is gemini
+ 1340 | 		return "", URLNotGemini
+ 1341 | 	}
+ 1342 | 
+ 1343 | 	return GetNormalizedURL(u), nil
+ 1344 | }
+ 1345 | 
+ 1346 | func GetNormalizedURL(u *url.URL) string {
+ 1347 | 	var buf strings.Builder
+ 1348 | 
+ 1349 | 	// Hostname
+ 1350 | 	if u.Port() == "" || u.Port() == "1965" {
+ 1351 | 		buf.WriteString(u.Scheme)
+ 1352 | 		buf.WriteString("://")
+ 1353 | 		buf.WriteString(u.Hostname())
+ 1354 | 		//buf.WriteString("/")
+ 1355 | 	} else {
+ 1356 | 		buf.WriteString(u.Scheme)
+ 1357 | 		buf.WriteString("://")
+ 1358 | 		buf.WriteString(u.Hostname())
+ 1359 | 		buf.WriteByte(':')
+ 1360 | 		buf.WriteString(u.Port())
+ 1361 | 		//buf.WriteString("/")
+ 1362 | 	}
+ 1363 | 
+ 1364 | 	// Path
+ 1365 | 	path := u.EscapedPath()
+ 1366 | 	if path == "" || (path != "" && path[0] != '/' && u.Host != "") {
+ 1367 | 		buf.WriteByte('/')
+ 1368 | 	}
+ 1369 | 	buf.WriteString(path)
+ 1370 | 
+ 1371 | 	// Queries and Fragments
+ 1372 | 	if u.ForceQuery || u.RawQuery != "" {
+ 1373 | 		buf.WriteByte('?')
+ 1374 | 		buf.WriteString(u.RawQuery)
+ 1375 | 	}
+ 1376 | 	if u.Fragment != "" {
+ 1377 | 		buf.WriteByte('#')
+ 1378 | 		buf.WriteString(u.EscapedFragment())
+ 1379 | 	}
+ 1380 | 
+ 1381 | 	return buf.String()
+ 1382 | }
+ 1383 | 
+ 1384 | // Go through gemini document to get keywords, title, and links
+ 1385 | func StripGeminiText(s string) (string, string) {
+ 1386 | 	var strippedTextBuilder strings.Builder
+ 1387 | 	text, _ := gemini.ParseText(strings.NewReader(s))
+ 1388 | 	title := ""
+ 1389 | 
+ 1390 | 	for _, line := range text {
+ 1391 | 		switch v := line.(type) {
+ 1392 | 		case gemini.LineHeading1:
+ 1393 | 			{
+ 1394 | 				if title == "" {
+ 1395 | 					title = string(v)
+ 1396 | 				}
+ 1397 | 			}
+ 1398 | 		case gemini.LineHeading2:
+ 1399 | 			{
+ 1400 | 			}
+ 1401 | 		case gemini.LineHeading3:
+ 1402 | 			{
+ 1403 | 			}
+ 1404 | 		case gemini.LineLink:
+ 1405 | 			{
+ 1406 | 				fmt.Fprintf(&strippedTextBuilder, "%s\n", v.String())
+ 1407 | 			}
+ 1408 | 		case gemini.LineListItem:
+ 1409 | 			{
+ 1410 | 				fmt.Fprintf(&strippedTextBuilder, "%s\n", v.String())
+ 1411 | 			}
+ 1412 | 		case gemini.LinePreformattingToggle:
+ 1413 | 			{
+ 1414 | 				fmt.Fprintf(&strippedTextBuilder, "%s\n", v.String())
+ 1415 | 			}
+ 1416 | 		case gemini.LinePreformattedText:
+ 1417 | 			{
+ 1418 | 				fmt.Fprintf(&strippedTextBuilder, "%s\n", v.String())
+ 1419 | 			}
+ 1420 | 		case gemini.LineQuote:
+ 1421 | 			{
+ 1422 | 				fmt.Fprintf(&strippedTextBuilder, "%s\n", v.String())
+ 1423 | 			}
+ 1424 | 		case gemini.LineText:
+ 1425 | 			{
+ 1426 | 				fmt.Fprintf(&strippedTextBuilder, "%s\n", v.String())
+ 1427 | 			}
+ 1428 | 		}
+ 1429 | 	}
+ 1430 | 
+ 1431 | 	// TODO: Strip blank lines at beginning and end of string
+ 1432 | 	// Use strings.TrimSpace?
+ 1433 | 
+ 1434 | 	return strings.TrimSpace(strippedTextBuilder.String()), title
+ 1435 | }

gemini/ask/db.go (created)

+    1 | package ask
+    2 | 
+    3 | import (
+    4 | 	"context"
+    5 | 	"database/sql"
+    6 | 	"strings"
+    7 | 	"time"
+    8 | 
+    9 | 	sis "gitlab.com/clseibold/smallnetinformationservices"
+   10 | )
+   11 | 
+   12 | func GetUser(conn *sql.DB, certHash string) (AskUser, bool) {
+   13 | 	//query := "SELECT id, username, language, timezone, is_staff, is_active, date_joined FROM members LEFT JOIN membercerts ON membercerts.memberid = members.id WHERE membercerts.certificate=?"
+   14 | 	query := "SELECT membercerts.id, membercerts.memberid, membercerts.title, membercerts.certificate, membercerts.is_active, membercerts.date_added, members.id, members.username, members.language, members.timezone, members.is_staff, members.is_active, members.date_joined FROM membercerts LEFT JOIN members ON membercerts.memberid = members.id WHERE membercerts.certificate=? AND membercerts.is_active = true"
+   15 | 	row := conn.QueryRowContext(context.Background(), query, certHash)
+   16 | 
+   17 | 	var user AskUser
+   18 | 	var certTitle interface{}
+   19 | 	err := row.Scan(&user.Certificate.Id, &user.Certificate.MemberId, &certTitle, &user.Certificate.Certificate, &user.Certificate.Is_active, &user.Certificate.Date_added, &user.Id, &user.Username, &user.Language, &user.Timezone, &user.Is_staff, &user.Is_active, &user.Date_joined)
+   20 | 	if err == sql.ErrNoRows {
+   21 | 		return AskUser{}, false
+   22 | 	} else if err != nil {
+   23 | 		//panic(err)
+   24 | 		return AskUser{}, false
+   25 | 	}
+   26 | 	if certTitle != nil {
+   27 | 		user.Certificate.Title = certTitle.(string)
+   28 | 	}
+   29 | 
+   30 | 	return user, true
+   31 | }
+   32 | 
+   33 | func RegisterUser(request sis.Request, conn *sql.DB, username string, certHash string) {
+   34 | 	username = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(username, "register"), "?"), "+"))
+   35 | 	// Ensure user doesn't already exist
+   36 | 	row := conn.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM membercerts WHERE certificate=?", certHash)
+   37 | 
+   38 | 	var numRows int
+   39 | 	err := row.Scan(&numRows)
+   40 | 	if err != nil {
+   41 | 		panic(err)
+   42 | 	}
+   43 | 	if numRows < 1 {
+   44 | 		// Certificate doesn't already exist - Register User by adding the member first, then the certificate after getting the memberid
+   45 | 		zone, _ := time.Now().Zone()
+   46 | 
+   47 | 		// TODO: Handle row2.Error and scan error
+   48 | 		var user AskUser
+   49 | 		row2 := conn.QueryRowContext(context.Background(), "INSERT INTO members (username, language, timezone, is_staff, is_active, date_joined) VALUES (?, ?, ?, ?, ?, ?) returning id, username, language, timezone, is_staff, is_active, date_joined", username, "en-US", zone, false, true, time.Now())
+   50 | 		row2.Scan(&user.Id, &user.Username, &user.Language, &user.Timezone, &user.Is_staff, &user.Is_active, &user.Date_joined)
+   51 | 
+   52 | 		// TODO: Handle row3.Error and scan error
+   53 | 		var cert AskUserCert
+   54 | 		row3 := conn.QueryRowContext(context.Background(), "INSERT INTO membercerts (memberid, certificate, is_active, date_added) VALUES (?, ?, ?, ?) returning id, memberid, title, certificate, is_active, date_added", user.Id, certHash, true, time.Now())
+   55 | 		row3.Scan(&cert.Id, &cert.MemberId, &cert.Title, &cert.Is_active, &cert.Date_added)
+   56 | 		user.Certificate = cert
+   57 | 	}
+   58 | 
+   59 | 	request.Redirect("/~ask/")
+   60 | }
+   61 | 
+   62 | func GetUserQuestions(conn *sql.DB, user AskUser) []Question {
+   63 | 	query := "SELECT questions.id, questions.topicid, questions.title, questions.text, questions.tags, questions.memberid, questions.date_added FROM questions WHERE questions.memberid=? ORDER BY questions.date_added DESC"
+   64 | 	rows, rows_err := conn.QueryContext(context.Background(), query, user.Id)
+   65 | 
+   66 | 	var questions []Question
+   67 | 	if rows_err == nil {
+   68 | 		defer rows.Close()
+   69 | 		for rows.Next() {
+   70 | 			question, scan_err := scanQuestionRows(rows)
+   71 | 			if scan_err == nil {
+   72 | 				question.User = user
+   73 | 				questions = append(questions, question)
+   74 | 			} else {
+   75 | 				panic(scan_err)
+   76 | 			}
+   77 | 		}
+   78 | 	}
+   79 | 
+   80 | 	return questions
+   81 | }
+   82 | 
+   83 | // With Totals
+   84 | func GetTopics(conn *sql.DB) []Topic {
+   85 | 	query := "SELECT topics.ID, topics.title, topics.description, topics.date_added, COUNT(questions.id) FROM topics LEFT JOIN questions ON questions.topicid=topics.id GROUP BY topics.ID, topics.title, topics.description, topics.date_added ORDER BY topics.ID"
+   86 | 	rows, rows_err := conn.QueryContext(context.Background(), query)
+   87 | 
+   88 | 	var topics []Topic
+   89 | 	if rows_err == nil {
+   90 | 		defer rows.Close()
+   91 | 		for rows.Next() {
+   92 | 			var topic Topic
+   93 | 			scan_err := rows.Scan(&topic.Id, &topic.Title, &topic.Description, &topic.Date_added, &topic.QuestionTotal)
+   94 | 			if scan_err == nil {
+   95 | 				topics = append(topics, topic)
+   96 | 			} else {
+   97 | 				panic(scan_err)
+   98 | 			}
+   99 | 		}
+  100 | 	}
+  101 | 
+  102 | 	return topics
+  103 | }
+  104 | 
+  105 | func getTopic(conn *sql.DB, topicid int) (Topic, bool) {
+  106 | 	query := "SELECT * FROM topics WHERE id=?"
+  107 | 	row := conn.QueryRowContext(context.Background(), query, topicid)
+  108 | 
+  109 | 	var topic Topic
+  110 | 	err := row.Scan(&topic.Id, &topic.Title, &topic.Description, &topic.Date_added)
+  111 | 	if err == sql.ErrNoRows {
+  112 | 		return Topic{}, false
+  113 | 	} else if err != nil {
+  114 | 		return Topic{}, false
+  115 | 	}
+  116 | 
+  117 | 	return topic, true
+  118 | }
+  119 | 
+  120 | func getRecentActivity_dates(conn *sql.DB) []time.Time {
+  121 | 	query := `SELECT DISTINCT cast(a.activity_date as date)
+  122 | 	FROM (SELECT questions.date_added as activity_date FROM questions
+  123 | 	UNION ALL
+  124 | 	SELECT answers.date_added as activity_date FROM answers LEFT JOIN questions ON questions.id=answers.questionid) a
+  125 | 	ORDER BY a.activity_date DESC`
+  126 | 
+  127 | 	rows, rows_err := conn.QueryContext(context.Background(), query)
+  128 | 
+  129 | 	var times []time.Time
+  130 | 	if rows_err == nil {
+  131 | 		defer rows.Close()
+  132 | 		for rows.Next() {
+  133 | 			var t time.Time
+  134 | 			scan_err := rows.Scan(&t)
+  135 | 			if scan_err == nil {
+  136 | 				times = append(times, t)
+  137 | 			} else {
+  138 | 				panic(scan_err)
+  139 | 			}
+  140 | 		}
+  141 | 	}
+  142 | 
+  143 | 	return times
+  144 | }
+  145 | 
+  146 | func getRecentActivity_Questions(conn *sql.DB) []Activity {
+  147 | 	query := `SELECT a.id, a.topicid, a.title, a.text, a.tags, a.memberid, a.activity, a.activity_date, a.answerid, members.*, topics.title
+  148 | FROM (SELECT questions.id, questions.topicid, questions.title, questions.text, questions.tags, questions.memberid, 'question' as activity, questions.date_added as activity_date, 0 as answerid FROM questions
+  149 | 	UNION ALL
+  150 | 	SELECT questions.id, questions.topicid, questions.title, questions.text, questions.tags, answers.memberid, 'answer' as activity, answers.date_added as activity_date, answers.id as answerid FROM answers LEFT JOIN questions ON questions.id=answers.questionid) a
+  151 | LEFT JOIN members ON members.id=a.memberid
+  152 | LEFT JOIN topics ON a.topicid=topics.id
+  153 | ORDER BY a.activity_date DESC`
+  154 | 
+  155 | 	rows, rows_err := conn.QueryContext(context.Background(), query)
+  156 | 
+  157 | 	var activities []Activity
+  158 | 	if rows_err == nil {
+  159 | 		defer rows.Close()
+  160 | 		for rows.Next() {
+  161 | 			activity, scan_err := scanActivityWithUser(rows)
+  162 | 			if scan_err == nil {
+  163 | 				activities = append(activities, activity)
+  164 | 			} else {
+  165 | 				panic(scan_err)
+  166 | 			}
+  167 | 		}
+  168 | 	}
+  169 | 
+  170 | 	return activities
+  171 | }
+  172 | 
+  173 | func getRecentActivityFromDate_Questions(conn *sql.DB, date time.Time) []Activity {
+  174 | 	query := `SELECT a.id, a.topicid, a.title, a.text, a.tags, a.memberid, a.activity, a.activity_date, a.answerid, members.*, topics.title
+  175 | FROM (SELECT questions.id, questions.topicid, questions.title, questions.text, questions.tags, questions.memberid, 'question' as activity, questions.date_added as activity_date, 0 as answerid FROM questions
+  176 | 	UNION ALL
+  177 | 	SELECT questions.id, questions.topicid, questions.title, questions.text, questions.tags, answers.memberid, 'answer' as activity, answers.date_added as activity_date, answers.id as answerid FROM answers LEFT JOIN questions ON questions.id=answers.questionid) a
+  178 | LEFT JOIN members ON members.id=a.memberid
+  179 | LEFT JOIN topics ON a.topicid=topics.id
+  180 | WHERE cast(a.activity_date as date) = ?
+  181 | ORDER BY a.activity_date DESC`
+  182 | 
+  183 | 	rows, rows_err := conn.QueryContext(context.Background(), query, date)
+  184 | 
+  185 | 	var activities []Activity
+  186 | 	if rows_err == nil {
+  187 | 		defer rows.Close()
+  188 | 		for rows.Next() {
+  189 | 			activity, scan_err := scanActivityWithUser(rows)
+  190 | 			if scan_err == nil {
+  191 | 				activities = append(activities, activity)
+  192 | 			} else {
+  193 | 				panic(scan_err)
+  194 | 			}
+  195 | 		}
+  196 | 	}
+  197 | 
+  198 | 	return activities
+  199 | }
+  200 | 
+  201 | func getQuestionsForTopic(conn *sql.DB, topicid int) []Question {
+  202 | 	query := "SELECT questions.id, questions.topicid, questions.title, questions.text, questions.tags, questions.memberid, questions.date_added, members.id, members.username, members.language, members.timezone, members.is_staff, members.is_active, members.date_joined FROM questions LEFT JOIN members ON members.id=questions.memberid WHERE questions.topicid=? ORDER BY questions.date_added DESC"
+  203 | 	rows, rows_err := conn.QueryContext(context.Background(), query, topicid)
+  204 | 
+  205 | 	var questions []Question
+  206 | 	if rows_err == nil {
+  207 | 		defer rows.Close()
+  208 | 		for rows.Next() {
+  209 | 			question, scan_err := scanQuestionRowsWithUser(rows)
+  210 | 			if scan_err == nil {
+  211 | 				questions = append(questions, question)
+  212 | 			} else {
+  213 | 				panic(scan_err)
+  214 | 			}
+  215 | 		}
+  216 | 	}
+  217 | 
+  218 | 	return questions
+  219 | }
+  220 | 
+  221 | func getQuestion(conn *sql.DB, topicid int, questionid int) (Question, bool) {
+  222 | 	// TODO: Get Selected Answer as well
+  223 | 	query := "SELECT questions.id, questions.topicid, questions.title, questions.text, questions.tags, questions.memberid, questions.date_added, members.id, members.username, members.language, members.timezone, members.is_staff, members.is_active, members.date_joined FROM questions LEFT JOIN members ON members.id=questions.memberid WHERE questions.id=? AND questions.topicid=?"
+  224 | 	row := conn.QueryRowContext(context.Background(), query, questionid, topicid)
+  225 | 
+  226 | 	question, err := scanQuestionWithUser(row)
+  227 | 	if err == sql.ErrNoRows {
+  228 | 		return Question{}, false
+  229 | 	} else if err != nil {
+  230 | 		return Question{}, false
+  231 | 	}
+  232 | 
+  233 | 	return question, true
+  234 | }
+  235 | 
+  236 | func createQuestionWithTitle(conn *sql.DB, topicid int, title string, user AskUser) (Question, error) {
+  237 | 	query := "INSERT INTO questions (topicid, title, memberid, date_added) VALUES (?, ?, ?, CURRENT_TIMESTAMP) RETURNING id, topicid, title, text, tags, memberid, date_added"
+  238 | 	row := conn.QueryRowContext(context.Background(), query, topicid, title, user.Id)
+  239 | 	return scanQuestion(row)
+  240 | }
+  241 | func createQuestionWithText(conn *sql.DB, topicid int, text string, user AskUser) (Question, error) {
+  242 | 	query := "INSERT INTO questions (topicid, text, memberid, date_added) VALUES (?, ?, ?, CURRENT_TIMESTAMP) RETURNING id, topicid, title, text, tags, memberid, date_added"
+  243 | 	row := conn.QueryRowContext(context.Background(), query, topicid, text, user.Id)
+  244 | 	return scanQuestion(row)
+  245 | }
+  246 | func createQuestionTitan(conn *sql.DB, topicid int, title string, text string, user AskUser) (Question, error) {
+  247 | 	query := "INSERT INTO questions (topicid, title, text, memberid, date_added) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) RETURNING id, topicid, title, text, tags, memberid, date_added"
+  248 | 	row := conn.QueryRowContext(context.Background(), query, topicid, title, text, user.Id)
+  249 | 	return scanQuestion(row)
+  250 | }
+  251 | 
+  252 | func updateQuestionTitle(conn *sql.DB, question Question, title string, user AskUser) (Question, error) {
+  253 | 	query := "UPDATE questions SET title=? WHERE id=? AND memberid=? RETURNING id, topicid, title, text, tags, memberid, date_added"
+  254 | 	row := conn.QueryRowContext(context.Background(), query, title, question.Id, user.Id)
+  255 | 	return scanQuestion(row)
+  256 | }
+  257 | func updateQuestionText(conn *sql.DB, question Question, text string, user AskUser) (Question, error) {
+  258 | 	query := "UPDATE questions SET text=? WHERE id=? AND memberid=? RETURNING id, topicid, title, text, tags, memberid, date_added"
+  259 | 	row := conn.QueryRowContext(context.Background(), query, text, question.Id, user.Id)
+  260 | 	return scanQuestion(row)
+  261 | }
+  262 | 
+  263 | func getAnswersForQuestion(conn *sql.DB, question Question) []Answer {
+  264 | 	query := "SELECT answers.*, members.id, members.username, members.language, members.timezone, members.is_staff, members.is_active, members.date_joined FROM answers LEFT JOIN members ON answers.memberid=members.id WHERE answers.questionid=? ORDER BY date_added ASC"
+  265 | 	rows, rows_err := conn.QueryContext(context.Background(), query, question.Id)
+  266 | 
+  267 | 	var answers []Answer
+  268 | 	if rows_err == nil {
+  269 | 		defer rows.Close()
+  270 | 		for rows.Next() {
+  271 | 			answer, scan_err := scanAnswerRows(rows)
+  272 | 			if scan_err == nil {
+  273 | 				answers = append(answers, answer)
+  274 | 			} else {
+  275 | 				panic(scan_err)
+  276 | 			}
+  277 | 		}
+  278 | 	}
+  279 | 
+  280 | 	return answers
+  281 | }
+  282 | 
+  283 | func getAnswer(conn *sql.DB, questionid int, answerid int) (Answer, bool) {
+  284 | 	// TODO: Get Selected Answer as well
+  285 | 	query := "SELECT answers.id, answers.questionid, answers.text, answers.gemlog_url, answers.memberid, answers.date_added, members.id, members.username, members.language, members.timezone, members.is_staff, members.is_active, members.date_joined FROM answers LEFT JOIN members ON members.id=answers.memberid WHERE answers.id=? AND answers.questionid=?"
+  286 | 	row := conn.QueryRowContext(context.Background(), query, answerid, questionid)
+  287 | 
+  288 | 	answer, err := scanAnswerWithUser(row)
+  289 | 	if err == sql.ErrNoRows {
+  290 | 		return Answer{}, false
+  291 | 	} else if err != nil {
+  292 | 		return Answer{}, false
+  293 | 	}
+  294 | 
+  295 | 	return answer, true
+  296 | }
+  297 | 
+  298 | func createAnswerWithText(conn *sql.DB, questionid int, text string, user AskUser) (Answer, error) {
+  299 | 	query := "INSERT INTO answers (questionid, text, memberid, date_added) VALUES (?, ?, ?, CURRENT_TIMESTAMP) RETURNING id, questionid, text, gemlog_url, memberid, date_added"
+  300 | 	row := conn.QueryRowContext(context.Background(), query, questionid, text, user.Id)
+  301 | 	return scanAnswer(row)
+  302 | }
+  303 | func createAnswerAsGemlog(conn *sql.DB, questionid int, url string, user AskUser) (Answer, error) {
+  304 | 	query := "INSERT INTO answers (questionid, gemlog_url, memberid, date_added) VALUES (?, ?, ?, CURRENT_TIMESTAMP) RETURNING id, questionid, text, gemlog_url, memberid, date_added"
+  305 | 	row := conn.QueryRowContext(context.Background(), query, questionid, url, user.Id)
+  306 | 	return scanAnswer(row)
+  307 | }
+  308 | 
+  309 | func updateAnswerText(conn *sql.DB, answer Answer, text string, user AskUser) (Answer, error) {
+  310 | 	query := "UPDATE answers SET text=? WHERE id=? AND memberid=? RETURNING id, questionid, text, gemlog_url, memberid, date_added"
+  311 | 	row := conn.QueryRowContext(context.Background(), query, text, answer.Id, user.Id)
+  312 | 	return scanAnswer(row)
+  313 | }
+  314 | 
+  315 | func getUpvotesWithUsers(conn *sql.DB, answer Answer) []Upvote {
+  316 | 	query := "SELECT upvotes.id, upvotes.answerid, upvotes.memberid, upvotes.date_added, members.id, members.username, members.language, members.timezone, members.is_staff, members.is_active, members.date_joined FROM upvotes LEFT JOIN members ON members.id=upvotes.memberid WHERE upvotes.answerid=? ORDER BY upvotes.date_added"
+  317 | 	rows, rows_err := conn.QueryContext(context.Background(), query, answer.Id)
+  318 | 
+  319 | 	var upvotes []Upvote
+  320 | 	if rows_err == nil {
+  321 | 		defer rows.Close()
+  322 | 		for rows.Next() {
+  323 | 			upvote, scan_err := scanUpvoteRowsWithUser(rows)
+  324 | 			if scan_err == nil {
+  325 | 				upvotes = append(upvotes, upvote)
+  326 | 			} else {
+  327 | 				panic(scan_err)
+  328 | 			}
+  329 | 		}
+  330 | 	}
+  331 | 
+  332 | 	return upvotes
+  333 | }
+  334 | 
+  335 | func addUpvote(conn *sql.DB, answer Answer, user AskUser) (Upvote, error) {
+  336 | 	query := `UPDATE OR INSERT INTO upvotes (answerid, memberid, date_added)
+  337 | 	VALUES (?, ?, CURRENT_TIMESTAMP)
+  338 | 	MATCHING (answerid, memberid)
+  339 | 	RETURNING id, answerid, memberid, date_added`
+  340 | 	row := conn.QueryRowContext(context.Background(), query, answer.Id, user.Id)
+  341 | 	return scanUpvote(row)
+  342 | }
+  343 | 
+  344 | func removeUpvote(conn *sql.DB, answer Answer, user AskUser) error {
+  345 | 	query := `DELETE FROM upvotes WHERE answerid=? AND memberid=?`
+  346 | 	_, err := conn.ExecContext(context.Background(), query, answer.Id, user.Id)
+  347 | 	return err
+  348 | }

gemini/ask/types.go (created)

+    1 | package ask
+    2 | 
+    3 | import (
+    4 | 	"time"
+    5 | 	"net/url"
+    6 | 	"database/sql"
+    7 | 	"strings"
+    8 | )
+    9 | 
+   10 | type AskUser struct {
+   11 | 	Id int
+   12 | 	Username string
+   13 | 	Certificate AskUserCert
+   14 | 	Language string
+   15 | 	Timezone string
+   16 | 	Is_staff bool
+   17 | 	Is_active bool
+   18 | 	Date_joined time.Time
+   19 | }
+   20 | 
+   21 | type AskUserCert struct {
+   22 | 	Id int
+   23 | 	MemberId int
+   24 | 	Title string
+   25 | 	Certificate string
+   26 | 	Is_active bool
+   27 | 	Date_added time.Time
+   28 | }
+   29 | 
+   30 | func ScanAskUserCert(row *sql.Row) AskUserCert {
+   31 | 	cert := AskUserCert{}
+   32 | 	var title interface{}
+   33 | 	row.Scan(&cert.Id, &cert.MemberId, &title, &cert.Certificate, &cert.Is_active, &cert.Date_added)
+   34 | 	if title != nil {
+   35 | 		cert.Title = title.(string)
+   36 | 	}
+   37 | 
+   38 | 	return cert
+   39 | }
+   40 | 
+   41 | type Topic struct {
+   42 | 	Id int
+   43 | 	Title string
+   44 | 	Description string
+   45 | 	Date_added time.Time
+   46 | 
+   47 | 	QuestionTotal int
+   48 | }
+   49 | 
+   50 | type Question struct {
+   51 | 	Id int
+   52 | 	TopicId int
+   53 | 	Title string // Nullable
+   54 | 	Text string // Nullable
+   55 | 	Tags string // Nullable
+   56 | 	MemberId int // Nullable
+   57 | 	Date_added time.Time
+   58 | 
+   59 | 	User AskUser
+   60 | 	SelectedAnswer int
+   61 | }
+   62 | 
+   63 | func scanQuestion(row *sql.Row) (Question, error) {
+   64 | 	question := Question{}
+   65 | 	var title interface{}
+   66 | 	var text interface{}
+   67 | 	var tags interface{}
+   68 | 	var memberid interface{}
+   69 | 	err := row.Scan(&question.Id, &question.TopicId, &title, &text, &tags, &memberid, &question.Date_added)
+   70 | 	if err != nil {
+   71 | 		return Question{}, err
+   72 | 	}
+   73 | 	if title != nil {
+   74 | 		if s, ok := title.([]uint8); ok {
+   75 | 			question.Title = string(s)
+   76 | 		} else if s, ok := title.(string); ok {
+   77 | 			question.Title = s
+   78 | 		}
+   79 | 	}
+   80 | 	if text != nil {
+   81 | 		if s, ok := text.([]uint8); ok {
+   82 | 			question.Text = string(s)
+   83 | 		} else if s, ok := text.(string); ok {
+   84 | 			question.Text = s
+   85 | 		}
+   86 | 	}
+   87 | 	if tags != nil {
+   88 | 		if s, ok := tags.([]uint8); ok {
+   89 | 			question.Tags = string(s)
+   90 | 		} else if s, ok := tags.(string); ok {
+   91 | 			question.Tags = s
+   92 | 		}
+   93 | 	}
+   94 | 	if memberid != nil {
+   95 | 		question.MemberId = int(memberid.(int64))
+   96 | 	}
+   97 | 
+   98 | 	return question, nil
+   99 | }
+  100 | 
+  101 | func scanQuestionWithUser(row *sql.Row) (Question, error) {
+  102 | 	question := Question{}
+  103 | 	var title interface{}
+  104 | 	var text interface{}
+  105 | 	var tags interface{}
+  106 | 	var memberid interface{}
+  107 | 	err := row.Scan(&question.Id, &question.TopicId, &title, &text, &tags, &memberid, &question.Date_added, &question.User.Id, &question.User.Username, &question.User.Language, &question.User.Timezone, &question.User.Is_staff, &question.User.Is_active, &question.User.Date_joined)
+  108 | 	if err != nil {
+  109 | 		return Question{}, err
+  110 | 	}
+  111 | 	if title != nil {
+  112 | 		if s, ok := title.([]uint8); ok {
+  113 | 			question.Title = string(s)
+  114 | 		} else if s, ok := title.(string); ok {
+  115 | 			question.Title = s
+  116 | 		}
+  117 | 	}
+  118 | 	if text != nil {
+  119 | 		if s, ok := text.([]uint8); ok {
+  120 | 			question.Text = string(s)
+  121 | 		} else if s, ok := text.(string); ok {
+  122 | 			question.Text = s
+  123 | 		}
+  124 | 	}
+  125 | 	if tags != nil {
+  126 | 		if s, ok := tags.([]uint8); ok {
+  127 | 			question.Tags = string(s)
+  128 | 		} else if s, ok := tags.(string); ok {
+  129 | 			question.Tags = s
+  130 | 		}
+  131 | 	}
+  132 | 	if memberid != nil {
+  133 | 		question.MemberId = int(memberid.(int64))
+  134 | 	}
+  135 | 
+  136 | 	return question, nil
+  137 | }
+  138 | 
+  139 | 
+  140 | func scanQuestionRows(rows *sql.Rows) (Question, error) {
+  141 | 	question := Question{}
+  142 | 	var title interface{}
+  143 | 	var text interface{}
+  144 | 	var tags interface{}
+  145 | 	var memberid interface{}
+  146 | 	err := rows.Scan(&question.Id, &question.TopicId, &title, &text, &tags, &memberid, &question.Date_added)
+  147 | 	if err != nil {
+  148 | 		return Question{}, err
+  149 | 	}
+  150 | 	if title != nil {
+  151 | 		if s, ok := title.([]uint8); ok {
+  152 | 			question.Title = string(s)
+  153 | 		} else if s, ok := title.(string); ok {
+  154 | 			question.Title = s
+  155 | 		}
+  156 | 	}
+  157 | 	if text != nil {
+  158 | 		if s, ok := text.([]uint8); ok {
+  159 | 			question.Text = string(s)
+  160 | 		} else if s, ok := text.(string); ok {
+  161 | 			question.Text = s
+  162 | 		}
+  163 | 	}
+  164 | 	if tags != nil {
+  165 | 		if s, ok := tags.([]uint8); ok {
+  166 | 			question.Tags = string(s)
+  167 | 		} else if s, ok := tags.(string); ok {
+  168 | 			question.Tags = s
+  169 | 		}
+  170 | 	}
+  171 | 	if memberid != nil {
+  172 | 		question.MemberId = int(memberid.(int64))
+  173 | 	}
+  174 | 
+  175 | 	return question, nil
+  176 | }
+  177 | func scanQuestionRowsWithUser(rows *sql.Rows) (Question, error) {
+  178 | 	question := Question{}
+  179 | 	var title interface{}
+  180 | 	var text interface{}
+  181 | 	var tags interface{}
+  182 | 	var memberid interface{}
+  183 | 	err := rows.Scan(&question.Id, &question.TopicId, &title, &text, &tags, &memberid, &question.Date_added, &question.User.Id, &question.User.Username, &question.User.Language, &question.User.Timezone, &question.User.Is_staff, &question.User.Is_active, &question.User.Date_joined)
+  184 | 	if err != nil {
+  185 | 		return Question{}, err
+  186 | 	}
+  187 | 	if title != nil {
+  188 | 		if s, ok := title.([]uint8); ok {
+  189 | 			question.Title = string(s)
+  190 | 		} else if s, ok := title.(string); ok {
+  191 | 			question.Title = s
+  192 | 		}
+  193 | 	}
+  194 | 	if text != nil {
+  195 | 		if s, ok := text.([]uint8); ok {
+  196 | 			question.Text = string(s)
+  197 | 		} else if s, ok := text.(string); ok {
+  198 | 			question.Text = s
+  199 | 		}
+  200 | 	}
+  201 | 	if tags != nil {
+  202 | 		if s, ok := tags.([]uint8); ok {
+  203 | 			question.Tags = string(s)
+  204 | 		} else if s, ok := tags.(string); ok {
+  205 | 			question.Tags = s
+  206 | 		}
+  207 | 	}
+  208 | 	if memberid != nil {
+  209 | 		question.MemberId = int(memberid.(int64))
+  210 | 	}
+  211 | 
+  212 | 	return question, nil
+  213 | }
+  214 | 
+  215 | type Answer struct {
+  216 | 	Id int
+  217 | 	QuestionId int // Nullable
+  218 | 	Text string // Nullable
+  219 | 	Gemlog_url *url.URL // Nullable
+  220 | 	MemberId int // Nullable
+  221 | 	Date_added time.Time
+  222 | 
+  223 | 	User AskUser
+  224 | 	Upvotes int
+  225 | }
+  226 | 
+  227 | func scanAnswer(row *sql.Row) (Answer, error) {
+  228 | 	answer := Answer{}
+  229 | 	var text interface{}
+  230 | 	var gemlog_url interface{}
+  231 | 	var memberid interface{}
+  232 | 	err := row.Scan(&answer.Id, &answer.QuestionId, &text, &gemlog_url, &memberid, &answer.Date_added)
+  233 | 	if err != nil {
+  234 | 		return (Answer{}), err
+  235 | 	}
+  236 | 	if text != nil {
+  237 | 		if s, ok := text.([]uint8); ok {
+  238 | 			answer.Text = string(s)
+  239 | 		} else if s, ok := text.(string); ok {
+  240 | 			answer.Text = s
+  241 | 		}
+  242 | 	}
+  243 | 	if gemlog_url != nil {
+  244 | 		var gemlog_url_string = string(gemlog_url.([]uint8))
+  245 | 		var err2 error
+  246 | 		answer.Gemlog_url, err2 = url.Parse(gemlog_url_string)
+  247 | 		if err2 != nil {
+  248 | 			// TODO
+  249 | 		}
+  250 | 	}
+  251 | 	if memberid != nil {
+  252 | 		answer.MemberId = int(memberid.(int64))
+  253 | 	}
+  254 | 
+  255 | 	return answer, nil
+  256 | }
+  257 | 
+  258 | func scanAnswerWithUser(row *sql.Row) (Answer, error) {
+  259 | 	answer := Answer{}
+  260 | 	var text interface{}
+  261 | 	var gemlog_url interface{}
+  262 | 	var memberid interface{}
+  263 | 	err := row.Scan(&answer.Id, &answer.QuestionId, &text, &gemlog_url, &memberid, &answer.Date_added, &answer.User.Id, &answer.User.Username, &answer.User.Language, &answer.User.Timezone, &answer.User.Is_staff, &answer.User.Is_active, &answer.User.Date_joined)
+  264 | 	if err != nil {
+  265 | 		return (Answer{}), err
+  266 | 	}
+  267 | 	if text != nil {
+  268 | 		if s, ok := text.([]uint8); ok {
+  269 | 			answer.Text = string(s)
+  270 | 		} else if s, ok := text.(string); ok {
+  271 | 			answer.Text = s
+  272 | 		}
+  273 | 	}
+  274 | 	if gemlog_url != nil {
+  275 | 		var gemlog_url_string = string(gemlog_url.([]uint8))
+  276 | 		var err2 error
+  277 | 		answer.Gemlog_url, err2 = url.Parse(gemlog_url_string)
+  278 | 		if err2 != nil {
+  279 | 			// TODO
+  280 | 		}
+  281 | 	}
+  282 | 	if memberid != nil {
+  283 | 		answer.MemberId = int(memberid.(int64))
+  284 | 	}
+  285 | 
+  286 | 	return answer, nil
+  287 | }
+  288 | 
+  289 | // With User
+  290 | func scanAnswerRows(rows *sql.Rows) (Answer, error) {
+  291 | 	answer := Answer{}
+  292 | 	var text interface{}
+  293 | 	var gemlog_url interface{}
+  294 | 	var memberid interface{}
+  295 | 	err := rows.Scan(&answer.Id, &answer.QuestionId, &text, &gemlog_url, &memberid, &answer.Date_added, &answer.User.Id, &answer.User.Username, &answer.User.Language, &answer.User.Timezone, &answer.User.Is_staff, &answer.User.Is_active, &answer.User.Date_joined)
+  296 | 	if err != nil {
+  297 | 		return (Answer{}), err
+  298 | 	}
+  299 | 	if text != nil {
+  300 | 		if s, ok := text.([]uint8); ok {
+  301 | 			answer.Text = string(s)
+  302 | 		} else if s, ok := text.(string); ok {
+  303 | 			answer.Text = s
+  304 | 		}
+  305 | 	}
+  306 | 	if gemlog_url != nil {
+  307 | 		var gemlog_url_string = string(gemlog_url.([]uint8))
+  308 | 		var err2 error
+  309 | 		answer.Gemlog_url, err2 = url.Parse(gemlog_url_string)
+  310 | 		if err2 != nil {
+  311 | 			// TODO
+  312 | 		}
+  313 | 	}
+  314 | 	if memberid != nil {
+  315 | 		answer.MemberId = int(memberid.(int64))
+  316 | 	}
+  317 | 
+  318 | 	return answer, nil
+  319 | }
+  320 | 
+  321 | type Activity struct {
+  322 | 	Q Question
+  323 | 	Activity string
+  324 | 	Activity_date time.Time
+  325 | 	AnswerId int
+  326 | 	User AskUser
+  327 | 	TopicTitle string
+  328 | }
+  329 | 
+  330 | 
+  331 | func scanActivityWithUser(rows *sql.Rows) (Activity, error) {
+  332 | 	activity := Activity{}
+  333 | 	var title interface{}
+  334 | 	var text interface{}
+  335 | 	var tags interface{}
+  336 | 	var memberid interface{}
+  337 | 	err := rows.Scan(&activity.Q.Id, &activity.Q.TopicId, &title, &text, &tags, &memberid, &activity.Activity, &activity.Activity_date, &activity.AnswerId, &activity.User.Id, &activity.User.Username, &activity.User.Language, &activity.User.Timezone, &activity.User.Is_staff, &activity.User.Is_active, &activity.User.Date_joined, &activity.TopicTitle)
+  338 | 	if err != nil {
+  339 | 		return Activity{}, err
+  340 | 	}
+  341 | 	if title != nil {
+  342 | 		activity.Q.Title = string(title.([]uint8))
+  343 | 	}
+  344 | 	if text != nil {
+  345 | 		activity.Q.Text = text.(string)
+  346 | 	}
+  347 | 	if tags != nil {
+  348 | 		activity.Q.Tags = string(tags.([]uint8))
+  349 | 	}
+  350 | 	if memberid != nil {
+  351 | 		activity.Q.MemberId = int(memberid.(int64))
+  352 | 	}
+  353 | 
+  354 | 	activity.Activity = strings.TrimSpace(activity.Activity)
+  355 | 
+  356 | 	return activity, nil
+  357 | }
+  358 | 
+  359 | type QuestionComment struct {
+  360 | 	Id int
+  361 | 	QuestionId int
+  362 | 	Text string
+  363 | 	MemberId int
+  364 | 	Date_added time.Time
+  365 | }
+  366 | 
+  367 | type AnswerComment struct {
+  368 | 	Id int
+  369 | 	AnswerId int
+  370 | 	Text string
+  371 | 	MemberId int
+  372 | 	Date_added time.Time
+  373 | }
+  374 | 
+  375 | type Upvote struct {
+  376 | 	Id int
+  377 | 	AnswerId int
+  378 | 	MemberId int
+  379 | 	Date_added time.Time
+  380 | 
+  381 | 	User AskUser
+  382 | }
+  383 | 
+  384 | func scanUpvote(row *sql.Row) (Upvote, error) {
+  385 | 	upvote := Upvote{}
+  386 | 	var memberid interface{}
+  387 | 	err := row.Scan(&upvote.Id, &upvote.AnswerId, &memberid, &upvote.Date_added)
+  388 | 	if err != nil {
+  389 | 		return (Upvote{}), err
+  390 | 	}
+  391 | 	if memberid != nil {
+  392 | 		upvote.MemberId = int(memberid.(int64))
+  393 | 	}
+  394 | 
+  395 | 	return upvote, nil
+  396 | }
+  397 | 
+  398 | func scanUpvoteRowsWithUser(rows *sql.Rows) (Upvote, error) {
+  399 | 	upvote := Upvote{}
+  400 | 	var memberid interface{}
+  401 | 	err := rows.Scan(&upvote.Id, &upvote.AnswerId, &memberid, &upvote.Date_added, &upvote.User.Id, &upvote.User.Username, &upvote.User.Language, &upvote.User.Timezone, &upvote.User.Is_staff, &upvote.User.Is_active, &upvote.User.Date_joined)
+  402 | 	if err != nil {
+  403 | 		return (Upvote{}), err
+  404 | 	}
+  405 | 	if memberid != nil {
+  406 | 		upvote.MemberId = int(memberid.(int64))
+  407 | 	}
+  408 | 
+  409 | 	return upvote, nil
+  410 | }

gemini/chat/chat.go

   ... | ...
    33 | 	username     string
    34 | 	clientNumber atomic.Int64
    35 | }
    36 | 
    37 | func HandleChat(s sis.ServerHandle) {
+   38 | 	publishDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-19T13:51:00", time.Local)
+   39 | 	updateDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-19T13:51:00", time.Local)
    38 | 	context := ChatContext{
    39 | 		changeInt: 0,
    40 | 		mutex:     sync.RWMutex{},
    41 | 		//messages:     deque.New[ChatText](0, 30),
    42 | 		messages:     make([]ChatText, 0, 100),
   ... | ...
    63 | 	})
    64 | 	s.AddRoute("/chat/:username", func(request sis.Request) {
    65 | 		username := request.GetParam("username")
    66 | 		if username == "" {
    67 | 			request.Redirect("/chat/")
+   70 | 			return
+   71 | 		}
+   72 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: publishDate, UpdateDate: updateDate, Language: "en", Abstract: "# AuraGem Live Chat\nThis chat is heavily inspired by Mozz's chat, but the UI has been tailored for most gemini browsers. Message history is cleared every 24 hours. This chat makes use of keepalive packets so that clients (that support them) will not timeout.\n"})
+   73 | 		if request.ScrollMetadataRequested {
+   74 | 			request.SendAbstract("")
    68 | 			return
    69 | 		}
    70 | 		var builder strings.Builder
    71 | 		fmt.Fprintf(&builder, "# Live Chat\nThis chat is heavily inspired by Mozz's chat, but the UI has been tailored for most gemini browsers. Message history is cleared every 24 hours. This chat makes use of keepalive packets so that clients (that support them) will not timeout.\n=> gemini://chat.mozz.us/ Mozz's Chat\n\n")
    72 | 		fmt.Fprintf(&builder, "=> /chat/%s/send Send Message\n=> titan://auragem.letz.dev/chat/%s/send Send Message via Titan\n\n", url.PathEscape(username), url.PathEscape(username))
   ... | ...
   176 | 		message = strings.ReplaceAll(message, "\n###", "")
   177 | 		message = strings.ReplaceAll(message, "\n##", "")
   178 | 		message = strings.ReplaceAll(message, "\n#", "")
   179 | 		message = strings.ReplaceAll(message, "\n-[", "")
   180 | 
-  181 | 		sendChan <- (ChatText{username, message, time.Now(), ""})
+  188 | 		if !request.ScrollMetadataRequested {
+  189 | 			sendChan <- (ChatText{username, message, time.Now(), ""})
+  190 | 		}
   182 | 		//return c.NoContent(gig.StatusRedirectTemporary, "gemini://auragem.letz.dev/chat/"+url.PathEscape(username))
   ... | ...
   178 | 		//return c.NoContent(gig.StatusRedirectTemporary, "gemini://auragem.letz.dev/chat/"+url.PathEscape(username))
-  183 | 		request.Redirect("gemini://auragem.letz.dev/chat/" + url.PathEscape(username))
+  192 | 		request.Redirect(request.Server.Scheme() + "auragem.letz.dev/chat/" + url.PathEscape(username))
   184 | 	}
   185 | 
   186 | 	s.AddRoute("/chat/:username/send", sendFunc)
   187 | 	s.AddUploadRoute("/chat/:username/send", sendFunc) // Titan Upload
   188 | 

gemini/devlog.go

   ... | ...
    -1 | package gemini
     0 | 
     1 | import (
+    4 | 	"time"
+    5 | 
     4 | 	utils "gitlab.com/clseibold/auragem_sis/gemini/utils"
     5 | 	sis "gitlab.com/clseibold/smallnetinformationservices"
     6 | )
     7 | 
     8 | func handleDevlog(s sis.ServerHandle) {
   ... | ...
     4 | 	utils "gitlab.com/clseibold/auragem_sis/gemini/utils"
     5 | 	sis "gitlab.com/clseibold/smallnetinformationservices"
     6 | )
     7 | 
     8 | func handleDevlog(s sis.ServerHandle) {
-    9 | 	/*g.AddRoute("/~krixano/gemlog/atom.xml", func(request sis.Request) {
-   10 | 		// c.NoContent(gig.StatusRedirectTemporary, "/devlog/atom.xml")
-   11 | 		request.TextWithMimetype("text/xml", generateAtomFrom("SIS/gemini/devlog/index.gmi", "gemini://auragem.letz.dev", "gemini://auragem.letz.dev/devlog", "Christian \"Krixano\" Seibold", "christian.seibold32@outlook.com"))
-   12 | 	})*/
-   13 | 
+   11 | 	publishDate, _ := time.ParseInLocation(time.RFC3339, "2021-04-24T00:00:00", time.Local)
    14 | 	s.AddRoute("/devlog/atom.xml", func(request sis.Request) {
   ... | ...
    10 | 	s.AddRoute("/devlog/atom.xml", func(request sis.Request) {
-   15 | 		request.TextWithMimetype("text/xml", utils.GenerateAtomFrom("SIS/auragem_gemini/devlog/index.gmi", "gemini://auragem.letz.dev", "gemini://auragem.letz.dev/devlog", "Christian \"Krixano\" Seibold", "krixano@protonmail.com"))
+   13 | 		atom, feedTitle, lastUpdate := utils.GenerateAtomFrom("SIS/auragem_gemini/devlog/index.gmi", "gemini://auragem.letz.dev", "gemini://auragem.letz.dev/devlog", "Christian Lee Seibold", "christian.seibold32@outlook.com")
+   14 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# " + feedTitle + "\n"})
+   15 | 		if request.ScrollMetadataRequested {
+   16 | 			request.SendAbstract("text/xml")
+   17 | 			return
+   18 | 		}
+   19 | 		request.TextWithMimetype("text/xml", atom)
    16 | 	})
    17 | }

gemini/gemini.go

   ... | ...
     9 | 	"unicode"
    10 | 	"unicode/utf8"
    11 | 
    12 | 	"github.com/spf13/cobra"
    13 | 
+   14 | 	"gitlab.com/clseibold/auragem_sis/gemini/ask"
    14 | 	"gitlab.com/clseibold/auragem_sis/gemini/chat"
    15 | 	"gitlab.com/clseibold/auragem_sis/gemini/music"
    16 | 	"gitlab.com/clseibold/auragem_sis/gemini/search"
   ... | ...
    12 | 	"gitlab.com/clseibold/auragem_sis/gemini/chat"
    13 | 	"gitlab.com/clseibold/auragem_sis/gemini/music"
    14 | 	"gitlab.com/clseibold/auragem_sis/gemini/search"
+   18 | 	"gitlab.com/clseibold/auragem_sis/gemini/starwars"
    17 | 	"gitlab.com/clseibold/auragem_sis/gemini/textgame"
    18 | 	"gitlab.com/clseibold/auragem_sis/gemini/textola"
    19 | 	"gitlab.com/clseibold/auragem_sis/gemini/texts"
   ... | ...
    15 | 	"gitlab.com/clseibold/auragem_sis/gemini/textgame"
    16 | 	"gitlab.com/clseibold/auragem_sis/gemini/textola"
    17 | 	"gitlab.com/clseibold/auragem_sis/gemini/texts"
-   20 | 	youtube "gitlab.com/clseibold/auragem_sis/gemini/youtube"
+   22 | 	"gitlab.com/clseibold/auragem_sis/gemini/youtube"
    21 | 	sis "gitlab.com/clseibold/smallnetinformationservices"
    22 | 	// "gitlab.com/clseibold/auragem_sis/lifekept"
    23 | 	/*"gitlab.com/clseibold/auragem_sis/ask"
    24 | 	"gitlab.com/clseibold/auragem_sis/music"
    25 | 	"gitlab.com/clseibold/auragem_sis/search"
   ... | ...
    30 | 	Short: "Start SIS",
    31 | 	Run:   RunServer,
    32 | }
    33 | 
    34 | func RunServer(cmd *cobra.Command, args []string) {
-   35 | 	//f, _ := os.Create("access.log")
-   36 | 	//gig.DefaultWriter = io.MultiWriter(f, os.Stdout)
-   37 | 
    38 | 	context, err := sis.InitSIS("./SIS/")
   ... | ...
    34 | 	context, err := sis.InitSIS("./SIS/")
+   38 | 	if err != nil {
+   39 | 		panic(err)
+   40 | 	}
    39 | 	context.AdminServer().BindAddress = "0.0.0.0"
    40 | 	context.AdminServer().Hostname = "auragem.letz.dev"
    41 | 	context.AdminServer().AddCertificate("auragem.pem")
    42 | 	context.SaveConfiguration()
    43 | 	context.GetPortListener("0.0.0.0", "1995").AddCertificate("auragem.letz.dev", "auragem.pem")
   ... | ...
    39 | 	context.AdminServer().BindAddress = "0.0.0.0"
    40 | 	context.AdminServer().Hostname = "auragem.letz.dev"
    41 | 	context.AdminServer().AddCertificate("auragem.pem")
    42 | 	context.SaveConfiguration()
    43 | 	context.GetPortListener("0.0.0.0", "1995").AddCertificate("auragem.letz.dev", "auragem.pem")
-   44 | 	if err != nil {
-   45 | 		panic(err)
-   46 | 	}
    47 | 
   ... | ...
    43 | 
-   48 | 	// ----- AuraGem Servers -----
+   47 | 	setupAuraGem(context)
+   48 | 	setupScholasticDiversity(context)
+   49 | 	setupScrollProtocol(context)
+   50 | 
+   51 | 	context.Start()
+   52 | }
    49 | 
   ... | ...
    45 | 
-   50 | 	geminiServer := context.AddServer(sis.Server{Type: sis.ServerType_Gemini, Name: "auragem_gemini", Hostname: "auragem.letz.dev"})
+   54 | func setupAuraGem(context *sis.SISContext) {
+   55 | 	geminiServer := context.AddServer(sis.Server{Type: sis.ServerType_Gemini, Name: "auragem_gemini", Hostname: "auragem.letz.dev", DefaultLanguage: "en"})
    51 | 	context.GetPortListener("0.0.0.0", "1965").AddCertificate("auragem.letz.dev", "auragem.pem")
    52 | 
    53 | 	geminiServer.AddDirectory("/*", "./")
    54 | 	geminiServer.AddFile("/.well-known/security.txt", "./security.txt")
    55 | 	geminiServer.AddProxyRoute("/nex/*", "$auragem_nex/*", '1')
   ... | ...
    51 | 	context.GetPortListener("0.0.0.0", "1965").AddCertificate("auragem.letz.dev", "auragem.pem")
    52 | 
    53 | 	geminiServer.AddDirectory("/*", "./")
    54 | 	geminiServer.AddFile("/.well-known/security.txt", "./security.txt")
    55 | 	geminiServer.AddProxyRoute("/nex/*", "$auragem_nex/*", '1')
-   56 | 
-   57 | 	// Guestbook via Titan
    58 | 	geminiServer.AddUploadRoute("/guestbook.gmi", handleGuestbook)
    59 | 
   ... | ...
    55 | 	geminiServer.AddUploadRoute("/guestbook.gmi", handleGuestbook)
    56 | 
+   63 | 	// Proxies
+   64 | 	youtube.HandleYoutube(geminiServer)
+   65 | 	handleGithub(geminiServer)
+   66 | 	// twitch.HandleTwitch(geminiServer)
+   67 | 
+   68 | 	// Services
    60 | 	handleDevlog(geminiServer)
   ... | ...
    56 | 	handleDevlog(geminiServer)
-   61 | 	youtube.HandleYoutube(geminiServer)
    62 | 	handleWeather(geminiServer)
   ... | ...
    58 | 	handleWeather(geminiServer)
-   63 | 	handleGithub(geminiServer)
    64 | 	textgame.HandleTextGame(geminiServer)
    65 | 	chat.HandleChat(geminiServer)
    66 | 	textola.HandleTextola(geminiServer)
    67 | 	music.HandleMusic(geminiServer)
    68 | 	search.HandleSearchEngine(geminiServer)
   ... | ...
    64 | 	textgame.HandleTextGame(geminiServer)
    65 | 	chat.HandleChat(geminiServer)
    66 | 	textola.HandleTextola(geminiServer)
    67 | 	music.HandleMusic(geminiServer)
    68 | 	search.HandleSearchEngine(geminiServer)
-   69 | 	// twitch.HandleTwitch(geminiServer)
-   70 | 	// ask.HandleAsk(geminiServer)
-   71 | 
-   72 | 	nexServer := context.AddServer(sis.Server{Type: sis.ServerType_Nex, Name: "auragem_nex", Hostname: "auragem.letz.dev"})
-   73 | 	nexServer.AddDirectory("/*", "./")
-   74 | 	nexServer.AddProxyRoute("/gemini/*", "$auragem_gemini/*", '1')
-   75 | 	nexServer.AddProxyRoute("/scholasticdiversity/*", "$scholasticdiversity_gemini/*", '1')
+   76 | 	starwars.HandleStarWars(geminiServer)
+   77 | 	ask.HandleAsk(geminiServer)
    76 | 
   ... | ...
    72 | 
-   77 | 	// ----- Scholastic Diversity stuff -----
-   78 | 	scholasticdiversity_gemini := context.AddServer(sis.Server{Type: sis.ServerType_Gemini, Name: "scholasticdiversity_gemini", Hostname: "scholasticdiversity.us.to"})
-   79 | 	context.GetPortListener("0.0.0.0", "1965").AddCertificate("scholasticdiversity.us.to", "scholasticdiversity.pem")
-   80 | 	scholasticdiversity_gemini.AddDirectory("/*", "./")
-   81 | 
-   82 | 	texts.HandleTexts(scholasticdiversity_gemini)
    83 | 	// Add "/texts/" redirect from auragem gemini server to scholastic diversity gemini server
    84 | 	geminiServer.AddRoute("/texts/*", func(request sis.Request) {
    85 | 		unescaped, err := url.PathUnescape(request.GlobString)
    86 | 		if err != nil {
    87 | 			request.TemporaryFailure(err.Error())
   ... | ...
    88 | 			return
    89 | 		}
    90 | 		request.Redirect("gemini://scholasticdiversity.us.to/scriptures/%s", unescaped)
    91 | 	})
    92 | 
-   93 | 	gopherServer := context.AddServer(sis.Server{Type: sis.ServerType_Gopher, Name: "gopher", Hostname: "auragem.letz.dev"})
-   94 | 	gopherServer.AddRoute("/", func(request sis.Request) {
-   95 | 		request.GophermapLine("i", "                             AuraGem Gopher Server", "/", "", "")
-   96 | 		request.GophermapLine("i", "", "/", "", "")
-   97 | 		request.GophermapLine("0", "About this server", "/about.txt", "", "")
-   98 | 		request.GophermapLine("i", "", "/", "", "")
-   99 | 		request.GophermapLine("7", "Search Geminispace", "/g/search/s/", "", "")
-  100 | 		request.GophermapLine("1", "Devlog", "/g/devlog/", "", "")
-  101 | 		request.GophermapLine("1", "Personal Log", "/g/~clseibold/", "", "")
-  102 | 		request.GophermapLine("0", "My Experience Within the Bitreich IRC", "/on_bitreich.txt", "", "")
-  103 | 		request.GophermapLine("0", "Freedom of Protocols Initiative", "/freedom_of_protocols_initiative.txt", "", "")
-  104 | 		request.GophermapLine("i", "", "/", "", "")
-  105 | 
-  106 | 		request.GophermapLine("i", "Services/Info", "/", "", "")
-  107 | 		request.GophermapLine("1", "AuraGem Public Radio", "/g/music/public_radio/", "", "")
-  108 | 		request.GophermapLine("1", "Search Engine Homepage", "/g/search/", "", "")
-  109 | 		request.GophermapLine("1", "YouTube Proxy", "/g/youtube/", "", "")
-  110 | 		request.GophermapLine("1", "Scholastic Diversity", "/scholasticdiversity/", "", "")
-  111 | 		request.GophermapLine("i", "", "/", "", "")
-  112 | 
-  113 | 		request.GophermapLine("i", "Software", "/", "", "")
-  114 | 		request.GophermapLine("1", "Misfin-Server", "/g/misfin-server/", "", "")
-  115 | 		request.GophermapLine("i", "", "/", "", "")
-  116 | 
-  117 | 		request.GophermapLine("i", "Links", "/", "", "")
-  118 | 		request.GophermapLine("1", "Gopher Starting Point", "/iOS/gopher", "forthworks.com", "70")
-  119 | 		request.GophermapLine("7", "Search Via Veronica-2", "/v2/vs", "gopher.floodgap.com", "70")
-  120 | 		request.GophermapLine("7", "Search Via Quarry", "/quarry", "gopher.icu", "70")
-  121 | 		request.GophermapLine("1", "Gopherpedia", "/", "gopherpedia.com", "70")
-  122 | 		request.GophermapLine("1", "Bongusta Phlog Aggregator", "/bongusta", "i-logout.cz", "70")
-  123 | 		request.GophermapLine("1", "Moku Pona Phlog Aggregator", "/moku-pona", "gopher.black", "70")
-  124 | 		request.GophermapLine("1", "Mare Tranquillitatis People's Circumlunar Zaibatsu", "/", "zaibatsu.circumlunar.space", "70")
-  125 | 		request.GophermapLine("1", "Cosmic Voyage", "/", "cosmic.voyage", "70")
-  126 | 		request.GophermapLine("1", "Mozz.Us", "/", "mozz.us", "70")
-  127 | 		request.GophermapLine("1", "Quux", "/", "gopher.quux.org", "70")
-  128 | 		request.GophermapLine("1", "Mateusz' gophre lair", "/", "gopher.viste.fr", "70")
-  129 | 		request.GophermapLine("i", "", "/", "", "")
+   89 | 	scrollServer := context.AddServer(sis.Server{Type: sis.ServerType_Scroll, Name: "auragem_scroll", Hostname: "auragem.letz.dev", DefaultLanguage: "en"})
+   90 | 	context.GetPortListener("0.0.0.0", "5699").AddCertificate("auragem.letz.dev", "auragem.pem")
+   91 | 	scrollServer.AddProxyRoute("/*", "$auragem_gemini/*", '1')
   130 | 
   ... | ...
   126 | 
-  131 | 		request.GophermapLine("i", "Sister Sites", "/", "", "")
-  132 | 		request.GophermapLine("h", "AuraGem Gemini Server", "URL:gemini://auragem.letz.dev", "", "")
-  133 | 		request.GophermapLine("h", "AuraGem Nex Server", "URL:nex://auragem.letz.dev", "", "")
-  134 | 		request.GophermapLine("h", "Scholastic Diversity Gemini Server", "URL:gemini://scholasticdiversity.us.to", "", "")
-  135 | 		request.GophermapLine("i", "", "/", "", "")
+   93 | 	nexServer := context.AddServer(sis.Server{Type: sis.ServerType_Nex, Name: "auragem_nex", Hostname: "auragem.letz.dev", DefaultLanguage: "en"})
+   94 | 	nexServer.AddDirectory("/*", "./")
+   95 | 	nexServer.AddProxyRoute("/gemini/*", "$auragem_gemini/*", '1')
+   96 | 	nexServer.AddProxyRoute("/scholasticdiversity/*", "$scholasticdiversity_gemini/*", '1')
+   97 | 	nexServer.AddProxyRoute("/scrollprotocol/*", "$scrollprotocol_gemini/*", '1')
   136 | 
   ... | ...
   132 | 
-  137 | 		request.GophermapLine("i", "Ways to Contact Me:", "", "", "")
-  138 | 		request.GophermapLine("i", "IRC: ##misfin on libera.chat", "/", "", "")
-  139 | 		request.GophermapLine("h", "Email", "URL:mailto:christian.seibold32@outlook.com", "", "")
-  140 | 		request.GophermapLine("h", "Misfin Mail", "URL:misfin://clseibold@auragem.letz.dev", "", "")
-  141 | 		request.GophermapLine("i", "", "/", "", "")
+   99 | 	spartanServer := context.AddServer(sis.Server{Type: sis.ServerType_Spartan, Name: "spartan", Hostname: "auragem.letz.dev", DefaultLanguage: "en"})
+  100 | 	spartanServer.AddFile("/", "./index.gmi")
+  101 | 	spartanServer.AddProxyRoute("/*", "$auragem_gemini/*", '1')
   142 | 
   ... | ...
   138 | 
-  143 | 		request.GophermapLine("i", "Powered By", "/", "", "")
-  144 | 		request.GophermapLine("i", "This server is powered by Smallnet Information Services (SIS):", "/", "", "")
-  145 | 		request.GophermapLine("h", "SIS Project", "URL:https://gitlab.com/clseibold/smallnetinformationservices/", "", "")
-  146 | 		request.GophermapLine("i", "Note that while SIS docs use the term \"proxying\" to describe requests of one server being handed off to another server of a different protocol, this is not proxying proper. The default document format of the protocol (index gemtext files, gophermaps, and Nex Listings) is translated when needed, but that and links are the only conversions that happen. This form of \"proxying\" all happens internally in the server software and *not* over the network or sockets. It is functionally equivalent to protocol proxying, but works slightly differently.", "/", "", "")
-  147 | 		request.GophermapLine("i", "", "/", "", "")
-  148 | 	})
+  103 | 	gopherServer := context.AddServer(sis.Server{Type: sis.ServerType_Gopher, Name: "gopher", Hostname: "auragem.letz.dev", DefaultLanguage: "en"})
   149 | 	gopherServer.AddDirectory("/*", "./")
   150 | 	gopherServer.AddProxyRoute("/g/*", "$auragem_gemini/*", '1')
   151 | 	gopherServer.AddProxyRoute("/scholasticdiversity/*", "$scholasticdiversity_gemini/*", '1')
   ... | ...
   147 | 	gopherServer.AddDirectory("/*", "./")
   148 | 	gopherServer.AddProxyRoute("/g/*", "$auragem_gemini/*", '1')
   149 | 	gopherServer.AddProxyRoute("/scholasticdiversity/*", "$scholasticdiversity_gemini/*", '1')
-  152 | 
-  153 | 	spartanServer := context.AddServer(sis.Server{Type: sis.ServerType_Spartan, Name: "spartan", Hostname: "auragem.letz.dev"})
-  154 | 	spartanServer.AddRoute("/", func(request sis.Request) {
-  155 | 		request.Gemini(`# AuraGem Spartan Server
+  107 | 	gopherServer.AddProxyRoute("/scrollprotocol/*", "$scrollprotocol_scroll/*", '1')
+  108 | }
   156 | 
   ... | ...
   152 | 
-  157 | =: /g/search/s/ 🔍 Search
-  158 | => /g/devlog/ Devlog
-  159 | => /g/~clseibold/ Personal Log
-  160 | => /g/music/public_radio/ AuraGem Public Radio
+  110 | func setupScholasticDiversity(context *sis.SISContext) {
+  111 | 	scholasticdiversity_gemini := context.AddServer(sis.Server{Type: sis.ServerType_Gemini, Name: "scholasticdiversity_gemini", Hostname: "scholasticdiversity.us.to", DefaultLanguage: "en"})
+  112 | 	context.GetPortListener("0.0.0.0", "1965").AddCertificate("scholasticdiversity.us.to", "scholasticdiversity.pem")
+  113 | 	scholasticdiversity_gemini.AddDirectory("/*", "./")
   161 | 
   ... | ...
   157 | 
-  162 | ## Software
-  163 | => /g/misfin-server/ Misfin-Server
+  115 | 	texts.HandleTexts(scholasticdiversity_gemini)
   164 | 
   ... | ...
   160 | 
-  165 | ## Other
-  166 | => /g/search/ Search Engine Homepage
-  167 | => /g/youtube/ YouTube Proxy
+  117 | 	scholasticdiversity_scroll := context.AddServer(sis.Server{Type: sis.ServerType_Scroll, Name: "scholasticdiversity_scroll", Hostname: "scholasticdiversity.us.to", DefaultLanguage: "en"})
+  118 | 	context.GetPortListener("0.0.0.0", "5699").AddCertificate("scholasticdiversity.us.to", "scholasticdiversity.pem")
+  119 | 	scholasticdiversity_scroll.AddProxyRoute("/*", "$scholasticdiversity_gemini/*", '1')
+  120 | }
   168 | 
   ... | ...
   164 | 
-  169 | ## Sister Sites
-  170 | => gemini://auragem.letz.dev/ AuraGem Gemini Server
-  171 | => nex://auragem.letz.dev/ AuraGem Nex Server
-  172 | => gemini://scholasticdiversity.us.to/ Scholastic Diversity
+  122 | func setupScrollProtocol(context *sis.SISContext) {
+  123 | 	scrollProtocol_scroll := context.AddServer(sis.Server{Type: sis.ServerType_Scroll, Name: "scrollprotocol_scroll", Hostname: "scrollprotocol.us.to", DefaultLanguage: "en"})
+  124 | 	context.GetPortListener("0.0.0.0", "5699").AddCertificate("scrollprotocol.us.to", "scrollprotocol.pem")
+  125 | 	scrollProtocol_scroll.AddDirectory("/*", "/")
   173 | 
   ... | ...
   169 | 
-  174 | ## Powered By
-  175 | This server is powered by Smallnet Information Services (SIS):
-  176 | => https://gitlab.com/clseibold/smallnetinformationservices/ SIS Project
-  177 | `)
-  178 | 	})
-  179 | 	spartanServer.AddProxyRoute("/g/*", "$auragem_gemini/*", '1')
-  180 | 
-  181 | 	context.Start()
+  127 | 	scrollProtocol_gemini := context.AddServer(sis.Server{Type: sis.ServerType_Gemini, Name: "scrollprotocol_gemini", Hostname: "scrollprotocol.us.to", DefaultLanguage: "en"})
+  128 | 	context.GetPortListener("0.0.0.0", "1965").AddCertificate("scrollprotocol.us.to", "scrollprotocol.pem")
+  129 | 	scrollProtocol_gemini.AddProxyRoute("/*", "$scrollprotocol_scroll/*", '1')
   182 | }
   183 | 
   184 | func handleGuestbook(request sis.Request) {
   185 | 	guestbookPrefix := `# AuraGem Guestbook
   186 | 

gemini/music/music.go

   ... | ...
     6 | 	"database/sql"
     7 | 	"fmt"
     8 | 	"net/url"
     9 | 	"os"
    10 | 	"path/filepath"
+   11 | 	"strconv"
    11 | 	"strings"
    12 | 	"time"
   ... | ...
     8 | 	"strings"
     9 | 	"time"
+   14 | 
+   15 | 	_ "embed"
    13 | 
    14 | 	"github.com/dhowden/tag"
    15 | 	"gitlab.com/clseibold/auragem_sis/config"
    16 | 	"gitlab.com/clseibold/auragem_sis/db"
    17 | 	sis "gitlab.com/clseibold/smallnetinformationservices"
   ... | ...
    36 | 
    37 | => /music/register Register Page
    38 | => /music/quota How the Quota System Works
    39 | `
    40 | 
+   44 | //go:embed music_index.gmi
+   45 | var index_gmi string
+   46 | 
+   47 | //go:embed music_index_scroll.scroll
+   48 | var index_scroll string
+   49 | 
    41 | func HandleMusic(s sis.ServerHandle) {
   ... | ...
    37 | func HandleMusic(s sis.ServerHandle) {
+   51 | 	publishDate, _ := time.ParseInLocation(time.RFC3339, "2022-07-15T00:00:00", time.Local)
+   52 | 	updateDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-19T13:51:00", time.Local)
    42 | 	// ffmpeg (goav) Stuff: Register all formats and codecs
    43 | 	// avformat.AvRegisterAll()
    44 | 	// avcodec.AvcodecRegisterAll()
    45 | 
    46 | 	// Database Connection
   ... | ...
    56 | 	//defer throttlePool.ReleasePool()
    57 | 
    58 | 	handleRadioService(s, conn)
    59 | 
    60 | 	s.AddRoute("/music/", func(request sis.Request) {
+   72 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: publishDate, UpdateDate: updateDate, Language: "en", Abstract: "# AuraGem Music\nA music service where you can upload a limited number of mp3s over Titan and listen to your private music library over Scroll/Gemini/Spartan. Stream individual songs or full albums, or use the \"Shuffled Stream\" feature that acts like a private radio of random songs from your library.\n"})
+   73 | 		if request.ScrollMetadataRequested {
+   74 | 			request.SendAbstract("")
+   75 | 			return
+   76 | 		}
+   77 | 
    61 | 		cert := request.UserCert
    62 | 		if cert == nil {
   ... | ...
    58 | 		cert := request.UserCert
    59 | 		if cert == nil {
-   63 | 			request.Gemini(`# AuraGem Music
-   64 | 
-   65 | Welcome to the new AuraGem Music Service, where you can upload a limited number of mp3s over Titan and listen to your private music library over Gemini.
-   66 | 
-   67 | Note: Remember to make sure your certificate is selected on this page if you've already registered.
-   68 | 
-   69 | In order to register, create and enable a client certificate and then head over to the Register Cert page:
-   70 | 
-   71 | => /music/register Register Cert
-   72 | => /music/quota How the Quota System Works
-   73 | => gemini://transjovian.org/titan About Titan
-   74 | 
-   75 | => /music/public_radio/ Public Radio
-   76 | 
-   77 | ## Features
-   78 | 
-   79 | * Upload MP3s
-   80 | * Music organized by Artist and Album
-   81 | * Stream a full album or a particular artist's songs
-   82 | * "Shuffled stream" - infinite stream of random songs from user's private library
-   83 | * Delete songs from library
-   84 | 
-   85 | ## The Legality of AuraGem Music
-   86 | 
-   87 | AuraGem Music is currently not a distribution platform. Instead, it hosts a user's personal collection of music for their own consumption. Therefore, the music a user uploads is only visible to that person and will not be distributed to others. Uploading music that the user has bought is legitimate. However, the user is solely responsible for any pirated content that they have uploaded.
-   88 | 	`)
+   80 | 			if request.Type == sis.ServerType_Gemini {
+   81 | 				request.Gemini(index_gmi)
+   82 | 			} else if request.Type == sis.ServerType_Scroll {
+   83 | 				request.Scroll(index_scroll)
+   84 | 			} else if request.Type == sis.ServerType_Spartan {
+   85 | 				request.TemporaryFailure("Service not available over Spartan. Please visit over Gemini or Scroll.")
+   86 | 			} else if request.Type == sis.ServerType_Nex {
+   87 | 				request.TemporaryFailure("Service not available over Nex. Please visit over Gemini or Scroll.")
+   88 | 			} else if request.Type == sis.ServerType_Gopher {
+   89 | 				request.TemporaryFailure("Service not available over Gopher. Please visit over Gemini or Scroll.")
+   90 | 			}
    89 | 			return
    90 | 		} else {
    91 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
    92 | 			if !isRegistered {
    93 | 				request.Gemini(registerNotification)
   ... | ...
    91 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
    92 | 			if !isRegistered {
    93 | 				request.Gemini(registerNotification)
    94 | 				return
    95 | 			} else {
+   98 | 				request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: publishDate, UpdateDate: updateDate, Language: "en", Abstract: "# AuraGem Music - " + user.Username + "\n"})
+   99 | 				if request.ScrollMetadataRequested {
+  100 | 					request.SendAbstract("")
+  101 | 					return
+  102 | 				}
    96 | 				getUserDashboard(request, conn, user)
    97 | 				return
    98 | 			}
    99 | 		}
   100 | 	})
   ... | ...
    98 | 			}
    99 | 		}
   100 | 	})
   101 | 
   102 | 	s.AddRoute("/music/quota", func(request sis.Request) {
+  110 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: publishDate, UpdateDate: updateDate, Language: "en", Abstract: "# AuraGem Music - How the Quota System Works\nDescribes the quota system.\n"})
+  111 | 		if request.ScrollMetadataRequested {
+  112 | 			request.SendAbstract("")
+  113 | 			return
+  114 | 		}
   103 | 		template := `# AuraGem Music - How the Quota System Works
   104 | 
   105 | Each song adds to your quota 1 divided by the number of people who have uploaded that same song. If 3 people have uploaded the 3 same songs, only 1 song gets added to each person's quota (3 songs / 3 uploaders). However, if you are the only person who has uploaded a song, then 1 will be added to your quota (1 song / 1 uploader). The maximum quota that each user has is currently set to %d.
   106 | 
   107 | Note that the below calculations assume an average mp3 file size of 7.78 MB per song:
   ... | ...
   114 | `
   115 | 		request.Gemini(fmt.Sprintf(template, userSongQuota))
   116 | 	})
   117 | 
   118 | 	s.AddRoute("/music/about", func(request sis.Request) {
+  131 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: publishDate, UpdateDate: updateDate, Language: "en", Abstract: "# About AuraGem Music\n"})
+  132 | 		if request.ScrollMetadataRequested {
+  133 | 			request.SendAbstract("")
+  134 | 			return
+  135 | 		}
   119 | 		template := `# About AuraGem Music
   120 | 
   121 | This is a gemini capsule that allows users to upload their own mp3s (or oggs) to thier own private library (via Titan) and stream/download them via Gemini. A user's library is completely private. Nobody else can see the library, and songs are only streamable by the user that uploaded that song.
   122 | 
   123 | In order to save space, AuraGem Music deduplicates songs by taking the hash of the audio contents. This is only done when the songs of multiple users are the *exact* same, and is done on upload of a song. Deduplication also has the benefit of lowering a user's quota. If the exact same song is in multiple users' libraries, the sum of the quotas for that song for each user adds up to 1. This is because the song is only stored once on the server. The quota is spread evenly between each of the users that have uploaded the song. The more users, the less quota each user has for that one song. You can find out more about how the quota system works below:
   ... | ...
   145 | 				}
   146 | 				openFile, err := os.Open(filepath.Join(musicDirectory, file.Filename))
   147 | 				if err != nil {
   148 | 					panic(err)
   149 | 				}
+  167 | 				request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# Random Music File\n"})
+  168 | 				request.SetNoLanguage()
+  169 | 				if request.ScrollMetadataRequested {
+  170 | 					request.SendAbstract("audio/mpeg")
+  171 | 					return
+  172 | 				}
   150 | 				request.Stream("audio/mpeg", openFile) // TODO: Use mimetype from db
   151 | 				openFile.Close()
   152 | 				return
   153 | 			}
   154 | 		}
   ... | ...
   157 | 	s.AddRoute("/music/upload", func(request sis.Request) {
   158 | 		if request.UserCert == nil {
   159 | 			request.RequestClientCert("Please enable a certificate.")
   160 | 			return
   161 | 		}
-  162 | 		titanHost := "titan://auragem.letz.dev/"
-  163 | 		if request.Hostname() == "192.168.0.60" {
-  164 | 			titanHost = "titan://192.168.0.60/"
-  165 | 		} else if request.Hostname() == "auragem.ddns.net" {
-  166 | 			titanHost = "titan://auragem.ddns.net/"
+  185 | 		uploadLink := ""
+  186 | 		uploadMethod := ""
+  187 | 		if request.Type == sis.ServerType_Gemini || request.Type == sis.ServerType_Scroll {
+  188 | 			titanHost := "titan://auragem.letz.dev/"
+  189 | 			if request.Hostname() == "192.168.0.60" {
+  190 | 				titanHost = "titan://192.168.0.60/"
+  191 | 			} else if request.Hostname() == "auragem.ddns.net" {
+  192 | 				titanHost = "titan://auragem.ddns.net/"
+  193 | 			}
+  194 | 			uploadLink = "=> " + titanHost + "/music/upload"
+  195 | 			uploadMethod = "Titan"
   167 | 		}
   168 | 
   ... | ...
   164 | 		}
   165 | 
-  169 | 		request.Gemini(fmt.Sprintf(`# Upload File with Titan
+  198 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# Upload File with " + uploadMethod + "\n"})
+  199 | 		if request.ScrollMetadataRequested {
+  200 | 			request.SendAbstract("")
+  201 | 			return
+  202 | 		}
+  203 | 
+  204 | 		request.Gemini(fmt.Sprintf(`# Upload File with %s
+  205 | 
+  206 | Upload an mp3 music file to this page with %s. It will then be automatically added to your library. Please make sure that the metadata tags on the mp3 are correct and filled in before uploading, especially the Title, AlbumArtist, and Album tags.
   170 | 
   ... | ...
   166 | 
-  171 | Upload an mp3 music file to this page with Titan. It will then be automatically added to your library. Please make sure that the metadata tags on the mp3 are correct and filled in before uploading, especially the Title, AlbumArtist, and Album tags.
+  208 | %s Upload
   172 | 
   ... | ...
   168 | 
-  173 | => %s/music/upload Upload
   174 | => gemini://transjovian.org/titan About Titan
   ... | ...
   170 | => gemini://transjovian.org/titan About Titan
-  175 | `, titanHost))
+  211 | `, uploadMethod, uploadMethod, uploadLink))
   176 | 	})
   177 | 
   178 | 	s.AddUploadRoute("/music/upload", func(request sis.Request) {
   179 | 		cert := request.UserCert
   180 | 		if cert == nil {
   ... | ...
   185 | 			if !isRegistered {
   186 | 				//return c.Gemini(registerNotification)
   187 | 				request.TemporaryFailure("You must be registered first before you can upload.")
   188 | 				return
   189 | 			} else {
-  190 | 				file, read_err := request.GetUploadData()
-  191 | 				if read_err != nil {
-  192 | 					return //read_err
-  193 | 				}
-  194 | 
-  195 | 				// First, check mimetype
+  226 | 				// First, check mimetype if using Titan
   196 | 				mimetype := request.DataMime
   ... | ...
   192 | 				mimetype := request.DataMime
-  197 | 				if !strings.HasPrefix(mimetype, "audio/mpeg") && !strings.HasPrefix(mimetype, "audio/mp3") {
+  228 | 				if (request.Type == sis.ServerType_Gemini || request.Type == sis.ServerType_Scroll) && !strings.HasPrefix(mimetype, "audio/mpeg") && !strings.HasPrefix(mimetype, "audio/mp3") {
   198 | 					request.TemporaryFailure("Only mp3 audio files are allowed.")
   ... | ...
   194 | 					request.TemporaryFailure("Only mp3 audio files are allowed.")
+  230 | 					return
+  231 | 				} else if !(request.Type == sis.ServerType_Gemini || request.Type == sis.ServerType_Scroll) {
+  232 | 					request.TemporaryFailure("Upload only supported via Titan.")
   199 | 					return
   200 | 				}
   201 | 
   ... | ...
   197 | 					return
   198 | 				}
   199 | 
+  236 | 				// Check the size
+  237 | 				if request.DataSize > 15*1024*1024 { // Max of 15 MB
+  238 | 					request.TemporaryFailure("File too large. Max size is 15 MiB.")
+  239 | 					return
+  240 | 				}
+  241 | 
+  242 | 				file, read_err := request.GetUploadData()
+  243 | 				if read_err != nil {
+  244 | 					return //read_err
+  245 | 				}
+  246 | 
   202 | 				// TODO: Check if data folder is mounted properly before doing anything?
   203 | 
   204 | 				// Then, get hash of file
   205 | 				hash, _ := tag.Sum(bytes.NewReader(file))
   206 | 				fmt.Printf("Hash: %s\n", hash)
   ... | ...
   277 | 
   278 | 					// Add to user library
   279 | 					AddFileToUserLibrary(conn, musicFile.Id, user.Id, false)
   280 | 				}
   281 | 
-  282 | 				request.Redirect("gemini://%s/music/", request.Hostname())
+  327 | 				request.Redirect("%s%s/music/", request.Server.Scheme(), request.Hostname())
   283 | 				return
   284 | 				//return c.NoContent(gig.StatusRedirectTemporary, "gemini://%s/music/", c.URL().Host)
   285 | 			}
   286 | 		}
   287 | 	})
   ... | ...
   305 | 				if !exists {
   306 | 					request.NotFound("File not found.")
   307 | 					return
   308 | 				}
   309 | 
+  355 | 				abstract := "# " + file.Title + "\n"
+  356 | 				if file.Album != "" {
+  357 | 					abstract += "Album: " + file.Album + "\n"
+  358 | 				}
+  359 | 				if file.Tracknumber != 0 {
+  360 | 					abstract += "Track: " + strconv.Itoa(file.Tracknumber) + "\n"
+  361 | 				}
+  362 | 				if file.Discnumber != 0 && file.Discnumber != 1 {
+  363 | 					abstract += "Disk: " + strconv.Itoa(file.Discnumber) + "\n"
+  364 | 				}
+  365 | 				if file.Albumartist != "" {
+  366 | 					abstract += "Album Artist: " + file.Albumartist + "\n"
+  367 | 				}
+  368 | 				if file.Composer != "" {
+  369 | 					abstract += "Composer: " + file.Composer + "\n"
+  370 | 				}
+  371 | 				if file.Genre != "" {
+  372 | 					abstract += "Genre: " + file.Genre + "\n"
+  373 | 				}
+  374 | 				if file.Releaseyear != 0 {
+  375 | 					abstract += "Release Year: " + strconv.Itoa(file.Releaseyear) + "\n"
+  376 | 				}
+  377 | 				abstract += "Kbps: " + strconv.Itoa(int(file.CbrKbps)) + "\n"
+  378 | 				if file.Attribution != "" {
+  379 | 					abstract += "\nAttribution:\n" + file.Attribution + "\n"
+  380 | 				}
+  381 | 				request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: file.Artist, Abstract: abstract})
+  382 | 				request.SetNoLanguage()
+  383 | 				if request.ScrollMetadataRequested {
+  384 | 					request.SendAbstract("audio/mpeg")
+  385 | 					return
+  386 | 				}
+  387 | 
   310 | 				StreamFile(request, file)
   311 | 				return
   312 | 				//q := `SELECT COUNT(*) FROM uploads INNER JOIN library ON uploads.fileid=library.id WHERE uploads.memberid=? AND library.filename=?`
   313 | 			}
   314 | 		}
   ... | ...
   325 | 				request.Gemini(registerNotification)
   326 | 				return
   327 | 			} else {
   328 | 				albums := GetAlbumsInUserLibrary(conn, user.Id)
   329 | 
+  408 | 				request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: user.Date_joined, Abstract: "# AuraGem Music - " + user.Username + "\n## Albums\n"})
+  409 | 				if request.ScrollMetadataRequested {
+  410 | 					request.SendAbstract("")
+  411 | 					return
+  412 | 				}
+  413 | 
   330 | 				var builder strings.Builder
   331 | 				for _, album := range albums {
   332 | 					fmt.Fprintf(&builder, "=> /music/artist/%s/%s %s - %s\n", url.PathEscape(album.Albumartist), url.PathEscape(album.Album), album.Album, album.Albumartist)
   333 | 				}
   334 | 
   ... | ...
   354 | 			if !isRegistered {
   355 | 				request.Gemini(registerNotification)
   356 | 				return
   357 | 			} else {
   358 | 				artists := GetArtistsInUserLibrary(conn, user.Id)
+  443 | 
+  444 | 				request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: user.Date_joined, Abstract: "# AuraGem Music - " + user.Username + "\n## Artists\n"})
+  445 | 				if request.ScrollMetadataRequested {
+  446 | 					request.SendAbstract("")
+  447 | 					return
+  448 | 				}
   359 | 
   360 | 				var builder strings.Builder
   361 | 				for _, artist := range artists {
   362 | 					fmt.Fprintf(&builder, "=> /music/artist/%s %s\n", url.PathEscape(artist), artist)
   363 | 				}
   ... | ...
   441 | 			user, isRegistered := GetUser(conn, request.UserCertHash_Gemini())
   442 | 			if !isRegistered {
   443 | 				request.Gemini(registerNotification)
   444 | 				return
   445 | 			} else {
+  536 | 				request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# AuraGem Music Shuffled Stream - " + user.Username + "\n"})
+  537 | 				if request.ScrollMetadataRequested {
+  538 | 					request.SendAbstract("audio/mpeg")
+  539 | 					return
+  540 | 				}
+  541 | 
   446 | 				StreamRandomFiles(request, conn, user)
   447 | 				return
   448 | 			}
   449 | 		}
   450 | 	})
   ... | ...
   597 | 	err := row.Scan(&numRows)
   598 | 	if err != nil {
   599 | 		panic(err)
   600 | 	}
   601 | 	if numRows < 1 {
+  698 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# AuraGem Music - Register User " + username + "\n"})
+  699 | 		if request.ScrollMetadataRequested {
+  700 | 			request.SendAbstract("")
+  701 | 			return
+  702 | 		}
+  703 | 
   602 | 		// Certificate doesn't already exist - Register User
   603 | 		zone, _ := time.Now().Zone()
   604 | 		conn.ExecContext(context.Background(), "INSERT INTO members (certificate, username, language, timezone, is_staff, is_active, date_joined) VALUES (?, ?, ?, ?, ?, ?, ?)", certHash, username, "en-US", zone, false, true, time.Now())
   605 | 
   606 | 		user, _ := GetUser(conn, certHash)
   ... | ...
   659 | 
   660 | 	request.Gemini(fmt.Sprintf(template, user.Username, user.QuotaCount, userSongQuota, user.QuotaCount/float64(userSongQuota)*100, builder.String()))
   661 | }
   662 | 
   663 | func artistAlbums(request sis.Request, conn *sql.DB, user MusicUser, artist string) {
+  766 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# AuraGem Music - " + user.Username + "\n## Artist Albums: " + artist + "\n"})
+  767 | 	if request.ScrollMetadataRequested {
+  768 | 		request.SendAbstract("")
+  769 | 		return
+  770 | 	}
+  771 | 
   664 | 	albums := GetAlbumsFromArtistInUserLibrary(conn, user.Id, artist)
   665 | 
   666 | 	var builder strings.Builder
   667 | 	for _, album := range albums {
   668 | 		fmt.Fprintf(&builder, "=> /music/artist/%s/%s %s\n", url.PathEscape(album.Albumartist), url.PathEscape(album.Album), album.Album)
   ... | ...
   678 | %s
   679 | `, user.Username, artist, url.PathEscape(artist), builder.String()))
   680 | }
   681 | 
   682 | func albumSongs(request sis.Request, conn *sql.DB, user MusicUser, artist string, album string) {
+  791 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# AuraGem Music - " + user.Username + "\n## Album: " + album + " by " + artist + "\n"})
+  792 | 	if request.ScrollMetadataRequested {
+  793 | 		request.SendAbstract("")
+  794 | 		return
+  795 | 	}
+  796 | 
   683 | 	musicFiles := GetFilesFromAlbumInUserLibrary(conn, user.Id, artist, album)
   684 | 
   685 | 	var builder strings.Builder
   686 | 	var albumartist string
   687 | 	for i, file := range musicFiles {
   ... | ...
   705 | %s
   706 | `, user.Username, album, artist, url.PathEscape(albumartist), albumartist, url.PathEscape(albumartist), url.PathEscape(album), builder.String()))
   707 | }
   708 | 
   709 | func adminPage(request sis.Request, conn *sql.DB, user MusicUser) {
+  824 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# AuraGem Music - Admin\n"})
+  825 | 	if request.ScrollMetadataRequested {
+  826 | 		request.SendAbstract("")
+  827 | 		return
+  828 | 	}
+  829 | 
   710 | 	var builder strings.Builder
   711 | 	radioGenres := Admin_RadioGenreCounts(conn)
   712 | 	for _, genre := range radioGenres {
   713 | 		fmt.Fprintf(&builder, "=> /music/admin/genre?%s %s (%d)\n", url.QueryEscape(genre.Name), genre.Name, genre.Count)
   714 | 	}
   ... | ...
   736 | 
   737 | 	request.Gemini(fmt.Sprintf(template, globalQuotaCount, globalSongQuota, globalQuotaCount/globalSongQuota*100, avgUserQuotaCount, userSongQuota, avgUserQuotaCount/float64(userSongQuota)*100, userCount, artistCount, albumCount, builder.String()))
   738 | }
   739 | 
   740 | func adminGenrePage(request sis.Request, conn *sql.DB, user MusicUser, genre_string string) {
+  861 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# AuraGem Music - Admin: Genre" + genre_string + "\n"})
+  862 | 	if request.ScrollMetadataRequested {
+  863 | 		request.SendAbstract("")
+  864 | 		return
+  865 | 	}
+  866 | 
   741 | 	songsInGenre := GetFilesInGenre(conn, genre_string)
   742 | 	var builder strings.Builder
   743 | 	fmt.Fprintf(&builder, "```\n")
   744 | 	for _, song := range songsInGenre {
   745 | 		fmt.Fprintf(&builder, "%-25s %-25s\n", song.Title, song.Artist)
   ... | ...
   752 | `, genre_string, builder.String()))
   753 | }
   754 | 
   755 | // Streams all songs in album in one streams
   756 | func streamAlbumSongs(request sis.Request, conn *sql.DB, user MusicUser, artist string, album string) {
+  883 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# Stream Album " + album + " by " + artist + "\n"})
+  884 | 	if request.ScrollMetadataRequested {
+  885 | 		request.SendAbstract("")
+  886 | 		return
+  887 | 	}
+  888 | 
   757 | 	musicFiles := GetFilesFromAlbumInUserLibrary(conn, user.Id, artist, album)
   758 | 	fmt.Printf("Music Files: %v\n", musicFiles)
   759 | 
   760 | 	/*filenames := make([]string, 0, len(musicFiles))
   761 | 	for _, file := range musicFiles {
   ... | ...
   764 | 
   765 | 	StreamMultipleFiles(request, musicFiles)
   766 | }
   767 | 
   768 | func streamArtistSongs(request sis.Request, conn *sql.DB, user MusicUser, artist string) {
+  901 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# Stream Songs by " + artist + "\n"})
+  902 | 	if request.ScrollMetadataRequested {
+  903 | 		request.SendAbstract("")
+  904 | 		return
+  905 | 	}
+  906 | 
   769 | 	musicFiles := GetFilesFromArtistInUserLibrary(conn, user.Id, artist)
   770 | 
   771 | 	/*filenames := make([]string, 0, len(musicFiles))
   772 | 	for _, file := range musicFiles {
   773 | 		filenames = append(filenames, file.Filename)
   ... | ...
   777 | }
   778 | 
   779 | // ----- Manage Library Functions -----
   780 | 
   781 | func manageLibrary(request sis.Request, user MusicUser) {
+  920 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: user.Date_joined, Abstract: "# Manage Library - " + user.Username + "\n"})
+  921 | 	if request.ScrollMetadataRequested {
+  922 | 		request.SendAbstract("")
+  923 | 		return
+  924 | 	}
+  925 | 
   782 | 	request.Gemini(fmt.Sprintf(`# Manage Library - %s
   783 | 
   784 | Choose what you want to do. These links will direct you to pages that will allow you to select songs out of your library for the action you selected.
   785 | 
   786 | => /music/ Dashboard
   ... | ...
   790 | 
   791 | `, user.Username))
   792 | }
   793 | 
   794 | func manageLibrary_deleteSelection(request sis.Request, conn *sql.DB, user MusicUser) {
+  939 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# Manage Library: Delete Selection - " + user.Username + "\n"})
+  940 | 	if request.ScrollMetadataRequested {
+  941 | 		request.SendAbstract("")
+  942 | 		return
+  943 | 	}
+  944 | 
   795 | 	// TODO: Add Pagination
   796 | 	musicFiles := GetFilesInUserLibrary(conn, user.Id)
   797 | 
   798 | 	var builder strings.Builder
   799 | 	for _, file := range musicFiles {
   ... | ...
   822 | 
   823 | func manageLibrary_deleteFile(request sis.Request, conn *sql.DB, user MusicUser, hash string) {
   824 | 	file, exists := GetFileInUserLibrary_hash(conn, hash, user.Id)
   825 | 	if !exists {
   826 | 		request.TemporaryFailure("File not in user library.")
+  977 | 		return
+  978 | 	}
+  979 | 
+  980 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Abstract: "# Manage Library: Delete File - " + user.Username + "\nDelete " + file.Title + " by " + file.Artist + " (" + file.Album + ")\n"})
+  981 | 	if request.ScrollMetadataRequested {
+  982 | 		request.SendAbstract("")
   827 | 		return
   828 | 	}
   829 | 
   830 | 	RemoveFileFromUserLibrary(conn, file.Id, user.Id)
   831 | 	request.Redirect("/music/manage/delete")

gemini/music/music_index.gmi (created)

+    1 | # AuraGem Music
+    2 | 
+    3 | Welcome to the new AuraGem Music Service, where you can upload a limited number of mp3s over Titan and listen to your private music library over Gemini. The service is also available over the Scroll Protocol.
+    4 | 
+    5 | Note: Remember to make sure your certificate is selected on this page if you've already registered.
+    6 | 
+    7 | In order to register, create and enable a client certificate and then head over to the Register Cert page:
+    8 | 
+    9 | => /music/register Register Cert
+   10 | => /music/quota How the Quota System Works
+   11 | => gemini://transjovian.org/titan About Titan
+   12 | 
+   13 | => /music/public_radio/ Public Radio
+   14 | 
+   15 | ## Features
+   16 | 
+   17 | * Upload MP3s
+   18 | * Music organized by Artist and Album
+   19 | * Stream a full album or a particular artist's songs
+   20 | * "Shuffled stream" - infinite stream of random songs from user's private library
+   21 | * Delete songs from library
+   22 | 
+   23 | ## The Legality of AuraGem Music
+   24 | 
+   25 | AuraGem Music is currently not a distribution platform. Instead, it hosts a user's personal collection of music for their own consumption. Therefore, the music a user uploads is only visible to that person and will not be distributed to others. Uploading music that the user has bought is legitimate. However, the user is solely responsible for any pirated content that they have uploaded.
+   26 | 
+   27 | => scroll://auragem.letz.dev/music/ Via Scroll

gemini/music/music_index_scroll.scroll (created)

+    1 | # AuraGem Music
+    2 | 
+    3 | Welcome to the new AuraGem Music Service, where you can upload a limited number of mp3s over Titan and listen to your private music library over Scroll. This service is also available over Gemini+Titan.
+    4 | 
+    5 | Note: Remember to make sure your certificate is selected on this page if you've already registered.
+    6 | 
+    7 | In order to register, create and enable a client certificate and then head over to the Register Cert page:
+    8 | 
+    9 | => /music/register Register Cert
+   10 | => /music/quota How the Quota System Works
+   11 | => gemini://transjovian.org/titan About Titan
+   12 | 
+   13 | => /music/public_radio/ Public Radio
+   14 | 
+   15 | ## Features
+   16 | 
+   17 | * Upload MP3s
+   18 | * Music organized by Artist and Album
+   19 | * Stream a full album or a particular artist's songs
+   20 | * "Shuffled stream" - infinite stream of random songs from user's private library
+   21 | * Delete songs from library
+   22 | 
+   23 | ## The Legality of AuraGem Music
+   24 | 
+   25 | AuraGem Music is currently not a distribution platform. Instead, it hosts a user's personal collection of music for their own consumption. Therefore, the music a user uploads is only visible to that person and will not be distributed to others. Uploading music that the user has bought is legitimate. However, the user is solely responsible for any pirated content that they have uploaded.
+   26 | 
+   27 | => gemini://auragem.letz.dev/music/ Via Gemini

gemini/music/radio.go

   ... | ...
   164 | 	})
   165 | 	s.AddRoute("/music/public_radio/", func(request sis.Request) {
   166 | 		request.Redirect("/music/public_radio")
   167 | 	})
   168 | 	s.AddRoute("/music/public_radio", func(request sis.Request) {
+  169 | 		creationDate, _ := time.ParseInLocation(time.RFC3339, "2023-09-19T00:00:00", time.Local)
+  170 | 		updateDate, _ := time.ParseInLocation(time.RFC3339, "2023-11-30T00:00:00", time.Local)
+  171 | 		abstract := `# AuraGem Music: Public Radio
+  172 | 
+  173 | This is AuraGem Music's public radio that plays public domain and royalty free music. All music is collected from sources like the Free Music Archive, archive.org, and Chosic, and stored on my server. This radio does not proxy from the web, unlike other radios over on Gopherspace.
+  174 | `
+  175 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: creationDate.UTC(), UpdateDate: updateDate.UTC(), Language: "en", Abstract: abstract})
+  176 | 		if request.ScrollMetadataRequested {
+  177 | 			request.SendAbstract("")
+  178 | 			return
+  179 | 		}
+  180 | 
   169 | 		var builder strings.Builder
   170 | 		for _, station := range radioStations {
   171 | 			fmt.Fprintf(&builder, "=> /music/public_radio/%s/ %s Station\n", url.PathEscape(station.Name), station.Name)
   172 | 		}
   173 | 		request.Gemini(fmt.Sprintf(`# AuraGem Music: Public Radio

gemini/music/stations.go

   ... | ...
    27 | 
    28 | 	go radioService(conn, radioBuffer, station)
    29 | 	go fakeClient(radioBuffer, station)
    30 | 
    31 | 	s.AddRoute("/music/public_radio/"+url.PathEscape(station.Name), func(request sis.Request) {
+   32 | 		creationDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-14T18:07:00", time.Local)
+   33 | 		creationDate = creationDate.UTC()
+   34 | 		abstract := fmt.Sprintf("# AuraGem Public Radio - %s Station\n\n%s\nClients Connected: %d\n", station.Name, station.Description, radioBuffer.clientCount)
+   35 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: creationDate, UpdateDate: creationDate, Language: "en", Abstract: abstract})
+   36 | 		if request.ScrollMetadataRequested {
+   37 | 			request.SendAbstract("")
+   38 | 			return
+   39 | 		}
+   40 | 
    32 | 		currentTime := time.Now()
    33 | 		radioGenre := GetRadioGenre(currentTime, station)
    34 | 
    35 | 		attribution := ""
    36 | 		if radioBuffer.currentMusicFile.Attribution != "" {
   ... | ...
   153 | `
   154 | 		request.Gemini(fmt.Sprintf(template, station.Name, station.Description, url.PathEscape(station.Name), url.PathEscape(station.Name), radioBuffer.clientCount, currentTime.Format("03:04 PM"), radioGenre, radioBuffer.currentMusicFile.Title, radioBuffer.currentMusicFile.Artist, attribution, scheduleBuilder.String()))
   155 | 	})
   156 | 
   157 | 	s.AddRoute("/music/public_radio/"+url.PathEscape(station.Name)+"/schedule_feed", func(request sis.Request) {
+  167 | 		creationDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-14T18:07:00", time.Local)
+  168 | 		creationDate = creationDate.UTC()
+  169 | 		abstract := fmt.Sprintf("# AuraGem Public Radio - %s Station Schedule\n", station.Name)
+  170 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: creationDate, UpdateDate: time.Now(), Language: "en", Abstract: abstract})
+  171 | 		if request.ScrollMetadataRequested {
+  172 | 			request.SendAbstract("")
+  173 | 			return
+  174 | 		}
+  175 | 
   158 | 		currentTime := time.Now()
   159 | 		current_wd := currentTime.Weekday()
   160 | 		program := station.ProgramInfo[current_wd]
   161 | 		episode := station.CurrentEpisode[program]
   162 | 		var hour int64 = 0
   ... | ...
   198 | 	})
   199 | 	s.AddRoute("/music/stream/public_radio/"+url.PathEscape(station.Name), func(request sis.Request) {
   200 | 		request.Redirect("/music/stream/public_radio/" + url.PathEscape(station.Name) + ".mp3")
   201 | 	})
   202 | 	s.AddRoute("/music/stream/public_radio/"+url.PathEscape(station.Name)+".mp3", func(request sis.Request) {
+  221 | 		creationDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-14T18:07:00", time.Local)
+  222 | 		creationDate = creationDate.UTC()
+  223 | 		abstract := ""
+  224 | 		if request.ScrollMetadataRequested {
+  225 | 			currentTime := time.Now()
+  226 | 			radioGenre := GetRadioGenre(currentTime, station)
+  227 | 			attribution := ""
+  228 | 			if radioBuffer.currentMusicFile.Attribution != "" {
+  229 | 				attribution = "\n" + radioBuffer.currentMusicFile.Attribution
+  230 | 			}
+  231 | 
+  232 | 			abstract = fmt.Sprintf("# AuraGem Public Radio - %s Station\n\n%s\nClients Currently Connected to Station: %d\nCurrent Time and Genre: %s CST (%s)\nCurrent song playing: %s by %s\n%s", station.Name, station.Description, radioBuffer.clientCount, currentTime.Format("03:04 PM"), radioGenre, radioBuffer.currentMusicFile.Title, radioBuffer.currentMusicFile.Artist, attribution)
+  233 | 		}
+  234 | 
+  235 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: creationDate, UpdateDate: time.Now(), Language: "en", Abstract: abstract})
+  236 | 		if request.ScrollMetadataRequested {
+  237 | 			request.SendAbstract("audio/mpeg")
+  238 | 			return
+  239 | 		}
+  240 | 
   203 | 		// Station streaming here
   204 | 		// Add to client count
   205 | 		radioBuffer.clientCount += 1
   206 | 		(*totalClientsConnected) += 1
   207 | 

gemini/search/feedback.go

   ... | ...
    30 | 		if request.GetParam("token") != token {
    31 | 			request.TemporaryFailure("A token is required.")
    32 | 			return
    33 | 		}
    34 | 
+   35 | 		if request.DataSize > 5*1024*1024 {
+   36 | 			request.TemporaryFailure("Size too large.")
+   37 | 			return
+   38 | 		}
+   39 | 
    35 | 		// TODO: Check that the mimetype is gemini or text file
    36 | 
    37 | 		data, err := request.GetUploadData()
    38 | 		if err != nil {
    39 | 			return //err
   ... | ...
    61 | 
    62 | 		err = request.Server.FS().WriteFile("searchfeedback.gmi", data, 0600)
    63 | 		if err != nil {
    64 | 			return //err
    65 | 		}
-   66 | 		request.Redirect("gemini://%s:%s/search/feedback.gmi", request.Server.Hostname(), request.Server.Port())
+   71 | 		request.Redirect("%s%s:%s/search/feedback.gmi", request.Server.Scheme(), request.Server.Hostname(), request.Server.Port())
    67 | 		return
    68 | 	} else {
    69 | 		fileData, err := request.Server.FS().ReadFile("searchfeedback.gmi")
    70 | 		if err != nil {
    71 | 			request.TemporaryFailure(err.Error())

gemini/search/search.go

   ... | ...
    -1 | package search
     0 | 
     1 | // TODO: Add Favicons text to database and have crawler look for favicon.txt file
-    4 | // TODO: Also add language to pages table in database
-    5 | // TODO: Line count of pages in database
     6 | // TOOD: track Hashtags and Mentions (mentions can start with @ or ~)
     7 | 
     8 | import (
     9 | 	"context"
    10 | 	"database/sql"
   ... | ...
   137 | 	// Search Engine Handles
   138 | 	s.AddRoute("/search", func(request sis.Request) {
   139 | 		request.Redirect("/search/")
   140 | 	})
   141 | 	// TODO: Removed Tag Index (=> /search/tags 🏷️ Tag Index)
+  140 | 	publishDate, _ := time.ParseInLocation(time.RFC3339, "2021-07-01T00:00:00", time.Local)
+  141 | 	updateDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-13T00:00:00", time.Local)
   142 | 	s.AddRoute("/search/", func(request sis.Request) {
   ... | ...
   138 | 	s.AddRoute("/search/", func(request sis.Request) {
+  143 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search\n"})
+  144 | 		if request.ScrollMetadataRequested {
+  145 | 			request.SendAbstract("")
+  146 | 			return
+  147 | 		}
+  148 | 
   143 | 		request.Gemini("# AuraGem Search\n\n")
   144 | 		request.PromptLine("/search/s/", "🔍 Search")
   145 | 		request.Gemini(`=> /search/random/ 🎲 Goto Random Capsule
   146 | => /search/backlinks/ Check Backlinks
   147 | 
   ... | ...
   192 | 		//
   193 | 		// => https://www.patreon.com/krixano Patreon
   194 | 	})
   195 | 
   196 | 	s.AddRoute("/search/configure_default", func(request sis.Request) {
+  203 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# Configure Default Search Engine in Lagrange\n"})
+  204 | 		if request.ScrollMetadataRequested {
+  205 | 			request.SendAbstract("")
+  206 | 			return
+  207 | 		}
+  208 | 
   197 | 		request.Gemini(`# Configure Default Search Engine in Lagrange
   198 | 
   199 | 1. Go to File -> Preferences -> General
   200 | 2. Paste the following link into the Search URL field:
   201 | > gemini://auragem.letz.dev/search/s
   ... | ...
   201 | > gemini://auragem.letz.dev/search/s
   202 | `)
   203 | 	})
   204 | 
   205 | 	s.AddRoute("/search/features", func(request sis.Request) {
+  218 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search Features\n"})
+  219 | 		if request.ScrollMetadataRequested {
+  220 | 			request.SendAbstract("")
+  221 | 			return
+  222 | 		}
+  223 | 
   206 | 		request.Gemini(`# AuraGem Search Features
   207 | 
   208 | ## Current State of Features
   209 | * Full Text Search of page and file metadata, with Stemming, because apparently other search engines think it's important and unique to advertise one of the most common features in searching systems, lol.
   210 | * Complex search queries using AND, OR, and NOT operators, as well as grouping using parentheses and quotes for multiword search terms. By default, if you do not use any of these operators, search terms are combined using OR, much like you would expect from web search engines. However, searches that have all the terms provided will still be ranked higher than searches with just one or a portion of the terms provided.
   ... | ...
   279 | 	var totalSizeCache float64 = -1
   280 | 	var totalSizeTextCache float64 = -1
   281 | 	var lastCacheTime time.Time
   282 | 	s.AddRoute("/search/stats", func(request sis.Request) {
   283 | 		currentTime := time.Now()
+  302 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: currentTime, Abstract: "# AuraGem Search Stats\n"})
+  303 | 		if request.ScrollMetadataRequested {
+  304 | 			request.SendAbstract("")
+  305 | 			return
+  306 | 		}
+  307 | 
   284 | 		if totalSizeCache == -1 || lastCacheTime.Add(refreshCacheEvery).Before(currentTime) {
   285 | 			row := conn.QueryRowContext(context.Background(), "SELECT COUNT(*), MAX(LAST_SUCCESSFUL_VISIT), SUM(SIZE) FROM pages")
   286 | 			row.Scan(&pagesCountCache, &lastCrawlCache, &totalSizeCache)
   287 | 			// Convert totalSize to GB
   288 | 			lastCacheTime = currentTime
   ... | ...
   285 | 			row := conn.QueryRowContext(context.Background(), "SELECT COUNT(*), MAX(LAST_SUCCESSFUL_VISIT), SUM(SIZE) FROM pages")
   286 | 			row.Scan(&pagesCountCache, &lastCrawlCache, &totalSizeCache)
   287 | 			// Convert totalSize to GB
   288 | 			lastCacheTime = currentTime
   289 | 		}
+  314 | 
   290 | 		totalSize := totalSizeCache
   291 | 		totalSize /= 1024 // Bytes to KB
   292 | 		totalSize /= 1024 // KB to MB
   293 | 		totalSize /= 1024 // MB to GB
   294 | 
   ... | ...
   346 | 			return
   347 | 		} else if query == "" {
   348 | 			request.RequestInput("Capsule:")
   349 | 			return
   350 | 		} else {
+  376 | 			request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Add Capsule to Index\n"})
+  377 | 			if request.ScrollMetadataRequested {
+  378 | 				request.SendAbstract("")
+  379 | 				return
+  380 | 			}
+  381 | 
   351 | 			queryUrl, parse_err := url.Parse(query)
   352 | 			if parse_err != nil {
   353 | 				request.Redirect("/search/add_capsule")
   354 | 				return
   355 | 			}
   ... | ...
   379 | 			return
   380 | 		} else if query == "" {
   381 | 			request.RequestInput("Gemini URL:")
   382 | 			return
   383 | 		} else {
+  415 | 			request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Backlinks\n"})
+  416 | 			if request.ScrollMetadataRequested {
+  417 | 				request.SendAbstract("")
+  418 | 				return
+  419 | 			}
+  420 | 
   384 | 			// Check that gemini url in query string is correct
   385 | 			queryUrl, parse_err := url.Parse(query)
   386 | 			if parse_err != nil {
   387 | 				request.Redirect("/search/backlinks")
   388 | 				return
   ... | ...
   409 | 			return
   410 | 		} else if query == "" {
   411 | 			request.RequestInput("Search Query:")
   412 | 			return
   413 | 		} else {
+  451 | 			request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - '" + query + "'\n"})
+  452 | 			if request.ScrollMetadataRequested {
+  453 | 				request.SendAbstract("")
+  454 | 				return
+  455 | 			}
+  456 | 
   414 | 			// Page 1
   415 | 			handleSearch(request, conn, query, 1, false)
   416 | 			return
   417 | 		}
   418 | 	})
   ... | ...
   431 | 			return
   432 | 		} else if query == "" {
   433 | 			request.RequestInput("Search Query:")
   434 | 			return
   435 | 		} else {
+  479 | 			request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - '" + query + "' Page " + pageStr + "\n"})
+  480 | 			if request.ScrollMetadataRequested {
+  481 | 				request.SendAbstract("")
+  482 | 				return
+  483 | 			}
+  484 | 
   436 | 			handleSearch(request, conn, query, page, false)
   437 | 			return
   438 | 		}
   439 | 	})
   440 | 
   ... | ...
   474 | 			return
   475 | 		}
   476 | 	})
   477 | 
   478 | 	s.AddRoute("/search/recent", func(request sis.Request) {
+  528 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - 50 Most Recently Indexed\n"})
+  529 | 		if request.ScrollMetadataRequested {
+  530 | 			request.SendAbstract("")
+  531 | 			return
+  532 | 		}
+  533 | 
   479 | 		pages := getRecent(conn)
   480 | 
   481 | 		var builder strings.Builder
   482 | 		buildPageResults(&builder, pages, false, false)
   483 | 
   ... | ...
   489 | %s
   490 | `, builder.String()))
   491 | 	})
   492 | 
   493 | 	s.AddRoute("/search/capsules", func(request sis.Request) {
+  549 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - List of Capsules\n"})
+  550 | 		if request.ScrollMetadataRequested {
+  551 | 			request.SendAbstract("")
+  552 | 			return
+  553 | 		}
+  554 | 
   494 | 		capsules := getCapsules(conn)
   495 | 
   496 | 		var builder strings.Builder
   497 | 		for _, capsule := range capsules {
   498 | 			if capsule.Title == "" {
   ... | ...
   510 | %s
   511 | `, builder.String()))
   512 | 	})
   513 | 
   514 | 	s.AddRoute("/search/tags", func(request sis.Request) {
+  576 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Tag Index\n"})
+  577 | 		if request.ScrollMetadataRequested {
+  578 | 			request.SendAbstract("")
+  579 | 			return
+  580 | 		}
+  581 | 
   515 | 		tags := getTags(conn)
   516 | 
   517 | 		var builder strings.Builder
   518 | 		for _, tag := range tags {
   519 | 			fmt.Fprintf(&builder, "=> /search/tag/%s %s (%d)\n", url.PathEscape(tag.Name), tag.Name, tag.Count)
   ... | ...
   542 | %s
   543 | `, request.GetParam("name"), builder.String()))
   544 | 	})
   545 | 
   546 | 	s.AddRoute("/search/feeds", func(request sis.Request) {
+  614 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Indexed Feeds\n"})
+  615 | 		if request.ScrollMetadataRequested {
+  616 | 			request.SendAbstract("")
+  617 | 			return
+  618 | 		}
+  619 | 
   547 | 		pages := getFeeds(conn)
   548 | 
   549 | 		var builder strings.Builder
   550 | 		buildPageResults(&builder, pages, false, false)
   551 | 
   ... | ...
   556 | 
   557 | %s
   558 | `, builder.String()))
   559 | 	})
   560 | 
-  561 | 	s.AddRoute("/search/test", func(request sis.Request) {
-  562 | 		request.Redirect("/search/yearposts")
-  563 | 	})
   564 | 	s.AddRoute("/search/yearposts", func(request sis.Request) {
   ... | ...
   560 | 	s.AddRoute("/search/yearposts", func(request sis.Request) {
+  635 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Posts From The Past Year\n"})
+  636 | 		if request.ScrollMetadataRequested {
+  637 | 			request.SendAbstract("")
+  638 | 			return
+  639 | 		}
+  640 | 
   565 | 		page := 1
   566 | 		results := 40
   567 | 		skip := (page - 1) * results
   568 | 
   569 | 		pages, totalResultsCount := getPagesWithPublishDateFromLastYear(conn, results, skip)
   ... | ...
   596 | `, builder.String()))
   597 | 	})
   598 | 
   599 | 	s.AddRoute("/search/yearposts/:page", func(request sis.Request) {
   600 | 		pageStr := request.GetParam("page")
+  677 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Posts From The Past Year, Page " + pageStr + "\n"})
+  678 | 		if request.ScrollMetadataRequested {
+  679 | 			request.SendAbstract("")
+  680 | 			return
+  681 | 		}
+  682 | 
   601 | 		page, err := strconv.Atoi(pageStr)
   602 | 		if err != nil {
   603 | 			request.BadRequest("Couldn't parse int.")
   604 | 			return
   605 | 		}
   ... | ...
   636 | %s
   637 | `, builder.String()))
   638 | 	})
   639 | 
   640 | 	s.AddRoute("/search/audio", func(request sis.Request) {
-  641 | 		/* pageStr := c.Param("page")
-  642 | 		page_int, parse_err := strconv.ParseInt(pageStr, 10, 64)
-  643 | 		if parse_err != nil {
-  644 | 			return c.NoContent(gig.StatusBadRequest, "Page Number Error")
-  645 | 		}*/
+  723 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Indexed Audio Files\n"})
+  724 | 		if request.ScrollMetadataRequested {
+  725 | 			request.SendAbstract("")
+  726 | 			return
+  727 | 		}
+  728 | 
   646 | 		pages, _, _ := getAudioFiles(conn, 1)
   647 | 
   648 | 		var builder strings.Builder
   649 | 		buildPageResults(&builder, pages, false, false)
   650 | 		/*for _, page := range pages {
   ... | ...
   677 | 		page_int, parse_err := strconv.ParseInt(pageStr, 10, 64)
   678 | 		if parse_err != nil {
   679 | 			request.BadRequest("Page Number Error")
   680 | 			return
   681 | 		}
+  765 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Indexed Audio Files, Page " + pageStr + "\n"})
+  766 | 		if request.ScrollMetadataRequested {
+  767 | 			request.SendAbstract("")
+  768 | 			return
+  769 | 		}
+  770 | 
   682 | 		pages, _, hasNextPage := getAudioFiles(conn, page_int)
   683 | 
   684 | 		var builder strings.Builder
   685 | 		buildPageResults(&builder, pages, false, false)
   686 | 
   ... | ...
   737 | 			return
   738 | 		}
   739 | 	})
   740 | 
   741 | 	s.AddRoute("/search/images", func(request sis.Request) {
+  831 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Indexed Image Files\n"})
+  832 | 		if request.ScrollMetadataRequested {
+  833 | 			request.SendAbstract("")
+  834 | 			return
+  835 | 		}
+  836 | 
   742 | 		pages, _, _ := getImageFiles(conn, 1)
   743 | 
   744 | 		var builder strings.Builder
   745 | 		buildPageResults(&builder, pages, false, false)
   746 | 		/*for _, page := range pages {
   ... | ...
   773 | 		page_int, parse_err := strconv.ParseInt(pageStr, 10, 64)
   774 | 		if parse_err != nil {
   775 | 			request.BadRequest("Page Number Error")
   776 | 			return
   777 | 		}
+  873 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Indexed Image Files, Page " + pageStr + "\n"})
+  874 | 		if request.ScrollMetadataRequested {
+  875 | 			request.SendAbstract("")
+  876 | 			return
+  877 | 		}
+  878 | 
   778 | 		pages, _, hasNextPage := getImageFiles(conn, page_int)
   779 | 		if len(pages) == 0 {
   780 | 			request.NotFound("Page not found.")
   781 | 			return
   782 | 		}
   ... | ...
   801 | %s
   802 | `, builder.String()))
   803 | 	})
   804 | 
   805 | 	s.AddRoute("/search/twtxt", func(request sis.Request) {
+  907 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Indexed Twtxt Files\n"})
+  908 | 		if request.ScrollMetadataRequested {
+  909 | 			request.SendAbstract("")
+  910 | 			return
+  911 | 		}
+  912 | 
   806 | 		pages := getTwtxtFiles(conn)
   807 | 		if len(pages) == 0 {
   808 | 			request.NotFound("Page not found.")
   809 | 			return
   810 | 		}
   ... | ...
   827 | %s
   828 | `, builder.String()))
   829 | 	})
   830 | 
   831 | 	s.AddRoute("/search/security", func(request sis.Request) {
+  939 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Indexed Security.txt Files\n"})
+  940 | 		if request.ScrollMetadataRequested {
+  941 | 			request.SendAbstract("")
+  942 | 			return
+  943 | 		}
+  944 | 
   832 | 		pages := getSecurityTxtFiles(conn)
   833 | 		if len(pages) == 0 {
   834 | 			request.NotFound("Page not found.")
   835 | 			return
   836 | 		}
   ... | ...
   857 | 		query, err := request.Query()
   858 | 		if err != nil {
   859 | 			request.TemporaryFailure(err.Error())
   860 | 			return
   861 | 		} else if query == "" {
+  975 | 			request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Mimetypes\n"})
+  976 | 			if request.ScrollMetadataRequested {
+  977 | 				request.SendAbstract("")
+  978 | 				return
+  979 | 			}
+  980 | 
   862 | 			mimetypesList := getMimetypes(conn)
   863 | 			var mimetypes strings.Builder
   864 | 			for _, item := range mimetypesList {
   865 | 				fmt.Fprintf(&mimetypes, "=> /search/s/?%s %s (%d)\n", url.QueryEscape("CONTENTTYPE:("+item.mimetype+")"), item.mimetype, item.count)
   866 | 			}
   ... | ...
   871 | => /search/s/ Search
   872 | 
   873 | %s
   874 | `, mimetypes.String()))
   875 | 		} else {
+  995 | 			request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Abstract: "# AuraGem Search - Indexed of Mimetype '" + query + "'\n"})
+  996 | 			if request.ScrollMetadataRequested {
+  997 | 				request.SendAbstract("")
+  998 | 				return
+  999 | 			}
+ 1000 | 
   876 | 			pages := getMimetypeFiles(conn, query)
   877 | 			if len(pages) == 0 {
   878 | 				request.NotFound("Page not found.")
   879 | 				return
   880 | 			}

gemini/starwars/queries.go (created)

+    1 | package starwars
+    2 | 
+    3 | import (
+    4 | 	"context"
+    5 | 	"database/sql"
+    6 | 	"time"
+    7 | )
+    8 | 
+    9 | func GetMovies(conn *sql.DB, timeline bool) ([]Movie, time.Time) {
+   10 | 	lastUpdate := time.Time{}
+   11 | 	var movies []Movie
+   12 | 	var q string
+   13 | 	if timeline {
+   14 | 		q = `SELECT r.ID, r."NAME", r.TIMELINEDATE, r.PUBLICATIONDATE, r.PRODUCTIONCOMPANY, r.DISTRIBUTOR, r.DATE_ADDED FROM MOVIES r order by timelinedate asc`
+   15 | 	} else {
+   16 | 		q = `SELECT r.ID, r."NAME", r.TIMELINEDATE, r.PUBLICATIONDATE, r.PRODUCTIONCOMPANY, r.DISTRIBUTOR, r.DATE_ADDED FROM MOVIES r order by publicationdate asc`
+   17 | 	}
+   18 | 	rows, rows_err := conn.QueryContext(context.Background(), q)
+   19 | 	if rows_err == nil {
+   20 | 		defer rows.Close()
+   21 | 		for rows.Next() {
+   22 | 			var movie Movie
+   23 | 			scan_err := rows.Scan(&movie.Id, &movie.Name, &movie.TimelineDate, &movie.PublicationDate, &movie.ProductionCompany, &movie.Distributor, &movie.Date_added)
+   24 | 			if scan_err == nil {
+   25 | 				if lastUpdate.Before(movie.Date_added) {
+   26 | 					lastUpdate = movie.Date_added
+   27 | 				}
+   28 | 				movies = append(movies, movie)
+   29 | 			}
+   30 | 		}
+   31 | 	}
+   32 | 
+   33 | 	return movies, lastUpdate
+   34 | }
+   35 | 
+   36 | func GetShows(conn *sql.DB) ([]TVShow, time.Time) {
+   37 | 	lastUpdate := time.Time{}
+   38 | 	var shows []TVShow
+   39 | 	q := `SELECT r.ID, r."NAME", r.DATE_ADDED FROM TVSHOWS r`
+   40 | 	rows, rows_err := conn.QueryContext(context.Background(), q)
+   41 | 	if rows_err == nil {
+   42 | 		defer rows.Close()
+   43 | 		for rows.Next() {
+   44 | 			var show TVShow
+   45 | 			scan_err := rows.Scan(&show.Id, &show.Name, &show.Date_added)
+   46 | 			if scan_err == nil {
+   47 | 				if lastUpdate.Before(show.Date_added) {
+   48 | 					lastUpdate = show.Date_added
+   49 | 				}
+   50 | 				shows = append(shows, show)
+   51 | 			}
+   52 | 		}
+   53 | 	}
+   54 | 
+   55 | 	return shows, lastUpdate
+   56 | }
+   57 | 
+   58 | func GetComicSeries_Full(conn *sql.DB) ([]ComicSeries, time.Time) {
+   59 | 	lastUpdate := time.Time{}
+   60 | 	var series []ComicSeries
+   61 | 	q := `SELECT r.ID, r."NAME", r.MINISERIES, r.STARTYEAR, r.DATE_ADDED, (SELECT COUNT(*) FROM COMICTPBS WHERE COMICTPBS.COMICSERIESID=r.ID) as tpbcount, (SELECT COUNT(*) FROM COMICISSUES WHERE COMICISSUES.COMICSERIESID=r.ID AND COMICISSUES.ANNUAL=false) as issuecount FROM COMICSERIES r WHERE r.miniseries=false`
+   62 | 	rows, rows_err := conn.QueryContext(context.Background(), q)
+   63 | 	if rows_err == nil {
+   64 | 		defer rows.Close()
+   65 | 		for rows.Next() {
+   66 | 			var serie ComicSeries
+   67 | 			scan_err := rows.Scan(&serie.Id, &serie.Name, &serie.Miniseries, &serie.StartYear, &serie.Date_added, &serie.TPBCount, &serie.IssueCount)
+   68 | 			if scan_err == nil {
+   69 | 				if lastUpdate.Before(serie.Date_added) {
+   70 | 					lastUpdate = serie.Date_added
+   71 | 				}
+   72 | 				series = append(series, serie)
+   73 | 			}
+   74 | 		}
+   75 | 	}
+   76 | 
+   77 | 	return series, lastUpdate
+   78 | }
+   79 | 
+   80 | func GetComicCrossovers(conn *sql.DB, timeline bool) ([]ComicTPB, time.Time) {
+   81 | 	lastUpdate := time.Time{}
+   82 | 	var tpbs []ComicTPB
+   83 | 	var q string
+   84 | 	if timeline {
+   85 | 		q = `SELECT r.ID, r."NAME", r.CROSSOVER, r.TIMELINEDATE, r.PUBLICATIONDATE, r.DATE_ADDED FROM COMICTPBS r WHERE r.CROSSOVER=true order by timelinedate ASC`
+   86 | 	} else {
+   87 | 		q = `SELECT r.ID, r."NAME", r.CROSSOVER, r.TIMELINEDATE, r.PUBLICATIONDATE, r.DATE_ADDED FROM COMICTPBS r WHERE r.CROSSOVER=true order by publicationdate ASC`
+   88 | 	}
+   89 | 	rows, rows_err := conn.QueryContext(context.Background(), q)
+   90 | 	if rows_err == nil {
+   91 | 		defer rows.Close()
+   92 | 		for rows.Next() {
+   93 | 			var tpb ComicTPB
+   94 | 			scan_err := rows.Scan(&tpb.Id, &tpb.Name, &tpb.Crossover, &tpb.TimelineDate, &tpb.PublicationDate, &tpb.Date_added)
+   95 | 			if scan_err == nil {
+   96 | 				if lastUpdate.Before(tpb.Date_added) {
+   97 | 					lastUpdate = tpb.Date_added
+   98 | 				}
+   99 | 				tpbs = append(tpbs, tpb)
+  100 | 			} else {
+  101 | 				panic(scan_err)
+  102 | 			}
+  103 | 		}
+  104 | 	} else {
+  105 | 		panic(rows_err)
+  106 | 	}
+  107 | 
+  108 | 	return tpbs, lastUpdate
+  109 | }
+  110 | 
+  111 | func GetComicSeries_Miniseries(conn *sql.DB) ([]ComicSeries, time.Time) {
+  112 | 	lastUpdate := time.Time{}
+  113 | 	var series []ComicSeries
+  114 | 	q := `SELECT r.ID, r."NAME", r.MINISERIES, r.STARTYEAR, r.DATE_ADDED, (SELECT COUNT(*) FROM COMICTPBS WHERE COMICTPBS.COMICSERIESID=r.ID) as tpbcount, (SELECT COUNT(*) FROM COMICISSUES WHERE COMICISSUES.COMICSERIESID=r.ID AND COMICISSUES.ANNUAL=false) as issuecount FROM COMICSERIES r WHERE r.miniseries=true`
+  115 | 	rows, rows_err := conn.QueryContext(context.Background(), q)
+  116 | 	if rows_err == nil {
+  117 | 		defer rows.Close()
+  118 | 		for rows.Next() {
+  119 | 			var serie ComicSeries
+  120 | 			scan_err := rows.Scan(&serie.Id, &serie.Name, &serie.Miniseries, &serie.StartYear, &serie.Date_added, &serie.TPBCount, &serie.IssueCount)
+  121 | 			if scan_err == nil {
+  122 | 				if lastUpdate.Before(serie.Date_added) {
+  123 | 					lastUpdate = serie.Date_added
+  124 | 				}
+  125 | 				series = append(series, serie)
+  126 | 			}
+  127 | 		}
+  128 | 	}
+  129 | 
+  130 | 	return series, lastUpdate
+  131 | }
+  132 | 
+  133 | func GetTPBs(conn *sql.DB, timeline bool) ([]ComicTPB, time.Time) {
+  134 | 	lastUpdate := time.Time{}
+  135 | 	var tpbs []ComicTPB
+  136 | 	var q string
+  137 | 	if timeline {
+  138 | 		q = `SELECT r.ID, r.VOLUME, r."NAME", r.CROSSOVER, r.TIMELINEDATE, r.PUBLICATIONDATE, r.DATE_ADDED, comicseries.ID, comicseries."NAME", comicseries.MINISERIES, comicseries.STARTYEAR, comicseries.DATE_ADDED, (SELECT COUNT(*) FROM COMICISSUES WHERE COMICISSUES.COMICTPBID=r.ID) as issuecount FROM COMICTPBS r LEFT JOIN COMICSERIES ON COMICSERIES.ID=r.COMICSERIESID order by timelinedate ASC`
+  139 | 	} else {
+  140 | 		q = `SELECT r.ID, r.VOLUME, r."NAME", r.CROSSOVER, r.TIMELINEDATE, r.PUBLICATIONDATE, r.DATE_ADDED, comicseries.ID, comicseries."NAME", comicseries.MINISERIES, comicseries.STARTYEAR, comicseries.DATE_ADDED, (SELECT COUNT(*) FROM COMICISSUES WHERE COMICISSUES.COMICTPBID=r.ID) as issuecount FROM COMICTPBS r LEFT JOIN COMICSERIES ON COMICSERIES.ID=r.COMICSERIESID order by publicationdate ASC`
+  141 | 	}
+  142 | 	rows, rows_err := conn.QueryContext(context.Background(), q) // TODO: ComicSeriesId
+  143 | 	if rows_err == nil {
+  144 | 		defer rows.Close()
+  145 | 		for rows.Next() {
+  146 | 			var tpb ComicTPB
+  147 | 
+  148 | 			var volume interface{}
+  149 | 			var timelineDate interface{}
+  150 | 			var series comicSeriesNullable
+  151 | 			scan_err := rows.Scan(&tpb.Id, &volume, &tpb.Name, &tpb.Crossover, &timelineDate, &tpb.PublicationDate, &tpb.Date_added, &series.Id, &series.Name, &series.Miniseries, &series.StartYear, &series.Date_added, &tpb.IssueCount)
+  152 | 			if scan_err == nil {
+  153 | 				if volume != nil {
+  154 | 					tpb.Volume = int(volume.(int32))
+  155 | 				}
+  156 | 				if timelineDate != nil {
+  157 | 					tpb.TimelineDate = int(timelineDate.(int32))
+  158 | 				}
+  159 | 				if lastUpdate.Before(tpb.Date_added) {
+  160 | 					lastUpdate = tpb.Date_added
+  161 | 				}
+  162 | 				tpb.Series = comicSeriesScan(series)
+  163 | 
+  164 | 				tpbs = append(tpbs, tpb)
+  165 | 			} else {
+  166 | 				panic(scan_err)
+  167 | 			}
+  168 | 		}
+  169 | 	} else {
+  170 | 		panic(rows_err)
+  171 | 	}
+  172 | 
+  173 | 	return tpbs, lastUpdate
+  174 | }
+  175 | 
+  176 | func GetComicIssues(conn *sql.DB, timeline bool) ([]ComicIssue, time.Time) {
+  177 | 	lastUpdate := time.Time{}
+  178 | 	var issues []ComicIssue
+  179 | 	var q string
+  180 | 	if timeline {
+  181 | 		q = `SELECT r.ID, r."NUMBER", r."NAME", r.ANNUAL, r.TIMELINEDATE, r.PUBLICATIONDATE, r.PUBLISHER, r.DATE_ADDED, comicseries.ID, comicseries."NAME", comicseries.MINISERIES, comicseries.STARTYEAR, comicseries.DATE_ADDED FROM COMICISSUES r LEFT JOIN COMICSERIES ON COMICSERIES.ID=r.COMICSERIESID order by r.TIMELINEDATE ASC, r.NUMBER ASC`
+  182 | 	} else {
+  183 | 		q = `SELECT r.ID, r."NUMBER", r."NAME", r.ANNUAL,r.TIMELINEDATE, r.PUBLICATIONDATE, r.PUBLISHER, r.DATE_ADDED, comicseries.ID, comicseries."NAME", comicseries.MINISERIES, comicseries.STARTYEAR, comicseries.DATE_ADDED FROM COMICISSUES r LEFT JOIN COMICSERIES ON COMICSERIES.ID=r.COMICSERIESID order by r.PUBLICATIONDATE ASC, r.NUMBER ASC`
+  184 | 	}
+  185 | 	rows, rows_err := conn.QueryContext(context.Background(), q) // TODO: ComicSeriesId
+  186 | 	if rows_err == nil {
+  187 | 		defer rows.Close()
+  188 | 		for rows.Next() {
+  189 | 			var issue ComicIssue
+  190 | 
+  191 | 			//var volume interface{}
+  192 | 			var timelineDate interface{}
+  193 | 			var series comicSeriesNullable
+  194 | 			scan_err := rows.Scan(&issue.Id, &issue.Number, &issue.Name, &issue.Annual, &timelineDate, &issue.PublicationDate, &issue.Publisher, &issue.Date_added, &series.Id, &series.Name, &series.Miniseries, &series.StartYear, &series.Date_added)
+  195 | 			if scan_err == nil {
+  196 | 				/*if volume != nil {
+  197 | 					tpb.Volume = int(volume.(int32))
+  198 | 				}*/
+  199 | 				if timelineDate != nil {
+  200 | 					issue.TimelineDate = int(timelineDate.(int32))
+  201 | 				}
+  202 | 				issue.Series = comicSeriesScan(series)
+  203 | 
+  204 | 				if lastUpdate.Before(issue.Date_added) {
+  205 | 					lastUpdate = issue.Date_added
+  206 | 				}
+  207 | 				issues = append(issues, issue)
+  208 | 			} else {
+  209 | 				panic(scan_err)
+  210 | 			}
+  211 | 		}
+  212 | 	} else {
+  213 | 		panic(rows_err)
+  214 | 	}
+  215 | 
+  216 | 	return issues, lastUpdate
+  217 | }
+  218 | 
+  219 | func GetComicOneshots(conn *sql.DB, timeline bool) ([]ComicIssue, time.Time) {
+  220 | 	lastUpdate := time.Time{}
+  221 | 	var issues []ComicIssue
+  222 | 	var q string
+  223 | 	if timeline {
+  224 | 		q = `SELECT r.ID, r."NUMBER", r."NAME", r.ANNUAL, r.TIMELINEDATE, r.PUBLICATIONDATE, r.PUBLISHER, r.DATE_ADDED FROM COMICISSUES r WHERE r.COMICSERIESID IS NULL order by r.TIMELINEDATE ASC, r.NUMBER ASC`
+  225 | 	} else {
+  226 | 		q = `SELECT r.ID, r."NUMBER", r."NAME", r.ANNUAL, r.TIMELINEDATE, r.PUBLICATIONDATE, r.PUBLISHER, r.DATE_ADDED FROM COMICISSUES r WHERE r.COMICSERIESID IS NULL order by r.PUBLICATIONDATE ASC, r.NUMBER ASC`
+  227 | 	}
+  228 | 	rows, rows_err := conn.QueryContext(context.Background(), q)
+  229 | 	if rows_err == nil {
+  230 | 		defer rows.Close()
+  231 | 		for rows.Next() {
+  232 | 			var issue ComicIssue
+  233 | 			var timelineDate interface{}
+  234 | 			scan_err := rows.Scan(&issue.Id, &issue.Number, &issue.Name, &issue.Annual, &timelineDate, &issue.PublicationDate, &issue.Publisher, &issue.Date_added)
+  235 | 			if scan_err == nil {
+  236 | 				/*if volume != nil {
+  237 | 					tpb.Volume = int(volume.(int32))
+  238 | 				}*/
+  239 | 				if timelineDate != nil {
+  240 | 					issue.TimelineDate = int(timelineDate.(int32))
+  241 | 				}
+  242 | 
+  243 | 				if lastUpdate.Before(issue.Date_added) {
+  244 | 					lastUpdate = issue.Date_added
+  245 | 				}
+  246 | 				issues = append(issues, issue)
+  247 | 			} else {
+  248 | 				panic(scan_err)
+  249 | 			}
+  250 | 		}
+  251 | 	} else {
+  252 | 		panic(rows_err)
+  253 | 	}
+  254 | 
+  255 | 	return issues, lastUpdate
+  256 | }
+  257 | 
+  258 | func GetBookSeries(conn *sql.DB) ([]BookSeries, time.Time) {
+  259 | 	lastUpdate := time.Time{}
+  260 | 	var series []BookSeries
+  261 | 	q := `SELECT r.ID, r."NAME", r.DATE_ADDED, (SELECT COUNT(*) FROM BOOKS WHERE BOOKS.BOOKSERIESID=r.ID) as bookcount FROM BOOKSERIES r`
+  262 | 	rows, rows_err := conn.QueryContext(context.Background(), q)
+  263 | 	if rows_err == nil {
+  264 | 		defer rows.Close()
+  265 | 		for rows.Next() {
+  266 | 			var serie BookSeries
+  267 | 			scan_err := rows.Scan(&serie.Id, &serie.Name, &serie.Date_added, &serie.BookCount)
+  268 | 			if scan_err == nil {
+  269 | 				if lastUpdate.Before(serie.Date_added) {
+  270 | 					lastUpdate = serie.Date_added
+  271 | 				}
+  272 | 				series = append(series, serie)
+  273 | 			}
+  274 | 		}
+  275 | 	}
+  276 | 
+  277 | 	return series, lastUpdate
+  278 | }
+  279 | 
+  280 | func GetBooks(conn *sql.DB) ([]Book, time.Time) {
+  281 | 	lastUpdate := time.Time{}
+  282 | 	var books []Book
+  283 | 	q := `SELECT r.ID, r."NUMBER", r."NAME", r.BOOKTYPE, r.AUTHOR, r.BOOKSERIESID, r.TIMELINEDATE, r.PUBLICATIONDATE, r.PUBLISHER, r.DATE_ADDED, BOOKSERIES.ID, BOOKSERIES."NAME", BOOKSERIES.DATE_ADDED FROM BOOKS r LEFT JOIN BOOKSERIES ON BOOKSERIES.ID=r.BOOKSERIESID ORDER BY r.TIMELINEDATE ASC, r.NUMBER ASC`
+  284 | 	rows, rows_err := conn.QueryContext(context.Background(), q)
+  285 | 	if rows_err == nil {
+  286 | 		defer rows.Close()
+  287 | 		for rows.Next() {
+  288 | 			var bookNull bookNullable
+  289 | 			var bookSeriesNull bookSeriesNullable
+  290 | 			scan_err := rows.Scan(&bookNull.Id, &bookNull.Number, &bookNull.Name, &bookNull.BookType, &bookNull.Author, &bookNull.BookSeriesId, &bookNull.TimelineDate, &bookNull.PublicationDate, &bookNull.Publisher, &bookNull.Date_added, &bookSeriesNull.Id, &bookSeriesNull.Name, &bookSeriesNull.Date_added)
+  291 | 			if scan_err == nil {
+  292 | 				book := bookScan(bookNull)
+  293 | 				book.Series = bookSeriesScan(bookSeriesNull)
+  294 | 				if lastUpdate.Before(book.Date_added) {
+  295 | 					lastUpdate = book.Date_added
+  296 | 				}
+  297 | 				books = append(books, book)
+  298 | 			}
+  299 | 		}
+  300 | 	}
+  301 | 
+  302 | 	return books, lastUpdate
+  303 | }
+  304 | 
+  305 | func GetBookStandalones(conn *sql.DB) ([]Book, time.Time) {
+  306 | 	lastUpdate := time.Time{}
+  307 | 	var books []Book
+  308 | 	q := `SELECT r.ID, r."NUMBER", r."NAME", r.BOOKTYPE, r.AUTHOR, r.BOOKSERIESID, r.TIMELINEDATE, r.PUBLICATIONDATE, r.PUBLISHER, r.DATE_ADDED FROM BOOKS r WHERE r.BOOKSERIESID IS NULL ORDER BY r.TIMELINEDATE ASC, r.NUMBER ASC`
+  309 | 	rows, rows_err := conn.QueryContext(context.Background(), q)
+  310 | 	if rows_err == nil {
+  311 | 		defer rows.Close()
+  312 | 		for rows.Next() {
+  313 | 			var bookNull bookNullable
+  314 | 			scan_err := rows.Scan(&bookNull.Id, &bookNull.Number, &bookNull.Name, &bookNull.BookType, &bookNull.Author, &bookNull.BookSeriesId, &bookNull.TimelineDate, &bookNull.PublicationDate, &bookNull.Publisher, &bookNull.Date_added)
+  315 | 			if scan_err == nil {
+  316 | 				book := bookScan(bookNull)
+  317 | 				if lastUpdate.Before(book.Date_added) {
+  318 | 					lastUpdate = book.Date_added
+  319 | 				}
+  320 | 				books = append(books, book)
+  321 | 			}
+  322 | 		}
+  323 | 	}
+  324 | 
+  325 | 	return books, lastUpdate
+  326 | }

gemini/starwars/starwars.go (created)

+    1 | package starwars
+    2 | 
+    3 | import (
+    4 | 	"database/sql"
+    5 | 	"fmt"
+    6 | 	"strings"
+    7 | 	"time"
+    8 | 
+    9 | 	"gitlab.com/clseibold/auragem_sis/db"
+   10 | 	sis "gitlab.com/clseibold/smallnetinformationservices"
+   11 | )
+   12 | 
+   13 | var publishDate, _ = time.ParseInLocation(time.RFC3339, "2024-03-19T08:23:00", time.Local)
+   14 | 
+   15 | func HandleStarWars(s sis.ServerHandle) {
+   16 | 	conn := db.NewConn(db.StarWarsDB)
+   17 | 	conn.SetMaxOpenConns(500)
+   18 | 	conn.SetMaxIdleConns(3)
+   19 | 	conn.SetConnMaxLifetime(time.Hour * 4)
+   20 | 
+   21 | 	s.AddRoute("/starwars2", func(request sis.Request) {
+   22 | 		request.Redirect("/starwars2/")
+   23 | 	})
+   24 | 
+   25 | 	s.AddRoute("/starwars2/", func(request sis.Request) {
+   26 | 		updateDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-19T08:23:00", time.Local)
+   27 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: updateDate, Language: "en", Abstract: "# Star Wars Database\n"})
+   28 | 		if request.ScrollMetadataRequested {
+   29 | 			request.SendAbstract("")
+   30 | 			return
+   31 | 		}
+   32 | 
+   33 | 		request.Gemini(`# Star Wars Database
+   34 | 
+   35 | Welcome to the Star Wars Database.
+   36 | 
+   37 | ## Canon - Ordered By Timeline
+   38 | => /starwars2/timeline/movies Movies
+   39 | => /starwars2/timeline/shows TV Shows
+   40 | => /starwars2/timeline/comics Comics
+   41 | => /starwars2/timeline/bookseries Books
+   42 | => /starwars2/timeline/all All
+   43 | 
+   44 | ## Canon - Ordered By Publication
+   45 | => /starwars2/publication/movies Movies
+   46 | => /starwars2/publication/comics Comics
+   47 | 
+   48 | => /starwars/ The Old Database
+   49 | `)
+   50 | 	})
+   51 | 
+   52 | 	// Movies
+   53 | 	s.AddRoute("/starwars2/timeline/movies", func(request sis.Request) {
+   54 | 		handleMovies(request, conn, true)
+   55 | 	})
+   56 | 	s.AddRoute("/starwars2/publication/movies", func(request sis.Request) {
+   57 | 		handleMovies(request, conn, false)
+   58 | 	})
+   59 | 
+   60 | 	// Movies CSV
+   61 | 	s.AddRoute("/starwars2/timeline/movies/csv", func(request sis.Request) {
+   62 | 		handleMoviesCSV(request, conn, true)
+   63 | 	})
+   64 | 	s.AddRoute("/starwars2/publication/movies/csv", func(request sis.Request) {
+   65 | 		handleMoviesCSV(request, conn, false)
+   66 | 	})
+   67 | 
+   68 | 	s.AddRoute("/starwars2/timeline/shows", func(request sis.Request) {
+   69 | 		shows, lastUpdate := GetShows(conn)
+   70 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: TV Shows (Timeline)\n"})
+   71 | 		if request.ScrollMetadataRequested {
+   72 | 			request.SendAbstract("")
+   73 | 			return
+   74 | 		}
+   75 | 
+   76 | 		header, tableData := constructTableDataFromShows(shows)
+   77 | 		table := constructTable(header, tableData)
+   78 | 
+   79 | 		var builder strings.Builder
+   80 | 		fmt.Fprintf(&builder, "```\n%s\n```\n\n", table)
+   81 | 
+   82 | 		request.Gemini(fmt.Sprintf(`# Star Wars Shows
+   83 | 
+   84 | => /starwars2/ Home
+   85 | => /starwars2/timeline/shows/episodes/ Episodes
+   86 | 
+   87 | %s
+   88 | `, builder.String()))
+   89 | 	})
+   90 | 
+   91 | 	s.AddRoute("/starwars2/timeline/comics", func(request sis.Request) {
+   92 | 		fullSeries, lastUpdate := GetComicSeries_Full(conn)
+   93 | 		miniseries, lastUpdate2 := GetComicSeries_Miniseries(conn)
+   94 | 		if lastUpdate.Before(lastUpdate2) {
+   95 | 			lastUpdate = lastUpdate2
+   96 | 		}
+   97 | 		crossovers, lastUpdate2 := GetComicCrossovers(conn, true)
+   98 | 		if lastUpdate.Before(lastUpdate2) {
+   99 | 			lastUpdate = lastUpdate2
+  100 | 		}
+  101 | 		oneshots, lastUpdate2 := GetComicOneshots(conn, true)
+  102 | 		if lastUpdate.Before(lastUpdate2) {
+  103 | 			lastUpdate = lastUpdate2
+  104 | 		}
+  105 | 
+  106 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: Comics (Timeline)\n"})
+  107 | 		if request.ScrollMetadataRequested {
+  108 | 			request.SendAbstract("")
+  109 | 			return
+  110 | 		}
+  111 | 
+  112 | 		var builder strings.Builder
+  113 | 		fmt.Fprintf(&builder, "## Full Series\n")
+  114 | 		full_heading, full_data := constructTableDataFromSeries(fullSeries)
+  115 | 		full_table := constructTable(full_heading, full_data)
+  116 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", full_table)
+  117 | 
+  118 | 		fmt.Fprintf(&builder, "## Crossovers\n")
+  119 | 		crossovers_heading, crossovers_data := constructTableDataFromCrossover(crossovers)
+  120 | 		crossovers_table := constructTable(crossovers_heading, crossovers_data)
+  121 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", crossovers_table)
+  122 | 
+  123 | 		fmt.Fprintf(&builder, "## Miniseries\n")
+  124 | 		miniseries_heading, miniseries_data := constructTableDataFromSeries(miniseries)
+  125 | 		miniseries_table := constructTable(miniseries_heading, miniseries_data)
+  126 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", miniseries_table)
+  127 | 
+  128 | 		fmt.Fprintf(&builder, "## One-shots\n")
+  129 | 		oneshots_heading, oneshots_data := constructTableDataFromOneshots(oneshots)
+  130 | 		oneshots_table := constructTable(oneshots_heading, oneshots_data)
+  131 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", oneshots_table)
+  132 | 
+  133 | 		request.Gemini(fmt.Sprintf(`# Star Wars Comics - Series'
+  134 | 
+  135 | => /starwars2/ Home
+  136 | => /starwars2/timeline/comics/issues Issues
+  137 | => /starwars2/timeline/comics/tpbs TPBs
+  138 | 
+  139 | %s
+  140 | `, builder.String()))
+  141 | 	})
+  142 | 
+  143 | 	s.AddRoute("/starwars2/publication/comics", func(request sis.Request) {
+  144 | 		fullSeries, lastUpdate := GetComicSeries_Full(conn)
+  145 | 		miniseries, lastUpdate2 := GetComicSeries_Miniseries(conn)
+  146 | 		if lastUpdate.Before(lastUpdate2) {
+  147 | 			lastUpdate = lastUpdate2
+  148 | 		}
+  149 | 		crossovers, lastUpdate2 := GetComicCrossovers(conn, false)
+  150 | 		if lastUpdate.Before(lastUpdate2) {
+  151 | 			lastUpdate = lastUpdate2
+  152 | 		}
+  153 | 		oneshots, lastUpdate2 := GetComicOneshots(conn, false)
+  154 | 		if lastUpdate.Before(lastUpdate2) {
+  155 | 			lastUpdate = lastUpdate2
+  156 | 		}
+  157 | 
+  158 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: Comics (Publication)\n"})
+  159 | 		if request.ScrollMetadataRequested {
+  160 | 			request.SendAbstract("")
+  161 | 			return
+  162 | 		}
+  163 | 
+  164 | 		var builder strings.Builder
+  165 | 		fmt.Fprintf(&builder, "## Full Series\n")
+  166 | 		full_heading, full_data := constructTableDataFromSeries(fullSeries)
+  167 | 		full_table := constructTable(full_heading, full_data)
+  168 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", full_table)
+  169 | 
+  170 | 		fmt.Fprintf(&builder, "## Crossovers\n")
+  171 | 		crossovers_heading, crossovers_data := constructTableDataFromCrossover(crossovers)
+  172 | 		crossovers_table := constructTable(crossovers_heading, crossovers_data)
+  173 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", crossovers_table)
+  174 | 
+  175 | 		fmt.Fprintf(&builder, "## Miniseries\n")
+  176 | 		miniseries_heading, miniseries_data := constructTableDataFromSeries(miniseries)
+  177 | 		miniseries_table := constructTable(miniseries_heading, miniseries_data)
+  178 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", miniseries_table)
+  179 | 
+  180 | 		fmt.Fprintf(&builder, "## One-shots\n")
+  181 | 		oneshots_heading, oneshots_data := constructTableDataFromOneshots(oneshots)
+  182 | 		oneshots_table := constructTable(oneshots_heading, oneshots_data)
+  183 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", oneshots_table)
+  184 | 
+  185 | 		request.Gemini(fmt.Sprintf(`# Star Wars Comics - Series'
+  186 | 
+  187 | => /starwars2/ Home
+  188 | => /starwars2/publication/comics/issues Issues
+  189 | => /starwars2/publication/comics/tpbs TPBs
+  190 | 
+  191 | %s
+  192 | `, builder.String()))
+  193 | 	})
+  194 | 
+  195 | 	s.AddRoute("/starwars2/timeline/comics/tpbs", func(request sis.Request) {
+  196 | 		tpbs, lastUpdate := GetTPBs(conn, true)
+  197 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: Comic TPBs (Timeline)\n"})
+  198 | 		if request.ScrollMetadataRequested {
+  199 | 			request.SendAbstract("")
+  200 | 			return
+  201 | 		}
+  202 | 
+  203 | 		heading, data := constructTableDataFromTPBs(tpbs)
+  204 | 		table := constructTable(heading, data)
+  205 | 
+  206 | 		var builder strings.Builder
+  207 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", table)
+  208 | 
+  209 | 		request.Gemini(fmt.Sprintf(`# Star Wars Comics - TPBs
+  210 | 
+  211 | => /starwars2/ Home
+  212 | => /starwars2/timeline/comics Comic Series'
+  213 | => /starwars2/timeline/comics/issues Issues
+  214 | => /starwars2/timeline/comics/tpbs TPBs
+  215 | 
+  216 | %s
+  217 | `, builder.String()))
+  218 | 	})
+  219 | 
+  220 | 	s.AddRoute("/starwars2/publication/comics/tpbs", func(request sis.Request) {
+  221 | 		tpbs, lastUpdate := GetTPBs(conn, false)
+  222 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: Comic TPBs (Publication)\n"})
+  223 | 		if request.ScrollMetadataRequested {
+  224 | 			request.SendAbstract("")
+  225 | 			return
+  226 | 		}
+  227 | 
+  228 | 		heading, data := constructTableDataFromTPBs(tpbs)
+  229 | 		table := constructTable(heading, data)
+  230 | 
+  231 | 		var builder strings.Builder
+  232 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", table)
+  233 | 
+  234 | 		request.Gemini(fmt.Sprintf(`# Star Wars Comics - TPBs
+  235 | 
+  236 | => /starwars2/ Home
+  237 | => /starwars2/publication/comics Comic Series'
+  238 | => /starwars2/publication/comics/issues Issues
+  239 | => /starwars2/publication/comics/tpbs TPBs
+  240 | 
+  241 | %s
+  242 | `, builder.String()))
+  243 | 	})
+  244 | 
+  245 | 	s.AddRoute("/starwars2/timeline/comics/issues", func(request sis.Request) {
+  246 | 		issues, lastUpdate := GetComicIssues(conn, true)
+  247 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: Comic Issues (Timeline)\n"})
+  248 | 		if request.ScrollMetadataRequested {
+  249 | 			request.SendAbstract("")
+  250 | 			return
+  251 | 		}
+  252 | 
+  253 | 		heading, data := constructTableDataFromIssues(issues)
+  254 | 		table := constructTable(heading, data)
+  255 | 
+  256 | 		var builder strings.Builder
+  257 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", table)
+  258 | 
+  259 | 		request.Gemini(fmt.Sprintf(`# Star Wars Comics - Issues
+  260 | 
+  261 | => /starwars2/ Home
+  262 | => /starwars2/timeline/comics Comic Series'
+  263 | => /starwars2/timeline/comics/issues Issues
+  264 | => /starwars2/timeline/comics/tpbs TPBs
+  265 | 
+  266 | %s
+  267 | `, builder.String()))
+  268 | 	})
+  269 | 
+  270 | 	s.AddRoute("/starwars2/publication/comics/issues", func(request sis.Request) {
+  271 | 		issues, lastUpdate := GetComicIssues(conn, false)
+  272 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: Comic Issues (Publication)\n"})
+  273 | 		if request.ScrollMetadataRequested {
+  274 | 			request.SendAbstract("")
+  275 | 			return
+  276 | 		}
+  277 | 
+  278 | 		heading, data := constructTableDataFromIssues(issues)
+  279 | 		table := constructTable(heading, data)
+  280 | 
+  281 | 		var builder strings.Builder
+  282 | 		fmt.Fprintf(&builder, "```\n%s```\n\n", table)
+  283 | 
+  284 | 		request.Gemini(fmt.Sprintf(`# Star Wars Comics - Issues
+  285 | 
+  286 | => /starwars2/ Home
+  287 | => /starwars2/publication/comics Comic Series'
+  288 | => /starwars2/publication/comics/issues Issues
+  289 | => /starwars2/publication/comics/tpbs TPBs
+  290 | 
+  291 | %s
+  292 | `, builder.String()))
+  293 | 	})
+  294 | 
+  295 | 	s.AddRoute("/starwars2/timeline/bookseries", func(request sis.Request) {
+  296 | 		var builder strings.Builder
+  297 | 
+  298 | 		series, lastUpdate := GetBookSeries(conn)
+  299 | 		series_header, series_tableData := constructTableDataFromBookSeries(series)
+  300 | 		series_table := constructTable(series_header, series_tableData)
+  301 | 		fmt.Fprintf(&builder, "## Series'\n```\n%s\n```\n\n", series_table)
+  302 | 
+  303 | 		standalones, lastUpdate2 := GetBookStandalones(conn)
+  304 | 		if lastUpdate.Before(lastUpdate2) {
+  305 | 			lastUpdate = lastUpdate2
+  306 | 		}
+  307 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: Book Series (Timeline)\n"})
+  308 | 		if request.ScrollMetadataRequested {
+  309 | 			request.SendAbstract("")
+  310 | 			return
+  311 | 		}
+  312 | 
+  313 | 		standalones_header, standalones_tableData := constructTableDataFromBookStandalones(standalones)
+  314 | 		standalones_table := constructTable(standalones_header, standalones_tableData)
+  315 | 		fmt.Fprintf(&builder, "## Standalones\n```\n%s\n```\n\n", standalones_table)
+  316 | 
+  317 | 		request.Gemini(fmt.Sprintf(`# Star Wars Book Series'
+  318 | 
+  319 | => /starwars2/ Home
+  320 | => /starwars2/timeline/bookseries Book Series'
+  321 | => /starwars2/timeline/books Books
+  322 | 
+  323 | %s
+  324 | `, builder.String()))
+  325 | 	})
+  326 | 
+  327 | 	s.AddRoute("/starwars2/timeline/books", func(request sis.Request) {
+  328 | 		books, lastUpdate := GetBooks(conn)
+  329 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: Books (Timeline)\n"})
+  330 | 		if request.ScrollMetadataRequested {
+  331 | 			request.SendAbstract("")
+  332 | 			return
+  333 | 		}
+  334 | 
+  335 | 		header, tableData := constructTableDataFromBooks(books)
+  336 | 		table := constructTable(header, tableData)
+  337 | 
+  338 | 		var builder strings.Builder
+  339 | 		fmt.Fprintf(&builder, "```\n%s\n```\n\n", table)
+  340 | 
+  341 | 		request.Gemini(fmt.Sprintf(`# Star Wars Books
+  342 | 
+  343 | => /starwars2/ Home
+  344 | => /starwars2/timeline/bookseries Book Series'
+  345 | => /starwars2/timeline/books Books
+  346 | 
+  347 | %s
+  348 | `, builder.String()))
+  349 | 	})
+  350 | }
+  351 | 
+  352 | func handleMovies(request sis.Request, conn *sql.DB, timeline bool) {
+  353 | 	movies, lastUpdate := GetMovies(conn, timeline)
+  354 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: Movies\n"})
+  355 | 	if request.ScrollMetadataRequested {
+  356 | 		request.SendAbstract("")
+  357 | 		return
+  358 | 	}
+  359 | 
+  360 | 	header, tableData := constructTableDataFromMovies(movies)
+  361 | 	table := constructTable(header, tableData)
+  362 | 
+  363 | 	var builder strings.Builder
+  364 | 	fmt.Fprintf(&builder, "```\n%s\n```\n", table)
+  365 | 
+  366 | 	request.Gemini(fmt.Sprintf(`# Star Wars Movies
+  367 | 
+  368 | => /starwars2/ Home
+  369 | 
+  370 | %s
+  371 | => movies/csv CSV File
+  372 | `, builder.String()))
+  373 | }
+  374 | 
+  375 | func handleMoviesCSV(request sis.Request, conn *sql.DB, timeline bool) {
+  376 | 	movies, lastUpdate := GetMovies(conn, timeline)
+  377 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: publishDate, UpdateDate: lastUpdate, Language: "en", Abstract: "# Star Wars Database: Movies CSV\n"})
+  378 | 	if request.ScrollMetadataRequested {
+  379 | 		request.SendAbstract("text/csv")
+  380 | 		return
+  381 | 	}
+  382 | 
+  383 | 	header, tableData := constructTableDataFromMovies(movies)
+  384 | 
+  385 | 	var builder strings.Builder
+  386 | 	for colNum, col := range header {
+  387 | 		fmt.Fprintf(&builder, "%s", col)
+  388 | 		if colNum < len(header)-1 {
+  389 | 			fmt.Fprintf(&builder, ",")
+  390 | 		}
+  391 | 	}
+  392 | 	fmt.Fprintf(&builder, "\n")
+  393 | 
+  394 | 	for _, row := range tableData {
+  395 | 		for colNum, col := range row {
+  396 | 			fmt.Fprintf(&builder, "%s", col)
+  397 | 			if colNum < len(row)-1 {
+  398 | 				fmt.Fprintf(&builder, ",")
+  399 | 			}
+  400 | 		}
+  401 | 		fmt.Fprintf(&builder, "\n")
+  402 | 	}
+  403 | 
+  404 | 	request.TextWithMimetype("text/csv", builder.String())
+  405 | 	//return c.Blob("text/csv", []byte(builder.String()))
+  406 | }

gemini/starwars/tables.go (created)

+    1 | package starwars
+    2 | 
+    3 | import (
+    4 | 	"strings"
+    5 | 	"math"
+    6 | 	"fmt"
+    7 | 	"strconv"
+    8 | 	"github.com/rivo/uniseg"
+    9 | )
+   10 | 
+   11 | func constructTableDataFromMovies(movies []Movie) ([]string, [][]string) {
+   12 | 	var rows [][]string
+   13 | 	for _, movie := range movies {
+   14 | 		cols := make([]string, 3)
+   15 | 		cols[0] = movie.Name
+   16 | 		year, aby := convertIntToStarWarsYear(movie.TimelineDate)
+   17 | 			if !aby {
+   18 | 				cols[1] = fmt.Sprintf("%d BBY", year)
+   19 | 			} else {
+   20 | 				cols[1] = fmt.Sprintf("%d ABY", year)
+   21 | 		}
+   22 | 		year, month, day := movie.PublicationDate.Date()
+   23 | 		cols[2] = fmt.Sprintf("%04d-%02d-%02d", year, month, day)
+   24 | 
+   25 | 		rows = append(rows, cols)
+   26 | 	}
+   27 | 	return []string { "TITLE", "BBY/ABY", "PUBLICATION" }, rows
+   28 | }
+   29 | 
+   30 | func constructTableDataFromShows(shows []TVShow) ([]string, [][]string) {
+   31 | 	var rows [][]string
+   32 | 	for _, show := range shows {
+   33 | 		cols := make([]string, 1)
+   34 | 		cols[0] = show.Name
+   35 | 
+   36 | 		rows = append(rows, cols)
+   37 | 	}
+   38 | 	return []string { "TITLE" }, rows
+   39 | }
+   40 | 
+   41 | func constructTableDataFromSeries(series []ComicSeries) ([]string, [][]string) {
+   42 | 	var rows [][]string
+   43 | 	for _, serie := range series {
+   44 | 		cols := make([]string, 4)
+   45 | 		cols[0] = serie.Name
+   46 | 		cols[1] = strconv.Itoa(serie.IssueCount)
+   47 | 		cols[2] = strconv.Itoa(serie.TPBCount)
+   48 | 		cols[3] = strconv.Itoa(serie.StartYear)
+   49 | 
+   50 | 		rows = append(rows, cols)
+   51 | 	}
+   52 | 	return []string { "TITLE", "ISSUES", "TPBs", "YEAR" }, rows
+   53 | }
+   54 | 
+   55 | func constructTableDataFromCrossover(tpbs []ComicTPB) ([]string, [][]string) {
+   56 | 	var rows [][]string
+   57 | 	for _, tpb := range tpbs {
+   58 | 		cols := make([]string, 2)
+   59 | 		cols[0] = tpb.Name
+   60 | 		year, month, day := tpb.PublicationDate.Date()
+   61 | 		cols[1] = fmt.Sprintf("%04d-%02d-%02d", year, month, day)
+   62 | 
+   63 | 		rows = append(rows, cols)
+   64 | 	}
+   65 | 	return []string { "TITLE", "PUBLICATION" }, rows
+   66 | }
+   67 | 
+   68 | func constructTableDataFromTPBs(tpbs []ComicTPB) ([]string, [][]string) {
+   69 | 	var rows [][]string
+   70 | 	for _, tpb := range tpbs {
+   71 | 		cols := make([]string, 5)
+   72 | 		if tpb.Series.Name != "" {
+   73 | 			cols[0] = fmt.Sprintf("%s (%d)", tpb.Series.Name, tpb.Series.StartYear)
+   74 | 		} else {
+   75 | 			cols[0] = "-"
+   76 | 		}
+   77 | 		cols[1] = strconv.Itoa(tpb.Volume)
+   78 | 		cols[2] = tpb.Name
+   79 | 		cols[3] = strconv.Itoa(tpb.IssueCount)
+   80 | 		year, month, day := tpb.PublicationDate.Date()
+   81 | 		cols[4] = fmt.Sprintf("%04d-%02d-%02d", year, month, day)
+   82 | 
+   83 | 		rows = append(rows, cols)
+   84 | 	}
+   85 | 	return []string { "SERIES", "VOL", "TITLE", "ISSUES", "PUBLICATION" }, rows
+   86 | }
+   87 | 
+   88 | func constructTableDataFromIssues(issues []ComicIssue) ([]string, [][]string) {
+   89 | 	var rows [][]string
+   90 | 	for _, issue := range issues {
+   91 | 		cols := make([]string, 6)
+   92 | 		if issue.Series.Name != "" {
+   93 | 			cols[0] = fmt.Sprintf("%s (%d)", issue.Series.Name, issue.Series.StartYear)
+   94 | 			cols[1] = strconv.Itoa(issue.Number)
+   95 | 			if issue.Annual {
+   96 | 				cols[1] = "A" + cols[1]
+   97 | 			}
+   98 | 		} else {
+   99 | 			cols[0] = "-"
+  100 | 			cols[1] = "-"
+  101 | 		}
+  102 | 		cols[2] = issue.Name
+  103 | 
+  104 | 		year, aby := convertIntToStarWarsYear(issue.TimelineDate)
+  105 | 		if !aby {
+  106 | 				cols[3] = fmt.Sprintf("%d BBY", year)
+  107 | 			} else {
+  108 | 				cols[3] = fmt.Sprintf("%d ABY", year)
+  109 | 		}
+  110 | 
+  111 | 		year, month, day := issue.PublicationDate.Date()
+  112 | 		cols[4] = fmt.Sprintf("%04d-%02d-%02d", year, month, day)
+  113 | 		cols[5] = issue.Publisher
+  114 | 
+  115 | 		rows = append(rows, cols)
+  116 | 	}
+  117 | 	return []string { "SERIES", "#", "TITLE", "BBY/ABY", "PUBLICATION", "PUBLISHER" }, rows
+  118 | }
+  119 | 
+  120 | func constructTableDataFromOneshots(issues []ComicIssue) ([]string, [][]string) {
+  121 | 	var rows [][]string
+  122 | 	for _, issue := range issues {
+  123 | 		cols := make([]string, 4)
+  124 | 		cols[0] = issue.Name
+  125 | 
+  126 | 		year, aby := convertIntToStarWarsYear(issue.TimelineDate)
+  127 | 		if !aby {
+  128 | 				cols[1] = fmt.Sprintf("%d BBY", year)
+  129 | 			} else {
+  130 | 				cols[1] = fmt.Sprintf("%d ABY", year)
+  131 | 		}
+  132 | 
+  133 | 		year, month, day := issue.PublicationDate.Date()
+  134 | 		cols[2] = fmt.Sprintf("%04d-%02d-%02d", year, month, day)
+  135 | 		cols[3] = issue.Publisher
+  136 | 
+  137 | 		rows = append(rows, cols)
+  138 | 	}
+  139 | 	return []string { "TITLE", "BBY/ABY", "PUBLICATION", "PUBLISHER" }, rows
+  140 | }
+  141 | 
+  142 | func constructTableDataFromBookSeries(series []BookSeries) ([]string, [][]string) {
+  143 | 	var rows [][]string
+  144 | 	for _, serie := range series {
+  145 | 		cols := make([]string, 2)
+  146 | 		cols[0] = serie.Name
+  147 | 		cols[1] = strconv.Itoa(serie.BookCount)
+  148 | 
+  149 | 		rows = append(rows, cols)
+  150 | 	}
+  151 | 	return []string { "TITLE", "BOOKS" }, rows
+  152 | }
+  153 | 
+  154 | func constructTableDataFromBooks(books []Book) ([]string, [][]string) {
+  155 | 	var rows [][]string
+  156 | 	for _, book := range books {
+  157 | 		cols := make([]string, 6)
+  158 | 		if book.Series.Name != "" {
+  159 | 			cols[0] = fmt.Sprintf("%s", book.Series.Name)
+  160 | 			cols[1] = strconv.Itoa(book.Number)
+  161 | 		} else {
+  162 | 			cols[0] = "-"
+  163 | 			cols[1] = "-"
+  164 | 		}
+  165 | 
+  166 | 		cols[2] = book.Name
+  167 | 		cols[3] = book.Author
+  168 | 
+  169 | 		year, aby := convertIntToStarWarsYear(book.TimelineDate)
+  170 | 		if !aby {
+  171 | 				cols[4] = fmt.Sprintf("%d BBY", year)
+  172 | 			} else {
+  173 | 				cols[4] = fmt.Sprintf("%d ABY", year)
+  174 | 		}
+  175 | 
+  176 | 		year, month, day := book.PublicationDate.Date()
+  177 | 		cols[5] = fmt.Sprintf("%04d-%02d-%02d", year, month, day)
+  178 | 
+  179 | 		rows = append(rows, cols)
+  180 | 	}
+  181 | 	return []string { "SERIES", "#", "TITLE", "AUTHOR", "BBY/ABY", "PUBLICATION" }, rows // TODO: Add Publisher?
+  182 | }
+  183 | 
+  184 | func constructTableDataFromBookStandalones(books []Book) ([]string, [][]string) {
+  185 | 	var rows [][]string
+  186 | 	for _, book := range books {
+  187 | 		cols := make([]string, 4)
+  188 | 
+  189 | 		cols[0] = book.Name
+  190 | 		cols[1] = book.Author
+  191 | 
+  192 | 		year, aby := convertIntToStarWarsYear(book.TimelineDate)
+  193 | 		if !aby {
+  194 | 				cols[2] = fmt.Sprintf("%d BBY", year)
+  195 | 			} else {
+  196 | 				cols[2] = fmt.Sprintf("%d ABY", year)
+  197 | 		}
+  198 | 
+  199 | 		year, month, day := book.PublicationDate.Date()
+  200 | 		cols[3] = fmt.Sprintf("%04d-%02d-%02d", year, month, day)
+  201 | 
+  202 | 		rows = append(rows, cols)
+  203 | 	}
+  204 | 	return []string { "TITLE", "AUTHOR", "BBY/ABY", "PUBLICATION" }, rows // TODO: Add Publisher?
+  205 | }
+  206 | 
+  207 | func constructTable(headingRow []string, data [][]string) string {
+  208 | 	if len(data) == 0 {
+  209 | 		return ""
+  210 | 	}
+  211 | 	cellLengthLimit := 37
+  212 | 	var builder strings.Builder
+  213 | 
+  214 | 	// Get maximum length of each column and number of lines (for overflow)
+  215 | 	colLengths := make([]int, len(data[0]))
+  216 | 	rowLines := make([]int, len(data))
+  217 | 	for colNum, col := range headingRow {
+  218 | 		graphemeCount := uniseg.GraphemeClusterCount(col)
+  219 | 		if graphemeCount > colLengths[colNum] && graphemeCount <= cellLengthLimit {
+  220 | 			colLengths[colNum] = graphemeCount
+  221 | 		}
+  222 | 	}
+  223 | 	for rowNum, row := range data {
+  224 | 		for colNum, col := range row {
+  225 | 			graphemeCount := uniseg.GraphemeClusterCount(col)
+  226 | 			if graphemeCount > colLengths[colNum] && graphemeCount <= cellLengthLimit {
+  227 | 				colLengths[colNum] = graphemeCount
+  228 | 			}
+  229 | 
+  230 | 			lines := int(math.Ceil(float64(graphemeCount) / float64(cellLengthLimit)))
+  231 | 			if lines > rowLines[rowNum] {
+  232 | 				rowLines[rowNum] = lines
+  233 | 			}
+  234 | 		}
+  235 | 	}
+  236 | 
+  237 | 	// Construct heading row - First Line
+  238 | 	for colNum, _ := range headingRow {
+  239 | 		length := colLengths[colNum]
+  240 | 
+  241 | 		if colNum == 0 {
+  242 | 			fmt.Fprintf(&builder, "╔═")
+  243 | 		} else {
+  244 | 			fmt.Fprintf(&builder, "╤═")
+  245 | 		}
+  246 | 
+  247 | 		for i := 0; i < length + 1; i++ {
+  248 | 			fmt.Fprintf(&builder, "═")
+  249 | 		}
+  250 | 	}
+  251 | 	fmt.Fprintf(&builder, "╗\n")
+  252 | 
+  253 | 	// Heading Row Contents
+  254 | 	for colNum, col := range headingRow {
+  255 | 		diff := colLengths[colNum] - len(col)
+  256 | 
+  257 | 		if colNum == 0 {
+  258 | 			fmt.Fprintf(&builder, "║ ")
+  259 | 		} else {
+  260 | 			fmt.Fprintf(&builder, "│ ")
+  261 | 		}
+  262 | 
+  263 | 		fmt.Fprintf(&builder, "%s", col)
+  264 | 		for i := 0; i < diff + 1; i++ {
+  265 | 			fmt.Fprintf(&builder, " ")
+  266 | 		}
+  267 | 	}
+  268 | 	fmt.Fprintf(&builder, "║\n")
+  269 | 
+  270 | 	// Heading Row Bottom
+  271 | 	for colNum, _ := range headingRow {
+  272 | 		length := colLengths[colNum]
+  273 | 
+  274 | 		if colNum == 0 {
+  275 | 			fmt.Fprintf(&builder, "╠═")
+  276 | 		} else {
+  277 | 			fmt.Fprintf(&builder, "╪═")
+  278 | 		}
+  279 | 
+  280 | 		for i := 0; i < length + 1; i++ {
+  281 | 			fmt.Fprintf(&builder, "═")
+  282 | 		}
+  283 | 	}
+  284 | 	fmt.Fprintf(&builder, "╣\n")
+  285 | 
+  286 | 	// Data
+  287 | 	for rowNum, row := range data { // TODO: I'm pretty sure this is very slow
+  288 | 		// Contents
+  289 | 		for rowLine := 1; rowLine <= rowLines[rowNum]; rowLine++ {
+  290 | 			for colNum, col := range row {
+  291 | 				if colNum == 0 {
+  292 | 					fmt.Fprintf(&builder, "║ ")
+  293 | 				} else {
+  294 | 					fmt.Fprintf(&builder, "│ ")
+  295 | 				}
+  296 | 
+  297 | 				graphemeCount := 0
+  298 | 				graphemeIndex := 0
+  299 | 				gr := uniseg.NewGraphemes(col)
+  300 | 
+  301 | 				start := (cellLengthLimit * rowLine) - cellLengthLimit - 1
+  302 | 				end := (cellLengthLimit * rowLine) - 1
+  303 | 				for gr.Next() {
+  304 | 					if graphemeIndex >= start && graphemeIndex < end {
+  305 | 						graphemeCount++
+  306 | 						runes := gr.Runes()
+  307 | 						fmt.Fprintf(&builder, "%s", string(runes))
+  308 | 					}
+  309 | 					graphemeIndex++
+  310 | 				}
+  311 | 
+  312 | 				diff := colLengths[colNum] - graphemeCount
+  313 | 
+  314 | 				/*if len(runes) > 0 {
+  315 | 					start := (cellLengthLimit * rowLine) - cellLengthLimit
+  316 | 					if start < len(runes) {
+  317 | 						end := (cellLengthLimit * rowLine)
+  318 | 						if end >= len(runes) {
+  319 | 							end = len(runes)
+  320 | 						}
+  321 | 						content := string(runes[start:end])
+  322 | 						fmt.Fprintf(&builder, "%s", content)
+  323 | 						diff = colLengths[colNum] - len(content)
+  324 | 					}
+  325 | 				}*/
+  326 | 				for i := 0; i < diff + 1; i++ {
+  327 | 					fmt.Fprintf(&builder, " ")
+  328 | 				}
+  329 | 			}
+  330 | 			fmt.Fprintf(&builder, "║\n")
+  331 | 		}
+  332 | 
+  333 | 		// Bottom
+  334 | 		if rowNum == len(data) - 1 {
+  335 | 			for colNum, _ := range row {
+  336 | 				length := colLengths[colNum]
+  337 | 
+  338 | 				if colNum == 0 {
+  339 | 					fmt.Fprintf(&builder, "╚═")
+  340 | 				} else {
+  341 | 					fmt.Fprintf(&builder, "╧═")
+  342 | 				}
+  343 | 
+  344 | 				for i := 0; i < length + 1; i++ {
+  345 | 					fmt.Fprintf(&builder, "═")
+  346 | 				}
+  347 | 			}
+  348 | 			fmt.Fprintf(&builder, "╝\n")
+  349 | 		} else {
+  350 | 			for colNum, _ := range row {
+  351 | 				length := colLengths[colNum]
+  352 | 
+  353 | 				if colNum == 0 {
+  354 | 					fmt.Fprintf(&builder, "╟─")
+  355 | 				} else {
+  356 | 					fmt.Fprintf(&builder, "┼─")
+  357 | 				}
+  358 | 
+  359 | 				for i := 0; i < length + 1; i++ {
+  360 | 					fmt.Fprintf(&builder, "─")
+  361 | 				}
+  362 | 			}
+  363 | 			fmt.Fprintf(&builder, "╢\n")
+  364 | 		}
+  365 | 	}
+  366 | 
+  367 | 	return builder.String()
+  368 | }

gemini/starwars/types.go (created)

+    1 | package starwars
+    2 | 
+    3 | import (
+    4 | 	"time"
+    5 | )
+    6 | 
+    7 | // Franchise
+    8 | 
+    9 | type ComicSeries struct {
+   10 | 	Id int
+   11 | 	Name string
+   12 | 	Oneoff bool
+   13 | 	Miniseries bool
+   14 | 	StartYear int
+   15 | 	Date_added time.Time
+   16 | 
+   17 | 	TPBCount int
+   18 | 	IssueCount int
+   19 | 	// AnnualCount int
+   20 | }
+   21 | 
+   22 | type comicSeriesNullable struct {
+   23 | 	Id interface{}
+   24 | 	Name interface{}
+   25 | 	Oneoff interface{}
+   26 | 	Miniseries interface{}
+   27 | 	StartYear interface{}
+   28 | 	Date_added interface{}
+   29 | }
+   30 | 
+   31 | func comicSeriesScan(series comicSeriesNullable) ComicSeries {
+   32 | 	var result ComicSeries
+   33 | 	if series.Id != nil {
+   34 | 		result.Id = int(series.Id.(int32))
+   35 | 	}
+   36 | 	if series.Name != nil {
+   37 | 		result.Name = string(series.Name.([]uint8))
+   38 | 	}
+   39 | 	if series.Oneoff != nil {
+   40 | 		result.Oneoff = series.Oneoff.(bool)
+   41 | 	}
+   42 | 	if series.Miniseries != nil {
+   43 | 		result.Miniseries = series.Miniseries.(bool)
+   44 | 	}
+   45 | 	if series.StartYear != nil {
+   46 | 		result.StartYear = int(series.StartYear.(int32))
+   47 | 	}
+   48 | 	if series.Date_added != nil {
+   49 | 		result.Date_added = series.Date_added.(time.Time)
+   50 | 	}
+   51 | 
+   52 | 	return result
+   53 | }
+   54 | 
+   55 | type ComicTPB struct {
+   56 | 	Id int
+   57 | 	Volume int
+   58 | 	Name string
+   59 | 	Crossover bool
+   60 | 
+   61 | 	ComicSeriesId int // Optional
+   62 | 	
+   63 | 	TimelineDate int // NOTE: Only used if TPB doesn't consist of individual issues
+   64 | 	PublicationDate time.Time
+   65 | 	Date_added time.Time
+   66 | 
+   67 | 	Series ComicSeries
+   68 | 	IssueCount int
+   69 | }
+   70 | 
+   71 | type ComicIssue struct {
+   72 | 	Id int
+   73 | 	Number int
+   74 | 	Name string
+   75 | 	Annual bool
+   76 | 	
+   77 | 	ComicSeriesId int
+   78 | 	ComicTPBId int
+   79 | 	ComicCrossoverId int
+   80 | 
+   81 | 	TimelineDate int
+   82 | 	PublicationDate time.Time
+   83 | 	Publisher string // Marvel, IDW
+   84 | 	Date_added time.Time
+   85 | 
+   86 | 	Series ComicSeries
+   87 | 	//TPB ComicTPB // TODO
+   88 | }
+   89 | 
+   90 | /*type ComicOmnibus struct {
+   91 | 	Id int
+   92 | 	Volume int
+   93 | 	Name string
+   94 | 	ComicSeriesId int
+   95 | 	TimelineDate string
+   96 | 	PublicationDate time.Time
+   97 | 	Date_added int
+   98 | }*/
+   99 | 
+  100 | type BookSeries struct {
+  101 | 	Id int
+  102 | 	Name string
+  103 | 	Date_added time.Time
+  104 | 
+  105 | 	BookCount int
+  106 | }
+  107 | 
+  108 | type bookSeriesNullable struct {
+  109 | 	Id interface{}
+  110 | 	Name interface{}
+  111 | 	Date_added interface{}
+  112 | }
+  113 | 
+  114 | 
+  115 | func bookSeriesScan(serie bookSeriesNullable) BookSeries {
+  116 | 	var result BookSeries
+  117 | 	if serie.Id != nil {
+  118 | 		result.Id = int(serie.Id.(int32))
+  119 | 	}
+  120 | 	if serie.Name != nil {
+  121 | 		result.Name = string(serie.Name.([]uint8))
+  122 | 	}
+  123 | 	if serie.Date_added != nil {
+  124 | 		result.Date_added = serie.Date_added.(time.Time)
+  125 | 	}
+  126 | 
+  127 | 	return result
+  128 | }
+  129 | 
+  130 | type Book struct {
+  131 | 	Id int
+  132 | 	Number int
+  133 | 	Name string
+  134 | 	BookType string // Adult, YA, Junior, Young, ShortStory
+  135 | 	Author string
+  136 | 	BookSeriesId int
+  137 | 	TimelineDate int
+  138 | 	PublicationDate time.Time
+  139 | 	Publisher string
+  140 | 	Date_added time.Time
+  141 | 
+  142 | 	Series BookSeries
+  143 | }
+  144 | 
+  145 | type bookNullable struct {
+  146 | 	Id interface{}
+  147 | 	Number interface{}
+  148 | 	Name interface{}
+  149 | 	BookType interface{}
+  150 | 	Author interface{}
+  151 | 	BookSeriesId interface{}
+  152 | 	TimelineDate interface{}
+  153 | 	PublicationDate interface{}
+  154 | 	Publisher interface{}
+  155 | 	Date_added interface{}
+  156 | }
+  157 | 
+  158 | func bookScan(book bookNullable) Book {
+  159 | 	var result Book
+  160 | 	if book.Id != nil {
+  161 | 		result.Id = int(book.Id.(int32))
+  162 | 	}
+  163 | 	if book.Number != nil {
+  164 | 		result.Number = int(book.Number.(int32))
+  165 | 	}
+  166 | 	if book.Name != nil {
+  167 | 		result.Name = string(book.Name.([]uint8))
+  168 | 	}
+  169 | 	if book.BookType != nil {
+  170 | 		result.BookType = string(book.BookType.([]uint8))
+  171 | 	}
+  172 | 	if book.Author != nil {
+  173 | 		result.Author = string(book.Author.([]uint8))
+  174 | 	}
+  175 | 	if book.BookSeriesId != nil {
+  176 | 		result.BookSeriesId = int(book.BookSeriesId.(int32))
+  177 | 	}
+  178 | 	if book.TimelineDate != nil {
+  179 | 		result.TimelineDate = int(book.TimelineDate.(int32))
+  180 | 	}
+  181 | 	if book.PublicationDate != nil {
+  182 | 		result.PublicationDate = book.PublicationDate.(time.Time)
+  183 | 	}
+  184 | 	if book.Publisher != nil {
+  185 | 		result.Publisher = string(book.Publisher.([]uint8))
+  186 | 	}
+  187 | 	if book.Date_added != nil {
+  188 | 		result.Date_added = book.Date_added.(time.Time)
+  189 | 	}
+  190 | 
+  191 | 	return result
+  192 | }
+  193 | 
+  194 | type Movie struct {
+  195 | 	Id int
+  196 | 	Name string
+  197 | 	TimelineDate int
+  198 | 	PublicationDate time.Time
+  199 | 	ProductionCompany string
+  200 | 	Distributor string
+  201 | 	Date_added time.Time
+  202 | }
+  203 | 
+  204 | type TVShow struct {
+  205 | 	Id int
+  206 | 	Name string
+  207 | 	Date_added time.Time
+  208 | }
+  209 | 
+  210 | type TVShowEpisode struct {
+  211 | 	Id int
+  212 | 	Number int
+  213 | 	Season int
+  214 | 	Name string
+  215 | 	TvShowId int
+  216 | 	TimelineDate int
+  217 | 	PublicationDate time.Time
+  218 | 	Date_added time.Time
+  219 | }
+  220 | 
+  221 | // -1 = 0 BBY; 0 = 0 ABY
+  222 | // (-timeline + 1) * -1 = BBY; BBY - 1 = -timeline
+  223 | func convertStarWarsYearToInt(year int, aby bool) int {
+  224 | 	if !aby {
+  225 | 		return year - 1
+  226 | 	} else {
+  227 | 		return year
+  228 | 	}
+  229 | }
+  230 | 
+  231 | func convertIntToStarWarsYear(timeline int) (int, bool) {
+  232 | 	if timeline < 0 { // BBY
+  233 | 		return (timeline + 1) * -1, false
+  234 | 	} else { // ABY
+  235 | 		return timeline, true
+  236 | 	}
+  237 | }

gemini/textola/textola.go

   ... | ...
   170 | 	filedata, _ := s.GetServer().FS.ReadFile("the_cask_of_amontillado.txt")
   171 | 	return makeTextFromString("Presenting Edgar Allan Poe's The Cask of Amontillado", string(filedata), TextolaContentType_OldClassicsFiction)
   172 | }
   173 | 
   174 | func HandleTextola(s sis.ServerHandle) {
-  175 | 	//fmt.Printf("GuestbookText: %s\n", theCaskOfAmontillado)
+  175 | 	publishDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-19T13:51:00", time.Local)
+  176 | 	updateDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-19T13:51:00", time.Local)
   176 | 	var context *TextolaContext = &TextolaContext{
   177 | 		currentText: getTextFromSchedule(s),
   178 | 		mutex:       sync.RWMutex{},
   179 | 	}
   180 | 	context.readCond = sync.NewCond(context.mutex.RLocker())
   ... | ...
   181 | 
   182 | 	//fmt.Printf("Textola Text: (Lines %d) %#v\n", len(context.currentText.lines), context.currentText.lines)
   183 | 
   184 | 	var connectedClients atomic.Int64
   185 | 	s.AddRoute("/textola/", func(request sis.Request) {
+  187 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: publishDate, UpdateDate: updateDate, Language: "en", Abstract: "# Textola\n"})
+  188 | 		if request.ScrollMetadataRequested {
+  189 | 			request.SendAbstract("")
+  190 | 			return
+  191 | 		}
   186 | 		request.Gemini("# Textola\n\n")
   187 | 		limiter := rate.NewLimiter(rate.Every(time.Second), 1)
   188 | 
   189 | 		connectedClients.Add(1)
   190 | 

gemini/texts/christianity/christianity.go

   ... | ...
    92 | 
    93 | 	// Cache the books from the ASV version of the bible. These should be the same for the ESV bible as well. Note: This does not include the apocrypha.
    94 | 	asvBooks := GetBooks(englishBibleVersions[0].Id, apiKey)
    95 | 
    96 | 	g.AddRoute("/scriptures/christian/", func(request sis.Request) {
+   97 | 		request.SetNoLanguage()
    97 | 		var builder strings.Builder
    98 | 		fmt.Fprintf(&builder, "## Bible Versions\n")
    99 | 		fmt.Fprintf(&builder, "### English\n")
   100 | 		fmt.Fprintf(&builder, "=> /scriptures/christian/bible/esv/ ESV Bible\n")
   101 | 		for _, version := range englishBibleVersions {
   ... | ...
   163 | Tags: #bible #new #old #testament #septuagint #pentateuch 
   164 | `, builder.String()))
   165 | 	})
   166 | 
   167 | 	g.AddRoute("/scriptures/christian/bible/esv/", func(request sis.Request) {
+  169 | 		request.SetLanguage("en-US")
   168 | 		var builder strings.Builder
   169 | 		for _, book := range asvBooks {
   170 | 			fmt.Fprintf(&builder, "=> /scriptures/christian/bible/esv/%s/ %s\n", url.PathEscape(book.Name+" 1"), book.Name)
   171 | 		}
   172 | 
   ... | ...
   183 | Users may not copy or download more than 500 verses of the ESV Bible or more than one half of any book of the ESV Bible.
   184 | `, builder.String()))
   185 | 	})
   186 | 
   187 | 	g.AddRoute("/scriptures/christian/bible/esv/:text", func(request sis.Request) {
+  190 | 		request.SetLanguage("en-US")
   188 | 		text := request.GetParam("text")
   189 | 		resp := GetPassages(text)
   190 | 		var builder strings.Builder
   191 | 		for _, s := range resp.Passages {
   192 | 			fmt.Fprintf(&builder, "%s", s)
   ... | ...
   199 | 	})
   200 | 
   201 | 	g.AddRoute("/scriptures/christian/bible/:id", func(request sis.Request) {
   202 | 		versionId := request.GetParam("id")
   203 | 		version := GetBibleVersion(versionId, apiKey)
+  207 | 		request.SetLanguage(version.Language.Id)
   204 | 		books := GetBooks(versionId, apiKey)
   205 | 		var builder strings.Builder
   206 | 		for _, book := range books {
   207 | 			fmt.Fprintf(&builder, "=> /scriptures/christian/bible/%s/%s/ %s\n", versionId, book.Id, book.Name)
   208 | 		}
   ... | ...
   221 | 
   222 | 	g.AddRoute("/scriptures/christian/bible/:id/:book", func(request sis.Request) {
   223 | 		versionId := request.GetParam("id")
   224 | 		bookId := request.GetParam("book")
   225 | 		version := GetBibleVersion(versionId, apiKey)
+  230 | 		request.SetLanguage(version.Language.Id)
   226 | 		book := GetBook(versionId, bookId, apiKey, true)
   227 | 		var builder strings.Builder
   228 | 		for _, chapter := range book.Chapters {
   229 | 			fmt.Fprintf(&builder, "=> /scriptures/christian/bible/%s/chapter/%s/ Chapter %s\n", versionId, chapter.Id, chapter.Number)
   230 | 		}
   ... | ...
   242 | 
   243 | 	g.AddRoute("/scriptures/christian/bible/:id/chapter/:chapter", func(request sis.Request) {
   244 | 		versionId := request.GetParam("id")
   245 | 		chapterId := request.GetParam("chapter")
   246 | 		version := GetBibleVersion(versionId, apiKey)
+  252 | 		request.SetLanguage(version.Language.Id)
   247 | 		chapter := GetChapter(versionId, chapterId, apiKey)
   248 | 		var builder strings.Builder
   249 | 		fmt.Fprintf(&builder, "%s", chapter.Content)
   250 | 		/*for _, chapter := range book.Chapters {
   251 | 			fmt.Fprintf(&builder, "=> /scriptures/christian/bible/%s/%s/%s Chapter %s\n", versionId, book.Id, chapter.Id, chapter.Number)

gemini/texts/islam/islam.go

   ... | ...
   106 | 	}
   107 | 
   108 | 	versionNames["arabic"] = "Qur'an"
   109 | 
   110 | 	g.AddRoute("/scriptures/islam", func(request sis.Request) {
+  111 | 		request.SetNoLanguage()
   111 | 		var builder strings.Builder
   112 | 		fmt.Fprintf(&builder, "## Qur'an Versions\n\n=> /scriptures/islam/quran/arabic/ Arabic\n")
   113 | 		fmt.Fprintf(&builder, "### English\n")
   114 | 		for _, version := range englishQuranVersions {
   115 | 			if version.Identifier != "" {
   ... | ...
   189 | 	g.AddRoute("/scriptures/islam/quran/:version/:surah", func(request sis.Request) {
   190 | 		versionId := request.GetParam("version")
   191 | 		surahNumber := request.GetParam("surah")
   192 | 
   193 | 		surah := GetSurah(versionId, surahNumber)
+  195 | 		request.SetLanguage(surah.Edition.Language)
   194 | 
   195 | 		var builder strings.Builder
   196 | 		for _, ayah := range surah.Ayahs {
   197 | 			fmt.Fprintf(&builder, "[%d] %s\n", ayah.NumberInSurah, ayah.Text)
   198 | 		}

gemini/texts/judaism/judaism.go

   ... | ...
    23 | 
    24 | func HandleJewishTexts(g sis.ServerHandle) {
    25 | 	//stripTags := bluemonday.StripTagsPolicy()
    26 | 
    27 | 	index := GetFullIndex()
+   28 | 	fmt.Printf("Index: %v\n", index)
    28 | 	//indexMap := make(map[string]int)
    29 | 
    30 | 	g.AddRoute("/scriptures/jewish/", func(request sis.Request) {
    31 | 		query, err := request.Query()
    32 | 		if err != nil {

gemini/texts/judaism/sefaria.go

   ... | ...
    73 | 	if err != nil {
    74 | 		fmt.Println(err)
    75 | 		return []SefariaIndexCategoryOrText{}
    76 | 	}
    77 | 
-   78 | 	req.Header.Set("User-Agent", "AuraGem")
+   78 | 	req.Header.Set("User-Agent", "ScholasticDiversity")
+   79 | 	req.Header.Set("accept", "application/json")
    79 | 	res, getErr := spaceClient.Do(req)
    80 | 	if getErr != nil {
    81 | 		fmt.Println(err)
    82 | 		return []SefariaIndexCategoryOrText{}
    83 | 	}
   ... | ...
   247 | 		_, filename, line, _ := runtime.Caller(0)
   248 | 		fmt.Printf("%s:%d %s\n", filename, line, err)
   249 | 		return SefariaText{}
   250 | 	}
   251 | 
-  252 | 	req.Header.Set("User-Agent", "AuraGem")
+  253 | 	req.Header.Set("User-Agent", "ScholasticDiversity")
+  254 | 	req.Header.Set("accept", "application/json")
   253 | 	res, getErr := spaceClient.Do(req)
   254 | 	if getErr != nil {
   255 | 		_, filename, line, _ := runtime.Caller(0)
   256 | 		fmt.Printf("%s:%d %s\n", filename, line, getErr)
   257 | 		return SefariaText{}
   ... | ...
   389 | 	AnchorRefExpanded []string `json:"anchorRefExpanded"`
   390 | 	SourceRef         string   `json:"sourceRef"`
   391 | 	SourceHeRef       string   `json:"sourceHeRef"`
   392 | 	AnchorVerse       int      `json:"anchorVerse"`
   393 | 	SourceHasEn       bool     `json:"sourceHasEn"`
-  394 | 	CompDate          int      `json:"compDate"`
+  396 | 	CompDate          []int    `json:"compDate"`
   395 | 	ErrorMargin       int      `json:"errorMargin"`
   396 | 	// AnchorVersion
   397 | 	//sourceVersion
   398 | 	DisplayedText   SefariaHebrewEnglishText `json:"displayedText"`
   399 | 	CommentaryNum   float64                  `json:"commentaryNum"` // Will have decimal if the commentary covers only a verse (a part of the given ref); will be whole number for number of commentaries on whole given ref
   ... | ...
   416 | 		_, filename, line, _ := runtime.Caller(0)
   417 | 		fmt.Printf("%s:%d %s\n", filename, line, err)
   418 | 		return []SefariaTextLink{}
   419 | 	}
   420 | 
-  421 | 	req.Header.Set("User-Agent", "AuraGem")
+  423 | 	req.Header.Set("User-Agent", "ScholasticDiversity")
+  424 | 	req.Header.Set("accept", "application/json")
   422 | 	res, getErr := spaceClient.Do(req)
   423 | 	if getErr != nil {
   424 | 		_, filename, line, _ := runtime.Caller(0)
   425 | 		fmt.Printf("%s:%d %s\n", filename, line, getErr)
   426 | 		return []SefariaTextLink{}
   ... | ...
   485 | 	if err != nil {
   486 | 		fmt.Println(err)
   487 | 		return SefariaCalendarResponse{}
   488 | 	}
   489 | 
-  490 | 	req.Header.Set("User-Agent", "AuraGem")
+  493 | 	req.Header.Set("User-Agent", "ScholasticDiversity")
   491 | 	res, getErr := spaceClient.Do(req)
   492 | 	if getErr != nil {
   493 | 		fmt.Println(err)
   494 | 		return SefariaCalendarResponse{}
   495 | 	}

gemini/texts/judaism/sefaria_test.go (created)

+    1 | package judaism
+    2 | 
+    3 | import (
+    4 | 	"fmt"
+    5 | 	"testing"
+    6 | )
+    7 | 
+    8 | // Test the sefaria index
+    9 | func TestSefariaIndex(t *testing.T) {
+   10 | 	index := GetFullIndex()
+   11 | 	fmt.Printf("index: %v\n", index)
+   12 | 	if len(index) == 0 {
+   13 | 		t.FailNow()
+   14 | 	}
+   15 | }
+   16 | 
+   17 | func TestSefariaText(t *testing.T) {
+   18 | 	text := GetText("Genesis 1", "en", "")
+   19 | 	if text.Ref == "" {
+   20 | 		t.FailNow()
+   21 | 	}
+   22 | }
+   23 | 
+   24 | func TestSefariaTextLinks(t *testing.T) {
+   25 | 	text := GetText("Genesis 1", "en", "")
+   26 | 	links := GetLinks(text.Ref, "en", "")
+   27 | 	if len(links) == 0 {
+   28 | 		t.FailNow()
+   29 | 	}
+   30 | }

gemini/utils/atom.go

   ... | ...
    12 | 	link  string
    13 | 	date  time.Time
    14 | 	title string
    15 | }
    16 | 
-   17 | func GenerateAtomFrom(file string, domain string, baseurl string, authorName string, authorEmail string) string {
+   17 | func GenerateAtomFrom(file string, domain string, baseurl string, authorName string, authorEmail string) (string, string, time.Time) {
    18 | 	//ISO8601Layout := "2006-01-02T15:04:05Z0700"
    19 | 	feedTitle := ""
    20 | 	var posts []AtomPost
    21 | 	last_updated, _ := time.Parse("2006-01-02T15:04:05Z", "2006-01-02T15:04:05Z")
    22 | 
   ... | ...
    77 | `, html.EscapeString(post.title), post.link, post.link, post_date_string)
    78 | 	}
    79 | 
    80 | 	fmt.Fprintf(&builder, ``)
    81 | 
-   82 | 	return builder.String()
+   82 | 	return builder.String(), feedTitle, last_updated
    83 | }

gemini/weather.go

   ... | ...
    13 | )
    14 | 
    15 | var apiKey = config.WeatherApiKey
    16 | 
    17 | func handleWeather(g sis.ServerHandle) {
+   18 | 	publishDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-19T13:51:00", time.Local)
    18 | 	g.AddRoute("/weather", func(request sis.Request) {
    19 | 		request.Redirect("/weather/")
    20 | 	})
    21 | 	g.AddRoute("/weather/", func(request sis.Request) {
   ... | ...
    17 | 	g.AddRoute("/weather", func(request sis.Request) {
    18 | 		request.Redirect("/weather/")
    19 | 	})
    20 | 	g.AddRoute("/weather/", func(request sis.Request) {
+   23 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{PublishDate: publishDate, UpdateDate: time.Now(), Language: "en", Abstract: "# Weather\n"})
+   24 | 		if request.ScrollMetadataRequested {
+   25 | 			request.SendAbstract("")
+   26 | 			return
+   27 | 		}
    22 | 		iqAirResponse := getNearestLocation(request)
    23 | 		request.Gemini(fmt.Sprintf(`# Weather for %s, %s, %s
    24 | 
    25 | %s
    26 | 

gemini/youtube/utils.go

   ... | ...
    52 | 
    53 | 	request.Gemini(fmt.Sprintf(template, builder.String()))
    54 | }
    55 | 
    56 | func getChannelPlaylists(request sis.Request, service *youtube.Service, channelId string, currentPage string) {
+   57 | 	request.SetNoLanguage()
    57 | 	template := `# Playlists for '%s'
    58 | 
    59 | => /youtube/channel/%s/ ChannelPage
    60 | %s`
    61 | 
   ... | ...
   104 | 		//log.Fatalf("Error: %v", err)
   105 | 		panic(err)
   106 | 	}
   107 | 
   108 | 	channel := response.Items[0]
+  110 | 	request.SetLanguage(channel.Snippet.DefaultLanguage)
   109 | 	uploadsPlaylistId := channel.ContentDetails.RelatedPlaylists.Uploads
   110 | 	time.Sleep(time.Millisecond * 120)
   111 | 
   112 | 	var call2 *youtube.PlaylistItemsListCall
   113 | 	if currentPage != "" {
   ... | ...
   151 | 		request.TemporaryFailure("Failed to get channel info.")
   152 | 		return
   153 | 	}
   154 | 
   155 | 	channel := response.Items[0]
+  158 | 	request.SetLanguage(channel.Snippet.DefaultLanguage)
   156 | 	uploadsPlaylistId := channel.ContentDetails.RelatedPlaylists.Uploads
   157 | 
   158 | 	time.Sleep(time.Millisecond * 120)
   159 | 	call2 := service.PlaylistItems.List([]string{"id", "snippet"}).PlaylistId(uploadsPlaylistId).MaxResults(100) // TODO
   160 | 	response2, err2 := call2.Do()
   ... | ...
   189 | 		return
   190 | 	}
   191 | 
   192 | 	playlist := response_pl.Items[0]
   193 | 	playlistTitle := playlist.Snippet.Title
+  197 | 	request.SetLanguage(playlist.Snippet.DefaultLanguage)
   194 | 
   195 | 	time.Sleep(time.Millisecond * 120)
   196 | 	var call *youtube.PlaylistItemsListCall
   197 | 	if currentPage != "" {
   198 | 		call = service.PlaylistItems.List([]string{"id", "snippet"}).PlaylistId(playlistId).MaxResults(50).PageToken(currentPage)

gemini/youtube/youtube.go

   ... | ...
    12 | 	"strings"
    13 | 	"time"
    14 | 
    15 | 	//"log"
    16 | 
-   17 | 	ytd "github.com/clseibold/youtube/v2"
+   17 | 	ytd "github.com/kkdai/youtube/v2"
    18 | 	"gitlab.com/clseibold/auragem_sis/config"
    19 | 	sis "gitlab.com/clseibold/smallnetinformationservices"
    20 | 	"google.golang.org/api/option"
    21 | 	"google.golang.org/api/youtube/v3"
    22 | )
   ... | ...
    53 | 	handleChannelPage(s, service)
    54 | 	handlePlaylistPage(s, service)
    55 | }
    56 | 
    57 | func indexRoute(request sis.Request) {
+   58 | 	creationDate, _ := time.ParseInLocation(time.RFC3339, "2024-03-17T11:57:00", time.Local)
+   59 | 	abstract := "#AuraGem YouTube Proxy\n\nProxies YouTube to Scroll/Gemini. Lets you search and download videos and playlists.\n"
+   60 | 	request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: "Christian Lee Seibold", PublishDate: creationDate.UTC(), UpdateDate: creationDate.UTC(), Language: "en", Abstract: abstract})
+   61 | 	if request.ScrollMetadataRequested {
+   62 | 		request.Scroll(abstract)
+   63 | 		return
+   64 | 	}
+   65 | 
    58 | 	request.Gemini("# AuraGem YouTube Proxy\n\nWelcome to the AuraGem YouTube Proxy!\n\n")
    59 | 	request.PromptLine("/youtube/search/", "Search")
    60 | 	request.Gemini("=> / AuraGem Home\n")
    61 | 	request.Gemini("=> gemini://kwiecien.us/gemcast/20210425.gmi See This Proxy Featured on Gemini Radio\n")
    62 | }
   ... | ...
    60 | 	request.Gemini("=> / AuraGem Home\n")
    61 | 	request.Gemini("=> gemini://kwiecien.us/gemcast/20210425.gmi See This Proxy Featured on Gemini Radio\n")
    62 | }
    63 | func getSearchRouteFunc(service *youtube.Service) sis.RequestHandler {
    64 | 	return func(request sis.Request) {
+   73 | 		request.SetNoLanguage()
    65 | 		query, err := request.RawQuery()
    66 | 		if err != nil {
    67 | 			request.TemporaryFailure(err.Error())
    68 | 			return
    69 | 		}
   ... | ...
    75 | 			if err != nil {
    76 | 				request.TemporaryFailure(err.Error())
    77 | 				return
    78 | 			}
    79 | 
+   89 | 			abstract := fmt.Sprintf("# AuraGem YouTube Proxy Search - Query %s\n", rawQuery)
+   90 | 			request.SetScrollMetadataResponse(sis.ScrollMetadata{Language: "en", Abstract: abstract})
+   91 | 			if request.ScrollMetadataRequested {
+   92 | 				request.Scroll(abstract)
+   93 | 				return
+   94 | 			}
+   95 | 
    80 | 			page := request.GetParam("page")
    81 | 			if page == "" {
    82 | 				searchYoutube(request, service, query, rawQuery, "")
    83 | 			} else {
    84 | 				searchYoutube(request, service, query, rawQuery, page)
   ... | ...
    88 | }
    89 | 
    90 | func getVideoPageRouteFunc(service *youtube.Service) sis.RequestHandler {
    91 | 	return func(request sis.Request) {
    92 | 		id := request.GetParam("id")
-   93 | 		call := service.Videos.List([]string{"id", "snippet"}).Id(id).MaxResults(1)
+  109 | 		call := service.Videos.List([]string{"id", "snippet", "status"}).Id(id).MaxResults(1)
    94 | 		response, err := call.Do()
    95 | 		if err != nil {
    96 | 			//log.Fatalf("Error: %v", err) // TODO
    97 | 			panic(err)
    98 | 		}
   ... | ...
    99 | 
   100 | 		if len(response.Items) == 0 {
   101 | 			request.TemporaryFailure("Video not found.")
   102 | 			return
   103 | 		}
-  104 | 		video := response.Items[0] // TODO: Error if video is not found
+  120 | 		video := response.Items[0]
+  121 | 
+  122 | 		lang := request.Server.DefaultLanguage()
+  123 | 		if video.Snippet.DefaultLanguage != "" {
+  124 | 			lang = video.Snippet.DefaultLanguage
+  125 | 		}
+  126 | 		publishDate := video.Snippet.PublishedAt
+  127 | 		if video.Status.PrivacyStatus == "private" {
+  128 | 			publishDate = video.Status.PublishAt
+  129 | 		}
+  130 | 		publishDateParsed, _ := time.Parse(time.RFC3339, publishDate)
+  131 | 		abstract := fmt.Sprintf("# Video - %s\n%s\n", html.UnescapeString(video.Snippet.Title), html.UnescapeString(video.Snippet.Description))
+  132 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: html.UnescapeString(video.Snippet.ChannelTitle), PublishDate: publishDateParsed.UTC(), UpdateDate: publishDateParsed.UTC(), Language: lang, Abstract: abstract})
+  133 | 		if request.ScrollMetadataRequested {
+  134 | 			request.Scroll(abstract)
+  135 | 			return
+  136 | 		}
   105 | 
   106 | 		//video.ContentDetails.RegionRestriction.Allowed
   107 | 
   108 | 		//video.ContentDetails.Definition
   109 | 
   ... | ...
   200 | 			request.TemporaryFailure("Error: Couldn't find video. %s\n", err.Error())
   201 | 			return
   202 | 		}
   203 | 
   204 | 		time.Sleep(time.Millisecond * 120)
-  205 | 		transcript, err := client.GetTranscript(video, "en")
-  206 | 		if err != nil {
+  237 | 
+  238 | 		// Go through each requested language and try to find a transcript for them. Otherwise, fallback to english.
+  239 | 		// If still no transcript found, then error out.
+  240 | 		requestedLanguages := append(request.ScrollRequestedLanguages, "en") // Append fallback language
+  241 | 		var transcript ytd.VideoTranscript
+  242 | 		var found bool = false
+  243 | 		for _, lang := range requestedLanguages {
+  244 | 			var err error
+  245 | 			transcript, err = client.GetTranscript(video, lang)
+  246 | 			if err == nil {
+  247 | 				request.SetLanguage(lang)
+  248 | 				found = true
+  249 | 				break
+  250 | 			}
+  251 | 		}
+  252 | 		if !found {
   207 | 			request.TemporaryFailure("Video doesn't have a transcript.\n")
   208 | 			return
   209 | 		}
   210 | 
   211 | 		request.Gemini(transcript.String())
   ... | ...
   246 | 		var captionFound ytd.CaptionTrack
   247 | 		for _, caption := range video.CaptionTracks {
   248 | 			if caption.Kind == kind && caption.LanguageCode == lang {
   249 | 				captionFound = caption
   250 | 				foundCaption = true
+  297 | 				request.SetLanguage(caption.LanguageCode)
   251 | 				break
   252 | 			}
   253 | 		}
   254 | 		if !foundCaption {
   255 | 			request.TemporaryFailure("Caption not found.")
   ... | ...
   370 | 				return
   371 | 				//return c.Gemini("Error: Video with Audio Not Found.\n%v", err) // TODO: Do different thing?
   372 | 			}
   373 | 		}
   374 | 
+  422 | 		// Handle Scroll protocol Metadata
+  423 | 		abstract := fmt.Sprintf("# %s\n%s\n", video.Title, video.Description)
+  424 | 		// TODO: The language should be a BCP47 string
+  425 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: video.Author, PublishDate: video.PublishDate.UTC(), Language: format.LanguageDisplayName(), Abstract: abstract})
+  426 | 		if request.ScrollMetadataRequested {
+  427 | 			request.Scroll(abstract)
+  428 | 			return
+  429 | 		}
+  430 | 
   375 | 		//format := video.Formats.AudioChannels(2).FindByQuality("hd1080")
   376 | 		//.DownloadSeparatedStreams(ctx, "", video, "hd1080", "mp4")
   377 | 		//resp, err := client.GetStream(video, format)
   378 | 
   379 | 		rc, _, err := client.GetStream(video, format)
   ... | ...
   380 | 		if err != nil {
   381 | 			request.TemporaryFailure("Video not found.\n")
   382 | 			return
   383 | 			//return c.Gemini("Error: Video Not Found\n%v", err)
   384 | 		}
-  385 | 		request.StreamBuffer(format.MimeType, rc, make([]byte, 2*1024*1024)) // 2 MB Buffer
+  441 | 		request.StreamBuffer(format.MimeType, rc, make([]byte, 1*1024*1024)) // 1 MB Buffer
   386 | 		//err2 := c.Stream(format.MimeType, rc)
   387 | 		rc.Close()
   388 | 
   389 | 		//url, err := client.GetStreamURL(video, format)
   390 | 
   ... | ...
   415 | 			//log.Fatalf("Error: %v", err) // TODO
   416 | 			panic(err)
   417 | 		}
   418 | 
   419 | 		channel := response.Items[0]
+  476 | 
+  477 | 		// Handle Scroll Protocol Metadata
+  478 | 		abstract := fmt.Sprintf("# Channel: %s\n%s\n", html.UnescapeString(channel.Snippet.Title), html.UnescapeString(channel.Snippet.Description))
+  479 | 		request.SetScrollMetadataResponse(sis.ScrollMetadata{Author: html.UnescapeString(channel.Snippet.Title), Language: channel.Snippet.DefaultLanguage, Abstract: abstract})
+  480 | 		if request.ScrollMetadataRequested {
+  481 | 			request.Scroll(abstract)
+  482 | 			return
+  483 | 		}
   420 | 
   421 | 		request.Gemini(fmt.Sprintf(template, html.UnescapeString(channel.Snippet.Title), channel.Id, channel.Id, channel.Id, channel.Id, html.UnescapeString(channel.Snippet.Description), channel.Id))
   422 | 	})
   423 | 
   424 | 	// Channel Playlists

go.mod

   ... | ...
     1 | module gitlab.com/clseibold/auragem_sis
     2 | 
     3 | go 1.22.0
     4 | 
     5 | require (
-    6 | 	github.com/clseibold/youtube/v2 v2.10.2-0.20240307001954-15aa838690ca
     7 | 	github.com/dhowden/tag v0.0.0-20240122214204-713ab0e94639
     8 | 	github.com/efarrer/iothrottler v0.0.3
     9 | 	github.com/gammazero/deque v0.2.1
    10 | 	github.com/go-stack/stack v1.8.1
    11 | 	github.com/google/go-github/v60 v60.0.0
   ... | ...
     8 | 	github.com/efarrer/iothrottler v0.0.3
     9 | 	github.com/gammazero/deque v0.2.1
    10 | 	github.com/go-stack/stack v1.8.1
    11 | 	github.com/google/go-github/v60 v60.0.0
    12 | 	github.com/juju/ratelimit v1.0.2
+   12 | 	github.com/kkdai/youtube/v2 v2.10.1
    13 | 	github.com/krayzpipes/cronticker v0.0.1
    14 | 	github.com/nakagami/firebirdsql v0.9.8
    15 | 	github.com/rs/zerolog v1.32.0
    16 | 	github.com/spf13/cobra v1.8.0
   ... | ...
    12 | 	github.com/krayzpipes/cronticker v0.0.1
    13 | 	github.com/nakagami/firebirdsql v0.9.8
    14 | 	github.com/rs/zerolog v1.32.0
    15 | 	github.com/spf13/cobra v1.8.0
-   17 | 	gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240305011400-a0904eef49b4
-   18 | 	golang.org/x/net v0.21.0
-   19 | 	golang.org/x/oauth2 v0.17.0
+   17 | 	gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240320064032-f24b54ba1726
+   18 | 	golang.org/x/net v0.22.0
+   19 | 	golang.org/x/oauth2 v0.18.0
    20 | 	golang.org/x/text v0.14.0
    21 | 	golang.org/x/time v0.5.0
   ... | ...
    17 | 	golang.org/x/text v0.14.0
    18 | 	golang.org/x/time v0.5.0
-   22 | 	google.golang.org/api v0.167.0
+   22 | 	google.golang.org/api v0.170.0
    23 | )
    24 | 
    25 | require (
    26 | 	cloud.google.com/go/compute v1.23.4 // indirect
    27 | 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
   ... | ...
    23 | )
    24 | 
    25 | require (
    26 | 	cloud.google.com/go/compute v1.23.4 // indirect
    27 | 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
+   28 | 	git.sr.ht/~adnano/go-gemini v0.2.4 // indirect
    28 | 	github.com/bitly/go-simplejson v0.5.1 // indirect
   ... | ...
    24 | 	github.com/bitly/go-simplejson v0.5.1 // indirect
-   29 | 	github.com/clseibold/go-gemini v0.0.0-20240226215632-c39755b92b21 // indirect
-   30 | 	github.com/dlclark/regexp2 v1.10.0 // indirect
-   31 | 	github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d // indirect
+   30 | 	github.com/clseibold/go-gemini v0.0.0-20240314051634-436d3e54df5c // indirect
+   31 | 	github.com/djherbis/times v1.6.0 // indirect
+   32 | 	github.com/dlclark/regexp2 v1.11.0 // indirect
+   33 | 	github.com/dop251/goja v0.0.0-20240220182346-e401ed450204 // indirect
    32 | 	github.com/eidolon/wordwrap v0.0.0-20161011182207-e0f54129b8bb // indirect
    33 | 	github.com/felixge/httpsnoop v1.0.4 // indirect
    34 | 	github.com/gabriel-vasile/mimetype v1.4.3 // indirect
    35 | 	github.com/go-logr/logr v1.4.1 // indirect
    36 | 	github.com/go-logr/stdr v1.2.2 // indirect
   ... | ...
    36 | 	github.com/go-logr/stdr v1.2.2 // indirect
    37 | 	github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
    38 | 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
    39 | 	github.com/golang/protobuf v1.5.3 // indirect
    40 | 	github.com/google/go-querystring v1.1.0 // indirect
-   41 | 	github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a // indirect
+   43 | 	github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 // indirect
    42 | 	github.com/google/s2a-go v0.1.7 // indirect
    43 | 	github.com/google/uuid v1.6.0 // indirect
    44 | 	github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
   ... | ...
    40 | 	github.com/google/s2a-go v0.1.7 // indirect
    41 | 	github.com/google/uuid v1.6.0 // indirect
    42 | 	github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
-   45 | 	github.com/googleapis/gax-go/v2 v2.12.1 // indirect
+   47 | 	github.com/googleapis/gax-go/v2 v2.12.2 // indirect
    46 | 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
    47 | 	github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
   ... | ...
    43 | 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
    44 | 	github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
-   48 | 	github.com/kkdai/youtube/v2 v2.10.0 // indirect
    49 | 	github.com/mattn/go-colorable v0.1.13 // indirect
    50 | 	github.com/mattn/go-isatty v0.0.19 // indirect
    51 | 	github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect
    52 | 	github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
   ... | ...
    48 | 	github.com/mattn/go-colorable v0.1.13 // indirect
    49 | 	github.com/mattn/go-isatty v0.0.19 // indirect
    50 | 	github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect
    51 | 	github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
+   54 | 	github.com/rivo/uniseg v0.4.7 // indirect
    53 | 	github.com/robfig/cron/v3 v3.0.1 // indirect
    54 | 	github.com/shopspring/decimal v1.2.0 // indirect
    55 | 	github.com/spf13/pflag v1.0.5 // indirect
    56 | 	github.com/warpfork/go-fsx v0.4.0 // indirect
    57 | 	gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b // indirect
   ... | ...
    54 | 	github.com/shopspring/decimal v1.2.0 // indirect
    55 | 	github.com/spf13/pflag v1.0.5 // indirect
    56 | 	github.com/warpfork/go-fsx v0.4.0 // indirect
    57 | 	gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b // indirect
    58 | 	go.opencensus.io v0.24.0 // indirect
-   59 | 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 // indirect
-   60 | 	go.opentelemetry.io/otel v1.23.0 // indirect
-   61 | 	go.opentelemetry.io/otel/metric v1.23.0 // indirect
-   62 | 	go.opentelemetry.io/otel/trace v1.23.0 // indirect
-   63 | 	golang.org/x/crypto v0.19.0 // indirect
+   61 | 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
+   62 | 	go.opentelemetry.io/otel v1.24.0 // indirect
+   63 | 	go.opentelemetry.io/otel/metric v1.24.0 // indirect
+   64 | 	go.opentelemetry.io/otel/trace v1.24.0 // indirect
+   65 | 	golang.org/x/crypto v0.21.0 // indirect
    64 | 	golang.org/x/sync v0.6.0 // indirect
   ... | ...
    60 | 	golang.org/x/sync v0.6.0 // indirect
-   65 | 	golang.org/x/sys v0.17.0 // indirect
+   67 | 	golang.org/x/sys v0.18.0 // indirect
    66 | 	google.golang.org/appengine v1.6.8 // indirect
   ... | ...
    62 | 	google.golang.org/appengine v1.6.8 // indirect
-   67 | 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 // indirect
-   68 | 	google.golang.org/grpc v1.61.1 // indirect
-   69 | 	google.golang.org/protobuf v1.32.0 // indirect
+   69 | 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 // indirect
+   70 | 	google.golang.org/grpc v1.62.1 // indirect
+   71 | 	google.golang.org/protobuf v1.33.0 // indirect
    70 | 	modernc.org/mathutil v1.4.2-0.20220822142738-b13e5b564332 // indirect
    71 | )
    72 | 
    73 | replace github.com/dhowden/tag => github.com/clseibold/tag v0.0.0-20230917192755-2c2210e229df

go.sum

   ... | ...
     1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
     2 | cloud.google.com/go/compute v1.23.4 h1:EBT9Nw4q3zyE7G45Wvv3MzolIrCJEuHys5muLY0wvAw=
     3 | cloud.google.com/go/compute v1.23.4/go.mod h1:/EJMj55asU6kAFnuZET8zqgwgJ9FvXWXOkkfQZa4ioI=
     4 | cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
     5 | cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
+    6 | git.sr.ht/~adnano/go-gemini v0.2.4 h1:L23qUmAAq+rwuIjOLFoJpiFq0BPK+AqhyGAqJ2O3JMU=
+    7 | git.sr.ht/~adnano/go-gemini v0.2.4/go.mod h1:GKBptE2mvJYtgVI60gXF8N7Z+JgrCkNfW4GgoR8h008=
     6 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
     7 | github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
     8 | github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
     9 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
    10 | github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
   ... | ...
    11 | github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
    12 | github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
    13 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
    14 | github.com/clseibold/go-gemini v0.0.0-20240226215632-c39755b92b21 h1:yIuvR+yzYAaOKzq9uM331p+IaFYYrecrAWEktJK4oAs=
    15 | github.com/clseibold/go-gemini v0.0.0-20240226215632-c39755b92b21/go.mod h1:OS3TRybkU3yIFETGhQfxm4Xhx/zGg1VKdpMUCQWEwuo=
+   18 | github.com/clseibold/go-gemini v0.0.0-20240314051634-436d3e54df5c h1:mPIVyUzsxB81KBda3gG/Y3qHfNvoGrrRcON565KEVos=
+   19 | github.com/clseibold/go-gemini v0.0.0-20240314051634-436d3e54df5c/go.mod h1:OS3TRybkU3yIFETGhQfxm4Xhx/zGg1VKdpMUCQWEwuo=
    16 | github.com/clseibold/tag v0.0.0-20230917192755-2c2210e229df h1:Z1tgK6mztKE317wx2dE+1eJYN53EZN3vVZjbsiwyXac=
    17 | github.com/clseibold/tag v0.0.0-20230917192755-2c2210e229df/go.mod h1:Z3Lomva4pyMWYezjMAU5QWRh0p1VvO4199OHlFnyKkM=
   ... | ...
    13 | github.com/clseibold/tag v0.0.0-20230917192755-2c2210e229df h1:Z1tgK6mztKE317wx2dE+1eJYN53EZN3vVZjbsiwyXac=
    14 | github.com/clseibold/tag v0.0.0-20230917192755-2c2210e229df/go.mod h1:Z3Lomva4pyMWYezjMAU5QWRh0p1VvO4199OHlFnyKkM=
-   18 | github.com/clseibold/youtube/v2 v2.10.2-0.20240307001954-15aa838690ca h1:Vx22Z/uufkF5LcKcRKwktNt98LRPE8B8TtEFUEHpkQk=
-   19 | github.com/clseibold/youtube/v2 v2.10.2-0.20240307001954-15aa838690ca/go.mod h1:+DiJe9yNKHwcTAFY95kx9BfFvfBC4bBacIvzM7gHfyw=
    20 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
    21 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
    22 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
    23 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
    24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
   ... | ...
    23 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
    24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
    25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
    26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
    27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+   30 | github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
+   31 | github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
    28 | github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
    29 | github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
   ... | ...
    25 | github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
    26 | github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-   30 | github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
-   31 | github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+   34 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+   35 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
    32 | github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
   ... | ...
    28 | github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
-   33 | github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw=
-   34 | github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
+   37 | github.com/dop251/goja v0.0.0-20240220182346-e401ed450204 h1:O7I1iuzEA7SG+dK8ocOBSlYAA9jBUmCYl/Qa7ey7JAM=
+   38 | github.com/dop251/goja v0.0.0-20240220182346-e401ed450204/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
    35 | github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
    36 | github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
    37 | github.com/efarrer/iothrottler v0.0.3 h1:6m8eKBQ1ouigjXQoBxwEWz12fUGGYfYppEJVcyZFcYg=
    38 | github.com/efarrer/iothrottler v0.0.3/go.mod h1:zGWF5N0NKSCskcPFytDAFwI121DdU/NfW4XOjpTR+ys=
    39 | github.com/eidolon/wordwrap v0.0.0-20161011182207-e0f54129b8bb h1:ioQwBmKdOCpMVS/bDaESqNWXIE/aw4+gsVtysCGMWZ4=
   ... | ...
    89 | github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8=
    90 | github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk=
    91 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
    92 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
    93 | github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
-   94 | github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a h1:fEBsGL/sjAuJrgah5XqmmYsTLzJp/TO9Lhy39gkverk=
-   95 | github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
+   98 | github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 h1:y3N7Bm7Y9/CtpiVkw/ZWj6lSlDF3F74SfKwfTCer72Q=
+   99 | github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
    96 | github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
    97 | github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
    98 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
    99 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
   100 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
   ... | ...
    98 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
    99 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
   100 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
   101 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
   102 | github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
-  103 | github.com/googleapis/gax-go/v2 v2.12.1 h1:9F8GV9r9ztXyAi00gsMQHNoF51xPZm8uj1dpYt2ZETM=
-  104 | github.com/googleapis/gax-go/v2 v2.12.1/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc=
+  107 | github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA=
+  108 | github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc=
   105 | github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
   106 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
   107 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
   108 | github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
   109 | github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
   ... | ...
   107 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
   108 | github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
   109 | github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
   110 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
   111 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
-  112 | github.com/kkdai/youtube/v2 v2.10.0 h1:s8gSWo3AxIafK560XwDVnha9aPXp3N2HQAh1x81R5Og=
-  113 | github.com/kkdai/youtube/v2 v2.10.0/go.mod h1:H5MLUXiXYuovcEhQT/uZf7BC/syIbAJlDKCDsG+WDsU=
+  116 | github.com/kkdai/youtube/v2 v2.10.1 h1:jdPho4R7VxWoRi9Wx4ULMq4+hlzSVOXxh4Zh83f2F9M=
+  117 | github.com/kkdai/youtube/v2 v2.10.1/go.mod h1:qL8JZv7Q1IoDs4nnaL51o/hmITXEIvyCIXopB0oqgVM=
   114 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
   115 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
   116 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
   117 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
   118 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
   ... | ...
   135 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
   136 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
   137 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
   138 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
   139 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+  144 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+  145 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
   140 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
   141 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
   142 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
   ... | ...
   138 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
   139 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
   140 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
-  143 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
-  144 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
+  149 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+  150 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
   145 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
   146 | github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
   147 | github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
   148 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
   149 | github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
   ... | ...
   156 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
   157 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
   158 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
   159 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
   160 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-  161 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-  162 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+  167 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+  168 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
   163 | github.com/warpfork/go-fsx v0.4.0 h1:mlSH89UOECT5+NdRo8gPaE92Pm1xvt6cbzGkFa4QcsA=
   164 | github.com/warpfork/go-fsx v0.4.0/go.mod h1:oTACCMj+Zle+vgVa5SAhGAh7WksYpLgGUCKEAVc+xPg=
   165 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
   ... | ...
   161 | github.com/warpfork/go-fsx v0.4.0 h1:mlSH89UOECT5+NdRo8gPaE92Pm1xvt6cbzGkFa4QcsA=
   162 | github.com/warpfork/go-fsx v0.4.0/go.mod h1:oTACCMj+Zle+vgVa5SAhGAh7WksYpLgGUCKEAVc+xPg=
   163 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-  166 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240305011400-a0904eef49b4 h1:Jp8ZWO9rlMNhsihjej85FynnOC6KBclNpPJ06IFYMHo=
-  167 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240305011400-a0904eef49b4/go.mod h1:+xMppp52ZAhfo5QdXvSgdtH1bZgQ8KIHz5cFYu5qhOc=
+  172 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317144711-f0ccd0a58ff7 h1:0hbVQ8zW0/d0IjNFa38nQOjoOO/oH4z9+rHtsUD5B9o=
+  173 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317144711-f0ccd0a58ff7/go.mod h1:Yw95GU1dH4dSW4pkXYLq6WJsWWmKrASHOowgYJGq7H8=
+  174 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317181933-52fec0a82b28 h1:ToQ89sKdGiYBnto5z7w9ZDZ+OH6cTeaPRYNUCCTfFcU=
+  175 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317181933-52fec0a82b28/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  176 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317183716-37387665420b h1:DA77p8oL52LjErtXFTaAQPynqiJWK6N+Sv6p80VzYlg=
+  177 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317183716-37387665420b/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  178 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317191105-fd6ee397c1ba h1:nK/syXQ7o3ej3iSB550wK+tiRTnjo+Sdj9LYccR6WL8=
+  179 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317191105-fd6ee397c1ba/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  180 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317193605-02e256eeda24 h1:uAyxd1IFuNaW24ULEtynTBEaYJDC57ilObNGyPfqybo=
+  181 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317193605-02e256eeda24/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  182 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317194928-e625dd753f9e h1:GWwNshXrZidLCkFlAKmEgekNwruiTqFJQZG5udXJXEg=
+  183 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240317194928-e625dd753f9e/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  184 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240318085521-879434868c5c h1:d/NxEoiml9BioLHBlfSBmUWL7RxG11UD5RH6CuiHbEI=
+  185 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240318085521-879434868c5c/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  186 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240318092405-9b42d9c3d84e h1:7Dnk5nNYp5ICu1LVL5xiHGQcpAEXl0K402ZKRNVHPew=
+  187 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240318092405-9b42d9c3d84e/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  188 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240318143605-d22eece89e44 h1:BKQXTIfQrEqz77YoMgpEOIlSAQy43hdV0YMSYtw2xRs=
+  189 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240318143605-d22eece89e44/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  190 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319063835-6db1965b8625 h1:z61O0TlaIgRpjEYCtX/OVNBXNDjflF0onZmjFb2xKNE=
+  191 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319063835-6db1965b8625/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  192 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319064654-23fc12590aa0 h1:OEdK16A53nGzGTGTH6I7JsB5c6YyXi1+tjUS2sswFbY=
+  193 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319064654-23fc12590aa0/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  194 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319065042-2e7069caa365 h1:v6t2kLP3ngDS5HJ8798AileESSQ4SsZTNB8NCrg13mw=
+  195 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319065042-2e7069caa365/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  196 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319065757-a5a3c89c49cb h1:YF+20YT8NPkzTUWu6yw8G0A2ffxXpOdSYoBwJUSGX78=
+  197 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319065757-a5a3c89c49cb/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  198 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319070402-52672e35a43d h1:P6YYmdcteOry+U/uoQDijA6gxRts5jxW+IH8cEgLKq0=
+  199 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319070402-52672e35a43d/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  200 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319071335-baadaacaec1e h1:npPnPHMnCQk/bsblVaBCGAN2flJ8I6rC9V5eDqC8U88=
+  201 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319071335-baadaacaec1e/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  202 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319072949-0fe917520b03 h1:35u88Nq8R2os8ytmdtnntxZ28wO7Ew1sbmbC+cUZfwk=
+  203 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319072949-0fe917520b03/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  204 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319073106-bd4546200c8f h1:YDJomccihBtAZSdKVqm0KRK08o+x4r41llauKpCnnys=
+  205 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319073106-bd4546200c8f/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  206 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319134410-f175db99b88f h1:rM1v/g4xWlA/zV2HCb5dD8dqT5/gjUtDPYZFq/c+tGA=
+  207 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319134410-f175db99b88f/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  208 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319142407-07f9c5b41f6a h1:ifMB/kdYnG5h6BgaJ6/ccIScyygOCWJEI/adL+8cQQg=
+  209 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319142407-07f9c5b41f6a/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  210 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319143712-0ca5ad5a0e7a h1:79hn8AWpRFze1B5mgUPDq7a3A90Qp9LDL/IN3BASrvk=
+  211 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319143712-0ca5ad5a0e7a/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  212 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319183527-3327ed1a0ca6 h1:KzjfIgFUiTrizDdYncutr2LnALNcZuf7WITZb+ePlu8=
+  213 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319183527-3327ed1a0ca6/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  214 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319184943-faeb15249c33 h1:1hPchnzZ9Y3QrzyHTmtYhKgwNu3Mnqo1/9QX6DqWnRo=
+  215 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319184943-faeb15249c33/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  216 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319185246-45324425122c h1:U6Da/lQzv+ogO4chDHgPl2siSl/H+ojS73K9MLHmwdQ=
+  217 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319185246-45324425122c/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  218 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319204445-c5e584afea65 h1:uUngDt0YQ/h0xXSVhjaFpWilEFwmxn+N4vVRRdB2GJY=
+  219 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319204445-c5e584afea65/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  220 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319221841-193016e2caf6 h1:Xa++6d0InGvf+5CfFS1/3Tx+nfdva7wLNcJZQIDY8CQ=
+  221 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319221841-193016e2caf6/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  222 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319223109-ff5cbb202bce h1:7JakuWo1ShIah6lMDUpa6tZ/z2jEMkbXGVN3HHA+bVs=
+  223 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319223109-ff5cbb202bce/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  224 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319223418-ab5dacf81e27 h1:dKGvpbnuSAhVhRe/gGCENLd+/GSJPDIsWHmvSD2p6bo=
+  225 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240319223418-ab5dacf81e27/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
+  226 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240320064032-f24b54ba1726 h1:xyCFBnlrAqqh1JBnca5DFvTWKYSyxnemIbdqZiSIfLQ=
+  227 | gitlab.com/clseibold/smallnetinformationservices v0.0.0-20240320064032-f24b54ba1726/go.mod h1:1K8FkDn3Ke4DQ+RTO5884rtfXjIquI5Foy5lfRTO9x4=
   168 | gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b h1:7gd+rd8P3bqcn/96gOZa3F5dpJr/vEiDQYlNb/y2uNs=
   169 | gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
   170 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
   171 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
   ... | ...
   167 | gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b h1:7gd+rd8P3bqcn/96gOZa3F5dpJr/vEiDQYlNb/y2uNs=
   168 | gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
   169 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
   170 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
-  172 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 h1:doUP+ExOpH3spVTLS0FcWGLnQrPct/hD/bCPbDRUEAU=
-  173 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0/go.mod h1:rdENBZMT2OE6Ne/KLwpiXudnAsbdrdBaqBvTN8M8BgA=
-  174 | go.opentelemetry.io/otel v1.23.0 h1:Df0pqjqExIywbMCMTxkAwzjLZtRf+bBKLbUcpxO2C9E=
-  175 | go.opentelemetry.io/otel v1.23.0/go.mod h1:YCycw9ZeKhcJFrb34iVSkyT0iczq/zYDtZYFufObyB0=
-  176 | go.opentelemetry.io/otel/metric v1.23.0 h1:pazkx7ss4LFVVYSxYew7L5I6qvLXHA0Ap2pwV+9Cnpo=
-  177 | go.opentelemetry.io/otel/metric v1.23.0/go.mod h1:MqUW2X2a6Q8RN96E2/nqNoT+z9BSms20Jb7Bbp+HiTo=
-  178 | go.opentelemetry.io/otel/trace v1.23.0 h1:37Ik5Ib7xfYVb4V1UtnT97T1jI+AoIYkJyPkuL4iJgI=
-  179 | go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk=
+  232 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
+  233 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
+  234 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
+  235 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
+  236 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
+  237 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
+  238 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
+  239 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
   180 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
   181 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
   182 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
   ... | ...
   178 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
   179 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
   180 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-  183 | golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
-  184 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+  243 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
+  244 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
   185 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
   186 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
   187 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
   188 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
   189 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
   ... | ...
   195 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
   196 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
   197 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
   198 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
   199 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-  200 | golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
-  201 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+  260 | golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
+  261 | golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
   202 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
   ... | ...
   198 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-  203 | golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
-  204 | golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
+  263 | golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
+  264 | golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
   205 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
   206 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
   207 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
   208 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
   209 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
   ... | ...
   214 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
   215 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
   216 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   217 | golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   218 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+  279 | golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   219 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   220 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   221 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   222 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   223 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   ... | ...
   219 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   220 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   221 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   222 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
   223 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-  224 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
-  225 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+  285 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
+  286 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
   226 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
   227 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
   228 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
   229 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
   230 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
   ... | ...
   243 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
   244 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
   245 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
   246 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
   247 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-  248 | google.golang.org/api v0.167.0 h1:CKHrQD1BLRii6xdkatBDXyKzM0mkawt2QP+H3LtPmSE=
-  249 | google.golang.org/api v0.167.0/go.mod h1:4FcBc686KFi7QI/U51/2GKKevfZMpM17sCdibqe/bSA=
+  309 | google.golang.org/api v0.170.0 h1:zMaruDePM88zxZBG+NG8+reALO2rfLhe/JShitLyT48=
+  310 | google.golang.org/api v0.170.0/go.mod h1:/xql9M2btF85xac/VAm4PsLMTLVGUOpq4BE9R8jyNy8=
   250 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
   251 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
   252 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
   253 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
   254 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
   ... | ...
   256 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
   257 | google.golang.org/genproto v0.0.0-20240205150955-31a09d347014 h1:g/4bk7P6TPMkAUbUhquq98xey1slwvuVJPosdBqYJlU=
   258 | google.golang.org/genproto v0.0.0-20240205150955-31a09d347014/go.mod h1:xEgQu1e4stdSSsxPDK8Azkrk/ECl5HvdPf6nbZrTS5M=
   259 | google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014 h1:x9PwdEgd11LgK+orcck69WVRo7DezSO4VUMPI4xpc8A=
   260 | google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014/go.mod h1:rbHMSEDyoYX62nRVLOCc4Qt1HbsdytAYoVwgjiOhF3I=
-  261 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9 h1:hZB7eLIaYlW9qXRfCq/qDaPdbeY3757uARz5Vvfv+cY=
-  262 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:YUWgXUFRPfoYK1IHMuxH5K6nPEXSCzIMljnQ59lLRCk=
+  322 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2 h1:9IZDv+/GcI6u+a4jRFRLxQs0RUCfavGfoOgEW6jpkI0=
+  323 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240311132316-a219d84964c2/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs=
   263 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
   264 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
   265 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
   266 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
   267 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
   ... | ...
   263 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
   264 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
   265 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
   266 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
   267 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-  268 | google.golang.org/grpc v1.61.1 h1:kLAiWrZs7YeDM6MumDe7m3y4aM6wacLzM1Y/wiLP9XY=
-  269 | google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
+  329 | google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk=
+  330 | google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE=
   270 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
   271 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
   272 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
   273 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
   274 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
   ... | ...
   276 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
   277 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
   278 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
   279 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
   280 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-  281 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
-  282 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+  342 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
+  343 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
   283 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
   284 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
   285 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
   286 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
   287 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=

main.go

   ... | ...
    -1 | package main
     0 | 
     1 | import (
+    4 | 	"log"
+    5 | 	"os"
+    6 | 	"runtime"
+    7 | 	"runtime/pprof"
+    8 | 
     4 | 	"gitlab.com/clseibold/auragem_sis/gemini"
     5 | 	_ "gitlab.com/clseibold/auragem_sis/migration"
     6 | 	//"io"
     7 | 	//"io/ioutil"
     8 | 	//"os"
   ... | ...
    10 | 	//"github.com/pitr/gig"
    11 | 	//"github.com/nakagami/firebirdsql"
    12 | 	//_ "gitlab.com/clseibold/auragem_sis/migration"
    13 | 	//"github.com/spf13/cobra"
    14 | )
+   20 | 
+   21 | const cpuprofile = "./cpu.prof"
+   22 | const memprofile = "./mem.prof"
    15 | 
    16 | func main() {
    17 | 	/*conn, _ := sql.Open("firebirdsql", firebirdConnectionString)
    18 | 	defer conn.Close()*/
    19 | 
   ... | ...
    15 | 
    16 | func main() {
    17 | 	/*conn, _ := sql.Open("firebirdsql", firebirdConnectionString)
    18 | 	defer conn.Close()*/
    19 | 
+   28 | 	if cpuprofile != "" {
+   29 | 		f, err := os.Create(cpuprofile)
+   30 | 		if err != nil {
+   31 | 			log.Fatal("could not create CPU profile: ", err)
+   32 | 		}
+   33 | 		defer f.Close() // error handling omitted for example
+   34 | 		if err := pprof.StartCPUProfile(f); err != nil {
+   35 | 			log.Fatal("could not start CPU profile: ", err)
+   36 | 		}
+   37 | 		defer pprof.StopCPUProfile()
+   38 | 	}
+   39 | 
    20 | 	gemini.GeminiCommand.Execute()
   ... | ...
    16 | 	gemini.GeminiCommand.Execute()
+   41 | 
+   42 | 	if memprofile != "" {
+   43 | 		f, err := os.Create(memprofile)
+   44 | 		if err != nil {
+   45 | 			log.Fatal("could not create memory profile: ", err)
+   46 | 		}
+   47 | 		defer f.Close() // error handling omitted for example
+   48 | 		runtime.GC()    // get up-to-date statistics
+   49 | 		if err := pprof.WriteHeapProfile(f); err != nil {
+   50 | 			log.Fatal("could not write memory profile: ", err)
+   51 | 		}
+   52 | 	}
    21 | }