From c54f32495b288c15525fc03d21c5bd5593ac20f2 Mon Sep 17 00:00:00 2001 From: Julien Riou Date: Wed, 27 Aug 2025 22:42:12 +0200 Subject: [PATCH] feat: Add text editor 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 --- src/cmd/coller/main.go | 5 ++++ src/cmd/collerd/README.md | 3 ++ src/internal/utils.go | 8 +++++ src/internal/utils_test.go | 47 +++++++++++++++++++++++++++++ src/server/config.go | 53 +++++++++++++++++++++++---------- src/server/db.go | 16 +++++++++- src/server/note.go | 1 + src/server/server.go | 12 ++++++-- src/server/templates/index.html | 49 +++++++++++++++++++++++++++--- 9 files changed, 170 insertions(+), 24 deletions(-) diff --git a/src/cmd/coller/main.go b/src/cmd/coller/main.go index e951247..c1d95a8 100644 --- a/src/cmd/coller/main.go +++ b/src/cmd/coller/main.go @@ -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") diff --git a/src/cmd/collerd/README.md b/src/cmd/collerd/README.md index cee1c6d..e81389d 100644 --- a/src/cmd/collerd/README.md +++ b/src/cmd/collerd/README.md @@ -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 diff --git a/src/internal/utils.go b/src/internal/utils.go index 7844792..d522bc7 100644 --- a/src/internal/utils.go +++ b/src/internal/utils.go @@ -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 +} diff --git a/src/internal/utils_test.go b/src/internal/utils_test.go index 1a02908..cc251f0 100644 --- a/src/internal/utils_test.go +++ b/src/internal/utils_test.go @@ -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) + } +} diff --git a/src/server/config.go b/src/server/config.go index 6888113..e5bc173 100644 --- a/src/server/config.go +++ b/src/server/config.go @@ -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", } } diff --git a/src/server/db.go b/src/server/db.go index b52708b..0186b72 100644 --- a/src/server/db.go +++ b/src/server/db.go @@ -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 { diff --git a/src/server/note.go b/src/server/note.go index 7127545..bff6b73 100644 --- a/src/server/note.go +++ b/src/server/note.go @@ -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 diff --git a/src/server/server.go b/src/server/server.go index 1a47e9b..7cb395c 100644 --- a/src/server/server.go +++ b/src/server/server.go @@ -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 { diff --git a/src/server/templates/index.html b/src/server/templates/index.html index ec30815..42faef1 100644 --- a/src/server/templates/index.html +++ b/src/server/templates/index.html @@ -7,7 +7,7 @@ {{block "header" .}}{{end}} -
+

New note

@@ -36,28 +36,69 @@
+
+ +
- +
+
- +
+ +
{{block "footer" .}}{{end}}