feat: Add text editor
All checks were successful
/ pre-commit (push) Successful in 1m45s

Use Monaco Editor to create notes. Support syntax highlighting. Store the
language with the note in the database to later support syntax highlighting in
a note web view.

Signed-off-by: Julien Riou <julien@riou.xyz>
This commit is contained in:
Julien Riou 2025-08-27 22:42:12 +02:00
parent b45c3e3253
commit c54f32495b
Signed by: jriou
GPG key ID: 9A099EDA51316854
9 changed files with 170 additions and 24 deletions

View file

@ -30,6 +30,7 @@ type NotePayload struct {
Encrypted bool `json:"encrypted"` Encrypted bool `json:"encrypted"`
Expiration int `json:"expiration,omitempty"` Expiration int `json:"expiration,omitempty"`
DeleteAfterRead bool `json:"delete_after_read,omitempty"` DeleteAfterRead bool `json:"delete_after_read,omitempty"`
Language string `json:"language"`
} }
type NoteResponse struct { type NoteResponse struct {
@ -68,6 +69,7 @@ func handleMain() int {
copier := flag.Bool("copier", false, "Print the copier command to decrypt the note") copier := flag.Bool("copier", false, "Print the copier command to decrypt the note")
bearer := flag.String("bearer", os.Getenv("COLLER_BEARER"), "Bearer token") bearer := flag.String("bearer", os.Getenv("COLLER_BEARER"), "Bearer token")
askBearer := flag.Bool("b", false, "Read bearer token from input") askBearer := flag.Bool("b", false, "Read bearer token from input")
language := flag.String("language", "", "Language of the note")
flag.Parse() flag.Parse()
@ -154,6 +156,9 @@ func handleMain() int {
if *deleteAfterRead { if *deleteAfterRead {
p.DeleteAfterRead = *deleteAfterRead p.DeleteAfterRead = *deleteAfterRead
} }
if *language != "" {
p.Language = *language
}
if *password != "" { if *password != "" {
logger.Debug("validating password") logger.Debug("validating password")

View file

@ -31,6 +31,8 @@ The file format is **JSON**:
* **prometheus_route** (string): Route to expose Prometheus metrics (default "/metrics") * **prometheus_route** (string): Route to expose Prometheus metrics (default "/metrics")
* **prometheus_notes_metric** (string): Name of the notes count metric (default "collerd_notes") * **prometheus_notes_metric** (string): Name of the notes count metric (default "collerd_notes")
* **observation_internal** (int): Number of seconds to wait between two observations (default 60) * **observation_internal** (int): Number of seconds to wait between two observations (default 60)
* **languages** ([]string): List of supported [languages](https://github.com/microsoft/monaco-editor/tree/main/src/basic-languages)
* **language** (string): Default language (default "text")
The configuration file is not required but the service might not be exposed to the public. The configuration file is not required but the service might not be exposed to the public.
@ -50,6 +52,7 @@ Body (JSON):
* **encrypted** (bool): true if the content has been encrypted by the client * **encrypted** (bool): true if the content has been encrypted by the client
* **expiration** (int): lifetime of the note in seconds (must be supported by the server) * **expiration** (int): lifetime of the note in seconds (must be supported by the server)
* **delete_after_read** (bool): delete the note after the first read * **delete_after_read** (bool): delete the note after the first read
* **language** (string): language of the note (must be supported by the server)
Response (JSON): Response (JSON):
* **id** (string): ID of the note * **id** (string): ID of the note

View file

@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings"
) )
func ReadConfig(file string, config interface{}) error { func ReadConfig(file string, config interface{}) error {
@ -109,3 +110,10 @@ func ReturnError(logger *slog.Logger, message string, err error) int {
} }
return RC_ERROR return RC_ERROR
} }
func ToLowerStringSlice(src []string) (dst []string) {
for _, s := range src {
dst = append(dst, strings.ToLower(s))
}
return
}

View file

@ -2,6 +2,7 @@ package internal
import ( import (
"fmt" "fmt"
"slices"
"testing" "testing"
) )
@ -38,3 +39,49 @@ func TestHumanDuration(t *testing.T) {
}) })
} }
} }
func TestToLowerStringsSlice(t *testing.T) {
input := []string{
"Text",
"CSS",
"Dockerfile",
"Go",
"HCL",
"HTML",
"Javascript",
"JSON",
"Markdown",
"Perl",
"Python",
"Ruby",
"Rust",
"Shell",
"SQL",
"YAML",
}
expected := []string{
"text",
"css",
"dockerfile",
"go",
"hcl",
"html",
"javascript",
"json",
"markdown",
"perl",
"python",
"ruby",
"rust",
"shell",
"sql",
"yaml",
}
got := ToLowerStringSlice(input)
if slices.Compare(got, expected) != 0 {
t.Errorf("got '%s', want '%s'", got, expected)
} else {
t.Logf("got '%s', want '%s'", got, expected)
}
}

View file

@ -7,22 +7,24 @@ import (
) )
type Config struct { type Config struct {
Title string `json:"title"` Title string `json:"title"`
DatabaseType string `json:"database_type"` DatabaseType string `json:"database_type"`
DatabaseDsn string `json:"database_dsn"` DatabaseDsn string `json:"database_dsn"`
IDLength int `json:"id_length"` IDLength int `json:"id_length"`
PasswordLength int `json:"password_length"` PasswordLength int `json:"password_length"`
ExpirationInterval int `json:"expiration_interval"` ExpirationInterval int `json:"expiration_interval"`
ListenAddress string `json:"listen_address"` ListenAddress string `json:"listen_address"`
ListenPort int `json:"listen_port"` ListenPort int `json:"listen_port"`
Expirations []int `json:"expirations"` Expirations []int `json:"expirations"`
Expiration int `json:"expiration"` Expiration int `json:"expiration"`
MaxUploadSize int64 `json:"max_upload_size"` MaxUploadSize int64 `json:"max_upload_size"`
ShowVersion bool `json:"show_version"` ShowVersion bool `json:"show_version"`
EnableMetrics bool `json:"enable_metrics"` EnableMetrics bool `json:"enable_metrics"`
PrometheusRoute string `json:"prometheus_route"` PrometheusRoute string `json:"prometheus_route"`
PrometheusNotesMetric string `json:"prometheus_notes_metric"` PrometheusNotesMetric string `json:"prometheus_notes_metric"`
ObservationInterval int `json:"observation_internal"` ObservationInterval int `json:"observation_internal"`
Languages []string `json:"languages"`
Language string `json:"language"`
} }
func NewConfig() *Config { func NewConfig() *Config {
@ -49,6 +51,25 @@ func NewConfig() *Config {
PrometheusRoute: "/metrics", PrometheusRoute: "/metrics",
PrometheusNotesMetric: "collerd_notes", PrometheusNotesMetric: "collerd_notes",
ObservationInterval: 60, ObservationInterval: 60,
Languages: []string{
"Text",
"CSS",
"Dockerfile",
"Go",
"HCL",
"HTML",
"Javascript",
"JSON",
"Markdown",
"Perl",
"Python",
"Ruby",
"Rust",
"Shell",
"SQL",
"YAML",
},
Language: "text",
} }
} }

View file

@ -20,6 +20,8 @@ type Database struct {
expirationInterval int expirationInterval int
expirations []int expirations []int
expiration int expiration int
languages []string
language string
} }
var gconfig = &gorm.Config{ var gconfig = &gorm.Config{
@ -52,6 +54,8 @@ func NewDatabase(logger *slog.Logger, config *Config) (d *Database, err error) {
expirationInterval: config.ExpirationInterval, expirationInterval: config.ExpirationInterval,
expirations: config.Expirations, expirations: config.Expirations,
expiration: config.Expiration, expiration: config.Expiration,
languages: internal.ToLowerStringSlice(config.Languages),
language: strings.ToLower(config.Language),
} }
if err = d.UpdateSchema(); err != nil { if err = d.UpdateSchema(); err != nil {
@ -109,7 +113,7 @@ func (d *Database) Get(id string) (*Note, error) {
return nil, nil return nil, nil
} }
func (d *Database) Create(content []byte, password string, encrypted bool, expiration int, deleteAfterRead bool) (note *Note, err error) { func (d *Database) Create(content []byte, password string, encrypted bool, expiration int, deleteAfterRead bool, language string) (note *Note, err error) {
if expiration == 0 { if expiration == 0 {
expiration = d.expiration expiration = d.expiration
} }
@ -118,11 +122,21 @@ func (d *Database) Create(content []byte, password string, encrypted bool, expir
return nil, fmt.Errorf("invalid expiration: must be one of %s", validExpirations) return nil, fmt.Errorf("invalid expiration: must be one of %s", validExpirations)
} }
if language == "" {
language = d.language
}
if !slices.Contains(d.languages, language) {
validLanguages := strings.Trim(strings.Join(strings.Fields(fmt.Sprint(d.languages)), ", "), "[]")
return nil, fmt.Errorf("invalid language: must be one of %s", validLanguages)
}
note = &Note{ note = &Note{
Content: content, Content: content,
ExpiresAt: time.Now().Add(time.Duration(expiration) * time.Second), ExpiresAt: time.Now().Add(time.Duration(expiration) * time.Second),
Encrypted: encrypted, Encrypted: encrypted,
DeleteAfterRead: deleteAfterRead, DeleteAfterRead: deleteAfterRead,
Language: language,
} }
if password != "" { if password != "" {
if err = internal.ValidatePassword(password); err != nil { if err = internal.ValidatePassword(password); err != nil {

View file

@ -18,6 +18,7 @@ type Note struct {
Encrypted bool `json:"encrypted"` Encrypted bool `json:"encrypted"`
ExpiresAt time.Time `json:"expires_at" gorm:"index"` ExpiresAt time.Time `json:"expires_at" gorm:"index"`
DeleteAfterRead bool `json:"delete_after_read"` DeleteAfterRead bool `json:"delete_after_read"`
Language string `json:"language"`
} }
// Generate ID and compress content before saving to the database // Generate ID and compress content before saving to the database

View file

@ -11,6 +11,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"git.riou.xyz/jriou/coller/internal" "git.riou.xyz/jriou/coller/internal"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@ -88,6 +89,7 @@ type CreateNotePayload struct {
Encrypted bool `json:"encrypted"` Encrypted bool `json:"encrypted"`
Expiration int `json:"expiration"` Expiration int `json:"expiration"`
DeleteAfterRead bool `json:"delete_after_read"` DeleteAfterRead bool `json:"delete_after_read"`
Language string `json:"language"`
} }
type CreateNoteResponse struct { type CreateNoteResponse struct {
@ -114,7 +116,7 @@ func (h *CreateNoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
note, err := h.db.Create(content, body.Password, body.Encrypted, body.Expiration, body.DeleteAfterRead) note, err := h.db.Create(content, body.Password, body.Encrypted, body.Expiration, body.DeleteAfterRead, body.Language)
if err != nil { if err != nil {
WriteError(w, "could not create note", err) WriteError(w, "could not create note", err)
return return
@ -187,6 +189,7 @@ type PageData struct {
Title string Title string
Version string Version string
Expirations []int Expirations []int
Languages []string
Err error Err error
URL string URL string
} }
@ -264,7 +267,7 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
} }
h.logger.Debug("checking content") h.logger.Debug("checking content")
if content == nil { if content == nil || len(content) == 0 {
h.PageData.Err = fmt.Errorf("empty note") h.PageData.Err = fmt.Errorf("empty note")
h.Templates.ExecuteTemplate(w, templateName, h.PageData) h.Templates.ExecuteTemplate(w, templateName, h.PageData)
return return
@ -275,6 +278,7 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
password := r.FormValue("password") password := r.FormValue("password")
expiration := r.FormValue("expiration") expiration := r.FormValue("expiration")
deleteAfterRead := r.FormValue("delete-after-read") deleteAfterRead := r.FormValue("delete-after-read")
language := r.FormValue("language")
if password == "" && noPassword == "" { if password == "" && noPassword == "" {
h.logger.Debug("generating password") h.logger.Debug("generating password")
@ -290,7 +294,7 @@ func (h *CreateNoteWithFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
} }
h.logger.Debug("saving note to the database") h.logger.Debug("saving note to the database")
note, err := h.db.Create(content, password, password != "", expirationInt, deleteAfterRead != "") note, err := h.db.Create(content, password, password != "", expirationInt, deleteAfterRead != "", language)
if err != nil { if err != nil {
h.PageData.Err = err h.PageData.Err = err
h.Templates.ExecuteTemplate(w, templateName, h.PageData) h.Templates.ExecuteTemplate(w, templateName, h.PageData)
@ -334,10 +338,12 @@ func (s *Server) Start() error {
// Web pages // Web pages
funcs := template.FuncMap{ funcs := template.FuncMap{
"HumanDuration": internal.HumanDuration, "HumanDuration": internal.HumanDuration,
"lower": strings.ToLower,
} }
p := PageData{ p := PageData{
Title: s.config.Title, Title: s.config.Title,
Expirations: s.config.Expirations, Expirations: s.config.Expirations,
Languages: s.config.Languages,
} }
if s.config.ShowVersion { if s.config.ShowVersion {

View file

@ -7,7 +7,7 @@
<body> <body>
{{block "header" .}}{{end}} {{block "header" .}}{{end}}
<form action="/create" method="post" enctype="multipart/form-data"> <form id="form" action="/create" method="post" enctype="multipart/form-data">
<div class="container mb-4"> <div class="container mb-4">
<p class="fs-4">New note</p> <p class="fs-4">New note</p>
</div> </div>
@ -36,28 +36,69 @@
</div> </div>
<div class="col"> <div class="col">
<select class="form-select" aria-label="Expiration" id="expiration" name="expiration"> <select class="form-select" aria-label="Expiration" id="expiration" name="expiration">
<option selected>Expiration</option> <option selected="selected" disabled>Expiration</option>
{{range .Expirations}} {{range .Expirations}}
<option value="{{.}}">{{HumanDuration .}}</option> <option value="{{.}}">{{HumanDuration .}}</option>
{{end}} {{end}}
</select> </select>
</div> </div>
<div class="col">
<select class="form-select" aria-label="Language" id="language" name="language">
<option selected="selected" value="" disabled>Language</option>
{{range .Languages}}
<option value="{{lower .}}">{{.}}</option>
{{end}}
</select>
</div>
</div> </div>
</div> </div>
<div class="container mb-4"> <div class="container mb-4">
<div class="row"> <div class="row">
<textarea class="form-control" id="content" name="content" cols="40" rows="12"></textarea> <div id="editor" name="editor" class="form-control"
style="height: 300px; resize: vertical; overflow: auto;"></div>
<input type="hidden" id="content" />
</div> </div>
</div> </div>
<div class="container mb-4"> <div class="container mb-4">
<div class="row text-center justify-content-center"> <div class="row text-center justify-content-center">
<div class="col-1"> <div class="col-1">
<button type="submit" class="btn btn-success">Create</button> <button type="submit" id="submit" class="btn btn-success">Create</button>
</div> </div>
</div> </div>
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs/loader.min.js"></script>
<script>
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/vs' } });
require(['vs/editor/editor.main'], function () {
var editor = monaco.editor.create(document.getElementById('editor'), {
theme: document.documentElement.getAttribute('data-bs-theme') == 'light' ? "vs" : "vs-dark",
language: document.getElementById("language").value,
});
// Syntax highlighting
document.getElementById("language").addEventListener("change", (e) => {
if (e.target.value != "") {
monaco.editor.setModelLanguage(editor.getModel(), e.target.value);
}
});
// Dark mode
document.getElementById("lightSwitch").addEventListener("click", () => {
if (document.documentElement.getAttribute('data-bs-theme') == 'light') {
monaco.editor.setTheme("vs")
} else if (document.documentElement.getAttribute('data-bs-theme') == 'dark') {
monaco.editor.setTheme("vs-dark")
}
});
// Copy content on submit
document.getElementById("form").addEventListener("formdata", (e) => {
e.formData.append('content', editor.getModel().getValue());
});
});
</script>
</form> </form>
{{block "footer" .}}{{end}} {{block "footer" .}}{{end}}