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:
parent
b45c3e3253
commit
c54f32495b
9 changed files with 170 additions and 24 deletions
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue