Compare commits
87 Commits
no-thumbna
...
master
Author | SHA1 | Date |
---|---|---|
Ben Visness | 6dd2a32780 | |
Ben Visness | b681bacc16 | |
Ben Visness | 9ecb65985e | |
Ben Visness | 04db4ad709 | |
Asaf Gartner | 1aab3b47b3 | |
Ben Visness | 23b1b30d22 | |
Ben Visness | ac54793fd0 | |
Ben Visness | 852ff7e53f | |
Ben Visness | 22265c9081 | |
Asaf Gartner | 7fd57f692b | |
Asaf Gartner | 71d236f1c2 | |
Asaf Gartner | 4568def378 | |
Ben Visness | e8201a254e | |
Asaf Gartner | 2065bad860 | |
Ben Visness | 5dd4880d4c | |
Ben Visness | ee491c7696 | |
Asaf Gartner | f085858e9e | |
Asaf Gartner | 639ea17a88 | |
Asaf Gartner | 2eb3288b2a | |
Ben Visness | 27dcdb4704 | |
Ben Visness | 5aa9dde8a1 | |
Asaf Gartner | cd36eb44eb | |
Asaf Gartner | 444f43a195 | |
Ben Visness | 6fba490392 | |
Asaf Gartner | 7800cd9fe1 | |
Ben Visness | 1e5c0c7b42 | |
Ben Visness | 032d6c435e | |
Ben Visness | 16e4b0327f | |
Ben Visness | 97e6c74c52 | |
Ben Visness | c5e458be8c | |
Asaf Gartner | b5d4fe9ba2 | |
Ben Visness | d896298117 | |
Asaf Gartner | 845a2d377c | |
Asaf Gartner | 79dcef9b7f | |
Asaf Gartner | d347b42e44 | |
Asaf Gartner | 8bc4b5a66c | |
Ben Visness | 76be9b668a | |
Ben Visness | 594860a080 | |
Ben Visness | 0276e5228c | |
Asaf Gartner | edeb519ddb | |
Asaf Gartner | 38acf4a904 | |
Ben Visness | c6893f3f3f | |
Ben Visness | 25cc5ef11b | |
Ben Visness | 45b4928d83 | |
Ben Visness | ca46c23d31 | |
Ben Visness | 36753d2a45 | |
Ben Visness | 16020a5b30 | |
Ben Visness | 2e3cb658af | |
Ben Visness | c83458fd30 | |
Ben Visness | a9ef54b98b | |
Ben Visness | b5ef12fa60 | |
Ben Visness | cae9fecc7c | |
Ben Visness | 6616e72ca6 | |
Asaf Gartner | 4533e8ae66 | |
Asaf Gartner | fdc7582701 | |
Ben Visness | 74f438afad | |
Ben Visness | ad62793262 | |
Ben Visness | 922690244d | |
Ben Visness | 368e657a79 | |
Ben Visness | 7b2d016fe2 | |
Ben Visness | 0895660972 | |
Ben Visness | 8aa18901b2 | |
Ben Visness | 5bc118d9e0 | |
Asaf Gartner | 633f8f1007 | |
Ben Visness | 54aa6682b1 | |
Ben Visness | c8808e21bf | |
Ben Visness | 8be575875d | |
Ben Visness | dd6e5e3b66 | |
Ben Visness | 57782aba5f | |
Ben Visness | 1ea9fbefbc | |
Ben Visness | ca28fe8063 | |
Ben Visness | a6caf8e9bd | |
Asaf Gartner | dcdbc67b6c | |
Ben Visness | 2d61286831 | |
Ben Visness | bb31644d6d | |
Ben Visness | f8b0d9ba85 | |
Ben Visness | be888a98f1 | |
Ben Visness | 88323ffbaa | |
Ben Visness | 1166bb6cf3 | |
Ben Visness | 96ea2e0268 | |
Jake Mason | 64d98c424f | |
Jake Mason | 03a08ad392 | |
Ilia Demianenko | 43b9f993dc | |
Jake Mason | a1c5086190 | |
Ben Visness | cace7fbcb1 | |
Ben Visness | 95bd54b39e | |
Ben Visness | cdacc5b3a0 |
|
@ -16,6 +16,7 @@ local/backups
|
||||||
/tmp
|
/tmp
|
||||||
*.exe
|
*.exe
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
__debug_bin*
|
||||||
|
|
||||||
# vim session saves
|
# vim session saves
|
||||||
Session.vim
|
Session.vim
|
||||||
|
|
|
@ -4,6 +4,7 @@ const RecvAddress = "admin@example.com"
|
||||||
const RecvName = "Admin"
|
const RecvName = "Admin"
|
||||||
const FromName = "From Name"
|
const FromName = "From Name"
|
||||||
const FromAddress = "from@address.com"
|
const FromAddress = "from@address.com"
|
||||||
const FromAddressPassword = "password"
|
const ServerUsername = "username"
|
||||||
|
const ServerPassword = "password"
|
||||||
const ServerAddress = "server.address"
|
const ServerAddress = "server.address"
|
||||||
const ServerPort = 587
|
const ServerPort = 587
|
||||||
|
|
|
@ -46,7 +46,7 @@ func sendMail(toAddress, toName, subject, contentHtml string) error {
|
||||||
)
|
)
|
||||||
return smtp.SendMail(
|
return smtp.SendMail(
|
||||||
fmt.Sprintf("%s:%d", ServerAddress, ServerPort),
|
fmt.Sprintf("%s:%d", ServerAddress, ServerPort),
|
||||||
smtp.PlainAuth("", FromAddress, FromAddressPassword, ServerAddress),
|
smtp.PlainAuth("", ServerUsername, ServerPassword, ServerAddress),
|
||||||
FromAddress,
|
FromAddress,
|
||||||
[]string{toAddress},
|
[]string{toAddress},
|
||||||
contents,
|
contents,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
- [ ] Export with [DiscordChatExporter](https://github.com/Tyrrrz/DiscordChatExporter) CLI 2.34
|
- [ ] Export with [DiscordChatExporter](https://github.com/Tyrrrz/DiscordChatExporter) CLI 2.39.1
|
||||||
|
|
||||||
```
|
```
|
||||||
DiscordChatExporter.Cli.exe export -c [thread-id] -t [token] -o [fishbowl].html --media
|
DiscordChatExporter.Cli.exe export -c [thread-id] -t [token] -o [fishbowl].html --media
|
||||||
|
@ -49,7 +49,7 @@
|
||||||
go run twemoji.go [fishbowl]-dragged.html files [fishbowl]-twemojied.html
|
go run twemoji.go [fishbowl]-dragged.html files [fishbowl]-twemojied.html
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] Fix timestamps
|
- [ ] Fix timestamps, validate they look correct
|
||||||
|
|
||||||
```
|
```
|
||||||
go run timestamps.go [fishbowl]-twemojied.html [fishbowl]-timestamped.html
|
go run timestamps.go [fishbowl]-twemojied.html [fishbowl]-timestamped.html
|
||||||
|
@ -59,7 +59,7 @@
|
||||||
- [ ] Create fishbowl folder under `hmn/src/templates/src/fishbowls/`
|
- [ ] Create fishbowl folder under `hmn/src/templates/src/fishbowls/`
|
||||||
- [ ] Copy timestamped html and files, rename html
|
- [ ] Copy timestamped html and files, rename html
|
||||||
- [ ] Remove everything from html but chatlog
|
- [ ] Remove everything from html but chatlog
|
||||||
- [ ] Remove js, css and whitney from files
|
- [ ] Remove js, css and ggsans from files
|
||||||
- [ ] Add content path to `fishbowl.go`
|
- [ ] Add content path to `fishbowl.go`
|
||||||
- [ ] Test locally
|
- [ ] Test locally
|
||||||
- [ ] Submit a pull request
|
- [ ] Submit a pull request
|
||||||
|
|
|
@ -4,11 +4,13 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) != 3 {
|
if len(os.Args) != 3 {
|
||||||
fmt.Println("Usage: go run timestamps.go [fishbowl].html [fishbowl]-timestamped.html")
|
fmt.Println("Usage: go run timestamps.go <fishbowl>.html <fishbowl>-timestamped.html")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,17 +24,19 @@ func main() {
|
||||||
|
|
||||||
html := string(htmlBytes)
|
html := string(htmlBytes)
|
||||||
|
|
||||||
regex, err := regexp.Compile(
|
regex := regexp.MustCompile(
|
||||||
"(<span class=\"chatlog__timestamp\">)(\\d+)-([A-Za-z]+)-(\\d+)( [^<]+</span>)",
|
`(<span class="?chatlog__timestamp"?><a href=[^>]+>)(\d+)/(\d+)/(\d+)( [^<]+</a></span>)`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
htmlOut := regex.ReplaceAllString(
|
htmlOut := regex.ReplaceAllStringFunc(html, func(s string) string {
|
||||||
html,
|
match := regex.FindStringSubmatch(s)
|
||||||
"$1$3 $2, 20$4$5",
|
month, err := strconv.ParseInt(match[2], 10, 64)
|
||||||
)
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
monthStr := time.Month(month).String()
|
||||||
|
return fmt.Sprintf("%s%s %s, %s%s", match[1], monthStr, match[3], match[4], match[5])
|
||||||
|
})
|
||||||
|
|
||||||
err = os.WriteFile(htmlOutPath, []byte(htmlOut), 0666)
|
err = os.WriteFile(htmlOutPath, []byte(htmlOut), 0666)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
2
go.mod
|
@ -11,6 +11,7 @@ require (
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.3.3
|
github.com/aws/aws-sdk-go-v2/credentials v1.3.3
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.13.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.13.0
|
||||||
github.com/aws/smithy-go v1.7.0
|
github.com/aws/smithy-go v1.7.0
|
||||||
|
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f
|
||||||
github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8
|
github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8
|
||||||
github.com/go-stack/stack v1.8.0
|
github.com/go-stack/stack v1.8.0
|
||||||
github.com/google/uuid v1.2.0
|
github.com/google/uuid v1.2.0
|
||||||
|
@ -56,6 +57,7 @@ require (
|
||||||
github.com/mitchellh/reflectwalk v1.0.1 // indirect
|
github.com/mitchellh/reflectwalk v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/teambition/rrule-go v1.7.2 // indirect
|
||||||
go.uber.org/atomic v1.10.0 // indirect
|
go.uber.org/atomic v1.10.0 // indirect
|
||||||
golang.org/x/net v0.6.0 // indirect
|
golang.org/x/net v0.6.0 // indirect
|
||||||
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7 // indirect
|
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7 // indirect
|
||||||
|
|
4
go.sum
|
@ -88,6 +88,8 @@ github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k
|
||||||
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||||
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
|
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
|
||||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||||
|
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ=
|
||||||
|
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
|
||||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||||
|
@ -275,6 +277,8 @@ github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vA
|
||||||
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
|
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
|
||||||
github.com/teacat/noire v1.1.0 h1:5IgJ1H8jodiSSYnrVadV2JjbAnEgCCjYUQxSUuaQ7Sg=
|
github.com/teacat/noire v1.1.0 h1:5IgJ1H8jodiSSYnrVadV2JjbAnEgCCjYUQxSUuaQ7Sg=
|
||||||
github.com/teacat/noire v1.1.0/go.mod h1:cetGlnqr+9yKJcFgRgYXOWJY66XIrrjUsGBwNlNNtAk=
|
github.com/teacat/noire v1.1.0/go.mod h1:cetGlnqr+9yKJcFgRgYXOWJY66XIrrjUsGBwNlNNtAk=
|
||||||
|
github.com/teambition/rrule-go v1.7.2 h1:goEajFWYydfCgavn2m/3w5U+1b3PGqPUHx/fFSVfTy0=
|
||||||
|
github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=
|
||||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||||
github.com/wellington/go-libsass v0.9.2 h1:6Ims04UDdBs6/CGSVK5JC8FNikR5ssrsMMKE/uaO5Q8=
|
github.com/wellington/go-libsass v0.9.2 h1:6Ims04UDdBs6/CGSVK5JC8FNikR5ssrsMMKE/uaO5Q8=
|
||||||
github.com/wellington/go-libsass v0.9.2/go.mod h1:mxgxgam0N0E+NAUMHLcu20Ccfc3mVpDkyrLDayqfiTs=
|
github.com/wellington/go-libsass v0.9.2/go.mod h1:mxgxgam0N0E+NAUMHLcu20Ccfc3mVpDkyrLDayqfiTs=
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
# You must already have configured your "AWS" (DigitalOcean) credentials via the AWS CLI.
|
||||||
|
|
||||||
|
s3 = boto3.resource("s3")
|
||||||
|
bucket = s3.Bucket("hmn-backup")
|
||||||
|
for obj in bucket.objects.filter(Prefix="db"):
|
||||||
|
print(obj.key)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Above is a list of all the available database backups.")
|
||||||
|
print("Enter the name of the one you would like to download (e.g. \"hmn_pg_dump_live_2023-09-24\"):")
|
||||||
|
filename = input()
|
||||||
|
|
||||||
|
s3 = boto3.client("s3")
|
||||||
|
s3.download_file("hmn-backup", f"db/{filename}", f"local/backups/{filename}")
|
||||||
|
|
||||||
|
print(f"Downloaded {filename} to local/backups.")
|
|
@ -1,15 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
s3cmd ls s3://hmn-backup/db/
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Above is a list of all the available database backups."
|
|
||||||
echo "Enter the name of the one you would like to download (e.g. \"hmn_pg_dump_live_2021-09-01\"):"
|
|
||||||
read filename
|
|
||||||
|
|
||||||
s3cmd get --force s3://hmn-backup/db/$filename ./local/backups/$filename
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Downloaded $filename to local/backups."
|
|
|
@ -0,0 +1 @@
|
||||||
|
boto3
|
After Width: | Height: | Size: 118 KiB |
After Width: | Height: | Size: 14 KiB |
|
@ -94,7 +94,7 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-symbol {
|
.fishbowl .chatlog__reference-symbol, .fishbowl .chatlog__reply-symbol {
|
||||||
height: 10px;
|
height: 10px;
|
||||||
margin: 6px 4px 4px 36px;
|
margin: 6px 4px 4px 36px;
|
||||||
border-left: 2px solid #4f545c;
|
border-left: 2px solid #4f545c;
|
||||||
|
@ -122,7 +122,7 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference {
|
.fishbowl .chatlog__reference, .fishbowl .chatlog__reply {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -133,53 +133,53 @@
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-avatar {
|
.fishbowl .chatlog__reference-avatar, .fishbowl .chatlog__reply-avatar {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-author {
|
.fishbowl .chatlog__reference-author, .fishbowl .chatlog__reply-author {
|
||||||
margin-right: 0.3rem;
|
margin-right: 0.3rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-content {
|
.fishbowl .chatlog__reference-content, .fishbowl .chatlog__reply-content {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link {
|
.fishbowl .chatlog__reference-link, .fishbowl .chatlog__reply-link {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link * {
|
.fishbowl .chatlog__reference-link *, .fishbowl .chatlog__reply-link * {
|
||||||
display: inline;
|
display: inline;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link .hljs {
|
.fishbowl .chatlog__reference-link .hljs, .fishbowl .chatlog__reply-link .hljs {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link .chatlog__markdown-quote {
|
.fishbowl .chatlog__reference-link .chatlog__markdown-quote, .fishbowl .chatlog__reply-link .chatlog__markdown-quote {
|
||||||
display: inline
|
display: inline
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link .chatlog__markdown-pre {
|
.fishbowl .chatlog__reference-link .chatlog__markdown-pre, .fishbowl .chatlog__reply-link .chatlog__markdown-pre {
|
||||||
display: inline
|
display: inline
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link:hover {
|
.fishbowl .chatlog__reference-link:hover, .fishbowl .chatlog__reply-link:hover {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler) {
|
.fishbowl .chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler), .fishbowl .chatlog__reply-link:hover *:not(.chatlog__markdown-spoiler) {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-edited-timestamp {
|
.fishbowl .chatlog__reference-edited-timestamp, .fishbowl .chatlog__reply-edited-timestamp {
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
color: #a3a6aa;
|
color: #a3a6aa;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
|
@ -95,7 +95,7 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-symbol {
|
.fishbowl .chatlog__reference-symbol, .fishbowl .chatlog__reply-symbol {
|
||||||
height: 10px;
|
height: 10px;
|
||||||
margin: 6px 4px 4px 36px;
|
margin: 6px 4px 4px 36px;
|
||||||
border-left: 2px solid #c7ccd1;
|
border-left: 2px solid #c7ccd1;
|
||||||
|
@ -123,7 +123,7 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference {
|
.fishbowl .chatlog__reference, .fishbowl .chatlog__reply {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -134,53 +134,53 @@
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-avatar {
|
.fishbowl .chatlog__reference-avatar, .fishbowl .chatlog__reply-avatar {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-author {
|
.fishbowl .chatlog__reference-author, .fishbowl .chatlog__reply-author {
|
||||||
margin-right: 0.3rem;
|
margin-right: 0.3rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-content {
|
.fishbowl .chatlog__reference-content, .fishbowl .chatlog__reply-content {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link {
|
.fishbowl .chatlog__reference-link, .fishbowl .chatlog__reply-link {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link * {
|
.fishbowl .chatlog__reference-link *, .fishbowl .chatlog__reply-link * {
|
||||||
display: inline;
|
display: inline;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link .hljs {
|
.fishbowl .chatlog__reference-link .hljs, .fishbowl .chatlog__reply-link .hljs {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link .chatlog__markdown-quote {
|
.fishbowl .chatlog__reference-link .chatlog__markdown-quote, .fishbowl .chatlog__reply-link .chatlog__markdown-quote {
|
||||||
display: inline
|
display: inline
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link .chatlog__markdown-pre {
|
.fishbowl .chatlog__reference-link .chatlog__markdown-pre, .fishbowl .chatlog__reply-link .chatlog__markdown-pre {
|
||||||
display: inline
|
display: inline
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link:hover {
|
.fishbowl .chatlog__reference-link:hover, .fishbowl .chatlog__reply-link:hover {
|
||||||
color: #2f3136;
|
color: #2f3136;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler) {
|
.fishbowl .chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler), .fishbowl .chatlog__reply-link:hover *:not(.chatlog__markdown-spoiler) {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fishbowl .chatlog__reference-edited-timestamp {
|
.fishbowl .chatlog__reference-edited-timestamp, .fishbowl .chatlog__reply-edited-timestamp {
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
color: #5e6772;
|
color: #5e6772;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
|
@ -52,19 +52,30 @@ function makeShowcaseItem(timelineItem) {
|
||||||
break;
|
break;
|
||||||
case TimelineMediaTypes.VIDEO:
|
case TimelineMediaTypes.VIDEO:
|
||||||
addThumbnailFunc = () => {
|
addThumbnailFunc = () => {
|
||||||
const video = document.createElement('video');
|
let thumbEl;
|
||||||
video.src = timelineItem.asset_url; // TODO: Use image thumbnails
|
if (timelineItem.thumbnail_url) {
|
||||||
video.controls = false;
|
thumbEl = document.createElement('img');
|
||||||
video.classList.add('h-100');
|
thumbEl.src = timelineItem.thumbnail_url;
|
||||||
video.preload = 'metadata';
|
} else {
|
||||||
itemEl.thumbnail.appendChild(video);
|
thumbEl = document.createElement('video');
|
||||||
|
thumbEl.src = timelineItem.asset_url;
|
||||||
|
thumbEl.controls = false;
|
||||||
|
thumbEl.preload = 'metadata';
|
||||||
|
}
|
||||||
|
thumbEl.classList.add('h-100');
|
||||||
|
itemEl.thumbnail.appendChild(thumbEl);
|
||||||
};
|
};
|
||||||
|
|
||||||
createModalContentFunc = () => {
|
createModalContentFunc = () => {
|
||||||
const modalVideo = document.createElement('video');
|
const modalVideo = document.createElement('video');
|
||||||
modalVideo.src = timelineItem.asset_url;
|
modalVideo.src = timelineItem.asset_url;
|
||||||
|
if (timelineItem.thumbnail_url) {
|
||||||
|
modalVideo.poster = timelineItem.thumbnail_url;
|
||||||
|
modalVideo.preload = 'none';
|
||||||
|
} else {
|
||||||
|
modalVideo.preload = 'metadata';
|
||||||
|
}
|
||||||
modalVideo.controls = true;
|
modalVideo.controls = true;
|
||||||
modalVideo.preload = 'metadata';
|
|
||||||
modalVideo.classList.add('mw-100', 'mh-60vh');
|
modalVideo.classList.add('mw-100', 'mh-60vh');
|
||||||
return modalVideo;
|
return modalVideo;
|
||||||
};
|
};
|
||||||
|
|
After Width: | Height: | Size: 228 KiB |
After Width: | Height: | Size: 267 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 236 253" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="books" x="0" y="0" width="235.74" height="252.97" style="fill:none;"/><path d="M77.61,252.704l0,-230.569l80.52,-0l-0,230.569l-80.52,0Zm59.686,-188.853l0,-20.883l-38.853,-0l0,20.883l38.853,0Zm0,128.926l0,-108.092l-38.853,-0l0,108.092l38.853,-0Zm-38.853,20.833l0,18.261l38.853,-0l0,-18.261l-38.853,0Zm-98.032,39.094l-0,-252.438l70.429,-0l-0,252.438l-70.429,0Zm49.596,-206.372l-0,-25.233l-28.763,-0l0,25.233l28.763,0Zm-0,142.369l-0,-121.536l-28.763,0l0,121.536l28.763,-0Zm-28.763,20.833l0,22.337c0,-0 28.763,-0 28.763,-0l-0,-22.337l-28.763,-0Zm143.656,43.17l-0,-252.438l70.429,-0l-0,252.438l-70.429,0Zm49.596,-206.372l-0,-25.233l-28.763,-0l0,25.233l28.763,0Zm-0,142.369l-0,-121.536l-28.763,0l0,121.536l28.763,-0Zm-28.763,20.833l0,22.337l28.763,-0l-0,-22.337l-28.763,-0Z"/></svg>
|
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 2.8 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 336 233" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="lightbulb" x="0" y="0" width="335.159" height="232.656" style="fill:none;"/><path d="M197.613,202.015c0,16.576 -13.457,30.034 -30.034,30.034c-16.576,-0 -30.034,-13.458 -30.034,-30.034l60.068,-0Zm0.06,-9.97l-60.206,-0c-3.155,-16.04 -5.152,-23.793 -32.39,-57.373c-10.513,-12.96 -16.178,-29.812 -16.178,-47.773c0,-43.425 35.256,-78.68 78.68,-78.68c43.425,-0 78.68,34.8 78.68,78.224c0,17.961 -5.665,34.814 -16.177,47.774c-27.238,33.58 -29.254,41.788 -32.409,57.828Zm-16.343,-20.834c3.88,-11.276 11.569,-24.225 32.572,-50.118c7.611,-9.383 11.524,-21.647 11.524,-34.65c-0,-31.893 -25.954,-57.391 -57.847,-57.391c-31.926,0 -57.846,25.92 -57.846,57.847c-0,13.003 3.913,25.266 11.524,34.649c20.946,25.823 28.639,38.556 32.521,49.663l27.552,0Zm-160.921,-151.684c-4.694,-3.321 -5.808,-9.827 -2.488,-14.52c3.321,-4.693 9.827,-5.808 14.52,-2.488l31.239,22.101c4.694,3.321 5.808,9.827 2.488,14.52c-3.32,4.693 -9.827,5.808 -14.52,2.488l-31.239,-22.101Zm12.032,153.97c-4.693,3.32 -11.199,2.205 -14.52,-2.488c-3.32,-4.693 -2.206,-11.199 2.488,-14.52l31.239,-22.101c4.693,-3.32 11.2,-2.205 14.52,2.488c3.32,4.693 2.206,11.199 -2.488,14.52l-31.239,22.101Zm-21.856,-74.77c-5.749,-0 -10.416,-4.668 -10.416,-10.417c-0,-5.749 4.667,-10.417 10.416,-10.417l38.267,0c5.749,0 10.417,4.668 10.417,10.417c-0,5.749 -4.668,10.417 -10.417,10.417l-38.267,-0Zm292.133,-95.906c4.693,-3.32 11.199,-2.205 14.519,2.488c3.321,4.693 2.206,11.199 -2.487,14.52l-31.239,22.101c-4.694,3.32 -11.2,2.206 -14.52,-2.488c-3.321,-4.693 -2.206,-11.199 2.487,-14.52l31.24,-22.101Zm12.032,153.97c4.693,3.321 5.808,9.827 2.487,14.52c-3.32,4.694 -9.826,5.808 -14.519,2.488l-31.24,-22.101c-4.693,-3.321 -5.808,-9.827 -2.487,-14.52c3.32,-4.693 9.826,-5.808 14.52,-2.488l31.239,22.101Zm9.823,-78.595c5.75,-0 10.417,4.667 10.417,10.416c0,5.749 -4.667,10.417 -10.417,10.417l-38.266,-0c-5.75,-0 -10.417,-4.668 -10.417,-10.417c-0,-5.749 4.667,-10.416 10.417,-10.416l38.266,-0Z"/></svg>
|
After Width: | Height: | Size: 2.3 KiB |
After Width: | Height: | Size: 12 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 301 229" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="presentation" x="-0" y="0" width="300.716" height="228.578" style="fill:none;"/><path d="M124.979,14.95l175.159,0l0,152.429l-137.896,0c-1.49,-7.262 -3.898,-14.19 -7.097,-20.659l124.334,-0l-0,-111.11l-143.514,-0c-2.302,-7.632 -6.074,-14.628 -10.986,-20.66Zm-119.77,196.191c-2.997,-8.252 -4.631,-17.155 -4.631,-26.437c0,-42.797 34.745,-77.542 77.541,-77.542c42.796,0 77.541,34.745 77.541,77.542c0,9.282 -1.634,18.185 -4.631,26.437c-22.091,10.548 -46.816,16.455 -72.91,16.455c-26.094,0 -50.819,-5.907 -72.91,-16.455Zm15.522,-13.612c17.693,7.296 37.075,11.317 57.388,11.317c20.314,0 39.695,-4.021 57.389,-11.317c0.919,-4.129 1.402,-8.421 1.402,-12.825c0,-32.448 -26.343,-58.792 -58.791,-58.792c-32.448,0 -58.791,26.344 -58.791,58.792c0,4.404 0.484,8.696 1.403,12.825Zm57.388,-196.547c28.741,-0 52.075,23.334 52.075,52.074c-0,28.741 -23.334,52.075 -52.075,52.075c-28.74,-0 -52.074,-23.334 -52.074,-52.075c-0,-28.74 23.334,-52.074 52.074,-52.074Zm0,18.75c-18.392,-0 -33.324,14.932 -33.324,33.324c-0,18.392 14.932,33.325 33.324,33.325c18.392,-0 33.325,-14.933 33.325,-33.325c-0,-18.392 -14.933,-33.324 -33.325,-33.324Z"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -7346,12 +7346,6 @@ article code {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto; }
|
margin-right: auto; }
|
||||||
|
|
||||||
.flex-shrink-0, .edit-form .edit-form-row > :first-child {
|
|
||||||
flex-shrink: 0; }
|
|
||||||
|
|
||||||
.flex-grow-1, .edit-form .edit-form-row > :nth-child(2) {
|
|
||||||
flex-grow: 1; }
|
|
||||||
|
|
||||||
.flex-fair {
|
.flex-fair {
|
||||||
flex-basis: 1px;
|
flex-basis: 1px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -7553,6 +7547,15 @@ article code {
|
||||||
.g5 {
|
.g5 {
|
||||||
gap: 4rem; }
|
gap: 4rem; }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid; }
|
||||||
|
|
||||||
|
.grid-1 {
|
||||||
|
grid-template-columns: 1fr; }
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr 1fr; }
|
||||||
|
|
||||||
.aspect-ratio--2x1 {
|
.aspect-ratio--2x1 {
|
||||||
padding-bottom: 50%; }
|
padding-bottom: 50%; }
|
||||||
|
|
||||||
|
@ -7582,9 +7585,25 @@ article code {
|
||||||
column-gap: 2rem; }
|
column-gap: 2rem; }
|
||||||
.cg5-ns {
|
.cg5-ns {
|
||||||
column-gap: 4rem; }
|
column-gap: 4rem; }
|
||||||
|
.grid-1-ns {
|
||||||
|
grid-template-columns: 1fr; }
|
||||||
|
.grid-2-ns {
|
||||||
|
grid-template-columns: 1fr 1fr; }
|
||||||
.bg--dim-ns {
|
.bg--dim-ns {
|
||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
background-color: var(--dim-background); } }
|
background-color: var(--dim-background); }
|
||||||
|
.g0-ns {
|
||||||
|
gap: 0; }
|
||||||
|
.g1-ns {
|
||||||
|
gap: 0.25rem; }
|
||||||
|
.g2-ns {
|
||||||
|
gap: 0.5rem; }
|
||||||
|
.g3-ns {
|
||||||
|
gap: 1rem; }
|
||||||
|
.g4-ns {
|
||||||
|
gap: 2rem; }
|
||||||
|
.g5-ns {
|
||||||
|
gap: 4rem; } }
|
||||||
|
|
||||||
@media screen and (min-width: 35em) and (max-width: 60em) {
|
@media screen and (min-width: 35em) and (max-width: 60em) {
|
||||||
.bi-avoid-m {
|
.bi-avoid-m {
|
||||||
|
@ -7607,9 +7626,25 @@ article code {
|
||||||
column-gap: 2rem; }
|
column-gap: 2rem; }
|
||||||
.cg5-m {
|
.cg5-m {
|
||||||
column-gap: 4rem; }
|
column-gap: 4rem; }
|
||||||
|
.grid-1-m {
|
||||||
|
grid-template-columns: 1fr; }
|
||||||
|
.grid-2-m {
|
||||||
|
grid-template-columns: 1fr 1fr; }
|
||||||
.bg--dim-m {
|
.bg--dim-m {
|
||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
background-color: var(--dim-background); } }
|
background-color: var(--dim-background); }
|
||||||
|
.g0-m {
|
||||||
|
gap: 0; }
|
||||||
|
.g1-m {
|
||||||
|
gap: 0.25rem; }
|
||||||
|
.g2-m {
|
||||||
|
gap: 0.5rem; }
|
||||||
|
.g3-m {
|
||||||
|
gap: 1rem; }
|
||||||
|
.g4-m {
|
||||||
|
gap: 2rem; }
|
||||||
|
.g5-m {
|
||||||
|
gap: 4rem; } }
|
||||||
|
|
||||||
@media screen and (min-width: 60em) {
|
@media screen and (min-width: 60em) {
|
||||||
.bi-avoid-l {
|
.bi-avoid-l {
|
||||||
|
@ -7632,9 +7667,25 @@ article code {
|
||||||
column-gap: 2rem; }
|
column-gap: 2rem; }
|
||||||
.cg5-l {
|
.cg5-l {
|
||||||
column-gap: 4rem; }
|
column-gap: 4rem; }
|
||||||
|
.grid-1-l {
|
||||||
|
grid-template-columns: 1fr; }
|
||||||
|
.grid-2-l {
|
||||||
|
grid-template-columns: 1fr 1fr; }
|
||||||
.bg--dim-l {
|
.bg--dim-l {
|
||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
background-color: var(--dim-background); } }
|
background-color: var(--dim-background); }
|
||||||
|
.g0-l {
|
||||||
|
gap: 0; }
|
||||||
|
.g1-l {
|
||||||
|
gap: 0.25rem; }
|
||||||
|
.g2-l {
|
||||||
|
gap: 0.5rem; }
|
||||||
|
.g3-l {
|
||||||
|
gap: 1rem; }
|
||||||
|
.g4-l {
|
||||||
|
gap: 2rem; }
|
||||||
|
.g5-l {
|
||||||
|
gap: 4rem; } }
|
||||||
|
|
||||||
.not-first:first-child {
|
.not-first:first-child {
|
||||||
display: none; }
|
display: none; }
|
||||||
|
|
After Width: | Height: | Size: 47 KiB |
After Width: | Height: | Size: 42 KiB |
After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 48 KiB |
After Width: | Height: | Size: 52 KiB |
After Width: | Height: | Size: 65 KiB |
After Width: | Height: | Size: 91 KiB |
After Width: | Height: | Size: 244 KiB |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 2.8 KiB |
|
@ -0,0 +1,36 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 475 283" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||||
|
<g id="Handlebars" transform="matrix(1,0,0,1,-100,-167)">
|
||||||
|
<path d="M404,196C407.778,166.333 384.333,169 364,184" style="fill:none;stroke:white;stroke-width:9px;"/>
|
||||||
|
</g>
|
||||||
|
<g id="Seat">
|
||||||
|
</g>
|
||||||
|
<path d="M120,42L160,42C177.006,42.052 186.77,48.289 191,58" style="fill:none;stroke:white;stroke-width:9px;"/>
|
||||||
|
<g id="Frame" transform="matrix(1,0,0,1,-100,-167)">
|
||||||
|
<path d="M314,390L445,274" style="fill:none;stroke:white;stroke-width:9px;"/>
|
||||||
|
<path d="M500,375L404,196" style="fill:none;stroke:white;stroke-width:9px;stroke-linecap:butt;"/>
|
||||||
|
<path d="M266,259L437,259" style="fill:none;stroke:white;stroke-width:9px;"/>
|
||||||
|
<path d="M314,390L248,209" style="fill:none;stroke:white;stroke-width:9px;stroke-linecap:butt;"/>
|
||||||
|
<g transform="matrix(0.815396,-0.0212494,-0.0212494,0.997554,66.2531,7.62625)">
|
||||||
|
<path d="M143.044,371.322L314,390" style="fill:none;stroke:white;stroke-width:10.11px;"/>
|
||||||
|
</g>
|
||||||
|
<path d="M175,375L266,259" style="fill:none;stroke:white;stroke-width:9px;"/>
|
||||||
|
</g>
|
||||||
|
<g id="Wheel" transform="matrix(1,0,0,1,-100,-167)">
|
||||||
|
<g transform="matrix(0.980392,0,0,0.980392,-46.0784,31.3725)">
|
||||||
|
<path d="M302,293.019C302,282.522 293.478,274 282.981,274L168.019,274C157.522,274 149,282.522 149,293.019L149,407.981C149,418.478 157.522,427 168.019,427L282.981,427C293.478,427 302,418.478 302,407.981L302,293.019ZM292.82,293.019L292.82,407.981C292.82,413.411 288.411,417.82 282.981,417.82C282.981,417.82 168.019,417.82 168.019,417.82C162.589,417.82 158.18,413.411 158.18,407.981L158.18,293.019C158.18,287.589 162.589,283.18 168.019,283.18L282.981,283.18C288.411,283.18 292.82,287.589 292.82,293.019Z" style="fill:white;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,-50,26)">
|
||||||
|
<path d="M237,343C237,339.689 234.311,337 231,337L219,337C215.689,337 213,339.689 213,343L213,355C213,358.311 215.689,361 219,361L231,361C234.311,361 237,358.311 237,355L237,343Z" style="fill:white;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="Wheel1" serif:id="Wheel" transform="matrix(1,0,0,1,225,-167)">
|
||||||
|
<g transform="matrix(0.980392,0,0,0.980392,-46.0784,31.3725)">
|
||||||
|
<path d="M302,293.019C302,282.522 293.478,274 282.981,274L168.019,274C157.522,274 149,282.522 149,293.019L149,407.981C149,418.478 157.522,427 168.019,427L282.981,427C293.478,427 302,418.478 302,407.981L302,293.019ZM292.82,293.019L292.82,407.981C292.82,413.411 288.411,417.82 282.981,417.82C282.981,417.82 168.019,417.82 168.019,417.82C162.589,417.82 158.18,413.411 158.18,407.981L158.18,293.019C158.18,287.589 162.589,283.18 168.019,283.18L282.981,283.18C288.411,283.18 292.82,287.589 292.82,293.019Z" style="fill:white;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,-50,26)">
|
||||||
|
<path d="M237,343C237,339.689 234.311,337 231,337L219,337C215.689,337 213,339.689 213,343L213,355C213,358.311 215.689,361 219,361L231,361C234.311,361 237,358.311 237,355L237,343Z" style="fill:white;"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.4 KiB |
After Width: | Height: | Size: 212 KiB |
|
@ -5,23 +5,28 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/assets"
|
||||||
"git.handmade.network/hmn/hmn/src/auth"
|
"git.handmade.network/hmn/hmn/src/auth"
|
||||||
"git.handmade.network/hmn/hmn/src/config"
|
"git.handmade.network/hmn/hmn/src/config"
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
"git.handmade.network/hmn/hmn/src/email"
|
"git.handmade.network/hmn/hmn/src/email"
|
||||||
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/logging"
|
"git.handmade.network/hmn/hmn/src/logging"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/perf"
|
"git.handmade.network/hmn/hmn/src/perf"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
|
"git.handmade.network/hmn/hmn/src/utils"
|
||||||
"git.handmade.network/hmn/hmn/src/website"
|
"git.handmade.network/hmn/hmn/src/website"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
@ -583,5 +588,34 @@ func init() {
|
||||||
}
|
}
|
||||||
adminCommand.AddCommand(extractImage)
|
adminCommand.AddCommand(extractImage)
|
||||||
|
|
||||||
|
uploadAsset := &cobra.Command{
|
||||||
|
Use: "uploadasset <file> <content type>",
|
||||||
|
Short: "Upload a file to our asset CDN",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if len(args) < 2 {
|
||||||
|
cmd.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fname := args[0]
|
||||||
|
contentType := args[1]
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
conn := db.NewConn()
|
||||||
|
defer conn.Close(ctx)
|
||||||
|
|
||||||
|
assetContents := utils.Must1(io.ReadAll(utils.Must1(os.Open(fname))))
|
||||||
|
assetFilename := filepath.Base(fname)
|
||||||
|
|
||||||
|
fmt.Printf("Uploading %s with content type %s...\n", assetFilename, contentType)
|
||||||
|
asset := utils.Must1(assets.Create(ctx, conn, assets.CreateInput{
|
||||||
|
Content: assetContents,
|
||||||
|
Filename: assetFilename,
|
||||||
|
ContentType: contentType,
|
||||||
|
}))
|
||||||
|
fmt.Printf("Uploaded and accessible at %s\n", hmnurl.BuildS3Asset(asset.S3Key))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adminCommand.AddCommand(uploadAsset)
|
||||||
|
|
||||||
addProjectCommands(adminCommand)
|
addProjectCommands(adminCommand)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
@ -129,12 +130,13 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As
|
||||||
|
|
||||||
var thumbnailKey *string
|
var thumbnailKey *string
|
||||||
|
|
||||||
previewBytes, err := ExtractPreview(ctx, in.ContentType, in.Content)
|
width := in.Width
|
||||||
if err != nil {
|
height := in.Height
|
||||||
|
if previewBytes, thumbWidth, thumbHeight, err := ExtractPreview(ctx, in.ContentType, in.Content); err != nil {
|
||||||
logging.Error().Err(err).Msg("Failed to generate preview for asset")
|
logging.Error().Err(err).Msg("Failed to generate preview for asset")
|
||||||
} else if len(previewBytes) > 0 {
|
} else if len(previewBytes) > 0 {
|
||||||
keyStr := AssetKey(id.String(), fmt.Sprintf("%s_thumb.png", id.String()))
|
keyStr := AssetKey(id.String(), fmt.Sprintf("%s_thumb.jpg", id.String()))
|
||||||
thumbnailType := "image/png"
|
thumbnailType := "image/jpeg"
|
||||||
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
|
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
|
||||||
Key: &keyStr,
|
Key: &keyStr,
|
||||||
|
@ -147,6 +149,11 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As
|
||||||
} else {
|
} else {
|
||||||
thumbnailKey = &keyStr
|
thumbnailKey = &keyStr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
width = thumbWidth
|
||||||
|
height = thumbHeight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save a record in our database
|
// Save a record in our database
|
||||||
|
@ -163,8 +170,8 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As
|
||||||
len(in.Content),
|
len(in.Content),
|
||||||
in.ContentType,
|
in.ContentType,
|
||||||
checksum,
|
checksum,
|
||||||
in.Width,
|
width,
|
||||||
in.Height,
|
height,
|
||||||
in.UploaderID,
|
in.UploaderID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -187,31 +194,46 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As
|
||||||
return asset, nil
|
return asset, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExtractPreview(ctx context.Context, mimeType string, inBytes []byte) ([]byte, error) {
|
func getFFMpegPath() string {
|
||||||
if config.Config.PreviewGeneration.FFMpegPath == "" {
|
path := config.Config.PreviewGeneration.FFMpegPath
|
||||||
return nil, nil
|
if path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
path, err = exec.LookPath("ffmpeg")
|
||||||
|
if err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractPreview(ctx context.Context, mimeType string, inBytes []byte) ([]byte, int, int, error) {
|
||||||
|
log := logging.ExtractLogger(ctx)
|
||||||
|
|
||||||
|
execPath := getFFMpegPath()
|
||||||
|
if execPath == "" {
|
||||||
|
return nil, 0, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(mimeType, "video") {
|
if !strings.HasPrefix(mimeType, "video") {
|
||||||
return nil, nil
|
return nil, 0, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.CreateTemp("", "hmnasset")
|
file, err := os.CreateTemp("", "hmnasset")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, oops.New(err, "Failed to create temp file for preview generation")
|
return nil, 0, 0, oops.New(err, "Failed to create temp file for preview generation")
|
||||||
}
|
}
|
||||||
defer os.Remove(file.Name())
|
defer os.Remove(file.Name())
|
||||||
_, err = file.Write(inBytes)
|
_, err = file.Write(inBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, oops.New(err, "Failed to write to temp file for preview generation")
|
return nil, 0, 0, oops.New(err, "Failed to write to temp file for preview generation")
|
||||||
}
|
}
|
||||||
err = file.Close()
|
err = file.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, oops.New(err, "Failed to close temp file for preview generation")
|
return nil, 0, 0, oops.New(err, "Failed to close temp file for preview generation")
|
||||||
}
|
}
|
||||||
|
|
||||||
args := fmt.Sprintf("-i %s -filter_complex [0]select=gte(n\\,1)[s0] -map [s0] -f image2 -vcodec png -vframes 1 pipe:1", file.Name())
|
args := fmt.Sprintf("-i %s -filter_complex [0]select=gte(n\\,1)[s0] -map [s0] -c:v mjpeg -f mjpeg -vframes 1 pipe:1", file.Name())
|
||||||
execPath := config.Config.PreviewGeneration.FFMpegPath
|
|
||||||
if config.Config.PreviewGeneration.CPULimitPath != "" {
|
if config.Config.PreviewGeneration.CPULimitPath != "" {
|
||||||
args = fmt.Sprintf("-l 10 -- %s %s", execPath, args)
|
args = fmt.Sprintf("-l 10 -- %s %s", execPath, args)
|
||||||
execPath = config.Config.PreviewGeneration.CPULimitPath
|
execPath = config.Config.PreviewGeneration.CPULimitPath
|
||||||
|
@ -224,11 +246,18 @@ func ExtractPreview(ctx context.Context, mimeType string, inBytes []byte) ([]byt
|
||||||
ffmpegCmd.Stderr = &errorOut
|
ffmpegCmd.Stderr = &errorOut
|
||||||
err = ffmpegCmd.Run()
|
err = ffmpegCmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error().Str("ffmpeg output", string(errorOut.Bytes())).Msg("FFMpeg returned error while generating preview thumbnail")
|
log.Error().Str("ffmpeg output", errorOut.String()).Msg("FFMpeg returned error while generating preview thumbnail")
|
||||||
return nil, oops.New(err, "FFMpeg failed for preview generation")
|
return nil, 0, 0, oops.New(err, "FFMpeg failed for preview generation")
|
||||||
}
|
}
|
||||||
|
|
||||||
return output.Bytes(), nil
|
imageBytes := output.Bytes()
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewBuffer(imageBytes))
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("failed to get width/height from video thumbnail")
|
||||||
|
return nil, 0, 0, oops.New(err, "FFMpeg failed for preview generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageBytes, cfg.Width, cfg.Height, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func BackgroundPreviewGeneration(ctx context.Context, conn *pgxpool.Pool) jobs.Job {
|
func BackgroundPreviewGeneration(ctx context.Context, conn *pgxpool.Pool) jobs.Job {
|
||||||
|
@ -238,11 +267,25 @@ func BackgroundPreviewGeneration(ctx context.Context, conn *pgxpool.Pool) jobs.J
|
||||||
go func() {
|
go func() {
|
||||||
defer job.Done()
|
defer job.Done()
|
||||||
log.Debug().Msg("Starting preview gen job")
|
log.Debug().Msg("Starting preview gen job")
|
||||||
|
|
||||||
|
if getFFMpegPath() == "" {
|
||||||
|
log.Warn().Msg("Couldn't find ffmpeg! No thumbnails will be generated.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
assets, err := db.Query[models.Asset](ctx, conn,
|
assets, err := db.Query[models.Asset](ctx, conn,
|
||||||
`
|
`
|
||||||
SELECT $columns
|
SELECT $columns
|
||||||
FROM asset
|
FROM asset
|
||||||
WHERE mime_type LIKE 'video%' AND (thumbnail_s3_key IS NULL OR thumbnail_s3_key = '')
|
WHERE
|
||||||
|
mime_type LIKE 'video%'
|
||||||
|
AND (
|
||||||
|
thumbnail_s3_key IS NULL
|
||||||
|
OR thumbnail_s3_key = ''
|
||||||
|
OR thumbnail_s3_key LIKE '%.png'
|
||||||
|
OR width = 0
|
||||||
|
OR height = 0
|
||||||
|
)
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -258,7 +301,11 @@ func BackgroundPreviewGeneration(ctx context.Context, conn *pgxpool.Pool) jobs.J
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
log.Debug().Str("AssetID", asset.ID.String()).Msg("Generating preview")
|
|
||||||
|
log := log.With().Str("AssetID", asset.ID.String()).Logger()
|
||||||
|
ctx := logging.AttachLoggerToContext(&log, ctx)
|
||||||
|
|
||||||
|
log.Debug().Msg("Generating preview")
|
||||||
assetUrl := hmnurl.BuildS3Asset(asset.S3Key)
|
assetUrl := hmnurl.BuildS3Asset(asset.S3Key)
|
||||||
resp, err := http.Get(assetUrl)
|
resp, err := http.Get(assetUrl)
|
||||||
if err != nil || resp.StatusCode != 200 {
|
if err != nil || resp.StatusCode != 200 {
|
||||||
|
@ -271,13 +318,13 @@ func BackgroundPreviewGeneration(ctx context.Context, conn *pgxpool.Pool) jobs.J
|
||||||
log.Error().Err(err).Msg("Failed to read asset body for preview generation")
|
log.Error().Err(err).Msg("Failed to read asset body for preview generation")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
thumbBytes, err := ExtractPreview(ctx, asset.MimeType, body)
|
thumbBytes, width, height, err := ExtractPreview(ctx, asset.MimeType, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to run extraction for preview generation")
|
log.Error().Err(err).Msg("Failed to run extraction for preview generation")
|
||||||
continue
|
continue
|
||||||
} else if len(thumbBytes) > 0 {
|
} else if len(thumbBytes) > 0 {
|
||||||
keyStr := AssetKey(asset.ID.String(), fmt.Sprintf("%s_thumb.png", asset.ID.String()))
|
keyStr := AssetKey(asset.ID.String(), fmt.Sprintf("%s_thumb.jpg", asset.ID.String()))
|
||||||
thumbnailType := "image/png"
|
thumbnailType := "image/jpeg"
|
||||||
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
|
Bucket: &config.Config.DigitalOcean.AssetsSpacesBucket,
|
||||||
Key: &keyStr,
|
Key: &keyStr,
|
||||||
|
@ -293,17 +340,24 @@ func BackgroundPreviewGeneration(ctx context.Context, conn *pgxpool.Pool) jobs.J
|
||||||
_, err = conn.Exec(ctx,
|
_, err = conn.Exec(ctx,
|
||||||
`
|
`
|
||||||
UPDATE asset
|
UPDATE asset
|
||||||
SET thumbnail_s3_key = $1
|
SET
|
||||||
WHERE asset.id = $2
|
thumbnail_s3_key = $1,
|
||||||
|
width = $2,
|
||||||
|
height = $3
|
||||||
|
WHERE asset.id = $4
|
||||||
`,
|
`,
|
||||||
keyStr,
|
keyStr,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
asset.ID,
|
asset.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to update asset for preview generation")
|
log.Error().Err(err).Msg("Failed to update asset for preview generation")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Debug().Str("AssetID", asset.ID.String()).Msg("Generated preview successfully!")
|
log.Debug().Msg("Generated preview successfully!")
|
||||||
|
} else {
|
||||||
|
log.Debug().Msg("No error, but no thumbnail was generated, skipping")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Debug().Msg("No more previews to generate")
|
log.Debug().Msg("No more previews to generate")
|
||||||
|
|
|
@ -0,0 +1,383 @@
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/config"
|
||||||
|
"git.handmade.network/hmn/hmn/src/jobs"
|
||||||
|
"git.handmade.network/hmn/hmn/src/logging"
|
||||||
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
"git.handmade.network/hmn/hmn/src/utils"
|
||||||
|
"github.com/emersion/go-ical"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RawCalendarData struct {
|
||||||
|
Name string
|
||||||
|
Url string
|
||||||
|
Data []byte
|
||||||
|
Hash [sha1.Size]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarEvent struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Desc string
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time
|
||||||
|
Duration time.Duration
|
||||||
|
CalName string
|
||||||
|
}
|
||||||
|
|
||||||
|
var unifiedCalendar *ical.Calendar
|
||||||
|
var rawCalendarData = make([]*RawCalendarData, 0)
|
||||||
|
var cachedICals = make(map[string][]byte)
|
||||||
|
var httpClient = http.Client{}
|
||||||
|
|
||||||
|
// NOTE(asaf): Passing an empty array for selectedCals returns all cals
|
||||||
|
func GetICal(selectedCals []string) ([]byte, error) {
|
||||||
|
if unifiedCalendar == nil {
|
||||||
|
return nil, oops.New(nil, "No calendar")
|
||||||
|
}
|
||||||
|
sort.Strings(selectedCals)
|
||||||
|
cacheKey := strings.Join(selectedCals, "##")
|
||||||
|
cachedICal, ok := cachedICals[cacheKey]
|
||||||
|
if ok {
|
||||||
|
return cachedICal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cal *ical.Calendar
|
||||||
|
if len(selectedCals) == 0 {
|
||||||
|
cal = unifiedCalendar
|
||||||
|
} else {
|
||||||
|
cal = newHMNCalendar()
|
||||||
|
for _, child := range unifiedCalendar.Children {
|
||||||
|
include := true
|
||||||
|
|
||||||
|
if child.Name == ical.CompEvent {
|
||||||
|
calName, _ := child.Props.Text(ical.PropComment)
|
||||||
|
if calName != "" {
|
||||||
|
found := false
|
||||||
|
for _, s := range selectedCals {
|
||||||
|
if calName == s {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
include = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if include {
|
||||||
|
cal.Children = append(cal.Children, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var calBytes []byte
|
||||||
|
if len(cal.Children) > 0 {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
err := ical.NewEncoder(&buffer).Encode(cal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, oops.New(err, "Failed to encode calendar to iCal")
|
||||||
|
}
|
||||||
|
calBytes = buffer.Bytes()
|
||||||
|
} else {
|
||||||
|
calBytes = emptyCalendarString()
|
||||||
|
}
|
||||||
|
cachedICals[cacheKey] = calBytes
|
||||||
|
return calBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFutureEvents() []CalendarEvent {
|
||||||
|
if unifiedCalendar == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
futureEvents := make([]CalendarEvent, 0)
|
||||||
|
eventObjects := unifiedCalendar.Events()
|
||||||
|
now := time.Now()
|
||||||
|
lastTime := now.Add(time.Hour * 24 * 365)
|
||||||
|
for _, ev := range eventObjects {
|
||||||
|
summary, err := ev.Props.Text(ical.PropSummary)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error().Err(err).Msg("Failed to get summary for calendar event")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime, err := ev.DateTimeStart(nil)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get start time for calendar event")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var evTimes []time.Time
|
||||||
|
set, err := ev.RecurrenceSet(nil)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get recurrence set for calendar event")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if set != nil {
|
||||||
|
evTimes = set.Between(now, lastTime, true)
|
||||||
|
} else if startTime.After(now) {
|
||||||
|
evTimes = []time.Time{startTime}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(evTimes) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
desc, err := ev.Props.Text(ical.PropDescription)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get description for calendar event")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
calName, _ := ev.Props.Text(ical.PropComment)
|
||||||
|
|
||||||
|
uid, err := ev.Props.Text(ical.PropUID)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get uid for calendar event")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
endTime, err := ev.DateTimeStart(nil)
|
||||||
|
if err != nil {
|
||||||
|
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get end time for calendar event")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
evDuration := endTime.Sub(startTime)
|
||||||
|
|
||||||
|
for _, t := range evTimes {
|
||||||
|
futureEvents = append(futureEvents, CalendarEvent{
|
||||||
|
ID: uid,
|
||||||
|
Name: summary,
|
||||||
|
Desc: desc,
|
||||||
|
StartTime: t,
|
||||||
|
EndTime: t.Add(evDuration),
|
||||||
|
Duration: evDuration,
|
||||||
|
CalName: calName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(futureEvents, func(i, j int) bool {
|
||||||
|
return futureEvents[i].StartTime.Before(futureEvents[j].StartTime)
|
||||||
|
})
|
||||||
|
return futureEvents
|
||||||
|
}
|
||||||
|
|
||||||
|
func MonitorCalendars(ctx context.Context) jobs.Job {
|
||||||
|
log := logging.ExtractLogger(ctx).With().Str("calendar goroutine", "calendar monitor").Logger()
|
||||||
|
if len(config.Config.Calendars) == 0 {
|
||||||
|
log.Info().Msg("No calendars specified in config")
|
||||||
|
return jobs.Noop()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = logging.AttachLoggerToContext(&log, ctx)
|
||||||
|
|
||||||
|
job := jobs.New()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
log.Info().Msg("Shutting down calendar monitor")
|
||||||
|
job.Done()
|
||||||
|
}()
|
||||||
|
log.Info().Msg("Running calendar monitor")
|
||||||
|
|
||||||
|
monitorTimer := time.NewTimer(time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-monitorTimer.C:
|
||||||
|
err := func() (err error) {
|
||||||
|
defer utils.RecoverPanicAsError(&err)
|
||||||
|
|
||||||
|
ReloadCalendars(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
logging.Error().Err(err).Msg("Panicked in MonitorCalendars")
|
||||||
|
}
|
||||||
|
monitorTimer.Reset(time.Minute)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReloadCalendars(ctx context.Context) {
|
||||||
|
log := logging.ExtractLogger(ctx)
|
||||||
|
|
||||||
|
// Download calendars
|
||||||
|
calChan := make(chan RawCalendarData, len(config.Config.Calendars))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(len(config.Config.Calendars))
|
||||||
|
for _, c := range config.Config.Calendars {
|
||||||
|
go func(cal config.CalendarSource) {
|
||||||
|
defer func() {
|
||||||
|
wg.Done()
|
||||||
|
logging.LogPanics(log)
|
||||||
|
}()
|
||||||
|
calUrl := cal.Url
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", calUrl, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to create request for calendar fetch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("Url", calUrl).Msg("Failed to fetch calendar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.StatusCode > 299 || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/calendar") {
|
||||||
|
log.Error().Str("Url", calUrl).Str("Status", res.Status).Msg("Failed to fetch calendar")
|
||||||
|
io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Str("Url", calUrl).Msg("Failed to fetch calendar")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
calChan <- RawCalendarData{Name: cal.Name, Url: calUrl, Data: data}
|
||||||
|
}(c)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
newRawCalendarData := make([]*RawCalendarData, 0, len(config.Config.Calendars))
|
||||||
|
Collect:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case d := <-calChan:
|
||||||
|
newRawCalendarData = append(newRawCalendarData, &d)
|
||||||
|
default:
|
||||||
|
break Collect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff calendars
|
||||||
|
same := true
|
||||||
|
for _, n := range newRawCalendarData {
|
||||||
|
n.Hash = sha1.Sum(n.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(newRawCalendarData, func(i, j int) bool {
|
||||||
|
return newRawCalendarData[i].Name < newRawCalendarData[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(newRawCalendarData) != len(rawCalendarData) {
|
||||||
|
same = false
|
||||||
|
} else {
|
||||||
|
for i := range newRawCalendarData {
|
||||||
|
newData := newRawCalendarData[i]
|
||||||
|
oldData := rawCalendarData[i]
|
||||||
|
if newData.Name != oldData.Name {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if newData.Hash != oldData.Hash {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if same {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unify calendars and clear cache
|
||||||
|
rawCalendarData = newRawCalendarData
|
||||||
|
cachedICals = make(map[string][]byte)
|
||||||
|
|
||||||
|
unified := newHMNCalendar()
|
||||||
|
|
||||||
|
var timezones []string
|
||||||
|
|
||||||
|
for _, calData := range rawCalendarData {
|
||||||
|
decoder := ical.NewDecoder(bytes.NewReader(calData.Data))
|
||||||
|
calNameProp := ical.NewProp(ical.PropComment)
|
||||||
|
calNameProp.SetText(calData.Name)
|
||||||
|
for {
|
||||||
|
cal, err := decoder.Decode()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
log.Error().Err(err).Str("Url", calData.Url).Msg("Failed to parse calendar")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range cal.Children {
|
||||||
|
if child.Name == ical.CompTimezone {
|
||||||
|
tzid, err := child.Props.Text(ical.PropTimezoneID)
|
||||||
|
if err != nil {
|
||||||
|
found := false
|
||||||
|
for _, s := range timezones {
|
||||||
|
if s == tzid {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
timezones = append(timezones, tzid)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if child.Name == ical.CompEvent {
|
||||||
|
child.Props.Set(calNameProp)
|
||||||
|
}
|
||||||
|
unified.Children = append(unified.Children, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unifiedCalendar = unified
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHMNCalendar() *ical.Calendar {
|
||||||
|
cal := ical.NewCalendar()
|
||||||
|
|
||||||
|
prodID := ical.NewProp(ical.PropProductID)
|
||||||
|
prodID.SetText("Handmade Network")
|
||||||
|
cal.Props.Set(prodID)
|
||||||
|
|
||||||
|
version := ical.NewProp(ical.PropVersion)
|
||||||
|
version.SetText("1.0")
|
||||||
|
cal.Props.Set(version)
|
||||||
|
|
||||||
|
name := ical.NewProp("X-WR-CALNAME")
|
||||||
|
name.SetText("Handmade Network")
|
||||||
|
cal.Props.Set(name)
|
||||||
|
|
||||||
|
return cal
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE(asaf): The ical library we're using doesn't like encoding empty calendars, so we have to do this manually.
|
||||||
|
func emptyCalendarString() []byte {
|
||||||
|
empty := `BEGIN:VCALENDAR
|
||||||
|
VERSION:1.0
|
||||||
|
PRODID:Handmade Network
|
||||||
|
X-WR-CALNAME:Handmade Network
|
||||||
|
END:VCALENDAR
|
||||||
|
`
|
||||||
|
|
||||||
|
return []byte(empty)
|
||||||
|
}
|
|
@ -78,6 +78,8 @@ var Config = HMNConfig{
|
||||||
BaseUrl: "https://api.twitch.tv/helix",
|
BaseUrl: "https://api.twitch.tv/helix",
|
||||||
BaseIDUrl: "https://id.twitch.tv/oauth2",
|
BaseIDUrl: "https://id.twitch.tv/oauth2",
|
||||||
},
|
},
|
||||||
|
Calendars: []CalendarSource{
|
||||||
|
},
|
||||||
EpisodeGuide: EpisodeGuide{
|
EpisodeGuide: EpisodeGuide{
|
||||||
CineraOutputPath: "./annotations/",
|
CineraOutputPath: "./annotations/",
|
||||||
Projects: map[string]string{"hero": "code", "riscy": "riscy", "bitwise": "bitwise"},
|
Projects: map[string]string{"hero": "code", "riscy": "riscy", "bitwise": "bitwise"},
|
||||||
|
|
|
@ -28,9 +28,11 @@ type HMNConfig struct {
|
||||||
DigitalOcean DigitalOceanConfig
|
DigitalOcean DigitalOceanConfig
|
||||||
Discord DiscordConfig
|
Discord DiscordConfig
|
||||||
Twitch TwitchConfig
|
Twitch TwitchConfig
|
||||||
|
Matrix MatrixConfig
|
||||||
EpisodeGuide EpisodeGuide
|
EpisodeGuide EpisodeGuide
|
||||||
DevConfig DevConfig
|
DevConfig DevConfig
|
||||||
PreviewGeneration PreviewGenerationConfig
|
PreviewGeneration PreviewGenerationConfig
|
||||||
|
Calendars []CalendarSource
|
||||||
}
|
}
|
||||||
|
|
||||||
type PostgresConfig struct {
|
type PostgresConfig struct {
|
||||||
|
@ -93,6 +95,18 @@ type TwitchConfig struct {
|
||||||
BaseIDUrl string
|
BaseIDUrl string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MatrixConfig struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
BaseUrl string
|
||||||
|
AnnouncementsRoomID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarSource struct {
|
||||||
|
Name string
|
||||||
|
Url string
|
||||||
|
}
|
||||||
|
|
||||||
type EpisodeGuide struct {
|
type EpisodeGuide struct {
|
||||||
CineraOutputPath string
|
CineraOutputPath string
|
||||||
Projects map[string]string // NOTE(asaf): Maps from slugs to default episode guide topic
|
Projects map[string]string // NOTE(asaf): Maps from slugs to default episode guide topic
|
||||||
|
|
|
@ -23,6 +23,34 @@ import (
|
||||||
"github.com/jpillora/backoff"
|
"github.com/jpillora/backoff"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type BotEvent struct {
|
||||||
|
Timestamp time.Time
|
||||||
|
Name string
|
||||||
|
Extra string
|
||||||
|
}
|
||||||
|
|
||||||
|
var botEvents = make([]BotEvent, 0, 1000)
|
||||||
|
var botEventsMutex = sync.Mutex{}
|
||||||
|
|
||||||
|
func RecordBotEvent(name, extra string) {
|
||||||
|
botEventsMutex.Lock()
|
||||||
|
defer botEventsMutex.Unlock()
|
||||||
|
if len(botEvents) > 1000 {
|
||||||
|
botEvents = botEvents[len(botEvents)-500:]
|
||||||
|
}
|
||||||
|
botEvents = append(botEvents, BotEvent{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Name: name,
|
||||||
|
Extra: extra,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBotEvents() []BotEvent {
|
||||||
|
botEventsMutex.Lock()
|
||||||
|
defer botEventsMutex.Unlock()
|
||||||
|
return botEvents[:]
|
||||||
|
}
|
||||||
|
|
||||||
func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
|
func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
|
||||||
log := logging.ExtractLogger(ctx).With().Str("module", "discord").Logger()
|
log := logging.ExtractLogger(ctx).With().Str("module", "discord").Logger()
|
||||||
ctx = logging.AttachLoggerToContext(&log, ctx)
|
ctx = logging.AttachLoggerToContext(&log, ctx)
|
||||||
|
@ -56,6 +84,11 @@ func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
|
||||||
log.Info().Msg("Connecting to the Discord gateway")
|
log.Info().Msg("Connecting to the Discord gateway")
|
||||||
bot := newBotInstance(dbConn)
|
bot := newBotInstance(dbConn)
|
||||||
err := bot.Run(ctx)
|
err := bot.Run(ctx)
|
||||||
|
disconnectMessage := ""
|
||||||
|
if err != nil {
|
||||||
|
disconnectMessage = err.Error()
|
||||||
|
}
|
||||||
|
RecordBotEvent("Disconnected", disconnectMessage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dur := boff.Duration()
|
dur := boff.Duration()
|
||||||
log.Error().
|
log.Error().
|
||||||
|
@ -101,6 +134,8 @@ type botInstance struct {
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
dbConn *pgxpool.Pool
|
dbConn *pgxpool.Pool
|
||||||
|
|
||||||
|
resuming bool
|
||||||
|
|
||||||
heartbeatIntervalMs int
|
heartbeatIntervalMs int
|
||||||
forceHeartbeat chan struct{}
|
forceHeartbeat chan struct{}
|
||||||
|
|
||||||
|
@ -193,6 +228,7 @@ func (bot *botInstance) Run(ctx context.Context) (err error) {
|
||||||
logging.ExtractLogger(ctx).Info().Msg("Discord asked us to reconnect to the gateway")
|
logging.ExtractLogger(ctx).Info().Msg("Discord asked us to reconnect to the gateway")
|
||||||
return nil
|
return nil
|
||||||
case OpcodeInvalidSession:
|
case OpcodeInvalidSession:
|
||||||
|
RecordBotEvent("Failed to resume - invalid session", "")
|
||||||
// We tried to resume but the session was invalid.
|
// We tried to resume but the session was invalid.
|
||||||
// Delete the session and reconnect from scratch again.
|
// Delete the session and reconnect from scratch again.
|
||||||
_, err := bot.dbConn.Exec(ctx, `DELETE FROM discord_session`)
|
_, err := bot.dbConn.Exec(ctx, `DELETE FROM discord_session`)
|
||||||
|
@ -264,8 +300,11 @@ func (bot *botInstance) connect(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecordBotEvent("Connected", "")
|
||||||
if shouldResume {
|
if shouldResume {
|
||||||
|
RecordBotEvent("Resuming with session ID", session.ID)
|
||||||
// Reconnect to the previous session
|
// Reconnect to the previous session
|
||||||
|
bot.resuming = true
|
||||||
err := bot.sendGatewayMessage(ctx, GatewayMessage{
|
err := bot.sendGatewayMessage(ctx, GatewayMessage{
|
||||||
Opcode: OpcodeResume,
|
Opcode: OpcodeResume,
|
||||||
Data: Resume{
|
Data: Resume{
|
||||||
|
@ -540,11 +579,20 @@ func (bot *botInstance) processEventMsg(ctx context.Context, msg *GatewayMessage
|
||||||
panic(fmt.Sprintf("processEventMsg must only be used on Dispatch messages (opcode %d). Validate this before you call this function.", OpcodeDispatch))
|
panic(fmt.Sprintf("processEventMsg must only be used on Dispatch messages (opcode %d). Validate this before you call this function.", OpcodeDispatch))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if bot.resuming {
|
||||||
|
name := ""
|
||||||
|
if msg.EventName != nil {
|
||||||
|
name = *msg.EventName
|
||||||
|
}
|
||||||
|
RecordBotEvent("Got event while resuming", name)
|
||||||
|
}
|
||||||
switch *msg.EventName {
|
switch *msg.EventName {
|
||||||
case "RESUMED":
|
case "RESUMED":
|
||||||
// Nothing to do, but at least we can log something
|
// Nothing to do, but at least we can log something
|
||||||
logging.ExtractLogger(ctx).Info().Msg("Finished resuming gateway session")
|
logging.ExtractLogger(ctx).Info().Msg("Finished resuming gateway session")
|
||||||
|
|
||||||
|
bot.resuming = false
|
||||||
|
RecordBotEvent("Done resuming", "")
|
||||||
bot.createApplicationCommands(ctx)
|
bot.createApplicationCommands(ctx)
|
||||||
case "MESSAGE_CREATE":
|
case "MESSAGE_CREATE":
|
||||||
newMessage := *MessageFromMap(msg.Data, "")
|
newMessage := *MessageFromMap(msg.Data, "")
|
||||||
|
|
|
@ -185,6 +185,7 @@ func Scrape(ctx context.Context, dbConn *pgxpool.Pool, channelID string, earlies
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
msg.Backfilled = true
|
||||||
err := HandleIncomingMessage(ctx, dbConn, &msg, createSnippets)
|
err := HandleIncomingMessage(ctx, dbConn, &msg, createSnippets)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
package discord
|
package discord
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -19,9 +23,15 @@ import (
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/parsing"
|
"git.handmade.network/hmn/hmn/src/parsing"
|
||||||
|
"git.handmade.network/hmn/hmn/src/utils"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var autostoreChannels = []string{
|
||||||
|
config.Config.Discord.ShowcaseChannelID,
|
||||||
|
// TODO(asaf): Add jam channel
|
||||||
|
}
|
||||||
|
|
||||||
func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, createSnippets bool) error {
|
func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, createSnippets bool) error {
|
||||||
deleted := false
|
deleted := false
|
||||||
var err error
|
var err error
|
||||||
|
@ -36,6 +46,10 @@ func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message
|
||||||
deleted, err = CleanUpShowcase(ctx, dbConn, msg)
|
deleted, err = CleanUpShowcase(ctx, dbConn, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !deleted && err == nil {
|
||||||
|
err = ShareToMatrix(ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
if !deleted && err == nil {
|
if !deleted && err == nil {
|
||||||
err = MaybeInternMessage(ctx, dbConn, msg)
|
err = MaybeInternMessage(ctx, dbConn, msg)
|
||||||
}
|
}
|
||||||
|
@ -44,6 +58,11 @@ func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message
|
||||||
err = HandleInternedMessage(ctx, dbConn, msg, deleted, createSnippets)
|
err = HandleInternedMessage(ctx, dbConn, msg, deleted, createSnippets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// when we needed her most...she vanished
|
||||||
|
// if !deleted && err == nil {
|
||||||
|
// err = FreyaMode(ctx, dbConn, msg)
|
||||||
|
// }
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,17 +75,7 @@ func CleanUpShowcase(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (boo
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
hasGoodContent := true
|
if !messageShouldBeStored(msg) {
|
||||||
if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
|
|
||||||
hasGoodContent = false
|
|
||||||
}
|
|
||||||
|
|
||||||
hasGoodAttachments := true
|
|
||||||
if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
|
|
||||||
hasGoodAttachments = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasGoodContent && !hasGoodAttachments {
|
|
||||||
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return deleted, oops.New(err, "failed to delete message")
|
return deleted, oops.New(err, "failed to delete message")
|
||||||
|
@ -82,7 +91,7 @@ func CleanUpShowcase(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (boo
|
||||||
err = SendMessages(ctx, dbConn, MessageToSend{
|
err = SendMessages(ctx, dbConn, MessageToSend{
|
||||||
ChannelID: channel.ID,
|
ChannelID: channel.ID,
|
||||||
Req: CreateMessageRequest{
|
Req: CreateMessageRequest{
|
||||||
Content: "Posts in #project-showcase are required to have either an image/video or a link. Discuss showcase content in #projects.",
|
Content: "Posts in #project-showcase are required to have either an image/video or a link, or start with `!til`. Discuss showcase content in #projects.",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -137,8 +146,163 @@ func CleanUpLibrary(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (bool
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FreyaMode(ctx context.Context, dbConn db.ConnOrTx, msg *Message) error {
|
||||||
|
if msg.Author.IsBot {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if msg.ChannelID == config.Config.Discord.ShowcaseChannelID || msg.ChannelID == config.Config.Discord.LibraryChannelID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
twitteryUrls := []string{
|
||||||
|
"https://twitter.com",
|
||||||
|
"https://x.com",
|
||||||
|
"https://vxtwitter.com",
|
||||||
|
"https://fxtwitter.com",
|
||||||
|
}
|
||||||
|
isTwittery := false
|
||||||
|
for _, url := range twitteryUrls {
|
||||||
|
if strings.Contains(msg.Content, url) {
|
||||||
|
isTwittery = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isTwittery {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FREYA MODE ENGAGED
|
||||||
|
approvedTweets := []string{
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1757836988495847568",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1752441092501361103",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1753813557966217268",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1746228932188295579",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1732687685850894799",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1761487879178736048",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1733820461492863442",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1732845451701871101",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1765680355657359585",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1784678195997852129",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1741468609044508831",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1759306434053870012",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1754929898492162178",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1782498313511534822",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1623737764041695232",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1718979996125925494",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1675945798448607248",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1662229911375953922",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1652235944752185345",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1386408507218427905",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1436696408506212353",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1444755552777670657",
|
||||||
|
"https://vxtwitter.com/FreyaHolmer/status/1232826293902888960",
|
||||||
|
}
|
||||||
|
tweet := approvedTweets[rand.Intn(len(approvedTweets))]
|
||||||
|
err := SendMessages(ctx, dbConn, MessageToSend{
|
||||||
|
ChannelID: msg.ChannelID,
|
||||||
|
Req: CreateMessageRequest{
|
||||||
|
Content: fmt.Sprintf("No. Only Freya is allowed to tweet. %s", tweet),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to send Freya tweet")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ShareToMatrix(ctx context.Context, msg *Message) error {
|
||||||
|
if msg.Flags&MessageFlagCrossposted == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if config.Config.Matrix.Username == "" {
|
||||||
|
logging.ExtractLogger(ctx).Warn().Msg("No Matrix user provided; Discord announcement will not be shared")
|
||||||
|
}
|
||||||
|
|
||||||
|
fullMsg, err := GetChannelMessage(ctx, msg.ChannelID, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to get published message contents")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyMarkdown := CleanUpMarkdown(ctx, fullMsg.Content)
|
||||||
|
bodyHTML := parsing.ParseMarkdown(bodyMarkdown, parsing.DiscordMarkdown)
|
||||||
|
|
||||||
|
// Log in to Matrix (we don't bother to keep access tokens around)
|
||||||
|
var accessToken string
|
||||||
|
{
|
||||||
|
type MatrixLogin struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
type MatrixLoginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
body := MatrixLogin{
|
||||||
|
Type: "m.login.password",
|
||||||
|
User: config.Config.Matrix.Username,
|
||||||
|
Password: config.Config.Matrix.Password,
|
||||||
|
}
|
||||||
|
bodyBytes := utils.Must1(json.Marshal(body))
|
||||||
|
res, err := http.Post(
|
||||||
|
"https://matrix.handmadecities.com/_matrix/client/r0/login",
|
||||||
|
"application/json",
|
||||||
|
bytes.NewReader(bodyBytes),
|
||||||
|
)
|
||||||
|
if err != nil || res.StatusCode >= 300 {
|
||||||
|
return oops.New(err, "failed to log into Matrix")
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
resBodyBytes := utils.Must1(io.ReadAll(res.Body))
|
||||||
|
var resBody MatrixLoginResponse
|
||||||
|
utils.Must(json.Unmarshal(resBodyBytes, &resBody))
|
||||||
|
|
||||||
|
accessToken = resBody.AccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create message
|
||||||
|
{
|
||||||
|
type MessageEvent struct {
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Format string `json:"format,omitempty"`
|
||||||
|
FormattedBody string `json:"formatted_body,omitempty"`
|
||||||
|
}
|
||||||
|
tid := "hmn" + strconv.Itoa(rand.Int())
|
||||||
|
body := MessageEvent{
|
||||||
|
MsgType: "m.text",
|
||||||
|
Body: bodyMarkdown,
|
||||||
|
Format: "org.matrix.custom.html",
|
||||||
|
FormattedBody: bodyHTML,
|
||||||
|
}
|
||||||
|
bodyBytes := utils.Must1(json.Marshal(body))
|
||||||
|
req := utils.Must1(http.NewRequestWithContext(
|
||||||
|
ctx,
|
||||||
|
http.MethodPut,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s",
|
||||||
|
config.Config.Matrix.BaseUrl,
|
||||||
|
config.Config.Matrix.AnnouncementsRoomID,
|
||||||
|
tid,
|
||||||
|
),
|
||||||
|
bytes.NewReader(bodyBytes),
|
||||||
|
))
|
||||||
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil || res.StatusCode >= 300 {
|
||||||
|
return oops.New(err, "failed to send Matrix message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.ExtractLogger(ctx).Info().
|
||||||
|
Str("contents", bodyMarkdown).
|
||||||
|
Msg("Published Discord announcement to Matrix")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func MaybeInternMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message) error {
|
func MaybeInternMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message) error {
|
||||||
if msg.ChannelID == config.Config.Discord.ShowcaseChannelID {
|
if messageShouldBeStored(msg) {
|
||||||
err := InternMessage(ctx, dbConn, msg)
|
err := InternMessage(ctx, dbConn, msg)
|
||||||
if errors.Is(err, errNotEnoughInfo) {
|
if errors.Is(err, errNotEnoughInfo) {
|
||||||
logging.ExtractLogger(ctx).Warn().
|
logging.ExtractLogger(ctx).Warn().
|
||||||
|
@ -190,8 +354,8 @@ func InternMessage(
|
||||||
|
|
||||||
_, err = dbConn.Exec(ctx,
|
_, err = dbConn.Exec(ctx,
|
||||||
`
|
`
|
||||||
INSERT INTO discord_message (id, channel_id, guild_id, url, user_id, sent_at, snippet_created)
|
INSERT INTO discord_message (id, channel_id, guild_id, url, user_id, sent_at, snippet_created, backfilled)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
`,
|
`,
|
||||||
msg.ID,
|
msg.ID,
|
||||||
msg.ChannelID,
|
msg.ChannelID,
|
||||||
|
@ -200,6 +364,7 @@ func InternMessage(
|
||||||
msg.Author.ID,
|
msg.Author.ID,
|
||||||
msg.Time(),
|
msg.Time(),
|
||||||
false,
|
false,
|
||||||
|
msg.Backfilled,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "failed to save new discord message")
|
return oops.New(err, "failed to save new discord message")
|
||||||
|
@ -241,7 +406,7 @@ func FetchInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msgId string)
|
||||||
// 1. Saves/updates content
|
// 1. Saves/updates content
|
||||||
// 2. Saves/updates snippet
|
// 2. Saves/updates snippet
|
||||||
// 3. Deletes content/snippet
|
// 3. Deletes content/snippet
|
||||||
func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, deleted bool, createSnippet bool) error {
|
func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, removeInternedMessage bool, createSnippet bool) error {
|
||||||
tx, err := dbConn.Begin(ctx)
|
tx, err := dbConn.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "failed to start transaction")
|
return oops.New(err, "failed to start transaction")
|
||||||
|
@ -252,7 +417,11 @@ func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message
|
||||||
if err != nil && !errors.Is(err, db.NotFound) {
|
if err != nil && !errors.Is(err, db.NotFound) {
|
||||||
return err
|
return err
|
||||||
} else if err == nil {
|
} else if err == nil {
|
||||||
if !deleted {
|
if !removeInternedMessage {
|
||||||
|
removeInternedMessage = !messageShouldBeStored(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !removeInternedMessage {
|
||||||
err = SaveMessageContents(ctx, tx, interned, msg)
|
err = SaveMessageContents(ctx, tx, interned, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -685,7 +854,7 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
||||||
|
|
||||||
if interned.MessageContent == nil {
|
if interned.MessageContent == nil {
|
||||||
// NOTE(asaf): Can't have a snippet without content
|
// NOTE(asaf): Can't have a snippet without content
|
||||||
// NOTE(asaf): Messages that only have an attachment also have blank content
|
// NOTE(asaf): Messages that only have an attachment also have a content struct with an empty content string
|
||||||
// TODO(asaf): Do we need to delete existing snippets in this case??? Not entirely sure how to trigger this through discord
|
// TODO(asaf): Do we need to delete existing snippets in this case??? Not entirely sure how to trigger this through discord
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -735,43 +904,42 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
||||||
if shouldCreate {
|
if shouldCreate {
|
||||||
// Get an asset ID or URL to make a snippet from
|
// Get an asset ID or URL to make a snippet from
|
||||||
assetId, url, err := getSnippetAssetOrUrl(ctx, tx, &interned.Message)
|
assetId, url, err := getSnippetAssetOrUrl(ctx, tx, &interned.Message)
|
||||||
if assetId != nil || url != nil {
|
|
||||||
contentMarkdown := interned.MessageContent.LastContent
|
|
||||||
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
|
||||||
|
|
||||||
_, err = tx.Exec(ctx,
|
contentMarkdown := interned.MessageContent.LastContent
|
||||||
`
|
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
||||||
INSERT INTO snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
`,
|
|
||||||
url,
|
|
||||||
interned.Message.SentAt,
|
|
||||||
contentMarkdown,
|
|
||||||
contentHTML,
|
|
||||||
assetId,
|
|
||||||
interned.Message.ID,
|
|
||||||
interned.HMNUser.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return oops.New(err, "failed to create snippet from attachment")
|
|
||||||
}
|
|
||||||
|
|
||||||
existingSnippet, err = FetchSnippetForMessage(ctx, tx, interned.Message.ID)
|
_, err = tx.Exec(ctx,
|
||||||
if err != nil {
|
`
|
||||||
return oops.New(err, "failed to fetch newly-created snippet")
|
INSERT INTO snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
|
||||||
}
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`,
|
||||||
|
url,
|
||||||
|
interned.Message.SentAt,
|
||||||
|
contentMarkdown,
|
||||||
|
contentHTML,
|
||||||
|
assetId,
|
||||||
|
interned.Message.ID,
|
||||||
|
interned.HMNUser.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to create snippet from attachment")
|
||||||
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx,
|
existingSnippet, err = FetchSnippetForMessage(ctx, tx, interned.Message.ID)
|
||||||
`
|
if err != nil {
|
||||||
UPDATE discord_message
|
return oops.New(err, "failed to fetch newly-created snippet")
|
||||||
SET snippet_created = TRUE
|
}
|
||||||
WHERE id = $1
|
|
||||||
`,
|
_, err = tx.Exec(ctx,
|
||||||
interned.Message.ID,
|
`
|
||||||
)
|
UPDATE discord_message
|
||||||
if err != nil {
|
SET snippet_created = TRUE
|
||||||
return oops.New(err, "failed to mark message as having snippet")
|
WHERE id = $1
|
||||||
}
|
`,
|
||||||
|
interned.Message.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to mark message as having snippet")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -913,3 +1081,36 @@ func messageHasLinks(content string) bool {
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func messageShouldBeStored(msg *Message) bool {
|
||||||
|
if msg == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(msg.Content)), "!til") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
autostore := false
|
||||||
|
for _, cid := range autostoreChannels {
|
||||||
|
if msg.ChannelID == cid {
|
||||||
|
autostore = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if autostore {
|
||||||
|
hasGoodContent := true
|
||||||
|
if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
|
||||||
|
hasGoodContent = false
|
||||||
|
}
|
||||||
|
|
||||||
|
hasGoodAttachments := true
|
||||||
|
if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
|
||||||
|
hasGoodAttachments = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasGoodContent || hasGoodAttachments
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
@ -244,20 +244,38 @@ const (
|
||||||
MessageTypeGuildInviteReminder MessageType = 22
|
MessageTypeGuildInviteReminder MessageType = 22
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MessageFlags int
|
||||||
|
|
||||||
|
const (
|
||||||
|
MessageFlagCrossposted MessageFlags = 1 << iota
|
||||||
|
MessageFlagIsCrosspost
|
||||||
|
MessageFlagSuppressEmbeds
|
||||||
|
MessageFlagSourceMessageDeleted
|
||||||
|
MessageFlagUrgent
|
||||||
|
MessageFlagHasThread
|
||||||
|
MessageFlagEphemeral
|
||||||
|
MessageFlagLoading
|
||||||
|
MessageFlagFailedToMentionSomeRolesInThread
|
||||||
|
MessageFlagSuppressNotifications
|
||||||
|
MessageFlagIsVoiceMessage
|
||||||
|
)
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object
|
// https://discord.com/developers/docs/resources/channel#message-object
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ChannelID string `json:"channel_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
GuildID *string `json:"guild_id"`
|
GuildID *string `json:"guild_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Author *User `json:"author"` // note that this may not be an actual valid user (see the docs)
|
Author *User `json:"author"` // note that this may not be an actual valid user (see the docs)
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
Type MessageType `json:"type"`
|
Type MessageType `json:"type"`
|
||||||
|
Flags MessageFlags `json:"flags"`
|
||||||
|
|
||||||
Attachments []Attachment `json:"attachments"`
|
Attachments []Attachment `json:"attachments"`
|
||||||
Embeds []Embed `json:"embeds"`
|
Embeds []Embed `json:"embeds"`
|
||||||
|
|
||||||
originalMap map[string]interface{}
|
originalMap map[string]interface{}
|
||||||
|
Backfilled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Message) JumpURL() string {
|
func (m *Message) JumpURL() string {
|
||||||
|
@ -317,6 +335,7 @@ func MessageFromMap(m interface{}, k string) *Message {
|
||||||
Author: UserFromMap(m, "author"),
|
Author: UserFromMap(m, "author"),
|
||||||
Timestamp: maybeString(mmap, "timestamp"),
|
Timestamp: maybeString(mmap, "timestamp"),
|
||||||
Type: MessageType(maybeInt(mmap, "type")),
|
Type: MessageType(maybeInt(mmap, "type")),
|
||||||
|
Flags: MessageFlags(maybeInt(mmap, "flags")),
|
||||||
|
|
||||||
originalMap: mmap,
|
originalMap: mmap,
|
||||||
}
|
}
|
||||||
|
@ -1003,3 +1022,11 @@ func maybeBoolP(m map[string]interface{}, k string) *bool {
|
||||||
boolval := val.(bool)
|
boolval := val.(bool)
|
||||||
return &boolval
|
return &boolval
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maybeArray(m map[string]any, k string) []any {
|
||||||
|
val, ok := m[k]
|
||||||
|
if !ok || val == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return val.([]any)
|
||||||
|
}
|
||||||
|
|
|
@ -48,7 +48,47 @@ func SendRegistrationEmail(
|
||||||
perf.EndBlock()
|
perf.EndBlock()
|
||||||
|
|
||||||
perf.StartBlock("EMAIL", "Sending email")
|
perf.StartBlock("EMAIL", "Sending email")
|
||||||
err = sendMail(toAddress, toName, "[handmade.network] Registration confirmation", contents)
|
err = sendMail(toAddress, toName, "[Handmade Network] Registration confirmation", contents)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to send email")
|
||||||
|
}
|
||||||
|
perf.EndBlock()
|
||||||
|
|
||||||
|
perf.EndBlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExistingAccountEmailData struct {
|
||||||
|
Name string
|
||||||
|
Username string
|
||||||
|
HomepageUrl string
|
||||||
|
LoginUrl string
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendExistingAccountEmail(
|
||||||
|
toAddress string,
|
||||||
|
toName string,
|
||||||
|
username string,
|
||||||
|
destination string,
|
||||||
|
perf *perf.RequestPerf,
|
||||||
|
) error {
|
||||||
|
perf.StartBlock("EMAIL", "Existing account email")
|
||||||
|
|
||||||
|
perf.StartBlock("EMAIL", "Rendering template")
|
||||||
|
contents, err := renderTemplate("email_account_existing.html", ExistingAccountEmailData{
|
||||||
|
Name: toName,
|
||||||
|
Username: username,
|
||||||
|
HomepageUrl: hmnurl.BuildHomepage(),
|
||||||
|
LoginUrl: hmnurl.BuildLoginPage(destination),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
perf.EndBlock()
|
||||||
|
|
||||||
|
perf.StartBlock("EMAIL", "Sending email")
|
||||||
|
err = sendMail(toAddress, toName, "[Handmade Network] You already have an account!", contents)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "Failed to send email")
|
return oops.New(err, "Failed to send email")
|
||||||
}
|
}
|
||||||
|
@ -80,7 +120,7 @@ func SendPasswordReset(toAddress string, toName string, username string, resetTo
|
||||||
perf.EndBlock()
|
perf.EndBlock()
|
||||||
|
|
||||||
perf.StartBlock("EMAIL", "Sending email")
|
perf.StartBlock("EMAIL", "Sending email")
|
||||||
err = sendMail(toAddress, toName, "[handmade.network] Your password reset request", contents)
|
err = sendMail(toAddress, toName, "[Handmade Network] Your password reset request", contents)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "Failed to send email")
|
return oops.New(err, "Failed to send email")
|
||||||
}
|
}
|
||||||
|
@ -91,6 +131,41 @@ func SendPasswordReset(toAddress string, toName string, username string, resetTo
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimeMachineEmailData struct {
|
||||||
|
ProfileUrl string
|
||||||
|
Username string
|
||||||
|
UserEmail string
|
||||||
|
DiscordUsername string
|
||||||
|
MediaUrls []string
|
||||||
|
DeviceInfo string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendTimeMachineEmail(profileUrl, username, userEmail, discordUsername string, mediaUrls []string, deviceInfo, description string, perf *perf.RequestPerf) error {
|
||||||
|
perf.StartBlock("EMAIL", "Time machine email")
|
||||||
|
defer perf.EndBlock()
|
||||||
|
|
||||||
|
contents, err := renderTemplate("email_time_machine.html", TimeMachineEmailData{
|
||||||
|
ProfileUrl: profileUrl,
|
||||||
|
Username: username,
|
||||||
|
UserEmail: userEmail,
|
||||||
|
DiscordUsername: discordUsername,
|
||||||
|
MediaUrls: mediaUrls,
|
||||||
|
DeviceInfo: deviceInfo,
|
||||||
|
Description: description,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = sendMail("team@handmade.network", "HMN Team", "[Time Machine] New submission", contents)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "Failed to send email")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var EmailRegex = regexp.MustCompile(`^[^:\p{Cc} ]+@[^:\p{Cc} ]+\.[^:\p{Cc} ]+$`)
|
var EmailRegex = regexp.MustCompile(`^[^:\p{Cc} ]+@[^:\p{Cc} ]+\.[^:\p{Cc} ]+$`)
|
||||||
|
|
||||||
func IsEmail(address string) bool {
|
func IsEmail(address string) bool {
|
||||||
|
|
|
@ -17,8 +17,9 @@ type Event struct {
|
||||||
|
|
||||||
type Jam struct {
|
type Jam struct {
|
||||||
Event
|
Event
|
||||||
Name string
|
Name string
|
||||||
Slug string
|
Slug string
|
||||||
|
UrlSlug string
|
||||||
}
|
}
|
||||||
|
|
||||||
var WRJ2021 = Jam{
|
var WRJ2021 = Jam{
|
||||||
|
@ -26,8 +27,9 @@ var WRJ2021 = Jam{
|
||||||
StartTime: time.Date(2021, 9, 27, 0, 0, 0, 0, time.UTC),
|
StartTime: time.Date(2021, 9, 27, 0, 0, 0, 0, time.UTC),
|
||||||
EndTime: time.Date(2021, 10, 4, 0, 0, 0, 0, time.UTC),
|
EndTime: time.Date(2021, 10, 4, 0, 0, 0, 0, time.UTC),
|
||||||
},
|
},
|
||||||
Name: "Wheel Reinvention Jam 2021",
|
Name: "Wheel Reinvention Jam 2021",
|
||||||
Slug: "WRJ2021",
|
Slug: "WRJ2021",
|
||||||
|
UrlSlug: "2021",
|
||||||
}
|
}
|
||||||
|
|
||||||
var WRJ2022 = Jam{
|
var WRJ2022 = Jam{
|
||||||
|
@ -35,8 +37,9 @@ var WRJ2022 = Jam{
|
||||||
StartTime: time.Date(2022, 8, 15, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
StartTime: time.Date(2022, 8, 15, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||||
EndTime: time.Date(2022, 8, 22, 8, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
EndTime: time.Date(2022, 8, 22, 8, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||||
},
|
},
|
||||||
Name: "Wheel Reinvention Jam 2022",
|
Name: "Wheel Reinvention Jam 2022",
|
||||||
Slug: "WRJ2022",
|
Slug: "WRJ2022",
|
||||||
|
UrlSlug: "2022",
|
||||||
}
|
}
|
||||||
|
|
||||||
var VJ2023 = Jam{
|
var VJ2023 = Jam{
|
||||||
|
@ -44,10 +47,32 @@ var VJ2023 = Jam{
|
||||||
StartTime: time.Date(2023, 4, 14, 0, 0, 0, 0, time.UTC),
|
StartTime: time.Date(2023, 4, 14, 0, 0, 0, 0, time.UTC),
|
||||||
EndTime: time.Date(2023, 4, 17, 0, 0, 0, 0, time.UTC),
|
EndTime: time.Date(2023, 4, 17, 0, 0, 0, 0, time.UTC),
|
||||||
},
|
},
|
||||||
Name: "Visibility Jam 2023",
|
Name: "Visibility Jam 2023",
|
||||||
Slug: "VJ2023",
|
Slug: "VJ2023",
|
||||||
|
UrlSlug: "visibility-2023",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var WRJ2023 = Jam{
|
||||||
|
Event: Event{
|
||||||
|
StartTime: time.Date(2023, 9, 25, 10, 0, 0, 0, utils.Must1(time.LoadLocation("Europe/London"))),
|
||||||
|
EndTime: time.Date(2023, 10, 1, 20, 0, 0, 0, utils.Must1(time.LoadLocation("Europe/London"))),
|
||||||
|
},
|
||||||
|
Name: "Wheel Reinvention Jam 2023",
|
||||||
|
Slug: "WRJ2023",
|
||||||
|
UrlSlug: "2023",
|
||||||
|
}
|
||||||
|
|
||||||
|
var LJ2024 = Jam{
|
||||||
|
Event: Event{
|
||||||
|
StartTime: time.Date(2024, 3, 15, 17, 0, 0, 0, time.UTC),
|
||||||
|
EndTime: time.Date(2024, 3, 25, 0, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
Name: "Learning Jam 2024",
|
||||||
|
Slug: "LJ2024",
|
||||||
|
UrlSlug: "learning-2024",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conferences
|
||||||
var HMS2022 = Event{
|
var HMS2022 = Event{
|
||||||
StartTime: time.Date(2022, 11, 16, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
StartTime: time.Date(2022, 11, 16, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||||
EndTime: time.Date(2022, 11, 18, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
EndTime: time.Date(2022, 11, 18, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||||
|
@ -63,7 +88,17 @@ var HMBoston2023 = Event{
|
||||||
EndTime: time.Date(2023, 8, 4, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
EndTime: time.Date(2023, 8, 4, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||||
}
|
}
|
||||||
|
|
||||||
var AllJams = []Jam{WRJ2021, WRJ2022, VJ2023}
|
var HMS2024 = Event{
|
||||||
|
StartTime: time.Date(2024, 11, 20, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||||
|
EndTime: time.Date(2024, 11, 22, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||||
|
}
|
||||||
|
|
||||||
|
var HMBoston2024 = Event{
|
||||||
|
StartTime: time.Date(2024, 8, 9, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||||
|
EndTime: time.Date(2024, 8, 10, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||||
|
}
|
||||||
|
|
||||||
|
var AllJams = []Jam{WRJ2021, WRJ2022, VJ2023, WRJ2023, LJ2024}
|
||||||
|
|
||||||
func CurrentJam() *Jam {
|
func CurrentJam() *Jam {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
@ -75,6 +110,17 @@ func CurrentJam() *Jam {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PreviousJam() *Jam {
|
||||||
|
now := time.Now()
|
||||||
|
var mostRecent *Jam
|
||||||
|
for i, jam := range AllJams {
|
||||||
|
if jam.EndTime.Before(now) {
|
||||||
|
mostRecent = &AllJams[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mostRecent
|
||||||
|
}
|
||||||
|
|
||||||
func JamBySlug(slug string) Jam {
|
func JamBySlug(slug string) Jam {
|
||||||
for _, jam := range AllJams {
|
for _, jam := range AllJams {
|
||||||
if jam.Slug == slug {
|
if jam.Slug == slug {
|
||||||
|
|
|
@ -44,10 +44,6 @@ func TestShowcase(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildShowcase(), RegexShowcase, nil)
|
AssertRegexMatch(t, BuildShowcase(), RegexShowcase, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStreams(t *testing.T) {
|
|
||||||
AssertRegexMatch(t, BuildStreams(), RegexStreams, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhenIsIt(t *testing.T) {
|
func TestWhenIsIt(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildWhenIsIt(), RegexWhenIsIt, nil)
|
AssertRegexMatch(t, BuildWhenIsIt(), RegexWhenIsIt, nil)
|
||||||
}
|
}
|
||||||
|
@ -142,9 +138,10 @@ func TestFeed(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProjectIndex(t *testing.T) {
|
func TestProjectIndex(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildProjectIndex(1), RegexProjectIndex, nil)
|
AssertRegexMatch(t, BuildProjectIndex(1, ""), RegexProjectIndex, nil)
|
||||||
AssertRegexMatch(t, BuildProjectIndex(2), RegexProjectIndex, map[string]string{"page": "2"})
|
AssertRegexMatch(t, BuildProjectIndex(1, "test"), RegexProjectIndex, map[string]string{"category": "test"})
|
||||||
assert.Panics(t, func() { BuildProjectIndex(0) })
|
AssertRegexMatch(t, BuildProjectIndex(2, "test"), RegexProjectIndex, map[string]string{"page": "2", "category": "test"})
|
||||||
|
assert.Panics(t, func() { BuildProjectIndex(0, "") })
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProjectNew(t *testing.T) {
|
func TestProjectNew(t *testing.T) {
|
||||||
|
@ -415,6 +412,57 @@ func TestJamRecap2023_Visibility(t *testing.T) {
|
||||||
AssertSubdomain(t, BuildJamRecap2023_Visibility(), "")
|
AssertSubdomain(t, BuildJamRecap2023_Visibility(), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestJamIndex2023(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildJamIndex2023(), RegexJamIndex2023, nil)
|
||||||
|
AssertSubdomain(t, BuildJamIndex2023(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJamFeed2023(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildJamFeed2023(), RegexJamFeed2023, nil)
|
||||||
|
AssertSubdomain(t, BuildJamFeed2023(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJamIndex2024_Learning(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildJamIndex2024_Learning(), RegexJamIndex2024_Learning, nil)
|
||||||
|
AssertSubdomain(t, BuildJamIndex2024_Learning(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJamFeed2024_Learning(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildJamFeed2024_Learning(), RegexJamFeed2024_Learning, nil)
|
||||||
|
AssertSubdomain(t, BuildJamFeed2024_Learning(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeMachine(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildTimeMachine(), RegexTimeMachine, nil)
|
||||||
|
AssertSubdomain(t, BuildTimeMachine(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeMachineSubmissions(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildTimeMachineSubmissions(), RegexTimeMachineSubmissions, nil)
|
||||||
|
AssertRegexMatch(t, BuildTimeMachineSubmission(123), RegexTimeMachineSubmissions, nil)
|
||||||
|
AssertSubdomain(t, BuildTimeMachineSubmissions(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeMachineAtomFeed(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildTimeMachineAtomFeed(), RegexTimeMachineAtomFeed, nil)
|
||||||
|
AssertSubdomain(t, BuildTimeMachineAtomFeed(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeMachineForm(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildTimeMachineForm(), RegexTimeMachineForm, nil)
|
||||||
|
AssertSubdomain(t, BuildTimeMachineForm(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeMachineFormDone(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildTimeMachineFormDone(), RegexTimeMachineFormDone, nil)
|
||||||
|
AssertSubdomain(t, BuildTimeMachineFormDone(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewsletterSignup(t *testing.T) {
|
||||||
|
AssertRegexMatch(t, BuildAPINewsletterSignup(), RegexNewsletterSignup, nil)
|
||||||
|
AssertSubdomain(t, BuildAPINewsletterSignup(), "")
|
||||||
|
}
|
||||||
|
|
||||||
func TestProjectNewJam(t *testing.T) {
|
func TestProjectNewJam(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildProjectNewJam(), RegexProjectNew, nil)
|
AssertRegexMatch(t, BuildProjectNewJam(), RegexProjectNew, nil)
|
||||||
AssertSubdomain(t, BuildProjectNewJam(), "")
|
AssertSubdomain(t, BuildProjectNewJam(), "")
|
||||||
|
|
|
@ -35,13 +35,6 @@ func BuildShowcase() string {
|
||||||
return Url("/showcase", nil)
|
return Url("/showcase", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexStreams = regexp.MustCompile("^/streams$")
|
|
||||||
|
|
||||||
func BuildStreams() string {
|
|
||||||
defer CatchPanic()
|
|
||||||
return Url("/streams", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
var RegexWhenIsIt = regexp.MustCompile("^/whenisit$")
|
var RegexWhenIsIt = regexp.MustCompile("^/whenisit$")
|
||||||
|
|
||||||
func BuildWhenIsIt() string {
|
func BuildWhenIsIt() string {
|
||||||
|
@ -77,6 +70,27 @@ func BuildJamIndex2022() string {
|
||||||
return Url("/jam/2022", nil)
|
return Url("/jam/2022", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var RegexJamFeed2022 = regexp.MustCompile("^/jam/2022/feed$")
|
||||||
|
|
||||||
|
func BuildJamFeed2022() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/jam/2022/feed", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexJamIndex2023 = regexp.MustCompile("^/jam/2023$")
|
||||||
|
|
||||||
|
func BuildJamIndex2023() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/jam/2023", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexJamFeed2023 = regexp.MustCompile("^/jam/2023/feed$")
|
||||||
|
|
||||||
|
func BuildJamFeed2023() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/jam/2023/feed", nil)
|
||||||
|
}
|
||||||
|
|
||||||
var RegexJamIndex2023_Visibility = regexp.MustCompile("^/jam/visibility-2023$")
|
var RegexJamIndex2023_Visibility = regexp.MustCompile("^/jam/visibility-2023$")
|
||||||
|
|
||||||
func BuildJamIndex2023_Visibility() string {
|
func BuildJamIndex2023_Visibility() string {
|
||||||
|
@ -98,11 +112,91 @@ func BuildJamRecap2023_Visibility() string {
|
||||||
return Url("/jam/visibility-2023/recap", nil)
|
return Url("/jam/visibility-2023/recap", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexJamFeed2022 = regexp.MustCompile("^/jam/2022/feed$")
|
var RegexJamIndex2024_Learning = regexp.MustCompile("^/jam/learning-2024$")
|
||||||
|
|
||||||
func BuildJamFeed2022() string {
|
func BuildJamIndex2024_Learning() string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
return Url("/jam/2022/feed", nil)
|
return Url("/jam/learning-2024", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexJamFeed2024_Learning = regexp.MustCompile("^/jam/learning-2024/feed$")
|
||||||
|
|
||||||
|
func BuildJamFeed2024_Learning() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/jam/learning-2024/feed", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexJamGuidelines2024_Learning = regexp.MustCompile("^/jam/learning-2024/guidelines$")
|
||||||
|
|
||||||
|
func BuildJamGuidelines2024_Learning() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/jam/learning-2024/guidelines", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexJamSaveTheDate = regexp.MustCompile("^/jam/upcoming$")
|
||||||
|
|
||||||
|
func BuildJamSaveTheDate() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/jam/upcoming", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildJamIndexAny(slug string) string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url(fmt.Sprintf("/jam/%s", slug), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexTimeMachine = regexp.MustCompile("^/timemachine$")
|
||||||
|
|
||||||
|
func BuildTimeMachine() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/timemachine", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexTimeMachineSubmissions = regexp.MustCompile("^/timemachine/submissions$")
|
||||||
|
|
||||||
|
func BuildTimeMachineSubmissions() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/timemachine/submissions", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildTimeMachineSubmission(id int) string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return UrlWithFragment("/timemachine/submissions", nil, strconv.Itoa(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexTimeMachineAtomFeed = regexp.MustCompile("^/timemachine/submissions/atom$")
|
||||||
|
|
||||||
|
func BuildTimeMachineAtomFeed() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/timemachine/submissions/atom", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexTimeMachineForm = regexp.MustCompile("^/timemachine/submit$")
|
||||||
|
|
||||||
|
func BuildTimeMachineForm() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/timemachine/submit", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexTimeMachineFormDone = regexp.MustCompile("^/timemachine/thanks$")
|
||||||
|
|
||||||
|
func BuildTimeMachineFormDone() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/timemachine/thanks", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexCalendarIndex = regexp.MustCompile("^/calendar$")
|
||||||
|
|
||||||
|
func BuildCalendarIndex() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/calendar", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var RegexCalendarICal = regexp.MustCompile("^/Handmade Network.ical$")
|
||||||
|
|
||||||
|
func BuildCalendarICal() string {
|
||||||
|
defer CatchPanic()
|
||||||
|
return Url("/Handmade Network.ical", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// QUESTION(ben): Can we change these routes?
|
// QUESTION(ben): Can we change these routes?
|
||||||
|
@ -118,7 +212,11 @@ var RegexLoginPage = regexp.MustCompile("^/login$")
|
||||||
|
|
||||||
func BuildLoginPage(redirectTo string) string {
|
func BuildLoginPage(redirectTo string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
return Url("/login", []Q{{Name: "redirect", Value: redirectTo}})
|
var q []Q
|
||||||
|
if redirectTo != "" {
|
||||||
|
q = append(q, Q{Name: "redirect", Value: redirectTo})
|
||||||
|
}
|
||||||
|
return Url("/login", q)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexLoginWithDiscord = regexp.MustCompile("^/login-with-discord$")
|
var RegexLoginWithDiscord = regexp.MustCompile("^/login-with-discord$")
|
||||||
|
@ -380,17 +478,21 @@ func BuildAtomFeedForShowcase() string {
|
||||||
* Projects
|
* Projects
|
||||||
*/
|
*/
|
||||||
|
|
||||||
var RegexProjectIndex = regexp.MustCompile("^/projects(/(?P<page>.+)?)?$")
|
var RegexProjectIndex = regexp.MustCompile(`^/projects(/(?P<category>[a-z0-9-]+)(/(?P<page>\d+))?)?$`)
|
||||||
|
|
||||||
func BuildProjectIndex(page int) string {
|
func BuildProjectIndex(page int, category string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
panic(oops.New(nil, "page must be >= 1"))
|
panic(oops.New(nil, "page must be >= 1"))
|
||||||
}
|
}
|
||||||
|
catpath := ""
|
||||||
|
if category != "" {
|
||||||
|
catpath = "/" + category
|
||||||
|
}
|
||||||
if page == 1 {
|
if page == 1 {
|
||||||
return Url("/projects", nil)
|
return Url(fmt.Sprintf("/projects%s", catpath), nil)
|
||||||
} else {
|
} else {
|
||||||
return Url(fmt.Sprintf("/projects/%d", page), nil)
|
return Url(fmt.Sprintf("/projects%s/%d", catpath, page), nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -820,6 +922,12 @@ func BuildDiscordShowcaseBacklog() string {
|
||||||
return Url("/discord_showcase_backlog", nil)
|
return Url("/discord_showcase_backlog", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var RegexDiscordBotDebugPage = regexp.MustCompile("^/discord_bot_debug$")
|
||||||
|
|
||||||
|
func BuildDiscordBotDebugPage() string {
|
||||||
|
return Url("/discord_bot_debug", nil)
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* API
|
* API
|
||||||
*/
|
*/
|
||||||
|
@ -830,6 +938,12 @@ func BuildAPICheckUsername() string {
|
||||||
return Url("/api/check_username", nil)
|
return Url("/api/check_username", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var RegexAPINewsletterSignup = regexp.MustCompile("^/api/newsletter_signup$")
|
||||||
|
|
||||||
|
func BuildAPINewsletterSignup() string {
|
||||||
|
return Url("/api/newsletter_signup", nil)
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Twitch stuff
|
* Twitch stuff
|
||||||
*/
|
*/
|
||||||
|
@ -926,6 +1040,12 @@ func BuildUserFile(filepath string) string {
|
||||||
return BuildPublic(fmt.Sprintf("media/%s", filepath), false)
|
return BuildPublic(fmt.Sprintf("media/%s", filepath), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Redirects
|
||||||
|
*/
|
||||||
|
|
||||||
|
var RegexUnwind = regexp.MustCompile(`^/unwind$`)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Other
|
* Other
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerMigration(AddBackfillToDiscordMessage{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddBackfillToDiscordMessage struct{}
|
||||||
|
|
||||||
|
func (m AddBackfillToDiscordMessage) Version() types.MigrationVersion {
|
||||||
|
return types.MigrationVersion(time.Date(2024, 3, 28, 18, 41, 7, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddBackfillToDiscordMessage) Name() string {
|
||||||
|
return "AddBackfillToDiscordMessage"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddBackfillToDiscordMessage) Description() string {
|
||||||
|
return "Add a backfill flag to discord messages"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddBackfillToDiscordMessage) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
ALTER TABLE discord_message
|
||||||
|
ADD COLUMN backfilled BOOLEAN NOT NULL default FALSE;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddBackfillToDiscordMessage) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
ALTER TABLE discord_message
|
||||||
|
DROP COLUMN backfilled;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerMigration(newsletter{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type newsletter struct{}
|
||||||
|
|
||||||
|
func (m newsletter) Version() types.MigrationVersion {
|
||||||
|
return types.MigrationVersion(time.Date(2024, 5, 7, 1, 34, 32, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m newsletter) Name() string {
|
||||||
|
return "newsletter"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m newsletter) Description() string {
|
||||||
|
return "Adds the newsletter signup"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m newsletter) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
CREATE TABLE newsletter_emails (
|
||||||
|
email VARCHAR(255) NOT NULL PRIMARY KEY
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m newsletter) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
DROP TABLE newsletter_emails;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ type Asset struct {
|
||||||
UploaderID *int `db:"uploader_id"`
|
UploaderID *int `db:"uploader_id"`
|
||||||
|
|
||||||
S3Key string `db:"s3_key"`
|
S3Key string `db:"s3_key"`
|
||||||
ThumbnailS3Key string `db:"thumbnail_s3_key'`
|
ThumbnailS3Key string `db:"thumbnail_s3_key"`
|
||||||
Filename string `db:"filename"`
|
Filename string `db:"filename"`
|
||||||
Size int `db:"size"`
|
Size int `db:"size"`
|
||||||
MimeType string `db:"mime_type"`
|
MimeType string `db:"mime_type"`
|
||||||
|
|
|
@ -167,14 +167,6 @@ article code {
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-shrink-0 {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-grow-1 {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-fair {
|
.flex-fair {
|
||||||
flex-basis: 1px;
|
flex-basis: 1px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
@ -372,6 +364,18 @@ article code {
|
||||||
.g4 { gap: $spacing-large; }
|
.g4 { gap: $spacing-large; }
|
||||||
.g5 { gap: $spacing-extra-large; }
|
.g5 { gap: $spacing-extra-large; }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-1 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.aspect-ratio--2x1 {
|
.aspect-ratio--2x1 {
|
||||||
padding-bottom: 50%;
|
padding-bottom: 50%;
|
||||||
}
|
}
|
||||||
|
@ -392,10 +396,19 @@ article code {
|
||||||
.cg3-ns { column-gap: $spacing-medium; }
|
.cg3-ns { column-gap: $spacing-medium; }
|
||||||
.cg4-ns { column-gap: $spacing-large; }
|
.cg4-ns { column-gap: $spacing-large; }
|
||||||
.cg5-ns { column-gap: $spacing-extra-large; }
|
.cg5-ns { column-gap: $spacing-extra-large; }
|
||||||
|
.grid-1-ns { grid-template-columns: 1fr; }
|
||||||
|
.grid-2-ns { grid-template-columns: 1fr 1fr; }
|
||||||
|
|
||||||
.bg--dim-ns {
|
.bg--dim-ns {
|
||||||
@include usevar(background-color, dim-background);
|
@include usevar(background-color, dim-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.g0-ns { gap: $spacing-none; }
|
||||||
|
.g1-ns { gap: $spacing-extra-small; }
|
||||||
|
.g2-ns { gap: $spacing-small; }
|
||||||
|
.g3-ns { gap: $spacing-medium; }
|
||||||
|
.g4-ns { gap: $spacing-large; }
|
||||||
|
.g5-ns { gap: $spacing-extra-large; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$breakpoint-medium} {
|
@media #{$breakpoint-medium} {
|
||||||
|
@ -409,10 +422,19 @@ article code {
|
||||||
.cg3-m { column-gap: $spacing-medium; }
|
.cg3-m { column-gap: $spacing-medium; }
|
||||||
.cg4-m { column-gap: $spacing-large; }
|
.cg4-m { column-gap: $spacing-large; }
|
||||||
.cg5-m { column-gap: $spacing-extra-large; }
|
.cg5-m { column-gap: $spacing-extra-large; }
|
||||||
|
.grid-1-m { grid-template-columns: 1fr; }
|
||||||
|
.grid-2-m { grid-template-columns: 1fr 1fr; }
|
||||||
|
|
||||||
.bg--dim-m {
|
.bg--dim-m {
|
||||||
@include usevar(background-color, dim-background);
|
@include usevar(background-color, dim-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.g0-m { gap: $spacing-none; }
|
||||||
|
.g1-m { gap: $spacing-extra-small; }
|
||||||
|
.g2-m { gap: $spacing-small; }
|
||||||
|
.g3-m { gap: $spacing-medium; }
|
||||||
|
.g4-m { gap: $spacing-large; }
|
||||||
|
.g5-m { gap: $spacing-extra-large; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media #{$breakpoint-large} {
|
@media #{$breakpoint-large} {
|
||||||
|
@ -426,10 +448,19 @@ article code {
|
||||||
.cg3-l { column-gap: $spacing-medium; }
|
.cg3-l { column-gap: $spacing-medium; }
|
||||||
.cg4-l { column-gap: $spacing-large; }
|
.cg4-l { column-gap: $spacing-large; }
|
||||||
.cg5-l { column-gap: $spacing-extra-large; }
|
.cg5-l { column-gap: $spacing-extra-large; }
|
||||||
|
.grid-1-l { grid-template-columns: 1fr; }
|
||||||
|
.grid-2-l { grid-template-columns: 1fr 1fr; }
|
||||||
|
|
||||||
.bg--dim-l {
|
.bg--dim-l {
|
||||||
@include usevar(background-color, dim-background);
|
@include usevar(background-color, dim-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.g0-l { gap: $spacing-none; }
|
||||||
|
.g1-l { gap: $spacing-extra-small; }
|
||||||
|
.g2-l { gap: $spacing-small; }
|
||||||
|
.g3-l { gap: $spacing-medium; }
|
||||||
|
.g4-l { gap: $spacing-large; }
|
||||||
|
.g5-l { gap: $spacing-extra-large; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.not-first:first-child {
|
.not-first:first-child {
|
||||||
|
|
Before Width: | Height: | Size: 766 B After Width: | Height: | Size: 766 B |
Before Width: | Height: | Size: 549 B After Width: | Height: | Size: 549 B |
Before Width: | Height: | Size: 910 B After Width: | Height: | Size: 910 B |
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 614 B After Width: | Height: | Size: 614 B |
Before Width: | Height: | Size: 776 B After Width: | Height: | Size: 776 B |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 488 B After Width: | Height: | Size: 488 B |
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |
Before Width: | Height: | Size: 922 B After Width: | Height: | Size: 922 B |
After Width: | Height: | Size: 86 B |
After Width: | Height: | Size: 86 B |
After Width: | Height: | Size: 70 B |
After Width: | Height: | Size: 57 B |
After Width: | Height: | Size: 88 B |
After Width: | Height: | Size: 641 B |
After Width: | Height: | Size: 462 B |
After Width: | Height: | Size: 371 B |
After Width: | Height: | Size: 185 B |
After Width: | Height: | Size: 529 B |
After Width: | Height: | Size: 470 B |
After Width: | Height: | Size: 513 B |
After Width: | Height: | Size: 548 B |
After Width: | Height: | Size: 455 B |
After Width: | Height: | Size: 825 B |
After Width: | Height: | Size: 538 B |
After Width: | Height: | Size: 581 B |
Before Width: | Height: | Size: 894 B After Width: | Height: | Size: 894 B |
|
@ -8,6 +8,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/calendar"
|
||||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
|
@ -102,6 +103,9 @@ func ProjectToTemplate(
|
||||||
func ProjectAndStuffToTemplate(p *hmndata.ProjectAndStuff, url string, theme string) Project {
|
func ProjectAndStuffToTemplate(p *hmndata.ProjectAndStuff, url string, theme string) Project {
|
||||||
res := ProjectToTemplate(&p.Project, url)
|
res := ProjectToTemplate(&p.Project, url)
|
||||||
res.Logo = ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, theme)
|
res.Logo = ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, theme)
|
||||||
|
for _, o := range p.Owners {
|
||||||
|
res.Owners = append(res.Owners, UserToTemplate(o, theme))
|
||||||
|
}
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -527,6 +531,16 @@ func EducationArticleToTemplate(a *models.EduArticle) EduArticle {
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CalendarEventToTemplate(ev *calendar.CalendarEvent) CalendarEvent {
|
||||||
|
return CalendarEvent{
|
||||||
|
Name: ev.Name,
|
||||||
|
Desc: ev.Desc,
|
||||||
|
StartTime: ev.StartTime.UTC(),
|
||||||
|
EndTime: ev.EndTime.UTC(),
|
||||||
|
CalName: ev.CalName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func maybeString(s *string) string {
|
func maybeString(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
@ -39,12 +39,16 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<!-- Main post -->
|
<!-- Main post -->
|
||||||
<div class="mb3">
|
<div class="{{ if .IsProjectPage }}mb3{{ end }}">
|
||||||
<div class="post-content overflow-x-auto">
|
<div class="post-content overflow-x-auto">
|
||||||
{{ .MainPost.Content }}
|
{{ .MainPost.Content }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ if not .IsProjectPage }}
|
||||||
|
{{ template "newsletter_signup.html" . }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<div class="optionbar"></div>
|
<div class="optionbar"></div>
|
||||||
|
|
||||||
{{ range .Comments }}
|
{{ range .Comments }}
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="ph3 ph0-ns">
|
||||||
|
<h2>Future events</h2>
|
||||||
|
<div class="cal_toggles mb2 flex flex-row g2">
|
||||||
|
{{ range .Calendars }}
|
||||||
|
<label class="db br3 pv1 ph2 pointer b--gray ba" for="{{ . }}">
|
||||||
|
<input id="{{ . }}" autocomplete="off" type="checkbox" value="{{ . }}" checked />
|
||||||
|
<span>{{ . }}</span>
|
||||||
|
</label>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<a class="ical_link" href="{{ .BaseICalUrl }}">Copy iCal Url</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-column g2">
|
||||||
|
{{ range .Events }}
|
||||||
|
<div data-calname="{{ .CalName }}" class="cal_event timeline-item pa3 br3">
|
||||||
|
<div>{{ timehtmlcontent .StartTime }}</div>
|
||||||
|
<div>
|
||||||
|
<strong class="f4 c--theme">{{ .Name }}</strong>
|
||||||
|
</div>
|
||||||
|
{{ with .Desc }}
|
||||||
|
<div class="mb2">{{ . }}</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ with .CalName }}
|
||||||
|
<div class="dib br2 ba b--gray ph1 f7">{{ . }}</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let events = document.querySelectorAll(".cal_event");
|
||||||
|
let toggles = document.querySelectorAll(".cal_toggles input");
|
||||||
|
let icalLink = document.querySelector(".ical_link");
|
||||||
|
let baseICalUrl = icalLink.href;
|
||||||
|
|
||||||
|
function refreshEvents() {
|
||||||
|
let cals = {};
|
||||||
|
for (let i = 0; i < toggles.length; ++i) {
|
||||||
|
cals[toggles[i].id] = toggles[i].checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < events.length; ++i) {
|
||||||
|
let ev = events[i];
|
||||||
|
let calName = ev.getAttribute("data-calname");
|
||||||
|
if (cals[calName]) {
|
||||||
|
ev.style.display = "block";
|
||||||
|
} else {
|
||||||
|
ev.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let icalFilter = [];
|
||||||
|
let hasAll = true;
|
||||||
|
for (let i = 0; i < toggles.length; ++i) {
|
||||||
|
if (toggles[i].checked) {
|
||||||
|
icalFilter.push(toggles[i].id);
|
||||||
|
} else {
|
||||||
|
hasAll = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
icalLink.disabled = false;
|
||||||
|
if (hasAll) {
|
||||||
|
icalLink.href = baseICalUrl;
|
||||||
|
} else if (icalFilter.length == 0) {
|
||||||
|
icalLink.removeAttribute("href");
|
||||||
|
} else {
|
||||||
|
icalFilter.sort();
|
||||||
|
let url = new URL(baseICalUrl);
|
||||||
|
let params = new URLSearchParams();
|
||||||
|
for (let i = 0; i < icalFilter.length; ++i) {
|
||||||
|
params.append(icalFilter[i], "true");
|
||||||
|
}
|
||||||
|
url.search = params.toString();
|
||||||
|
icalLink.href = url.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < toggles.length; ++i) {
|
||||||
|
toggles[i].addEventListener("input", refreshEvents);
|
||||||
|
}
|
||||||
|
|
||||||
|
icalLink.addEventListener("click", function(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
navigator.clipboard.writeText(icalLink.href);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
|
@ -13,12 +13,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a href="https://handmade-seattle.com/">
|
<a href="https://handmadecities.com/">
|
||||||
<h2>Handmade Seattle</h2>
|
<h2>Handmade Cities</h2>
|
||||||
</a>
|
</a>
|
||||||
<div class="{{ $bannerclass }}" style="background-image: url('{{ static "hms/banner_tall.jpg" }}')"></div>
|
<div class="{{ $bannerclass }}" style="background-image: url('{{ static "hms/banner_tall.jpg" }}')"></div>
|
||||||
<p>Handmade Seattle, the spiritual successor to HandmadeCon, was started in 2019 by Abner Coimbre, the founder of Handmade Network. From the start, Handmade Seattle has been an independent conference, free from corporate sponsorships. The conferences are hybrid online/physical, so you can participate no matter where in the world you live.</p>
|
<p>The Handmade Cities conferences are spiritual successors to HandmadeCon. Handmade Seattle is the flagship conference, a celebration of Handmade software and the Handmade community. Handmade Boston is a smaller conference featuring masterclass sessions that dive deep into technical details.</p>
|
||||||
<p>Tickets can be purchased at <a href="https://handmade-seattle.com/">the conference website</a>.</p>
|
<p>The Handmade Cities conferences are run by Abner Coimbre, founder of Handmade Network. Abner's conferences are completely independent and always free of corporate sponsorships. Both conferences are hybrid, with online and in-person tracks, so you can participate no matter where you are in the world.</p>
|
||||||
|
<p>Tickets can be purchased at <a href="https://handmadecities.com/">the conference website</a>.</p>
|
||||||
<p><a href="https://guide.handmade-seattle.com/c/">Talks and demos</a> can be viewed on the conference's website.</p>
|
<p><a href="https://guide.handmade-seattle.com/c/">Talks and demos</a> can be viewed on the conference's website.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td>Timestamp</td>
|
||||||
|
<td>Name</td>
|
||||||
|
<td>Extras</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .BotEvents }}
|
||||||
|
<tr>
|
||||||
|
<td>{{ rfc3339 .Timestamp }}</td>
|
||||||
|
<td>{{ .Name }}</td>
|
||||||
|
<td>{{ .Extra }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ end }}
|
|
@ -0,0 +1,16 @@
|
||||||
|
<p>
|
||||||
|
Hello {{ .Name }} - you already have a <a href="{{ .HomepageUrl }}">Handmade Network</a> account. Welcome back!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your username is <b>{{ .Username }}</b>. To sign in, please visit the following link and try signing in with your existing username:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{ .LoginUrl }}">{{ .LoginUrl }}</a>
|
||||||
|
</p>
|
||||||
|
<p>Thanks,<br />
|
||||||
|
The Handmade Network staff.</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<p style="font-size:small; -webkit-text-size-adjust:none; color: #666">
|
||||||
|
You are receiving this email because someone tried creating a new account with your email address at <a href="{{ .HomepageUrl }}">handmade.network</a>. If that wasn't you, kindly ignore this email.
|
||||||
|
</p>
|
|
@ -5,7 +5,7 @@
|
||||||
To complete the registration process, please use the following link:
|
To complete the registration process, please use the following link:
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ .CompleteRegistrationUrl }}">{{ .CompleteRegistrationUrl }}</a>.
|
<a href="{{ .CompleteRegistrationUrl }}">{{ .CompleteRegistrationUrl }}</a>
|
||||||
</p>
|
</p>
|
||||||
<p>Thanks,<br />
|
<p>Thanks,<br />
|
||||||
The Handmade Network staff.</p>
|
The Handmade Network staff.</p>
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
<p>Submission by <b><a href="{{ .ProfileUrl }}">{{ .Username }}</a></b> ({{ .UserEmail }}) {{ if .DiscordUsername }} On Discord: {{ .DiscordUsername }} {{ end }}</p>
|
||||||
|
<p>Submitted urls:<p>
|
||||||
|
<ul>
|
||||||
|
{{ range .MediaUrls }}
|
||||||
|
<li><a href="{{ . }}">{{ . }}</a></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
Device info:<br />
|
||||||
|
<pre>{{ .DeviceInfo }}</pre>
|
||||||
|
Description:<br />
|
||||||
|
<pre>{{ .Description }}</pre>
|
|
@ -64,7 +64,7 @@
|
||||||
|
|
||||||
<div class="fishbowl-banner br3 mb3">
|
<div class="fishbowl-banner br3 mb3">
|
||||||
<div class="pa3">
|
<div class="pa3">
|
||||||
This is a <b>fishbowl</b>: a panel conversation held on the Handmade Network Discord where a select few participants discuss a topic with depth and nuance. We host them every two months, so if you want to catch the next one, <a href="https://discord.gg/hmn" target="_blank">join the Discord!</a>
|
This is a <b>fishbowl</b>: a panel conversation held on the Handmade Network Discord where a select few participants discuss a topic in depth. We host them on a regular basis, so if you want to catch the next one, <a href="https://discord.gg/hmn" target="_blank">join the Discord!</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="ph3 ph0-ns">
|
<div class="ph3 ph0-ns">
|
||||||
<h2>Fishbowls</h2>
|
<h2>Fishbowls</h2>
|
||||||
|
|
||||||
<p>Every so often on the Discord, we host a <b>fishbowl</b>: a panel conversation where a select few community members can discuss a topic in detail. Fishbowls give us the opportunity to discuss difficult subjects with more nuance and detail than you can find anywhere else on the Internet. In many ways, they're a distillation of everything the network is about.</p>
|
<p>Every so often on the Discord, we host a <b>fishbowl</b>: a panel conversation where a select few community members can discuss a topic in detail. Fishbowls give us the opportunity to discuss complex topics in more depth and detail than a normal chat conversation would allow. They give our best conversations room to breathe.</p>
|
||||||
|
|
||||||
<p>This is an archive of those conversations. If you would like to catch one live, <a href="https://discord.gg/hmn" target="_blank">join the Discord!</a></p>
|
<p>This is an archive of those conversations. If you would like to catch one live, <a href="https://discord.gg/hmn" target="_blank">join the Discord!</a></p>
|
||||||
|
|
||||||
|
|
After Width: | Height: | Size: 228 KiB |
After Width: | Height: | Size: 89 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#662113" d="M22 33c0 2.209-1.791 3-4 3s-4-.791-4-3l1-9c0-2.209.791-2 3-2s3-.209 3 2l1 9z"/><path fill="#5C913B" d="M31.406 27.297C24.443 21.332 21.623 12.791 18 12.791c-3.623 0-6.443 8.541-13.405 14.506-2.926 2.507-1.532 3.957 2.479 3.667 3.576-.258 6.919-1.069 10.926-1.069s7.352.812 10.926 1.069c4.012.29 5.405-1.16 2.48-3.667z"/><path fill="#3E721D" d="M29.145 24.934C23.794 20.027 20.787 13 18 13c-2.785 0-5.793 7.027-11.144 11.934-4.252 3.898 5.572 4.773 11.144 0 5.569 4.773 15.396 3.898 11.145 0z"/><path fill="#5C913B" d="M29.145 20.959C23.794 16.375 20.787 9.811 18 9.811c-2.785 0-5.793 6.564-11.144 11.148-4.252 3.642 5.572 4.459 11.144 0 5.569 4.459 15.396 3.642 11.145 0z"/><path fill="#3E721D" d="M26.7 17.703C22.523 14.125 20.176 9 18 9c-2.174 0-4.523 5.125-8.7 8.703-3.319 2.844 4.35 3.482 8.7 0 4.349 3.482 12.02 2.844 8.7 0z"/><path fill="#5C913B" d="M26.7 14.726c-4.177-3.579-6.524-8.703-8.7-8.703-2.174 0-4.523 5.125-8.7 8.703-3.319 2.844 4.35 3.481 8.7 0 4.349 3.481 12.02 2.843 8.7 0z"/><path fill="#3E721D" d="M25.021 12.081C21.65 9.193 19.756 5.057 18 5.057c-1.755 0-3.65 4.136-7.021 7.024-2.679 2.295 3.511 2.809 7.021 0 3.51 2.81 9.701 2.295 7.021 0z"/><path fill="#5C913B" d="M25.021 9.839C21.65 6.951 19.756 2.815 18 2.815c-1.755 0-3.65 4.136-7.021 7.024-2.679 2.295 3.511 2.809 7.021 0 3.51 2.81 9.701 2.295 7.021 0z"/><path fill="#3E721D" d="M23.343 6.54C20.778 4.342 19.336 1.195 18 1.195c-1.335 0-2.778 3.148-5.343 5.345-2.038 1.747 2.671 2.138 5.343 0 2.671 2.138 7.382 1.746 5.343 0z"/><path fill="#5C913B" d="M23.343 5.345C20.778 3.148 19.336 0 18 0c-1.335 0-2.778 3.148-5.343 5.345-2.038 1.747 2.671 2.138 5.343 0 2.671 2.138 7.382 1.746 5.343 0z"/></svg>
|
After Width: | Height: | Size: 1.7 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><ellipse fill="#F5F8FA" cx="8.828" cy="18" rx="7.953" ry="13.281"/><path fill="#E1E8ED" d="M8.828 32.031C3.948 32.031.125 25.868.125 18S3.948 3.969 8.828 3.969 17.531 10.132 17.531 18s-3.823 14.031-8.703 14.031zm0-26.562C4.856 5.469 1.625 11.09 1.625 18s3.231 12.531 7.203 12.531S16.031 24.91 16.031 18 12.8 5.469 8.828 5.469z"/><circle fill="#8899A6" cx="6.594" cy="18" r="4.96"/><circle fill="#292F33" cx="6.594" cy="18" r="3.565"/><circle fill="#F5F8FA" cx="7.911" cy="15.443" r="1.426"/><ellipse fill="#F5F8FA" cx="27.234" cy="18" rx="7.953" ry="13.281"/><path fill="#E1E8ED" d="M27.234 32.031c-4.88 0-8.703-6.163-8.703-14.031s3.823-14.031 8.703-14.031S35.938 10.132 35.938 18s-3.824 14.031-8.704 14.031zm0-26.562c-3.972 0-7.203 5.622-7.203 12.531 0 6.91 3.231 12.531 7.203 12.531S34.438 24.91 34.438 18 31.206 5.469 27.234 5.469z"/><circle fill="#8899A6" cx="25" cy="18" r="4.96"/><circle fill="#292F33" cx="25" cy="18" r="3.565"/><circle fill="#F5F8FA" cx="26.317" cy="15.443" r="1.426"/></svg>
|
After Width: | Height: | Size: 1.0 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFDC5D" d="M30 20.145s.094-2.362-1.791-3.068c-1.667-.625-2.309.622-2.309.622s.059-1.913-1.941-2.622c-1.885-.667-2.75.959-2.75.959s-.307-1.872-2.292-2.417C17.246 13.159 16 14.785 16 14.785V2.576C16 1.618 15.458.001 13.458 0S11 1.66 11 2.576v20.5c0 1-1 1-1 0V20.41c0-3.792-2.037-6.142-2.75-6.792-.713-.65-1.667-.98-2.82-.734-1.956.416-1.529 1.92-.974 3.197 1.336 3.078 2.253 7.464 2.533 9.538.79 5.858 5.808 10.375 11.883 10.381 6.626.004 12.123-5.298 12.128-11.924v-3.931z"/></svg>
|
After Width: | Height: | Size: 553 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFDB5E" d="M34.956 17.916c0-.503-.12-.975-.321-1.404-1.341-4.326-7.619-4.01-16.549-4.221-1.493-.035-.639-1.798-.115-5.668.341-2.517-1.282-6.382-4.01-6.382-4.498 0-.171 3.548-4.148 12.322-2.125 4.688-6.875 2.062-6.875 6.771v10.719c0 1.833.18 3.595 2.758 3.885C8.195 34.219 7.633 36 11.238 36h18.044c1.838 0 3.333-1.496 3.333-3.334 0-.762-.267-1.456-.698-2.018 1.02-.571 1.72-1.649 1.72-2.899 0-.76-.266-1.454-.696-2.015 1.023-.57 1.725-1.649 1.725-2.901 0-.909-.368-1.733-.961-2.336.757-.611 1.251-1.535 1.251-2.581z"/><path fill="#EE9547" d="M23.02 21.249h8.604c1.17 0 2.268-.626 2.866-1.633.246-.415.109-.952-.307-1.199-.415-.247-.952-.108-1.199.307-.283.479-.806.775-1.361.775h-8.81c-.873 0-1.583-.71-1.583-1.583s.71-1.583 1.583-1.583H28.7c.483 0 .875-.392.875-.875s-.392-.875-.875-.875h-5.888c-1.838 0-3.333 1.495-3.333 3.333 0 1.025.475 1.932 1.205 2.544-.615.605-.998 1.445-.998 2.373 0 1.028.478 1.938 1.212 2.549-.611.604-.99 1.441-.99 2.367 0 1.12.559 2.108 1.409 2.713-.524.589-.852 1.356-.852 2.204 0 1.838 1.495 3.333 3.333 3.333h5.484c1.17 0 2.269-.625 2.867-1.632.247-.415.11-.952-.305-1.199-.416-.245-.953-.11-1.199.305-.285.479-.808.776-1.363.776h-5.484c-.873 0-1.583-.71-1.583-1.583s.71-1.583 1.583-1.583h6.506c1.17 0 2.27-.626 2.867-1.633.247-.416.11-.953-.305-1.199-.419-.251-.954-.11-1.199.305-.289.487-.799.777-1.363.777h-7.063c-.873 0-1.583-.711-1.583-1.584s.71-1.583 1.583-1.583h8.091c1.17 0 2.269-.625 2.867-1.632.247-.415.11-.952-.305-1.199-.417-.246-.953-.11-1.199.305-.289.486-.799.776-1.363.776H23.02c-.873 0-1.583-.71-1.583-1.583s.709-1.584 1.583-1.584z"/></svg>
|
After Width: | Height: | Size: 1.6 KiB |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#F4ABBA" d="M35.885 11.833c0-5.45-4.418-9.868-9.867-9.868-3.308 0-6.227 1.633-8.018 4.129-1.791-2.496-4.71-4.129-8.017-4.129-5.45 0-9.868 4.417-9.868 9.868 0 .772.098 1.52.266 2.241C1.751 22.587 11.216 31.568 18 34.034c6.783-2.466 16.249-11.447 17.617-19.959.17-.721.268-1.469.268-2.242z"/><path fill="#EA596E" d="M31.423 13.372c0-4.091-3.315-7.406-7.405-7.406-2.482 0-4.673 1.225-6.018 3.099-1.344-1.874-3.535-3.099-6.017-3.099-4.09 0-7.406 3.315-7.406 7.406 0 .579.074 1.141.199 1.681C5.805 21.442 12.908 28.184 18 30.034c5.091-1.851 12.195-8.592 13.223-14.98.127-.541.2-1.103.2-1.682z"/><path fill="#DD2E44" d="M27.191 14.831c0-2.801-2.27-5.072-5.07-5.072-1.7 0-3.2.839-4.121 2.123-.92-1.284-2.421-2.123-4.121-2.123-2.801 0-5.072 2.271-5.072 5.072 0 .397.05.781.136 1.151.705 4.376 5.569 8.992 9.056 10.259 3.485-1.268 8.352-5.884 9.055-10.259.088-.37.137-.755.137-1.151z"/></svg>
|
After Width: | Height: | Size: 955 B |