diff --git a/files/files.go b/files/files.go index 1bc4e9d..f957fb6 100644 --- a/files/files.go +++ b/files/files.go @@ -71,7 +71,7 @@ func PrepareWikiRoot() error { paths.userCredentialsJSON = filepath.Join(cfg.WikiDir, "users.json") paths.tokensJSON = filepath.Join(paths.cacheDir, "tokens.json") - paths.categoriesJSON = filepath.Join(cfg.WikiDir, "tags.json") + paths.categoriesJSON = filepath.Join(cfg.WikiDir, "categories.json") return nil } diff --git a/hyphae/categories/tags.go b/hyphae/categories/categories.go similarity index 60% rename from hyphae/categories/tags.go rename to hyphae/categories/categories.go index 73315ed..87f21d1 100644 --- a/hyphae/categories/tags.go +++ b/hyphae/categories/categories.go @@ -1,4 +1,4 @@ -// Package categories provides category management. +// Package categories provides category management. All operations in this package are mutexed. // // As per the long pondering, this is how categories (cats for short) // work in Mycorrhiza: @@ -14,31 +14,43 @@ // exist. If there are 1 or more hyphae in the cat, cat A exists. package categories -// For WithHypha and Contents, should the results be sorted? +import "sync" -// WithHypha returns what categories have the given hypha. +// WithHypha returns what categories have the given hypha. The hypha name must be canonical. func WithHypha(hyphaName string) (categoryList []string) { - panic("todo") - return + mutex.RLock() + defer mutex.RUnlock() + return hyphaToCategories[hyphaName].categoryList } -// Contents returns what hyphae are in the category. If the returned slice is empty, the category does not exist, and vice versa. +// Contents returns what hyphae are in the category. If the returned slice is empty, the category does not exist, and vice versa. The category name must be canonical. func Contents(catName string) (hyphaList []string) { - panic("todo") - return + mutex.RLock() + defer mutex.RUnlock() + return categoryToHyphae[catName].hyphaList } -// AddHyphaToCategory adds the hypha to the category and updates the records on the disk. If the hypha is already in the category, nothing happens. This operation is async-safe. +var mutex sync.RWMutex + +// AddHyphaToCategory adds the hypha to the category and updates the records on the disk. If the hypha is already in the category, nothing happens. func AddHyphaToCategory(hyphaName, catName string) { - for _, cat := range WithHypha(hyphaName) { - if cat == catName { - return - } + mutex.Lock() + if node, ok := hyphaToCategories[hyphaName]; ok { + node.storeCategory(catName) + } else { + hyphaToCategories[hyphaName] = &hyphaNode{categoryList: []string{catName}} } - panic("todo") + + if node, ok := categoryToHyphae[catName]; ok { + node.storeHypha(hyphaName) + } else { + categoryToHyphae[catName] = &categoryNode{hyphaList: []string{hyphaName}} + } + mutex.Unlock() } -// RemoveHyphaFromCategory removes the hypha from the category and updates the records on the disk. If the hypha is not in the category, nothing happens. This operation is async-safe. +// RemoveHyphaFromCategory removes the hypha from the category and updates the records on the disk. If the hypha is not in the category, nothing happens. func RemoveHyphaFromCategory(hyphaName, catName string) { - panic("todo") + mutex.Lock() + mutex.Unlock() } diff --git a/hyphae/categories/files.go b/hyphae/categories/files.go new file mode 100644 index 0000000..24a7c55 --- /dev/null +++ b/hyphae/categories/files.go @@ -0,0 +1,109 @@ +package categories + +import ( + "encoding/json" + "github.com/bouncepaw/mycorrhiza/files" + "github.com/bouncepaw/mycorrhiza/util" + "log" + "os" +) + +var categoryToHyphae = map[string]*categoryNode{} +var hyphaToCategories = map[string]*hyphaNode{} + +// InitCategories initializes the category system. Call it after the Structure is initialized. This function might terminate the program in case of a bad mood or filesystem faults. +func InitCategories() { + var ( + record, err = readCategoriesFromDisk() + ) + if err != nil { + log.Fatalln(err) + } + + for _, cat := range record.Categories { + if len(cat.Hyphae) == 0 { + continue + } + cat.Name = util.CanonicalName(cat.Name) + for i, hyphaName := range cat.Hyphae { + cat.Hyphae[i] = util.CanonicalName(hyphaName) + } + categoryToHyphae[cat.Name] = &categoryNode{hyphaList: cat.Hyphae} + } + + for cat, hyphaeInCat := range categoryToHyphae { + for _, hyphaName := range hyphaeInCat.hyphaList { + if node, ok := hyphaToCategories[hyphaName]; ok { + node.storeCategory(cat) + } else { + hyphaToCategories[hyphaName] = &hyphaNode{categoryList: []string{cat}} + } + } + } + + log.Println("Found", len(categoryToHyphae), "categories") + for cat, catNode := range categoryToHyphae { // TODO: remove when not needed + log.Println(cat, "->", catNode.hyphaList) + } + for hyp, hypNode := range hyphaToCategories { + log.Println(hyp, "<-", hypNode.categoryList) + } +} + +type categoryNode struct { + // TODO: ensure this is sorted + hyphaList []string +} + +func (cn *categoryNode) storeHypha(hypname string) { + for _, hyphaName := range cn.hyphaList { + if hyphaName == hypname { + return + } + } + cn.hyphaList = append(cn.hyphaList, hypname) +} + +type hyphaNode struct { + // TODO: ensure this is sorted + categoryList []string +} + +func (hn *hyphaNode) storeCategory(cat string) { + for _, category := range hn.categoryList { + if category == cat { + return + } + } + hn.categoryList = append(hn.categoryList, cat) +} + +type catFileRecord struct { + Categories []catRecord `json:"categories"` +} + +type catRecord struct { + Name string `json:"name"` + Hyphae []string `json:"hyphae"` +} + +func readCategoriesFromDisk() (catFileRecord, error) { + var ( + record catFileRecord + categoriesFile = files.CategoriesJSON() + fileContents, err = os.ReadFile(categoriesFile) + ) + if os.IsNotExist(err) { + return record, nil + } + if err != nil { + return record, err + } + + err = json.Unmarshal(fileContents, &record) + if err != nil { + return record, err + } + + return record, nil +} diff --git a/main.go b/main.go index 1fe5048..07e39a5 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ package main import ( + "github.com/bouncepaw/mycorrhiza/hyphae/categories" "github.com/bouncepaw/mycorrhiza/migration" "log" "os" @@ -48,6 +49,7 @@ func main() { history.InitGitRepo() migration.MigrateRocketsMaybe() shroom.SetHeaderLinks() + categories.InitCategories() // Static files: static.InitFS(files.StaticFiles()) diff --git a/static/default.css b/static/default.css index 6987520..d5f3565 100644 --- a/static/default.css +++ b/static/default.css @@ -60,7 +60,7 @@ header { width: 100%; margin-bottom: 1rem; } .layout { display: grid; grid-template-columns: auto 1fr; column-gap: 1rem; margin: 0 1rem; row-gap: 1rem; } .main-width { margin: 0; } main { grid-column: 1 / span 1; grid-row: 1 / span 2; } - .sibling-hyphae, .markup-toolbar, .help-topics { grid-column: 2 / span 1; grid-row: 1 / span 1; } + .sibling-hyphae, .markup-toolbar, .help-topics, .categories-card { grid-column: 2 / span 1; grid-row: 1 / span 1; } .action-toolbar { grid-column: 2 / span 1; grid-row: 2 / span 1; } .layout-card { width: 100%; } .edit-toolbar__buttons {display: grid; } @@ -74,10 +74,10 @@ header { width: 100%; margin-bottom: 1rem; } .layout { grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); } .layout-card {max-width: 18rem;} .main-width { margin: 0 auto; } - main { grid-column: 2 / span 1; grid-row: 1 / span 2; } - .sibling-hyphae, .markup-toolbar, .help-topics { grid-column: 3 / span 1; margin-left: 0; } + main { grid-column: 2 / span 1; grid-row: 1 / span 3; } + .sibling-hyphae, .markup-toolbar, .help-topics { grid-column: 3 / span 1; margin-left: 0; grid-row: 1 / span 2; } .markup-toolbar { grid-column: 3 / span 1; grid-row: 1 / span 2; } - .action-toolbar { grid-column: 1 / span 1; grid-row: 1 / span 1; } + .action-toolbar, .categories-card { grid-column: 1 / span 1; grid-row: 1 / span 1; } .edit-toolbar__buttons { grid-template-columns: 1fr; } } @@ -275,6 +275,16 @@ table { border: 0; background-color: #444444; color: #ddd; } mark { background: rgba(130, 80, 30, 5); color: inherit; } } +/* + * Categories + */ +.categories-card__entries { + padding-left: 1rem; +} +.categories-card__remove-form { + float: right; +} + /* * Shortcuts */ diff --git a/views/categories.go b/views/categories.go new file mode 100644 index 0000000..4b62fdc --- /dev/null +++ b/views/categories.go @@ -0,0 +1,59 @@ +package views + +import ( + "github.com/bouncepaw/mycorrhiza/hyphae/categories" + "github.com/bouncepaw/mycorrhiza/util" + "html/template" + "log" + "strings" +) + +const categoriesCardTmpl = `{{$hyphaName := .HyphaName +}}` + +var categoriesCardT *template.Template + +func init() { + categoriesCardT = template.Must(template. + New("category card"). + Funcs(template.FuncMap{ + "beautifulName": util.BeautifulName, + }). + Parse(categoriesCardTmpl)) +} + +func categoryCardHTML(hyphaName string) string { + var buf strings.Builder + err := categoriesCardT.Execute(&buf, struct { + HyphaName string + Categories []string + }{ + hyphaName, + categories.WithHypha(hyphaName), + }) + if err != nil { + log.Println(err) + } + return buf.String() +} diff --git a/views/readers.qtpl b/views/readers.qtpl index dd0ee53..7e41140 100644 --- a/views/readers.qtpl +++ b/views/readers.qtpl @@ -131,6 +131,7 @@ If you rename .prevnext, change the docs too. {%= hyphaInfo(rq, h) %} +{%s= categoryCardHTML(h.CanonicalName()) %} {%= siblingHyphaeHTML(siblings, lc) %} {%= viewScripts() %} diff --git a/views/readers.qtpl.go b/views/readers.qtpl.go index 9e02bd5..9d4c6e4 100644 --- a/views/readers.qtpl.go +++ b/views/readers.qtpl.go @@ -469,162 +469,167 @@ func StreamHyphaHTML(qw422016 *qt422016.Writer, rq *http.Request, lc *l18n.Local `) //line views/readers.qtpl:134 - streamsiblingHyphaeHTML(qw422016, siblings, lc) + qw422016.N().S(categoryCardHTML(h.CanonicalName())) //line views/readers.qtpl:134 qw422016.N().S(` +`) +//line views/readers.qtpl:135 + streamsiblingHyphaeHTML(qw422016, siblings, lc) +//line views/readers.qtpl:135 + qw422016.N().S(` `) -//line views/readers.qtpl:136 +//line views/readers.qtpl:137 streamviewScripts(qw422016) -//line views/readers.qtpl:136 +//line views/readers.qtpl:137 qw422016.N().S(` `) -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 } -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 func WriteHyphaHTML(qq422016 qtio422016.Writer, rq *http.Request, lc *l18n.Localizer, h hyphae.Hypha, contents string) { -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 qw422016 := qt422016.AcquireWriter(qq422016) -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 StreamHyphaHTML(qw422016, rq, lc, h, contents) -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 qt422016.ReleaseWriter(qw422016) -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 } -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 func HyphaHTML(rq *http.Request, lc *l18n.Localizer, h hyphae.Hypha, contents string) string { -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 qb422016 := qt422016.AcquireByteBuffer() -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 WriteHyphaHTML(qb422016, rq, lc, h, contents) -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 qs422016 := string(qb422016.B) -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 qt422016.ReleaseByteBuffer(qb422016) -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 return qs422016 -//line views/readers.qtpl:137 +//line views/readers.qtpl:138 } -//line views/readers.qtpl:139 +//line views/readers.qtpl:140 func StreamRevisionHTML(qw422016 *qt422016.Writer, rq *http.Request, lc *l18n.Localizer, h hyphae.Hypha, contents, revHash string) { -//line views/readers.qtpl:139 +//line views/readers.qtpl:140 qw422016.N().S(`

`) -//line views/readers.qtpl:143 +//line views/readers.qtpl:144 qw422016.E().S(lc.Get("ui.revision_warning")) -//line views/readers.qtpl:143 +//line views/readers.qtpl:144 qw422016.N().S(` `) -//line views/readers.qtpl:143 +//line views/readers.qtpl:144 qw422016.E().S(lc.Get("ui.revision_link")) -//line views/readers.qtpl:143 +//line views/readers.qtpl:144 qw422016.N().S(`

`) -//line views/readers.qtpl:144 +//line views/readers.qtpl:145 qw422016.N().S(NaviTitleHTML(h)) -//line views/readers.qtpl:144 +//line views/readers.qtpl:145 qw422016.N().S(` `) -//line views/readers.qtpl:145 +//line views/readers.qtpl:146 qw422016.N().S(contents) -//line views/readers.qtpl:145 +//line views/readers.qtpl:146 qw422016.N().S(`
`) -//line views/readers.qtpl:149 +//line views/readers.qtpl:150 streamviewScripts(qw422016) -//line views/readers.qtpl:149 +//line views/readers.qtpl:150 qw422016.N().S(` `) -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 } -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 func WriteRevisionHTML(qq422016 qtio422016.Writer, rq *http.Request, lc *l18n.Localizer, h hyphae.Hypha, contents, revHash string) { -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 qw422016 := qt422016.AcquireWriter(qq422016) -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 StreamRevisionHTML(qw422016, rq, lc, h, contents, revHash) -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 qt422016.ReleaseWriter(qw422016) -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 } -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 func RevisionHTML(rq *http.Request, lc *l18n.Localizer, h hyphae.Hypha, contents, revHash string) string { -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 qb422016 := qt422016.AcquireByteBuffer() -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 WriteRevisionHTML(qb422016, rq, lc, h, contents, revHash) -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 qs422016 := string(qb422016.B) -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 qt422016.ReleaseByteBuffer(qb422016) -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 return qs422016 -//line views/readers.qtpl:150 +//line views/readers.qtpl:151 } -//line views/readers.qtpl:152 +//line views/readers.qtpl:153 func streamviewScripts(qw422016 *qt422016.Writer) { -//line views/readers.qtpl:152 +//line views/readers.qtpl:153 qw422016.N().S(` `) -//line views/readers.qtpl:153 +//line views/readers.qtpl:154 for _, scriptPath := range cfg.ViewScripts { -//line views/readers.qtpl:153 +//line views/readers.qtpl:154 qw422016.N().S(` `) -//line views/readers.qtpl:155 +//line views/readers.qtpl:156 } -//line views/readers.qtpl:155 +//line views/readers.qtpl:156 qw422016.N().S(` `) -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 } -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 func writeviewScripts(qq422016 qtio422016.Writer) { -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 qw422016 := qt422016.AcquireWriter(qq422016) -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 streamviewScripts(qw422016) -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 qt422016.ReleaseWriter(qw422016) -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 } -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 func viewScripts() string { -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 qb422016 := qt422016.AcquireByteBuffer() -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 writeviewScripts(qb422016) -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 qs422016 := string(qb422016.B) -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 qt422016.ReleaseByteBuffer(qb422016) -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 return qs422016 -//line views/readers.qtpl:156 +//line views/readers.qtpl:157 }