Fork Notesium source and restructure into Go package layout
Initial fork of github.com/alonswartz/notesium into librenotes: - Source moved to internal/notesium/ (package notesium) - Thin entry point at cmd/librenotes/main.go - Module renamed to git.librete.ch/public/librenotes - main() exposed as notesium.Run() - LICENSE preserved (MIT), NOTICE added with attribution - Web assets and completion.bash co-located with embedding code to satisfy go:embed path constraints Closes #3, #34, #35. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
package notesium
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type NoteResponse struct {
|
||||
Note
|
||||
Path string `json:"Path"`
|
||||
Content string `json:"Content"`
|
||||
}
|
||||
|
||||
type NotePost struct {
|
||||
Content string `json:"Content"`
|
||||
Ctime time.Time `json:"Ctime"`
|
||||
}
|
||||
|
||||
type NotePatch struct {
|
||||
Content string `json:"Content"`
|
||||
LastMtime time.Time `json:"LastMtime"`
|
||||
}
|
||||
|
||||
type NoteDelete struct {
|
||||
LastMtime time.Time `json:"LastMtime"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"Error"`
|
||||
Code int `json:"Code"`
|
||||
}
|
||||
|
||||
func respondWithError(w http.ResponseWriter, errMsg string, statusCode int) {
|
||||
errorResponse := ErrorResponse{Error: errMsg, Code: statusCode}
|
||||
errorJSON, _ := json.Marshal(errorResponse)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
w.Write(errorJSON)
|
||||
}
|
||||
|
||||
func apiHeartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("Heartbeat received."))
|
||||
}
|
||||
|
||||
func apiList(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResponse, err := json.Marshal(noteCache)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(jsonResponse)
|
||||
}
|
||||
|
||||
func apiNote(dir string, w http.ResponseWriter, r *http.Request, readOnly bool) {
|
||||
pathSegments := strings.Split(r.URL.Path, "/")
|
||||
|
||||
var filename string
|
||||
if len(pathSegments) >= 4 {
|
||||
filename = pathSegments[3]
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
if filename == "" {
|
||||
respondWithError(w, "Filename not specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
case "POST":
|
||||
if readOnly {
|
||||
respondWithError(w, "NOTESIUM_DIR is set to read-only mode", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if filename != "" {
|
||||
respondWithError(w, "Filename should not be specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
respondWithError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var notePost NotePost
|
||||
if err := json.Unmarshal(body, ¬ePost); err != nil {
|
||||
respondWithError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if notePost.Ctime.IsZero() {
|
||||
respondWithError(w, "Ctime field is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
epochInt := notePost.Ctime.Unix()
|
||||
epochHex := fmt.Sprintf("%x", epochInt)
|
||||
filename = fmt.Sprintf("%s.md", epochHex)
|
||||
|
||||
path := filepath.Join(dir, filename)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
respondWithError(w, "File already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(notePost.Content), 0644); err != nil {
|
||||
respondWithError(w, "Error writing file: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
noteCache = nil
|
||||
populateCache(dir)
|
||||
|
||||
case "PATCH":
|
||||
if readOnly {
|
||||
respondWithError(w, "NOTESIUM_DIR is set to read-only mode", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if filename == "" {
|
||||
respondWithError(w, "Filename not specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := noteCache[filename]; !ok {
|
||||
respondWithError(w, "Note not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
respondWithError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var notePatch NotePatch
|
||||
if err := json.Unmarshal(body, ¬ePatch); err != nil {
|
||||
respondWithError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if notePatch.Content == "" {
|
||||
respondWithError(w, "Content field is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if notePatch.LastMtime.IsZero() {
|
||||
respondWithError(w, "LastMtime field is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, filename)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
respondWithError(w, "File does not exist: "+err.Error(), http.StatusNotFound)
|
||||
} else {
|
||||
respondWithError(w, "Error accessing file: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !info.ModTime().Truncate(time.Second).UTC().Equal(notePatch.LastMtime.Truncate(time.Second).UTC()) {
|
||||
respondWithError(w, "Refusing to overwrite. File changed on disk.", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(notePatch.Content), 0644); err != nil {
|
||||
respondWithError(w, "Error writing file: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
noteCache = nil
|
||||
populateCache(dir)
|
||||
|
||||
case "DELETE":
|
||||
if readOnly {
|
||||
respondWithError(w, "NOTESIUM_DIR is set to read-only mode", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if filename == "" {
|
||||
respondWithError(w, "Filename not specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
respondWithError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var noteDelete NoteDelete
|
||||
if err := json.Unmarshal(body, ¬eDelete); err != nil {
|
||||
respondWithError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if noteDelete.LastMtime.IsZero() {
|
||||
respondWithError(w, "LastMtime field is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
note, ok := noteCache[filename]
|
||||
if !ok {
|
||||
respondWithError(w, "Note not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, filename)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
respondWithError(w, "File does not exist: "+err.Error(), http.StatusNotFound)
|
||||
} else {
|
||||
respondWithError(w, "Error accessing file: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !info.ModTime().Truncate(time.Second).UTC().Equal(noteDelete.LastMtime.Truncate(time.Second).UTC()) {
|
||||
respondWithError(w, "Refusing to delete. File changed on disk.", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
if len(note.IncomingLinks) > 0 {
|
||||
respondWithError(w, "Refusing to delete. Note has IncomingLinks.", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
err = os.Remove(path)
|
||||
if err != nil {
|
||||
respondWithError(w, "Error deleting file: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
noteCache = nil
|
||||
populateCache(dir)
|
||||
|
||||
response := map[string]any{
|
||||
"Filename": filename,
|
||||
"Deleted": true,
|
||||
}
|
||||
jsonResponse, _ := json.Marshal(response)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(jsonResponse)
|
||||
return
|
||||
|
||||
default:
|
||||
respondWithError(w, "Method not supported", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
note, ok := noteCache[filename]
|
||||
if !ok {
|
||||
respondWithError(w, "Note not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, filename)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
respondWithError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
noteResponse := NoteResponse{
|
||||
Note: *note,
|
||||
Path: path,
|
||||
Content: string(content),
|
||||
}
|
||||
|
||||
jsonResponse, err := json.Marshal(noteResponse)
|
||||
if err != nil {
|
||||
respondWithError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(jsonResponse)
|
||||
}
|
||||
|
||||
func apiRuntime(dir string, w http.ResponseWriter, _ *http.Request, opts webOptions) {
|
||||
runtimeResponse := GetRuntimeInfo(dir, opts)
|
||||
jsonResponse, err := json.Marshal(runtimeResponse)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(jsonResponse)
|
||||
}
|
||||
|
||||
//////////////////////////////// experimental ////////////////////////////////
|
||||
|
||||
type bufferedResponseWriter struct {
|
||||
buf bytes.Buffer
|
||||
w http.ResponseWriter
|
||||
}
|
||||
|
||||
func (rw *bufferedResponseWriter) Write(p []byte) (n int, err error) {
|
||||
return rw.buf.Write(p)
|
||||
}
|
||||
|
||||
func (rw *bufferedResponseWriter) Flush() {
|
||||
rw.w.Write(rw.buf.Bytes())
|
||||
if f, ok := rw.w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func apiRaw(dir string, w http.ResponseWriter, r *http.Request) {
|
||||
pathSegments := strings.Split(r.URL.Path, "/")
|
||||
if len(pathSegments) < 4 || pathSegments[3] == "" {
|
||||
http.Error(w, "no command specified", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
command := pathSegments[3]
|
||||
|
||||
args := []string{command}
|
||||
queryParameters := r.URL.Query()
|
||||
for key, values := range queryParameters {
|
||||
for _, value := range values {
|
||||
if value == "true" {
|
||||
arg := fmt.Sprintf("--%s", key)
|
||||
args = append(args, arg)
|
||||
} else if value != "" && value != "false" {
|
||||
arg := fmt.Sprintf("--%s=%s", key, value)
|
||||
args = append(args, arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd, err := parseOptions(args)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
writer := &bufferedResponseWriter{w: w}
|
||||
defer writer.Flush()
|
||||
|
||||
switch cmd.Name {
|
||||
case "new":
|
||||
notesiumNew(dir, cmd.Options.(newOptions), writer)
|
||||
case "list":
|
||||
notesiumList(dir, cmd.Options.(listOptions), writer)
|
||||
case "links":
|
||||
notesiumLinks(dir, cmd.Options.(linksOptions), writer)
|
||||
case "lines":
|
||||
notesiumLines(dir, cmd.Options.(linesOptions), writer)
|
||||
case "stats":
|
||||
notesiumStats(dir, cmd.Options.(statsOptions), writer)
|
||||
case "version":
|
||||
notesiumVersion(cmd.Options.(versionOptions), writer)
|
||||
default:
|
||||
http.Error(w, fmt.Sprintf("unrecognized command: %s", command), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user