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"`
Expiration int `json:"expiration,omitempty"`
DeleteAfterRead bool `json:"delete_after_read,omitempty"`
Language string `json:"language"`
}
type NoteResponse struct {
@ -68,6 +69,7 @@ func handleMain() int {
copier := flag.Bool("copier", false, "Print the copier command to decrypt the note")
bearer := flag.String("bearer", os.Getenv("COLLER_BEARER"), "Bearer token")
askBearer := flag.Bool("b", false, "Read bearer token from input")
language := flag.String("language", "", "Language of the note")
flag.Parse()
@ -154,6 +156,9 @@ func handleMain() int {
if *deleteAfterRead {
p.DeleteAfterRead = *deleteAfterRead
}
if *language != "" {
p.Language = *language
}
if *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_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)
* **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.
@ -50,6 +52,7 @@ Body (JSON):
* **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)
* **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):
* **id** (string): ID of the note

View file

@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"regexp"
"strings"
)
func ReadConfig(file string, config interface{}) error {
@ -109,3 +110,10 @@ func ReturnError(logger *slog.Logger, message string, err error) int {
}
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 (
"fmt"
"slices"
"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 {
Title string `json:"title"`
DatabaseType string `json:"database_type"`
DatabaseDsn string `json:"database_dsn"`
IDLength int `json:"id_length"`
PasswordLength int `json:"password_length"`
ExpirationInterval int `json:"expiration_interval"`
ListenAddress string `json:"listen_address"`
ListenPort int `json:"listen_port"`
Expirations []int `json:"expirations"`
Expiration int `json:"expiration"`
MaxUploadSize int64 `json:"max_upload_size"`
ShowVersion bool `json:"show_version"`
EnableMetrics bool `json:"enable_metrics"`
PrometheusRoute string `json:"prometheus_route"`
PrometheusNotesMetric string `json:"prometheus_notes_metric"`
ObservationInterval int `json:"observation_internal"`
Title string `json:"title"`
DatabaseType string `json:"database_type"`
DatabaseDsn string `json:"database_dsn"`
IDLength int `json:"id_length"`
PasswordLength int `json:"password_length"`
ExpirationInterval int `json:"expiration_interval"`
ListenAddress string `json:"listen_address"`
ListenPort int `json:"listen_port"`
Expirations []int `json:"expirations"`
Expiration int `json:"expiration"`
MaxUploadSize int64 `json:"max_upload_size"`
ShowVersion bool `json:"show_version"`
EnableMetrics bool `json:"enable_metrics"`
PrometheusRoute string `json:"prometheus_route"`
PrometheusNotesMetric string `json:"prometheus_notes_metric"`
ObservationInterval int `json:"observation_internal"`
Languages []string `json:"languages"`
Language string `json:"language"`
}
func NewConfig() *Config {
@ -49,6 +51,25 @@ func NewConfig() *Config {
PrometheusRoute: "/metrics",
PrometheusNotesMetric: "collerd_notes",
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
expirations []int
expiration int
languages []string
language string
}
var gconfig = &gorm.Config{
@ -52,6 +54,8 @@ func NewDatabase(logger *slog.Logger, config *Config) (d *Database, err error) {
expirationInterval: config.ExpirationInterval,
expirations: config.Expirations,
expiration: config.Expiration,
languages: internal.ToLowerStringSlice(config.Languages),
language: strings.ToLower(config.Language),
}
if err = d.UpdateSchema(); err != nil {
@ -109,7 +113,7 @@ func (d *Database) Get(id string) (*Note, error) {
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 {
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)
}
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{
Content: content,
ExpiresAt: time.Now().Add(time.Duration(expiration) * time.Second),
Encrypted: encrypted,
DeleteAfterRead: deleteAfterRead,
Language: language,
}
if password != "" {
if err = internal.ValidatePassword(password); err != nil {

View file

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

View file

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

View file

@ -7,7 +7,7 @@
<body>
{{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">
<p class="fs-4">New note</p>
</div>
@ -36,28 +36,69 @@
</div>
<div class="col">
<select class="form-select" aria-label="Expiration" id="expiration" name="expiration">
<option selected>Expiration</option>
<option selected="selected" disabled>Expiration</option>
{{range .Expirations}}
<option value="{{.}}">{{HumanDuration .}}</option>
{{end}}
</select>
</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 class="container mb-4">
<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 class="container mb-4">
<div class="row text-center justify-content-center">
<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>
<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>
{{block "footer" .}}{{end}}