Implement beginnings of tagging

This commit is contained in:
Maximilian Friedersdorff 2025-11-04 17:05:49 +00:00
parent 4fb4bec5a8
commit 105275f3e0
5 changed files with 142 additions and 40 deletions

View file

@ -65,7 +65,8 @@ type Config struct {
var ( var (
Conf Config Conf Config
assets []Asset = []Asset{ assets []Asset = []Asset{
{Path: "css/bootstrap.min.css", Url: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"}, {Path: "css/bootstrap.min.css", Url: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"},
{Path: "js/bootstrap.bundle.min.js", Url: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"},
{Path: "css/tiny-mde.min.css", Url: "https://unpkg.com/tiny-markdown-editor/dist/tiny-mde.min.css"}, {Path: "css/tiny-mde.min.css", Url: "https://unpkg.com/tiny-markdown-editor/dist/tiny-mde.min.css"},
{Path: "js/tiny-mde.min.js", Url: "https://unpkg.com/tiny-markdown-editor/dist/tiny-mde.min.js"}, {Path: "js/tiny-mde.min.js", Url: "https://unpkg.com/tiny-markdown-editor/dist/tiny-mde.min.js"},
{Path: "icons/eye.svg", Url: "https://raw.githubusercontent.com/twbs/icons/refs/heads/main/icons/eye.svg"}, {Path: "icons/eye.svg", Url: "https://raw.githubusercontent.com/twbs/icons/refs/heads/main/icons/eye.svg"},

View file

@ -23,6 +23,10 @@
crossorigin="anonymous"> crossorigin="anonymous">
<link rel="icon" type="image/svg+xml" <link rel="icon" type="image/svg+xml"
href="/static/icons/favicon.svg"> href="/static/icons/favicon.svg">
<script src="/static/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"></script>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-sm bg-body-tertiary mb-3"> <nav class="navbar navbar-expand-sm bg-body-tertiary mb-3">
@ -35,6 +39,11 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/notes/">All Notes</a> <a class="nav-link" href="/notes/">All Notes</a>
</li> </li>
{{range .userTags}}
<li class="nav-item">
<a class="nav-link" href="/notes/?tag={{.}}">{{.}}</a>
</li>
{{end}}
{{template "navLinks" .}} {{template "navLinks" .}}
<li> <li>
<a class="nav-link" href="/logout/">Logout {{.user}}</a> <a class="nav-link" href="/logout/">Logout {{.user}}</a>

View file

@ -1,57 +1,96 @@
{{define "title"}}{{.note.Title}}{{end}} {{define "title"}}{{.note.Title}}{{end}}
{{define "main"}} {{define "main"}}
<div> <div>
{{.note.BodyRendered}} {{.note.BodyRendered}}
</div> </div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a class="btn btn-primary" href="{{.urlEdit}}">Edit</a> <a class="btn btn-primary" href="{{.urlEdit}}">Edit</a>
<a class="btn btn-danger" href="{{.urlDelete}}">Delete</a> <a class="btn btn-danger" href="{{.urlDelete}}">Delete</a>
</div> </div>
{{ if .isOwner }} {{ if .isOwner }}
<div class="mt-2"> <div class="accordion mt-3" id="supplementaryAccordion">
<h3>Ownership</h3> <div class="accordion-item">
{{if .note.Viewers}} <h3 class="accordion-header">
<p>This note is owned by <em>{{.note.Owner}}</em> and is further visible to</p> <button class="accordion-button collapsed"
<form action="{{.urlUnshare}}" method="POST"> type="button"
<table class="table vertical-align-middle"> data-bs-toggle="collapse"
{{range .viewers}} data-bs-target="#collapseOwnership"
<tr> aria-expanded="false"
<td>{{.}}</td> aria-controls="collapseOwnership">
<td class="text-end"> Ownership
<button class="btn btn-outline-warning btn-sm" type="submit" name="viewer" value="{{.}}">Un-Share</button> </button>
</td> </h3>
</tr> <div id="collapseOwnership" class="accordion-collapse collapse" data-bs-parent="#supplementaryAccordion">
{{end}} <div class="accordion-body">
</table> {{if .note.Viewers}}
</form> <p>This note is owned by <em>{{.note.Owner}}</em> and is further visible to</p>
{{else}} <form action="{{.urlUnshare}}" method="POST">
<p>This note is owned by <em>{{.note.Owner}}</em>.</p> <table class="table vertical-align-middle">
{{end}} {{range .viewers}}
<tr>
<td>{{.}}</td>
<td class="text-end">
<button class="btn btn-outline-warning btn-sm" type="submit" name="viewer" value="{{.}}">Un-Share</button>
</td>
</tr>
{{end}}
</table>
</form>
{{else}}
<p>This note is owned by <em>{{.note.Owner}}</em>.</p>
{{end}}
<form action="{{.urlShare}}" method="POST"> <form action="{{.urlShare}}" method="POST">
<div class="mb-3"> <div class="mb-3">
<input type="text" class="form-control" id="viewerInput" name="viewer" aria-described-by="viewerHelp" /> <input type="text" class="form-control" id="viewerInput" name="viewer" aria-described-by="viewerHelp" />
<div id="viewerHelp" class="form-text">Share with other user</div> <div id="viewerHelp" class="form-text">Share with other user</div>
</div>
<button class="btn btn-primary" type="submit">Share</button>
</div>
</div>
</div>
<div class="accordion-item">
<h3 class="accordion-header">
<button class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#collapseTags"
aria-expanded="false"
aria-controls="collapseTags">
Tags
</button>
</h3>
<div id="collapseTags" class="accordion-collapse collapse" data-bs-parent="#supplementaryAccordion">
<div class="accordion-body">
<form action="{{.urlSetTags}}" method="POST">
<div class="mb-3">
<input type="text" class="form-control" id="tagInput" name="tags" aria-described-by="tagHelp" value="{{.note.Tags}}"/>
<div id="tagHelp" class="form-text">Tags</div>
</div>
<button class="btn btn-primary" type="submit">Set Tags</button>
</form>
</div>
</div>
</div> </div>
<button class="btn btn-primary" type="submit">Share</button>
</div> </div>
{{end}} {{end}}
<script> <script>
let checkBoxes = document.querySelectorAll('input[type=checkbox]') let checkBoxes = document.querySelectorAll('input[type=checkbox]')
for (const i in checkBoxes) { for (const i in checkBoxes) {
let box = checkBoxes[i] let box = checkBoxes[i]
box.disabled = false box.disabled = false
box.onchange = function(event) { box.onchange = function(event) {
let form = new FormData() let form = new FormData()
form.append("box", i) form.append("box", i)
fetch("togglebox/", {method: "POST", body: form}).then((response) => { fetch("togglebox/", {method: "POST", body: form}).then((response) => {
location.reload(); location.reload();
}) })
}
} }
}
</script> </script>
{{end}} {{end}}

View file

@ -38,6 +38,7 @@ type Note struct {
Viewers map[string]struct{} Viewers map[string]struct{}
Uid string Uid string
LastModified time.Time LastModified time.Time
Tags []string
} }
type noteSet map[*Note]bool type noteSet map[*Note]bool
@ -124,6 +125,35 @@ func (ns *NoteStore) Del(note *Note, user string) {
delete(ns.notes[user], note) delete(ns.notes[user], note)
} }
func (ns *NoteStore) UserTags(user string) []string {
tagSet := make(map[string]bool)
notes, ok := ns.notes[user]
if !ok {
return make([]string, 0)
}
log.Printf("Got notes for user %v", notes)
for note := range notes {
log.Printf("considering note %s (%s)", note.Title, note.Tags)
for _, tag := range note.Tags {
tagSet[tag] = true
}
}
log.Printf("Tagset is %v", tagSet)
tags := make([]string, len(tagSet))
i := 0
for tag := range tagSet {
tags[i] = tag
i++
}
return tags
}
func fmtPath(path string) string { func fmtPath(path string) string {
return fmt.Sprintf("%s.%s", path, conf.Conf.Extension) return fmt.Sprintf("%s.%s", path, conf.Conf.Extension)
} }
@ -134,10 +164,11 @@ func (n *Note) marshalFrontmatter() ([]byte, error) {
for viewer := range n.Viewers { for viewer := range n.Viewers {
viewers = append(viewers, viewer) viewers = append(viewers, viewer)
} }
frontmatter := make(map[string]interface{}) frontmatter := make(map[string]any)
frontmatter["owner"] = n.Owner frontmatter["owner"] = n.Owner
frontmatter["viewers"] = viewers frontmatter["viewers"] = viewers
frontmatter["title"] = n.Title frontmatter["title"] = n.Title
frontmatter["tags"] = n.Tags
marshaled, err := yaml.Marshal(&frontmatter) marshaled, err := yaml.Marshal(&frontmatter)
return marshaled, err return marshaled, err
@ -154,6 +185,7 @@ func (n *Note) ViewersAsList() []string {
func NewNoteNoSave(title string, owner string) *Note { func NewNoteNoSave(title string, owner string) *Note {
note := &Note{Title: title, Owner: owner} note := &Note{Title: title, Owner: owner}
note.Viewers = make(map[string]struct{}) note.Viewers = make(map[string]struct{})
note.Tags = make([]string, 0, 5)
return note return note
} }
@ -254,7 +286,7 @@ func loadNote(uid string) (*Note, error) {
note.Body = body note.Body = body
note.LastModified = stat.ModTime() note.LastModified = stat.ModTime()
viewers := metaData["viewers"].([]interface{}) viewers := metaData["viewers"].([]any)
for _, viewer := range viewers { for _, viewer := range viewers {
v, ok := viewer.(string) v, ok := viewer.(string)
@ -270,6 +302,24 @@ func loadNote(uid string) (*Note, error) {
} }
log.Printf("Note %s shared with %v", note.Title, note.Viewers) log.Printf("Note %s shared with %v", note.Title, note.Viewers)
tags, ok := metaData["tags"]
if ok {
tags := tags.([]any)
for _, tag := range tags {
t, ok := tag.(string)
if !ok {
return nil, errors.New("invalid note, non string type in 'tags' in frontmatter")
}
if t == "" {
continue
}
note.Tags = append(note.Tags, t)
}
}
return note, nil return note, nil
} }

View file

@ -7,6 +7,7 @@ import (
"forgejo.gwairfelin.com/max/gonotes/internal/conf" "forgejo.gwairfelin.com/max/gonotes/internal/conf"
"forgejo.gwairfelin.com/max/gonotes/internal/middleware" "forgejo.gwairfelin.com/max/gonotes/internal/middleware"
"forgejo.gwairfelin.com/max/gonotes/internal/notes"
) )
type Ctx map[string]any type Ctx map[string]any
@ -31,7 +32,9 @@ func RenderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, context
return err return err
} }
context["user"] = r.Context().Value(middleware.ContextKey("user")).(string) user := r.Context().Value(middleware.ContextKey("user")).(string)
context["user"] = user
context["userTags"] = notes.Notes.UserTags(user)
err = t.ExecuteTemplate(w, "base", context) err = t.ExecuteTemplate(w, "base", context)
return err return err