+
ADDED box/constbox/contributors.zettel
Index: box/constbox/contributors.zettel
==================================================================
--- box/constbox/contributors.zettel
+++ box/constbox/contributors.zettel
@@ -0,0 +1,8 @@
+Zettelstore is a software for humans made from humans.
+
+=== Licensor(s)
+* Detlef Stern [[mailto:ds@zettelstore.de]]
+** Main author
+** Maintainer
+
+=== Contributors
ADDED box/constbox/delete.mustache
Index: box/constbox/delete.mustache
==================================================================
--- box/constbox/delete.mustache
+++ box/constbox/delete.mustache
@@ -0,0 +1,15 @@
+
+
+
Delete Zettel {{Zid}}
+
+
Do you really want to delete this zettel?
+
+{{#MetaPairs}}
+
{{Key}}:
{{Value}}
+{{/MetaPairs}}
+
+
+
+{{end}}
ADDED box/constbox/dependencies.zettel
Index: box/constbox/dependencies.zettel
==================================================================
--- box/constbox/dependencies.zettel
+++ box/constbox/dependencies.zettel
@@ -0,0 +1,149 @@
+Zettelstore is made with the help of other software and other artifacts.
+Thank you very much!
+
+This zettel lists all of them, together with their license.
+
+=== Go runtime and associated libraries
+; License
+: BSD 3-Clause "New" or "Revised" License
+```
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+
+=== Fsnotify
+; URL
+: [[https://fsnotify.org/]]
+; License
+: BSD 3-Clause "New" or "Revised" License
+; Source
+: [[https://github.com/fsnotify/fsnotify]]
+```
+Copyright (c) 2012 The Go Authors. All rights reserved.
+Copyright (c) 2012-2019 fsnotify Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+
+=== hoisie/mustache / cbroglie/mustache
+; URL & Source
+: [[https://github.com/hoisie/mustache]] / [[https://github.com/cbroglie/mustache]]
+; License
+: MIT License
+; Remarks
+: cbroglie/mustache is a fork from hoisie/mustache (starting with commit [f9b4cbf]).
+ cbroglie/mustache does not claim a copyright and includes just the license file from hoisie/mustache.
+ cbroglie/mustache obviously continues with the original license.
+
+```
+Copyright (c) 2009 Michael Hoisie
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+```
+
+=== pascaldekloe/jwt
+; URL & Source
+: [[https://github.com/pascaldekloe/jwt]]
+; License
+: [[CC0 1.0 Universal|https://creativecommons.org/publicdomain/zero/1.0/legalcode]]
+```
+To the extent possible under law, Pascal S. de Kloe has waived all
+copyright and related or neighboring rights to JWT. This work is
+published from The Netherlands.
+
+https://creativecommons.org/publicdomain/zero/1.0/legalcode
+```
+
+=== yuin/goldmark
+; URL & Source
+: [[https://github.com/yuin/goldmark]]
+; License
+: MIT License
+```
+MIT License
+
+Copyright (c) 2019 Yusuke Inuzuka
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
ADDED box/constbox/emoji_spin.gif
Index: box/constbox/emoji_spin.gif
==================================================================
--- box/constbox/emoji_spin.gif
+++ box/constbox/emoji_spin.gif
cannot compute difference between binary files
ADDED box/constbox/error.mustache
Index: box/constbox/error.mustache
==================================================================
--- box/constbox/error.mustache
+++ box/constbox/error.mustache
@@ -0,0 +1,6 @@
+
+
+
+
+
+
ADDED box/constbox/home.zettel
Index: box/constbox/home.zettel
==================================================================
--- box/constbox/home.zettel
+++ box/constbox/home.zettel
@@ -0,0 +1,44 @@
+=== Thank you for using Zettelstore!
+
+You will find the lastest information about Zettelstore at [[https://zettelstore.de]].
+Check that website regulary for [[upgrades|https://zettelstore.de/home/doc/trunk/www/download.wiki]] to the latest version.
+You should consult the [[change log|https://zettelstore.de/home/doc/trunk/www/changes.wiki]] before upgrading.
+Sometimes, you have to edit some of your Zettelstore-related zettel before upgrading.
+Since Zettelstore is currently in a development state, every upgrade might fix some of your problems.
+To check for versions, there is a zettel with the [[current version|00000000000001]] of your Zettelstore.
+
+If you have problems concerning Zettelstore,
+do not hesitate to get in [[contact with the main developer|mailto:ds@zettelstore.de]].
+
+=== Reporting errors
+If you have encountered an error, please include the content of the following zettel in your mail (if possible):
+* [[Zettelstore Version|00000000000001]]
+* [[Zettelstore Operating System|00000000000003]]
+* [[Zettelstore Startup Configuration|00000000000096]]
+* [[Zettelstore Runtime Configuration|00000000000100]]
+
+Additionally, you have to describe, what you have done before that error occurs
+and what you have expected instead.
+Please do not forget to include the error message, if there is one.
+
+Some of above Zettelstore zettel can only be retrieved if you enabled ""expert mode"".
+Otherwise, only some zettel are linked.
+To enable expert mode, edit the zettel [[Zettelstore Runtime Configuration|00000000000100]]:
+please set the metadata value of the key ''expert-mode'' to true.
+To do you, enter the string ''expert-mode:true'' inside the edit view of the metadata.
+
+=== Information about this zettel
+This zettel is your home zettel.
+It is part of the Zettelstore software itself.
+Every time you click on the [[Home|//]] link in the menu bar, you will be redirected to this zettel.
+
+You can change the content of this zettel by clicking on ""Edit"" above.
+This allows you to customize your home zettel.
+
+Alternatively, you can designate another zettel as your home zettel.
+Edit the [[Zettelstore Runtime Configuration|00000000000100]] by adding the metadata key ''home-zettel''.
+Its value is the identifier of the zettel that should act as the new home zettel.
+You will find the identifier of each zettel between their ""Edit"" and the ""Info"" link above.
+The identifier of this zettel is ''00010000000000''.
+If you provide a wrong identifier, this zettel will be shown as the home zettel.
+Take a look inside the manual for further details.
ADDED box/constbox/info.mustache
Index: box/constbox/info.mustache
==================================================================
--- box/constbox/info.mustache
+++ box/constbox/info.mustache
@@ -0,0 +1,48 @@
+
+
+
+{{/Retry}}
+
+
ADDED box/constbox/newtoc.zettel
Index: box/constbox/newtoc.zettel
==================================================================
--- box/constbox/newtoc.zettel
+++ box/constbox/newtoc.zettel
@@ -0,0 +1,4 @@
+This zettel lists all zettel that should act as a template for new zettel.
+These zettel will be included in the ""New"" menu of the WebUI.
+* [[New Zettel|00000000090001]]
+* [[New User|00000000090002]]
ADDED box/constbox/rename.mustache
Index: box/constbox/rename.mustache
==================================================================
--- box/constbox/rename.mustache
+++ box/constbox/rename.mustache
@@ -0,0 +1,19 @@
+
+
+
+
+{{/HasBackLinks}}
+
ADDED box/dirbox/dirbox.go
Index: box/dirbox/dirbox.go
==================================================================
--- box/dirbox/dirbox.go
+++ box/dirbox/dirbox.go
@@ -0,0 +1,420 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package dirbox provides a directory-based zettel box.
+package dirbox
+
+import (
+ "context"
+ "errors"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/dirbox/directory"
+ "zettelstore.de/z/box/filebox"
+ "zettelstore.de/z/box/manager"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/search"
+)
+
+func init() {
+ manager.Register("dir", func(u *url.URL, cdata *manager.ConnectData) (box.ManagedBox, error) {
+ path := getDirPath(u)
+ if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ dirSrvSpec, defWorker, maxWorker := getDirSrvInfo(u.Query().Get("type"))
+ dp := dirBox{
+ number: cdata.Number,
+ location: u.String(),
+ readonly: getQueryBool(u, "readonly"),
+ cdata: *cdata,
+ dir: path,
+ dirRescan: time.Duration(getQueryInt(u, "rescan", 60, 3600, 30*24*60*60)) * time.Second,
+ dirSrvSpec: dirSrvSpec,
+ fSrvs: uint32(getQueryInt(u, "worker", 1, defWorker, maxWorker)),
+ }
+ return &dp, nil
+ })
+}
+
+type directoryServiceSpec int
+
+const (
+ _ directoryServiceSpec = iota
+ dirSrvAny
+ dirSrvSimple
+ dirSrvNotify
+)
+
+func getDirPath(u *url.URL) string {
+ if u.Opaque != "" {
+ return filepath.Clean(u.Opaque)
+ }
+ return filepath.Clean(u.Path)
+}
+
+func getQueryBool(u *url.URL, key string) bool {
+ _, ok := u.Query()[key]
+ return ok
+}
+
+func getQueryInt(u *url.URL, key string, min, def, max int) int {
+ sVal := u.Query().Get(key)
+ if sVal == "" {
+ return def
+ }
+ iVal, err := strconv.Atoi(sVal)
+ if err != nil {
+ return def
+ }
+ if iVal < min {
+ return min
+ }
+ if iVal > max {
+ return max
+ }
+ return iVal
+}
+
+// dirBox uses a directory to store zettel as files.
+type dirBox struct {
+ number int
+ location string
+ readonly bool
+ cdata manager.ConnectData
+ dir string
+ dirRescan time.Duration
+ dirSrvSpec directoryServiceSpec
+ dirSrv directory.Service
+ mustNotify bool
+ fSrvs uint32
+ fCmds []chan fileCmd
+ mxCmds sync.RWMutex
+}
+
+func (dp *dirBox) Location() string {
+ return dp.location
+}
+
+func (dp *dirBox) Start(ctx context.Context) error {
+ dp.mxCmds.Lock()
+ dp.fCmds = make([]chan fileCmd, 0, dp.fSrvs)
+ for i := uint32(0); i < dp.fSrvs; i++ {
+ cc := make(chan fileCmd)
+ go fileService(i, cc)
+ dp.fCmds = append(dp.fCmds, cc)
+ }
+ dp.setupDirService()
+ dp.mxCmds.Unlock()
+ if dp.dirSrv == nil {
+ panic("No directory service")
+ }
+ return dp.dirSrv.Start()
+}
+
+func (dp *dirBox) Stop(ctx context.Context) error {
+ dirSrv := dp.dirSrv
+ dp.dirSrv = nil
+ err := dirSrv.Stop()
+ for _, c := range dp.fCmds {
+ close(c)
+ }
+ return err
+}
+
+func (dp *dirBox) notifyChanged(reason box.UpdateReason, zid id.Zid) {
+ if dp.mustNotify {
+ if chci := dp.cdata.Notify; chci != nil {
+ chci <- box.UpdateInfo{Reason: reason, Zid: zid}
+ }
+ }
+}
+
+func (dp *dirBox) getFileChan(zid id.Zid) chan fileCmd {
+ // Based on https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
+ sum := 2166136261 ^ uint32(zid)
+ sum *= 16777619
+ sum ^= uint32(zid >> 32)
+ sum *= 16777619
+
+ dp.mxCmds.RLock()
+ defer dp.mxCmds.RUnlock()
+ return dp.fCmds[sum%dp.fSrvs]
+}
+
+func (dp *dirBox) CanCreateZettel(ctx context.Context) bool {
+ return !dp.readonly
+}
+
+func (dp *dirBox) CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error) {
+ if dp.readonly {
+ return id.Invalid, box.ErrReadOnly
+ }
+
+ entry, err := dp.dirSrv.GetNew()
+ if err != nil {
+ return id.Invalid, err
+ }
+ meta := zettel.Meta
+ meta.Zid = entry.Zid
+ dp.updateEntryFromMeta(entry, meta)
+
+ err = setZettel(dp, entry, zettel)
+ if err == nil {
+ dp.dirSrv.UpdateEntry(entry)
+ }
+ dp.notifyChanged(box.OnUpdate, meta.Zid)
+ return meta.Zid, err
+}
+
+func (dp *dirBox) GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error) {
+ entry, err := dp.dirSrv.GetEntry(zid)
+ if err != nil || !entry.IsValid() {
+ return domain.Zettel{}, box.ErrNotFound
+ }
+ m, c, err := getMetaContent(dp, entry, zid)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ dp.cleanupMeta(ctx, m)
+ zettel := domain.Zettel{Meta: m, Content: domain.NewContent(c)}
+ return zettel, nil
+}
+
+func (dp *dirBox) GetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error) {
+ entry, err := dp.dirSrv.GetEntry(zid)
+ if err != nil || !entry.IsValid() {
+ return nil, box.ErrNotFound
+ }
+ m, err := getMeta(dp, entry, zid)
+ if err != nil {
+ return nil, err
+ }
+ dp.cleanupMeta(ctx, m)
+ return m, nil
+}
+
+func (dp *dirBox) FetchZids(ctx context.Context) (id.Set, error) {
+ entries, err := dp.dirSrv.GetEntries()
+ if err != nil {
+ return nil, err
+ }
+ result := id.NewSetCap(len(entries))
+ for _, entry := range entries {
+ result[entry.Zid] = true
+ }
+ return result, nil
+}
+
+func (dp *dirBox) SelectMeta(ctx context.Context, match search.MetaMatchFunc) (res []*meta.Meta, err error) {
+ entries, err := dp.dirSrv.GetEntries()
+ if err != nil {
+ return nil, err
+ }
+ res = make([]*meta.Meta, 0, len(entries))
+ // The following loop could be parallelized if needed for performance.
+ for _, entry := range entries {
+ m, err1 := getMeta(dp, entry, entry.Zid)
+ err = err1
+ if err != nil {
+ continue
+ }
+ dp.cleanupMeta(ctx, m)
+ dp.cdata.Enricher.Enrich(ctx, m, dp.number)
+
+ if match(m) {
+ res = append(res, m)
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ return res, nil
+}
+
+func (dp *dirBox) CanUpdateZettel(ctx context.Context, zettel domain.Zettel) bool {
+ return !dp.readonly
+}
+
+func (dp *dirBox) UpdateZettel(ctx context.Context, zettel domain.Zettel) error {
+ if dp.readonly {
+ return box.ErrReadOnly
+ }
+
+ meta := zettel.Meta
+ if !meta.Zid.IsValid() {
+ return &box.ErrInvalidID{Zid: meta.Zid}
+ }
+ entry, err := dp.dirSrv.GetEntry(meta.Zid)
+ if err != nil {
+ return err
+ }
+ if !entry.IsValid() {
+ // Existing zettel, but new in this box.
+ entry = &directory.Entry{Zid: meta.Zid}
+ dp.updateEntryFromMeta(entry, meta)
+ } else if entry.MetaSpec == directory.MetaSpecNone {
+ defaultMeta := filebox.CalcDefaultMeta(entry.Zid, entry.ContentExt)
+ if !meta.Equal(defaultMeta, true) {
+ dp.updateEntryFromMeta(entry, meta)
+ dp.dirSrv.UpdateEntry(entry)
+ }
+ }
+ err = setZettel(dp, entry, zettel)
+ if err == nil {
+ dp.notifyChanged(box.OnUpdate, meta.Zid)
+ }
+ return err
+}
+
+func (dp *dirBox) updateEntryFromMeta(entry *directory.Entry, meta *meta.Meta) {
+ entry.MetaSpec, entry.ContentExt = dp.calcSpecExt(meta)
+ basePath := dp.calcBasePath(entry)
+ if entry.MetaSpec == directory.MetaSpecFile {
+ entry.MetaPath = basePath + ".meta"
+ }
+ entry.ContentPath = basePath + "." + entry.ContentExt
+ entry.Duplicates = false
+}
+
+func (dp *dirBox) calcBasePath(entry *directory.Entry) string {
+ p := entry.ContentPath
+ if p == "" {
+ return filepath.Join(dp.dir, entry.Zid.String())
+ }
+ // ContentPath w/o the file extension
+ return p[0 : len(p)-len(filepath.Ext(p))]
+}
+
+func (dp *dirBox) calcSpecExt(m *meta.Meta) (directory.MetaSpec, string) {
+ if m.YamlSep {
+ return directory.MetaSpecHeader, "zettel"
+ }
+ syntax := m.GetDefault(meta.KeySyntax, "bin")
+ switch syntax {
+ case meta.ValueSyntaxNone, meta.ValueSyntaxZmk:
+ return directory.MetaSpecHeader, "zettel"
+ }
+ for _, s := range dp.cdata.Config.GetZettelFileSyntax() {
+ if s == syntax {
+ return directory.MetaSpecHeader, "zettel"
+ }
+ }
+ return directory.MetaSpecFile, syntax
+}
+
+func (dp *dirBox) AllowRenameZettel(ctx context.Context, zid id.Zid) bool {
+ return !dp.readonly
+}
+
+func (dp *dirBox) RenameZettel(ctx context.Context, curZid, newZid id.Zid) error {
+ if curZid == newZid {
+ return nil
+ }
+ curEntry, err := dp.dirSrv.GetEntry(curZid)
+ if err != nil || !curEntry.IsValid() {
+ return box.ErrNotFound
+ }
+ if dp.readonly {
+ return box.ErrReadOnly
+ }
+
+ // Check whether zettel with new ID already exists in this box.
+ if _, err = dp.GetMeta(ctx, newZid); err == nil {
+ return &box.ErrInvalidID{Zid: newZid}
+ }
+
+ oldMeta, oldContent, err := getMetaContent(dp, curEntry, curZid)
+ if err != nil {
+ return err
+ }
+
+ newEntry := directory.Entry{
+ Zid: newZid,
+ MetaSpec: curEntry.MetaSpec,
+ MetaPath: renamePath(curEntry.MetaPath, curZid, newZid),
+ ContentPath: renamePath(curEntry.ContentPath, curZid, newZid),
+ ContentExt: curEntry.ContentExt,
+ }
+
+ if err = dp.dirSrv.RenameEntry(curEntry, &newEntry); err != nil {
+ return err
+ }
+ oldMeta.Zid = newZid
+ newZettel := domain.Zettel{Meta: oldMeta, Content: domain.NewContent(oldContent)}
+ if err = setZettel(dp, &newEntry, newZettel); err != nil {
+ // "Rollback" rename. No error checking...
+ dp.dirSrv.RenameEntry(&newEntry, curEntry)
+ return err
+ }
+ err = deleteZettel(dp, curEntry, curZid)
+ if err == nil {
+ dp.notifyChanged(box.OnDelete, curZid)
+ dp.notifyChanged(box.OnUpdate, newZid)
+ }
+ return err
+}
+
+func (dp *dirBox) CanDeleteZettel(ctx context.Context, zid id.Zid) bool {
+ if dp.readonly {
+ return false
+ }
+ entry, err := dp.dirSrv.GetEntry(zid)
+ return err == nil && entry.IsValid()
+}
+
+func (dp *dirBox) DeleteZettel(ctx context.Context, zid id.Zid) error {
+ if dp.readonly {
+ return box.ErrReadOnly
+ }
+
+ entry, err := dp.dirSrv.GetEntry(zid)
+ if err != nil || !entry.IsValid() {
+ return box.ErrNotFound
+ }
+ dp.dirSrv.DeleteEntry(zid)
+ err = deleteZettel(dp, entry, zid)
+ if err == nil {
+ dp.notifyChanged(box.OnDelete, zid)
+ }
+ return err
+}
+
+func (dp *dirBox) ReadStats(st *box.ManagedBoxStats) {
+ st.ReadOnly = dp.readonly
+ st.Zettel, _ = dp.dirSrv.NumEntries()
+}
+
+func (dp *dirBox) cleanupMeta(ctx context.Context, m *meta.Meta) {
+ if role, ok := m.Get(meta.KeyRole); !ok || role == "" {
+ m.Set(meta.KeyRole, dp.cdata.Config.GetDefaultRole())
+ }
+ if syntax, ok := m.Get(meta.KeySyntax); !ok || syntax == "" {
+ m.Set(meta.KeySyntax, dp.cdata.Config.GetDefaultSyntax())
+ }
+}
+
+func renamePath(path string, curID, newID id.Zid) string {
+ dir, file := filepath.Split(path)
+ if cur := curID.String(); strings.HasPrefix(file, cur) {
+ file = newID.String() + file[len(cur):]
+ return filepath.Join(dir, file)
+ }
+ return path
+}
ADDED box/dirbox/directory/directory.go
Index: box/dirbox/directory/directory.go
==================================================================
--- box/dirbox/directory/directory.go
+++ box/dirbox/directory/directory.go
@@ -0,0 +1,53 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package directory manages the directory interface of a dirstore.
+package directory
+
+import "zettelstore.de/z/domain/id"
+
+// Service is the interface of a directory service.
+type Service interface {
+ Start() error
+ Stop() error
+ NumEntries() (int, error)
+ GetEntries() ([]*Entry, error)
+ GetEntry(zid id.Zid) (*Entry, error)
+ GetNew() (*Entry, error)
+ UpdateEntry(entry *Entry) error
+ RenameEntry(curEntry, newEntry *Entry) error
+ DeleteEntry(zid id.Zid) error
+}
+
+// MetaSpec defines all possibilities where meta data can be stored.
+type MetaSpec int
+
+// Constants for MetaSpec
+const (
+ _ MetaSpec = iota
+ MetaSpecNone // no meta information
+ MetaSpecFile // meta information is in meta file
+ MetaSpecHeader // meta information is in header
+)
+
+// Entry stores everything for a directory entry.
+type Entry struct {
+ Zid id.Zid
+ MetaSpec MetaSpec // location of meta information
+ MetaPath string // file path of meta information
+ ContentPath string // file path of zettel content
+ ContentExt string // (normalized) file extension of zettel content
+ Duplicates bool // multiple content files
+}
+
+// IsValid checks whether the entry is valid.
+func (e *Entry) IsValid() bool {
+ return e != nil && e.Zid.IsValid()
+}
ADDED box/dirbox/makedir.go
Index: box/dirbox/makedir.go
==================================================================
--- box/dirbox/makedir.go
+++ box/dirbox/makedir.go
@@ -0,0 +1,43 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package dirbox provides a directory-based zettel box.
+package dirbox
+
+import (
+ "zettelstore.de/z/box/dirbox/notifydir"
+ "zettelstore.de/z/box/dirbox/simpledir"
+ "zettelstore.de/z/kernel"
+)
+
+func getDirSrvInfo(dirType string) (directoryServiceSpec, int, int) {
+ for count := 0; count < 2; count++ {
+ switch dirType {
+ case kernel.BoxDirTypeNotify:
+ return dirSrvNotify, 7, 1499
+ case kernel.BoxDirTypeSimple:
+ return dirSrvSimple, 1, 1
+ default:
+ dirType = kernel.Main.GetConfig(kernel.BoxService, kernel.BoxDefaultDirType).(string)
+ }
+ }
+ panic("unable to set default dir box type: " + dirType)
+}
+
+func (dp *dirBox) setupDirService() {
+ switch dp.dirSrvSpec {
+ case dirSrvSimple:
+ dp.dirSrv = simpledir.NewService(dp.dir)
+ dp.mustNotify = true
+ default:
+ dp.dirSrv = notifydir.NewService(dp.dir, dp.dirRescan, dp.cdata.Notify)
+ dp.mustNotify = false
+ }
+}
ADDED box/dirbox/notifydir/notifydir.go
Index: box/dirbox/notifydir/notifydir.go
==================================================================
--- box/dirbox/notifydir/notifydir.go
+++ box/dirbox/notifydir/notifydir.go
@@ -0,0 +1,126 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package notifydir manages the notified directory part of a dirstore.
+package notifydir
+
+import (
+ "time"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/dirbox/directory"
+ "zettelstore.de/z/domain/id"
+)
+
+// notifyService specifies a directory scan service.
+type notifyService struct {
+ dirPath string
+ rescanTime time.Duration
+ done chan struct{}
+ cmds chan dirCmd
+ infos chan<- box.UpdateInfo
+}
+
+// NewService creates a new directory service.
+func NewService(directoryPath string, rescanTime time.Duration, chci chan<- box.UpdateInfo) directory.Service {
+ srv := ¬ifyService{
+ dirPath: directoryPath,
+ rescanTime: rescanTime,
+ cmds: make(chan dirCmd),
+ infos: chci,
+ }
+ return srv
+}
+
+// Start makes the directory service operational.
+func (srv *notifyService) Start() error {
+ tick := make(chan struct{})
+ rawEvents := make(chan *fileEvent)
+ events := make(chan *fileEvent)
+
+ ready := make(chan int)
+ go srv.directoryService(events, ready)
+ go collectEvents(events, rawEvents)
+ go watchDirectory(srv.dirPath, rawEvents, tick)
+
+ if srv.done != nil {
+ panic("src.done already set")
+ }
+ srv.done = make(chan struct{})
+ go ping(tick, srv.rescanTime, srv.done)
+ <-ready
+ return nil
+}
+
+// Stop stops the directory service.
+func (srv *notifyService) Stop() error {
+ close(srv.done)
+ srv.done = nil
+ return nil
+}
+
+func (srv *notifyService) notifyChange(reason box.UpdateReason, zid id.Zid) {
+ if chci := srv.infos; chci != nil {
+ chci <- box.UpdateInfo{Reason: reason, Zid: zid}
+ }
+}
+
+// NumEntries returns the number of managed zettel.
+func (srv *notifyService) NumEntries() (int, error) {
+ resChan := make(chan resNumEntries)
+ srv.cmds <- &cmdNumEntries{resChan}
+ return <-resChan, nil
+}
+
+// GetEntries returns an unsorted list of all current directory entries.
+func (srv *notifyService) GetEntries() ([]*directory.Entry, error) {
+ resChan := make(chan resGetEntries)
+ srv.cmds <- &cmdGetEntries{resChan}
+ return <-resChan, nil
+}
+
+// GetEntry returns the entry with the specified zettel id. If there is no such
+// zettel id, an empty entry is returned.
+func (srv *notifyService) GetEntry(zid id.Zid) (*directory.Entry, error) {
+ resChan := make(chan resGetEntry)
+ srv.cmds <- &cmdGetEntry{zid, resChan}
+ return <-resChan, nil
+}
+
+// GetNew returns an entry with a new zettel id.
+func (srv *notifyService) GetNew() (*directory.Entry, error) {
+ resChan := make(chan resNewEntry)
+ srv.cmds <- &cmdNewEntry{resChan}
+ result := <-resChan
+ return result.entry, result.err
+}
+
+// UpdateEntry notifies the directory of an updated entry.
+func (srv *notifyService) UpdateEntry(entry *directory.Entry) error {
+ resChan := make(chan struct{})
+ srv.cmds <- &cmdUpdateEntry{entry, resChan}
+ <-resChan
+ return nil
+}
+
+// RenameEntry notifies the directory of an renamed entry.
+func (srv *notifyService) RenameEntry(curEntry, newEntry *directory.Entry) error {
+ resChan := make(chan resRenameEntry)
+ srv.cmds <- &cmdRenameEntry{curEntry, newEntry, resChan}
+ return <-resChan
+}
+
+// DeleteEntry removes a zettel id from the directory of entries.
+func (srv *notifyService) DeleteEntry(zid id.Zid) error {
+ resChan := make(chan struct{})
+ srv.cmds <- &cmdDeleteEntry{zid, resChan}
+ <-resChan
+ return nil
+}
ADDED box/dirbox/notifydir/service.go
Index: box/dirbox/notifydir/service.go
==================================================================
--- box/dirbox/notifydir/service.go
+++ box/dirbox/notifydir/service.go
@@ -0,0 +1,255 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package notifydir manages the notified directory part of a dirstore.
+package notifydir
+
+import (
+ "log"
+ "time"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/dirbox/directory"
+ "zettelstore.de/z/domain/id"
+)
+
+// ping sends every tick a signal to reload the directory list
+func ping(tick chan<- struct{}, rescanTime time.Duration, done <-chan struct{}) {
+ ticker := time.NewTicker(rescanTime)
+ defer close(tick)
+ for {
+ select {
+ case _, ok := <-ticker.C:
+ if !ok {
+ return
+ }
+ tick <- struct{}{}
+ case _, ok := <-done:
+ if !ok {
+ ticker.Stop()
+ return
+ }
+ }
+ }
+}
+
+func newEntry(ev *fileEvent) *directory.Entry {
+ de := new(directory.Entry)
+ de.Zid = ev.zid
+ updateEntry(de, ev)
+ return de
+}
+
+func updateEntry(de *directory.Entry, ev *fileEvent) {
+ if ev.ext == "meta" {
+ de.MetaSpec = directory.MetaSpecFile
+ de.MetaPath = ev.path
+ return
+ }
+ if de.ContentExt != "" && de.ContentExt != ev.ext {
+ de.Duplicates = true
+ return
+ }
+ if de.MetaSpec != directory.MetaSpecFile {
+ if ev.ext == "zettel" {
+ de.MetaSpec = directory.MetaSpecHeader
+ } else {
+ de.MetaSpec = directory.MetaSpecNone
+ }
+ }
+ de.ContentPath = ev.path
+ de.ContentExt = ev.ext
+}
+
+type dirMap map[id.Zid]*directory.Entry
+
+func dirMapUpdate(dm dirMap, ev *fileEvent) {
+ de := dm[ev.zid]
+ if de == nil {
+ dm[ev.zid] = newEntry(ev)
+ return
+ }
+ updateEntry(de, ev)
+}
+
+func deleteFromMap(dm dirMap, ev *fileEvent) {
+ if ev.ext == "meta" {
+ if entry, ok := dm[ev.zid]; ok {
+ if entry.MetaSpec == directory.MetaSpecFile {
+ entry.MetaSpec = directory.MetaSpecNone
+ return
+ }
+ }
+ }
+ delete(dm, ev.zid)
+}
+
+// directoryService is the main service.
+func (srv *notifyService) directoryService(events <-chan *fileEvent, ready chan<- int) {
+ curMap := make(dirMap)
+ var newMap dirMap
+ for {
+ select {
+ case ev, ok := <-events:
+ if !ok {
+ return
+ }
+ switch ev.status {
+ case fileStatusReloadStart:
+ newMap = make(dirMap)
+ case fileStatusReloadEnd:
+ curMap = newMap
+ newMap = nil
+ if ready != nil {
+ ready <- len(curMap)
+ close(ready)
+ ready = nil
+ }
+ srv.notifyChange(box.OnReload, id.Invalid)
+ case fileStatusError:
+ log.Println("DIRBOX", "ERROR", ev.err)
+ case fileStatusUpdate:
+ srv.processFileUpdateEvent(ev, curMap, newMap)
+ case fileStatusDelete:
+ srv.processFileDeleteEvent(ev, curMap, newMap)
+ }
+ case cmd, ok := <-srv.cmds:
+ if ok {
+ cmd.run(curMap)
+ }
+ }
+ }
+}
+
+func (srv *notifyService) processFileUpdateEvent(ev *fileEvent, curMap, newMap dirMap) {
+ if newMap != nil {
+ dirMapUpdate(newMap, ev)
+ } else {
+ dirMapUpdate(curMap, ev)
+ srv.notifyChange(box.OnUpdate, ev.zid)
+ }
+}
+
+func (srv *notifyService) processFileDeleteEvent(ev *fileEvent, curMap, newMap dirMap) {
+ if newMap != nil {
+ deleteFromMap(newMap, ev)
+ } else {
+ deleteFromMap(curMap, ev)
+ srv.notifyChange(box.OnDelete, ev.zid)
+ }
+}
+
+type dirCmd interface {
+ run(m dirMap)
+}
+
+type cmdNumEntries struct {
+ result chan<- resNumEntries
+}
+type resNumEntries = int
+
+func (cmd *cmdNumEntries) run(m dirMap) {
+ cmd.result <- len(m)
+}
+
+type cmdGetEntries struct {
+ result chan<- resGetEntries
+}
+type resGetEntries []*directory.Entry
+
+func (cmd *cmdGetEntries) run(m dirMap) {
+ res := make([]*directory.Entry, len(m))
+ i := 0
+ for _, de := range m {
+ entry := *de
+ res[i] = &entry
+ i++
+ }
+ cmd.result <- res
+}
+
+type cmdGetEntry struct {
+ zid id.Zid
+ result chan<- resGetEntry
+}
+type resGetEntry = *directory.Entry
+
+func (cmd *cmdGetEntry) run(m dirMap) {
+ entry := m[cmd.zid]
+ if entry == nil {
+ cmd.result <- nil
+ } else {
+ result := *entry
+ cmd.result <- &result
+ }
+}
+
+type cmdNewEntry struct {
+ result chan<- resNewEntry
+}
+type resNewEntry struct {
+ entry *directory.Entry
+ err error
+}
+
+func (cmd *cmdNewEntry) run(m dirMap) {
+ zid, err := box.GetNewZid(func(zid id.Zid) (bool, error) {
+ _, ok := m[zid]
+ return !ok, nil
+ })
+ if err != nil {
+ cmd.result <- resNewEntry{nil, err}
+ return
+ }
+ entry := &directory.Entry{Zid: zid}
+ m[zid] = entry
+ cmd.result <- resNewEntry{&directory.Entry{Zid: zid}, nil}
+}
+
+type cmdUpdateEntry struct {
+ entry *directory.Entry
+ result chan<- struct{}
+}
+
+func (cmd *cmdUpdateEntry) run(m dirMap) {
+ entry := *cmd.entry
+ m[entry.Zid] = &entry
+ cmd.result <- struct{}{}
+}
+
+type cmdRenameEntry struct {
+ curEntry *directory.Entry
+ newEntry *directory.Entry
+ result chan<- resRenameEntry
+}
+
+type resRenameEntry = error
+
+func (cmd *cmdRenameEntry) run(m dirMap) {
+ newEntry := *cmd.newEntry
+ newZid := newEntry.Zid
+ if _, found := m[newZid]; found {
+ cmd.result <- &box.ErrInvalidID{Zid: newZid}
+ return
+ }
+ delete(m, cmd.curEntry.Zid)
+ m[newZid] = &newEntry
+ cmd.result <- nil
+}
+
+type cmdDeleteEntry struct {
+ zid id.Zid
+ result chan<- struct{}
+}
+
+func (cmd *cmdDeleteEntry) run(m dirMap) {
+ delete(m, cmd.zid)
+ cmd.result <- struct{}{}
+}
ADDED box/dirbox/notifydir/watch.go
Index: box/dirbox/notifydir/watch.go
==================================================================
--- box/dirbox/notifydir/watch.go
+++ box/dirbox/notifydir/watch.go
@@ -0,0 +1,300 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package notifydir manages the notified directory part of a dirstore.
+package notifydir
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+
+ "zettelstore.de/z/domain/id"
+)
+
+var validFileName = regexp.MustCompile(`^(\d{14}).*(\.(.+))$`)
+
+func matchValidFileName(name string) []string {
+ return validFileName.FindStringSubmatch(name)
+}
+
+type fileStatus int
+
+const (
+ fileStatusNone fileStatus = iota
+ fileStatusReloadStart
+ fileStatusReloadEnd
+ fileStatusError
+ fileStatusUpdate
+ fileStatusDelete
+)
+
+type fileEvent struct {
+ status fileStatus
+ path string // Full file path
+ zid id.Zid
+ ext string // File extension
+ err error // Error if Status == fileStatusError
+}
+
+type sendResult int
+
+const (
+ sendDone sendResult = iota
+ sendReload
+ sendExit
+)
+
+func watchDirectory(directory string, events chan<- *fileEvent, tick <-chan struct{}) {
+ defer close(events)
+
+ var watcher *fsnotify.Watcher
+ defer func() {
+ if watcher != nil {
+ watcher.Close()
+ }
+ }()
+
+ sendEvent := func(ev *fileEvent) sendResult {
+ select {
+ case events <- ev:
+ case _, ok := <-tick:
+ if ok {
+ return sendReload
+ }
+ return sendExit
+ }
+ return sendDone
+ }
+
+ sendError := func(err error) sendResult {
+ return sendEvent(&fileEvent{status: fileStatusError, err: err})
+ }
+
+ sendFileEvent := func(status fileStatus, path string, match []string) sendResult {
+ zid, err := id.Parse(match[1])
+ if err != nil {
+ return sendDone
+ }
+ event := &fileEvent{
+ status: status,
+ path: path,
+ zid: zid,
+ ext: match[3],
+ }
+ return sendEvent(event)
+ }
+
+ reloadStartEvent := &fileEvent{status: fileStatusReloadStart}
+ reloadEndEvent := &fileEvent{status: fileStatusReloadEnd}
+ reloadFiles := func() bool {
+ entries, err := os.ReadDir(directory)
+ if err != nil {
+ if res := sendError(err); res != sendDone {
+ return res == sendReload
+ }
+ return true
+ }
+
+ if res := sendEvent(reloadStartEvent); res != sendDone {
+ return res == sendReload
+ }
+
+ if watcher != nil {
+ watcher.Close()
+ }
+ watcher, err = fsnotify.NewWatcher()
+ if err != nil {
+ if res := sendError(err); res != sendDone {
+ return res == sendReload
+ }
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ if info, err1 := entry.Info(); err1 != nil || !info.Mode().IsRegular() {
+ continue
+ }
+ name := entry.Name()
+ match := matchValidFileName(name)
+ if len(match) > 0 {
+ path := filepath.Join(directory, name)
+ if res := sendFileEvent(fileStatusUpdate, path, match); res != sendDone {
+ return res == sendReload
+ }
+ }
+ }
+
+ if watcher != nil {
+ err = watcher.Add(directory)
+ if err != nil {
+ if res := sendError(err); res != sendDone {
+ return res == sendReload
+ }
+ }
+ }
+ if res := sendEvent(reloadEndEvent); res != sendDone {
+ return res == sendReload
+ }
+ return true
+ }
+
+ handleEvents := func() bool {
+ const createOps = fsnotify.Create | fsnotify.Write
+ const deleteOps = fsnotify.Remove | fsnotify.Rename
+
+ for {
+ select {
+ case wevent, ok := <-watcher.Events:
+ if !ok {
+ return false
+ }
+ path := filepath.Clean(wevent.Name)
+ match := matchValidFileName(filepath.Base(path))
+ if len(match) == 0 {
+ continue
+ }
+ if wevent.Op&createOps != 0 {
+ if fi, err := os.Lstat(path); err != nil || !fi.Mode().IsRegular() {
+ continue
+ }
+ if res := sendFileEvent(
+ fileStatusUpdate, path, match); res != sendDone {
+ return res == sendReload
+ }
+ }
+ if wevent.Op&deleteOps != 0 {
+ if res := sendFileEvent(
+ fileStatusDelete, path, match); res != sendDone {
+ return res == sendReload
+ }
+ }
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ return false
+ }
+ if res := sendError(err); res != sendDone {
+ return res == sendReload
+ }
+ case _, ok := <-tick:
+ return ok
+ }
+ }
+ }
+
+ for {
+ if !reloadFiles() {
+ return
+ }
+ if watcher == nil {
+ if _, ok := <-tick; !ok {
+ return
+ }
+ } else {
+ if !handleEvents() {
+ return
+ }
+ }
+ }
+}
+
+func sendCollectedEvents(out chan<- *fileEvent, events []*fileEvent) {
+ for _, ev := range events {
+ if ev.status != fileStatusNone {
+ out <- ev
+ }
+ }
+}
+
+func addEvent(events []*fileEvent, ev *fileEvent) []*fileEvent {
+ switch ev.status {
+ case fileStatusNone:
+ return events
+ case fileStatusReloadStart:
+ events = events[0:0]
+ case fileStatusUpdate, fileStatusDelete:
+ if len(events) > 0 && mergeEvents(events, ev) {
+ return events
+ }
+ }
+ return append(events, ev)
+}
+
+func mergeEvents(events []*fileEvent, ev *fileEvent) bool {
+ for i := len(events) - 1; i >= 0; i-- {
+ oev := events[i]
+ switch oev.status {
+ case fileStatusReloadStart, fileStatusReloadEnd:
+ return false
+ case fileStatusUpdate, fileStatusDelete:
+ if ev.path == oev.path {
+ if ev.status == oev.status {
+ return true
+ }
+ oev.status = fileStatusNone
+ return false
+ }
+ }
+ }
+ return false
+}
+
+func collectEvents(out chan<- *fileEvent, in <-chan *fileEvent) {
+ defer close(out)
+
+ var sendTime time.Time
+ sendTimeSet := false
+ ticker := time.NewTicker(500 * time.Millisecond)
+ defer ticker.Stop()
+
+ events := make([]*fileEvent, 0, 32)
+ buffer := false
+ for {
+ select {
+ case ev, ok := <-in:
+ if !ok {
+ sendCollectedEvents(out, events)
+ return
+ }
+ if ev.status == fileStatusReloadStart {
+ buffer = false
+ events = events[0:0]
+ }
+ if buffer {
+ if !sendTimeSet {
+ sendTime = time.Now().Add(1500 * time.Millisecond)
+ sendTimeSet = true
+ }
+ events = addEvent(events, ev)
+ if len(events) > 1024 {
+ sendCollectedEvents(out, events)
+ events = events[0:0]
+ sendTimeSet = false
+ }
+ continue
+ }
+ out <- ev
+ if ev.status == fileStatusReloadEnd {
+ buffer = true
+ }
+ case now := <-ticker.C:
+ if sendTimeSet && now.After(sendTime) {
+ sendCollectedEvents(out, events)
+ events = events[0:0]
+ sendTimeSet = false
+ }
+ }
+ }
+}
ADDED box/dirbox/notifydir/watch_test.go
Index: box/dirbox/notifydir/watch_test.go
==================================================================
--- box/dirbox/notifydir/watch_test.go
+++ box/dirbox/notifydir/watch_test.go
@@ -0,0 +1,56 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package notifydir manages the notified directory part of a dirstore.
+package notifydir
+
+import "testing"
+
+func sameStringSlices(sl1, sl2 []string) bool {
+ if len(sl1) != len(sl2) {
+ return false
+ }
+ for i := 0; i < len(sl1); i++ {
+ if sl1[i] != sl2[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func TestMatchValidFileName(t *testing.T) {
+ t.Parallel()
+ testcases := []struct {
+ name string
+ exp []string
+ }{
+ {"", []string{}},
+ {".txt", []string{}},
+ {"12345678901234.txt", []string{"12345678901234", ".txt", "txt"}},
+ {"12345678901234abc.txt", []string{"12345678901234", ".txt", "txt"}},
+ {"12345678901234.abc.txt", []string{"12345678901234", ".txt", "txt"}},
+ }
+
+ for i, tc := range testcases {
+ got := matchValidFileName(tc.name)
+ if len(got) == 0 {
+ if len(tc.exp) > 0 {
+ t.Errorf("TC=%d, name=%q, exp=%v, got=%v", i, tc.name, tc.exp, got)
+ }
+ } else {
+ if got[0] != tc.name {
+ t.Errorf("TC=%d, name=%q, got=%v", i, tc.name, got)
+ }
+ if !sameStringSlices(got[1:], tc.exp) {
+ t.Errorf("TC=%d, name=%q, exp=%v, got=%v", i, tc.name, tc.exp, got)
+ }
+ }
+ }
+}
ADDED box/dirbox/service.go
Index: box/dirbox/service.go
==================================================================
--- box/dirbox/service.go
+++ box/dirbox/service.go
@@ -0,0 +1,290 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package dirbox provides a directory-based zettel box.
+package dirbox
+
+import (
+ "os"
+
+ "zettelstore.de/z/box/dirbox/directory"
+ "zettelstore.de/z/box/filebox"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/input"
+)
+
+func fileService(num uint32, cmds <-chan fileCmd) {
+ for cmd := range cmds {
+ cmd.run()
+ }
+}
+
+type fileCmd interface {
+ run()
+}
+
+// COMMAND: getMeta ----------------------------------------
+//
+// Retrieves the meta data from a zettel.
+
+func getMeta(dp *dirBox, entry *directory.Entry, zid id.Zid) (*meta.Meta, error) {
+ rc := make(chan resGetMeta)
+ dp.getFileChan(zid) <- &fileGetMeta{entry, rc}
+ res := <-rc
+ close(rc)
+ return res.meta, res.err
+}
+
+type fileGetMeta struct {
+ entry *directory.Entry
+ rc chan<- resGetMeta
+}
+type resGetMeta struct {
+ meta *meta.Meta
+ err error
+}
+
+func (cmd *fileGetMeta) run() {
+ entry := cmd.entry
+ var m *meta.Meta
+ var err error
+ switch entry.MetaSpec {
+ case directory.MetaSpecFile:
+ m, err = parseMetaFile(entry.Zid, entry.MetaPath)
+ case directory.MetaSpecHeader:
+ m, _, err = parseMetaContentFile(entry.Zid, entry.ContentPath)
+ default:
+ m = filebox.CalcDefaultMeta(entry.Zid, entry.ContentExt)
+ }
+ if err == nil {
+ cmdCleanupMeta(m, entry)
+ }
+ cmd.rc <- resGetMeta{m, err}
+}
+
+// COMMAND: getMetaContent ----------------------------------------
+//
+// Retrieves the meta data and the content of a zettel.
+
+func getMetaContent(dp *dirBox, entry *directory.Entry, zid id.Zid) (*meta.Meta, string, error) {
+ rc := make(chan resGetMetaContent)
+ dp.getFileChan(zid) <- &fileGetMetaContent{entry, rc}
+ res := <-rc
+ close(rc)
+ return res.meta, res.content, res.err
+}
+
+type fileGetMetaContent struct {
+ entry *directory.Entry
+ rc chan<- resGetMetaContent
+}
+type resGetMetaContent struct {
+ meta *meta.Meta
+ content string
+ err error
+}
+
+func (cmd *fileGetMetaContent) run() {
+ var m *meta.Meta
+ var content string
+ var err error
+
+ entry := cmd.entry
+ switch entry.MetaSpec {
+ case directory.MetaSpecFile:
+ m, err = parseMetaFile(entry.Zid, entry.MetaPath)
+ if err == nil {
+ content, err = readFileContent(entry.ContentPath)
+ }
+ case directory.MetaSpecHeader:
+ m, content, err = parseMetaContentFile(entry.Zid, entry.ContentPath)
+ default:
+ m = filebox.CalcDefaultMeta(entry.Zid, entry.ContentExt)
+ content, err = readFileContent(entry.ContentPath)
+ }
+ if err == nil {
+ cmdCleanupMeta(m, entry)
+ }
+ cmd.rc <- resGetMetaContent{m, content, err}
+}
+
+// COMMAND: setZettel ----------------------------------------
+//
+// Writes a new or exsting zettel.
+
+func setZettel(dp *dirBox, entry *directory.Entry, zettel domain.Zettel) error {
+ rc := make(chan resSetZettel)
+ dp.getFileChan(zettel.Meta.Zid) <- &fileSetZettel{entry, zettel, rc}
+ err := <-rc
+ close(rc)
+ return err
+}
+
+type fileSetZettel struct {
+ entry *directory.Entry
+ zettel domain.Zettel
+ rc chan<- resSetZettel
+}
+type resSetZettel = error
+
+func (cmd *fileSetZettel) run() {
+ var err error
+ switch cmd.entry.MetaSpec {
+ case directory.MetaSpecFile:
+ err = cmd.runMetaSpecFile()
+ case directory.MetaSpecHeader:
+ err = cmd.runMetaSpecHeader()
+ case directory.MetaSpecNone:
+ // TODO: if meta has some additional infos: write meta to new .meta;
+ // update entry in dir
+ err = writeFileContent(cmd.entry.ContentPath, cmd.zettel.Content.AsString())
+ default:
+ panic("TODO: ???")
+ }
+ cmd.rc <- err
+}
+
+func (cmd *fileSetZettel) runMetaSpecFile() error {
+ f, err := openFileWrite(cmd.entry.MetaPath)
+ if err == nil {
+ err = writeFileZid(f, cmd.zettel.Meta.Zid)
+ if err == nil {
+ _, err = cmd.zettel.Meta.Write(f, true)
+ if err1 := f.Close(); err == nil {
+ err = err1
+ }
+ if err == nil {
+ err = writeFileContent(cmd.entry.ContentPath, cmd.zettel.Content.AsString())
+ }
+ }
+ }
+ return err
+}
+
+func (cmd *fileSetZettel) runMetaSpecHeader() error {
+ f, err := openFileWrite(cmd.entry.ContentPath)
+ if err == nil {
+ err = writeFileZid(f, cmd.zettel.Meta.Zid)
+ if err == nil {
+ _, err = cmd.zettel.Meta.WriteAsHeader(f, true)
+ if err == nil {
+ _, err = f.WriteString(cmd.zettel.Content.AsString())
+ if err1 := f.Close(); err == nil {
+ err = err1
+ }
+ }
+ }
+ }
+ return err
+}
+
+// COMMAND: deleteZettel ----------------------------------------
+//
+// Deletes an existing zettel.
+
+func deleteZettel(dp *dirBox, entry *directory.Entry, zid id.Zid) error {
+ rc := make(chan resDeleteZettel)
+ dp.getFileChan(zid) <- &fileDeleteZettel{entry, rc}
+ err := <-rc
+ close(rc)
+ return err
+}
+
+type fileDeleteZettel struct {
+ entry *directory.Entry
+ rc chan<- resDeleteZettel
+}
+type resDeleteZettel = error
+
+func (cmd *fileDeleteZettel) run() {
+ var err error
+
+ switch cmd.entry.MetaSpec {
+ case directory.MetaSpecFile:
+ err1 := os.Remove(cmd.entry.MetaPath)
+ err = os.Remove(cmd.entry.ContentPath)
+ if err == nil {
+ err = err1
+ }
+ case directory.MetaSpecHeader:
+ err = os.Remove(cmd.entry.ContentPath)
+ case directory.MetaSpecNone:
+ err = os.Remove(cmd.entry.ContentPath)
+ default:
+ panic("TODO: ???")
+ }
+ cmd.rc <- err
+}
+
+// Utility functions ----------------------------------------
+
+func readFileContent(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func parseMetaFile(zid id.Zid, path string) (*meta.Meta, error) {
+ src, err := readFileContent(path)
+ if err != nil {
+ return nil, err
+ }
+ inp := input.NewInput(src)
+ return meta.NewFromInput(zid, inp), nil
+}
+
+func parseMetaContentFile(zid id.Zid, path string) (*meta.Meta, string, error) {
+ src, err := readFileContent(path)
+ if err != nil {
+ return nil, "", err
+ }
+ inp := input.NewInput(src)
+ meta := meta.NewFromInput(zid, inp)
+ return meta, src[inp.Pos:], nil
+}
+
+func cmdCleanupMeta(m *meta.Meta, entry *directory.Entry) {
+ filebox.CleanupMeta(
+ m,
+ entry.Zid, entry.ContentExt,
+ entry.MetaSpec == directory.MetaSpecFile,
+ entry.Duplicates,
+ )
+}
+
+func openFileWrite(path string) (*os.File, error) {
+ return os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
+}
+
+func writeFileZid(f *os.File, zid id.Zid) error {
+ _, err := f.WriteString("id: ")
+ if err == nil {
+ _, err = f.Write(zid.Bytes())
+ if err == nil {
+ _, err = f.WriteString("\n")
+ }
+ }
+ return err
+}
+
+func writeFileContent(path, content string) error {
+ f, err := openFileWrite(path)
+ if err == nil {
+ _, err = f.WriteString(content)
+ if err1 := f.Close(); err == nil {
+ err = err1
+ }
+ }
+ return err
+}
ADDED box/dirbox/simpledir/simpledir.go
Index: box/dirbox/simpledir/simpledir.go
==================================================================
--- box/dirbox/simpledir/simpledir.go
+++ box/dirbox/simpledir/simpledir.go
@@ -0,0 +1,185 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package simpledir manages the directory part of a dirstore.
+package simpledir
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "sync"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/dirbox/directory"
+ "zettelstore.de/z/domain/id"
+)
+
+// simpleService specifies a directory service without scanning.
+type simpleService struct {
+ dirPath string
+ mx sync.Mutex
+}
+
+// NewService creates a new directory service.
+func NewService(directoryPath string) directory.Service {
+ return &simpleService{
+ dirPath: directoryPath,
+ }
+}
+
+func (ss *simpleService) Start() error {
+ ss.mx.Lock()
+ defer ss.mx.Unlock()
+ _, err := os.ReadDir(ss.dirPath)
+ return err
+}
+
+func (ss *simpleService) Stop() error {
+ return nil
+}
+
+func (ss *simpleService) NumEntries() (int, error) {
+ ss.mx.Lock()
+ defer ss.mx.Unlock()
+ entries, err := ss.getEntries()
+ if err == nil {
+ return len(entries), nil
+ }
+ return 0, err
+}
+
+func (ss *simpleService) GetEntries() ([]*directory.Entry, error) {
+ ss.mx.Lock()
+ defer ss.mx.Unlock()
+ entrySet, err := ss.getEntries()
+ if err != nil {
+ return nil, err
+ }
+ result := make([]*directory.Entry, 0, len(entrySet))
+ for _, entry := range entrySet {
+ result = append(result, entry)
+ }
+ return result, nil
+}
+func (ss *simpleService) getEntries() (map[id.Zid]*directory.Entry, error) {
+ dirEntries, err := os.ReadDir(ss.dirPath)
+ if err != nil {
+ return nil, err
+ }
+ entrySet := make(map[id.Zid]*directory.Entry)
+ for _, dirEntry := range dirEntries {
+ if dirEntry.IsDir() {
+ continue
+ }
+ if info, err1 := dirEntry.Info(); err1 != nil || !info.Mode().IsRegular() {
+ continue
+ }
+ name := dirEntry.Name()
+ match := matchValidFileName(name)
+ if len(match) == 0 {
+ continue
+ }
+ zid, err := id.Parse(match[1])
+ if err != nil {
+ continue
+ }
+ var entry *directory.Entry
+ if e, ok := entrySet[zid]; ok {
+ entry = e
+ } else {
+ entry = &directory.Entry{Zid: zid}
+ entrySet[zid] = entry
+ }
+ updateEntry(entry, filepath.Join(ss.dirPath, name), match[3])
+ }
+ return entrySet, nil
+}
+
+var validFileName = regexp.MustCompile(`^(\d{14}).*(\.(.+))$`)
+
+func matchValidFileName(name string) []string {
+ return validFileName.FindStringSubmatch(name)
+}
+
+func updateEntry(entry *directory.Entry, path, ext string) {
+ if ext == "meta" {
+ entry.MetaSpec = directory.MetaSpecFile
+ entry.MetaPath = path
+ } else if entry.ContentExt != "" && entry.ContentExt != ext {
+ entry.Duplicates = true
+ } else {
+ if entry.MetaSpec != directory.MetaSpecFile {
+ if ext == "zettel" {
+ entry.MetaSpec = directory.MetaSpecHeader
+ } else {
+ entry.MetaSpec = directory.MetaSpecNone
+ }
+ }
+ entry.ContentPath = path
+ entry.ContentExt = ext
+ }
+}
+
+func (ss *simpleService) GetEntry(zid id.Zid) (*directory.Entry, error) {
+ ss.mx.Lock()
+ defer ss.mx.Unlock()
+ return ss.getEntry(zid)
+}
+func (ss *simpleService) getEntry(zid id.Zid) (*directory.Entry, error) {
+ pattern := filepath.Join(ss.dirPath, zid.String()) + "*.*"
+ paths, err := filepath.Glob(pattern)
+ if err != nil {
+ return nil, err
+ }
+ if len(paths) == 0 {
+ return nil, nil
+ }
+ entry := &directory.Entry{Zid: zid}
+ for _, path := range paths {
+ ext := filepath.Ext(path)
+ if len(ext) > 0 && ext[0] == '.' {
+ ext = ext[1:]
+ }
+ updateEntry(entry, path, ext)
+ }
+ return entry, nil
+}
+
+func (ss *simpleService) GetNew() (*directory.Entry, error) {
+ ss.mx.Lock()
+ defer ss.mx.Unlock()
+ zid, err := box.GetNewZid(func(zid id.Zid) (bool, error) {
+ entry, err := ss.getEntry(zid)
+ if err != nil {
+ return false, nil
+ }
+ return !entry.IsValid(), nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &directory.Entry{Zid: zid}, nil
+}
+
+func (ss *simpleService) UpdateEntry(entry *directory.Entry) error {
+ // Nothing to to, since the actual file update is done by dirbox.
+ return nil
+}
+
+func (ss *simpleService) RenameEntry(curEntry, newEntry *directory.Entry) error {
+ // Nothing to to, since the actual file rename is done by dirbox.
+ return nil
+}
+
+func (ss *simpleService) DeleteEntry(zid id.Zid) error {
+ // Nothing to to, since the actual file delete is done by dirbox.
+ return nil
+}
ADDED box/filebox/filebox.go
Index: box/filebox/filebox.go
==================================================================
--- box/filebox/filebox.go
+++ box/filebox/filebox.go
@@ -0,0 +1,94 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package filebox provides boxes that are stored in a file.
+package filebox
+
+import (
+ "errors"
+ "net/url"
+ "path/filepath"
+ "strings"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/manager"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+)
+
+func init() {
+ manager.Register("file", func(u *url.URL, cdata *manager.ConnectData) (box.ManagedBox, error) {
+ path := getFilepathFromURL(u)
+ ext := strings.ToLower(filepath.Ext(path))
+ if ext != ".zip" {
+ return nil, errors.New("unknown extension '" + ext + "' in box URL: " + u.String())
+ }
+ return &zipBox{
+ number: cdata.Number,
+ name: path,
+ enricher: cdata.Enricher,
+ }, nil
+ })
+}
+
+func getFilepathFromURL(u *url.URL) string {
+ name := u.Opaque
+ if name == "" {
+ name = u.Path
+ }
+ components := strings.Split(name, "/")
+ fileName := filepath.Join(components...)
+ if len(components) > 0 && components[0] == "" {
+ return "/" + fileName
+ }
+ return fileName
+}
+
+var alternativeSyntax = map[string]string{
+ "htm": "html",
+}
+
+func calculateSyntax(ext string) string {
+ ext = strings.ToLower(ext)
+ if syntax, ok := alternativeSyntax[ext]; ok {
+ return syntax
+ }
+ return ext
+}
+
+// CalcDefaultMeta returns metadata with default values for the given entry.
+func CalcDefaultMeta(zid id.Zid, ext string) *meta.Meta {
+ m := meta.New(zid)
+ m.Set(meta.KeyTitle, zid.String())
+ m.Set(meta.KeySyntax, calculateSyntax(ext))
+ return m
+}
+
+// CleanupMeta enhances the given metadata.
+func CleanupMeta(m *meta.Meta, zid id.Zid, ext string, inMeta, duplicates bool) {
+ if title, ok := m.Get(meta.KeyTitle); !ok || title == "" {
+ m.Set(meta.KeyTitle, zid.String())
+ }
+
+ if inMeta {
+ if syntax, ok := m.Get(meta.KeySyntax); !ok || syntax == "" {
+ dm := CalcDefaultMeta(zid, ext)
+ syntax, ok = dm.Get(meta.KeySyntax)
+ if !ok {
+ panic("Default meta must contain syntax")
+ }
+ m.Set(meta.KeySyntax, syntax)
+ }
+ }
+
+ if duplicates {
+ m.Set(meta.KeyDuplicates, meta.ValueTrue)
+ }
+}
ADDED box/filebox/zipbox.go
Index: box/filebox/zipbox.go
==================================================================
--- box/filebox/zipbox.go
+++ box/filebox/zipbox.go
@@ -0,0 +1,261 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package filebox provides boxes that are stored in a file.
+package filebox
+
+import (
+ "archive/zip"
+ "context"
+ "io"
+ "regexp"
+ "strings"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/input"
+ "zettelstore.de/z/search"
+)
+
+var validFileName = regexp.MustCompile(`^(\d{14}).*(\.(.+))$`)
+
+func matchValidFileName(name string) []string {
+ return validFileName.FindStringSubmatch(name)
+}
+
+type zipEntry struct {
+ metaName string
+ contentName string
+ contentExt string // (normalized) file extension of zettel content
+ metaInHeader bool
+}
+
+type zipBox struct {
+ number int
+ name string
+ enricher box.Enricher
+ zettel map[id.Zid]*zipEntry // no lock needed, because read-only after creation
+}
+
+func (zp *zipBox) Location() string {
+ if strings.HasPrefix(zp.name, "/") {
+ return "file://" + zp.name
+ }
+ return "file:" + zp.name
+}
+
+func (zp *zipBox) Start(ctx context.Context) error {
+ reader, err := zip.OpenReader(zp.name)
+ if err != nil {
+ return err
+ }
+ defer reader.Close()
+ zp.zettel = make(map[id.Zid]*zipEntry)
+ for _, f := range reader.File {
+ match := matchValidFileName(f.Name)
+ if len(match) < 1 {
+ continue
+ }
+ zid, err := id.Parse(match[1])
+ if err != nil {
+ continue
+ }
+ zp.addFile(zid, f.Name, match[3])
+ }
+ return nil
+}
+
+func (zp *zipBox) addFile(zid id.Zid, name, ext string) {
+ entry := zp.zettel[zid]
+ if entry == nil {
+ entry = &zipEntry{}
+ zp.zettel[zid] = entry
+ }
+ switch ext {
+ case "zettel":
+ if entry.contentExt == "" {
+ entry.contentName = name
+ entry.contentExt = ext
+ entry.metaInHeader = true
+ }
+ case "meta":
+ entry.metaName = name
+ entry.metaInHeader = false
+ default:
+ if entry.contentExt == "" {
+ entry.contentExt = ext
+ entry.contentName = name
+ }
+ }
+}
+
+func (zp *zipBox) Stop(ctx context.Context) error {
+ zp.zettel = nil
+ return nil
+}
+
+func (zp *zipBox) CanCreateZettel(ctx context.Context) bool { return false }
+
+func (zp *zipBox) CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error) {
+ return id.Invalid, box.ErrReadOnly
+}
+
+func (zp *zipBox) GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error) {
+ entry, ok := zp.zettel[zid]
+ if !ok {
+ return domain.Zettel{}, box.ErrNotFound
+ }
+ reader, err := zip.OpenReader(zp.name)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ defer reader.Close()
+
+ var m *meta.Meta
+ var src string
+ var inMeta bool
+ if entry.metaInHeader {
+ src, err = readZipFileContent(reader, entry.contentName)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ inp := input.NewInput(src)
+ m = meta.NewFromInput(zid, inp)
+ src = src[inp.Pos:]
+ } else if metaName := entry.metaName; metaName != "" {
+ m, err = readZipMetaFile(reader, zid, metaName)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ src, err = readZipFileContent(reader, entry.contentName)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ inMeta = true
+ } else {
+ m = CalcDefaultMeta(zid, entry.contentExt)
+ }
+ CleanupMeta(m, zid, entry.contentExt, inMeta, false)
+ return domain.Zettel{Meta: m, Content: domain.NewContent(src)}, nil
+}
+
+func (zp *zipBox) GetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error) {
+ entry, ok := zp.zettel[zid]
+ if !ok {
+ return nil, box.ErrNotFound
+ }
+ reader, err := zip.OpenReader(zp.name)
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+ return readZipMeta(reader, zid, entry)
+}
+
+func (zp *zipBox) FetchZids(ctx context.Context) (id.Set, error) {
+ result := id.NewSetCap(len(zp.zettel))
+ for zid := range zp.zettel {
+ result[zid] = true
+ }
+ return result, nil
+}
+
+func (zp *zipBox) SelectMeta(ctx context.Context, match search.MetaMatchFunc) (res []*meta.Meta, err error) {
+ reader, err := zip.OpenReader(zp.name)
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+ for zid, entry := range zp.zettel {
+ m, err := readZipMeta(reader, zid, entry)
+ if err != nil {
+ continue
+ }
+ zp.enricher.Enrich(ctx, m, zp.number)
+ if match(m) {
+ res = append(res, m)
+ }
+ }
+ return res, nil
+}
+
+func (zp *zipBox) CanUpdateZettel(ctx context.Context, zettel domain.Zettel) bool {
+ return false
+}
+
+func (zp *zipBox) UpdateZettel(ctx context.Context, zettel domain.Zettel) error {
+ return box.ErrReadOnly
+}
+
+func (zp *zipBox) AllowRenameZettel(ctx context.Context, zid id.Zid) bool {
+ _, ok := zp.zettel[zid]
+ return !ok
+}
+
+func (zp *zipBox) RenameZettel(ctx context.Context, curZid, newZid id.Zid) error {
+ if _, ok := zp.zettel[curZid]; ok {
+ return box.ErrReadOnly
+ }
+ return box.ErrNotFound
+}
+
+func (zp *zipBox) CanDeleteZettel(ctx context.Context, zid id.Zid) bool { return false }
+
+func (zp *zipBox) DeleteZettel(ctx context.Context, zid id.Zid) error {
+ if _, ok := zp.zettel[zid]; ok {
+ return box.ErrReadOnly
+ }
+ return box.ErrNotFound
+}
+
+func (zp *zipBox) ReadStats(st *box.ManagedBoxStats) {
+ st.ReadOnly = true
+ st.Zettel = len(zp.zettel)
+}
+
+func readZipMeta(reader *zip.ReadCloser, zid id.Zid, entry *zipEntry) (m *meta.Meta, err error) {
+ var inMeta bool
+ if entry.metaInHeader {
+ m, err = readZipMetaFile(reader, zid, entry.contentName)
+ } else if metaName := entry.metaName; metaName != "" {
+ m, err = readZipMetaFile(reader, zid, entry.metaName)
+ inMeta = true
+ } else {
+ m = CalcDefaultMeta(zid, entry.contentExt)
+ }
+ if err == nil {
+ CleanupMeta(m, zid, entry.contentExt, inMeta, false)
+ }
+ return m, err
+}
+
+func readZipMetaFile(reader *zip.ReadCloser, zid id.Zid, name string) (*meta.Meta, error) {
+ src, err := readZipFileContent(reader, name)
+ if err != nil {
+ return nil, err
+ }
+ inp := input.NewInput(src)
+ return meta.NewFromInput(zid, inp), nil
+}
+
+func readZipFileContent(reader *zip.ReadCloser, name string) (string, error) {
+ f, err := reader.Open(name)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ buf, err := io.ReadAll(f)
+ if err != nil {
+ return "", err
+ }
+ return string(buf), nil
+}
ADDED box/helper.go
Index: box/helper.go
==================================================================
--- box/helper.go
+++ box/helper.go
@@ -0,0 +1,37 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package box provides a generic interface to zettel boxes.
+package box
+
+import (
+ "time"
+
+ "zettelstore.de/z/domain/id"
+)
+
+// GetNewZid calculates a new and unused zettel identifier, based on the current date and time.
+func GetNewZid(testZid func(id.Zid) (bool, error)) (id.Zid, error) {
+ withSeconds := false
+ for i := 0; i < 90; i++ { // Must be completed within 9 seconds (less than web/server.writeTimeout)
+ zid := id.New(withSeconds)
+ found, err := testZid(zid)
+ if err != nil {
+ return id.Invalid, err
+ }
+ if found {
+ return zid, nil
+ }
+ // TODO: do not wait here unconditionally.
+ time.Sleep(100 * time.Millisecond)
+ withSeconds = true
+ }
+ return id.Invalid, ErrConflict
+}
ADDED box/manager/anteroom.go
Index: box/manager/anteroom.go
==================================================================
--- box/manager/anteroom.go
+++ box/manager/anteroom.go
@@ -0,0 +1,165 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package manager coordinates the various boxes and indexes of a Zettelstore.
+package manager
+
+import (
+ "sync"
+
+ "zettelstore.de/z/domain/id"
+)
+
+type arAction int
+
+const (
+ arNothing arAction = iota
+ arReload
+ arUpdate
+ arDelete
+)
+
+type anteroom struct {
+ num uint64
+ next *anteroom
+ waiting map[id.Zid]arAction
+ curLoad int
+ reload bool
+}
+
+type anterooms struct {
+ mx sync.Mutex
+ nextNum uint64
+ first *anteroom
+ last *anteroom
+ maxLoad int
+}
+
+func newAnterooms(maxLoad int) *anterooms {
+ return &anterooms{maxLoad: maxLoad}
+}
+
+func (ar *anterooms) Enqueue(zid id.Zid, action arAction) {
+ if !zid.IsValid() || action == arNothing || action == arReload {
+ return
+ }
+ ar.mx.Lock()
+ defer ar.mx.Unlock()
+ if ar.first == nil {
+ ar.first = ar.makeAnteroom(zid, action)
+ ar.last = ar.first
+ return
+ }
+ for room := ar.first; room != nil; room = room.next {
+ if room.reload {
+ continue // Do not put zettel in reload room
+ }
+ a, ok := room.waiting[zid]
+ if !ok {
+ continue
+ }
+ switch action {
+ case a:
+ return
+ case arUpdate:
+ room.waiting[zid] = action
+ case arDelete:
+ room.waiting[zid] = action
+ }
+ return
+ }
+ if room := ar.last; !room.reload && (ar.maxLoad == 0 || room.curLoad < ar.maxLoad) {
+ room.waiting[zid] = action
+ room.curLoad++
+ return
+ }
+ room := ar.makeAnteroom(zid, action)
+ ar.last.next = room
+ ar.last = room
+}
+
+func (ar *anterooms) makeAnteroom(zid id.Zid, action arAction) *anteroom {
+ c := ar.maxLoad
+ if c == 0 {
+ c = 100
+ }
+ waiting := make(map[id.Zid]arAction, c)
+ waiting[zid] = action
+ ar.nextNum++
+ return &anteroom{num: ar.nextNum, next: nil, waiting: waiting, curLoad: 1, reload: false}
+}
+
+func (ar *anterooms) Reset() {
+ ar.mx.Lock()
+ defer ar.mx.Unlock()
+ ar.first = ar.makeAnteroom(id.Invalid, arReload)
+ ar.last = ar.first
+}
+
+func (ar *anterooms) Reload(newZids id.Set) uint64 {
+ ar.mx.Lock()
+ defer ar.mx.Unlock()
+ newWaiting := createWaitingSet(newZids, arUpdate)
+ ar.deleteReloadedRooms()
+
+ if ns := len(newWaiting); ns > 0 {
+ ar.nextNum++
+ ar.first = &anteroom{num: ar.nextNum, next: ar.first, waiting: newWaiting, curLoad: ns}
+ if ar.first.next == nil {
+ ar.last = ar.first
+ }
+ return ar.nextNum
+ }
+
+ ar.first = nil
+ ar.last = nil
+ return 0
+}
+
+func createWaitingSet(zids id.Set, action arAction) map[id.Zid]arAction {
+ waitingSet := make(map[id.Zid]arAction, len(zids))
+ for zid := range zids {
+ if zid.IsValid() {
+ waitingSet[zid] = action
+ }
+ }
+ return waitingSet
+}
+
+func (ar *anterooms) deleteReloadedRooms() {
+ room := ar.first
+ for room != nil && room.reload {
+ room = room.next
+ }
+ ar.first = room
+ if room == nil {
+ ar.last = nil
+ }
+}
+
+func (ar *anterooms) Dequeue() (arAction, id.Zid, uint64) {
+ ar.mx.Lock()
+ defer ar.mx.Unlock()
+ if ar.first == nil {
+ return arNothing, id.Invalid, 0
+ }
+ for zid, action := range ar.first.waiting {
+ roomNo := ar.first.num
+ delete(ar.first.waiting, zid)
+ if len(ar.first.waiting) == 0 {
+ ar.first = ar.first.next
+ if ar.first == nil {
+ ar.last = nil
+ }
+ }
+ return action, zid, roomNo
+ }
+ return arNothing, id.Invalid, 0
+}
ADDED box/manager/anteroom_test.go
Index: box/manager/anteroom_test.go
==================================================================
--- box/manager/anteroom_test.go
+++ box/manager/anteroom_test.go
@@ -0,0 +1,109 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package manager coordinates the various boxes and indexes of a Zettelstore.
+package manager
+
+import (
+ "testing"
+
+ "zettelstore.de/z/domain/id"
+)
+
+func TestSimple(t *testing.T) {
+ t.Parallel()
+ ar := newAnterooms(2)
+ ar.Enqueue(id.Zid(1), arUpdate)
+ action, zid, rno := ar.Dequeue()
+ if zid != id.Zid(1) || action != arUpdate || rno != 1 {
+ t.Errorf("Expected arUpdate/1/1, but got %v/%v/%v", action, zid, rno)
+ }
+ action, zid, _ = ar.Dequeue()
+ if zid != id.Invalid && action != arDelete {
+ t.Errorf("Expected invalid Zid, but got %v", zid)
+ }
+ ar.Enqueue(id.Zid(1), arUpdate)
+ ar.Enqueue(id.Zid(2), arUpdate)
+ if ar.first != ar.last {
+ t.Errorf("Expected one room, but got more")
+ }
+ ar.Enqueue(id.Zid(3), arUpdate)
+ if ar.first == ar.last {
+ t.Errorf("Expected more than one room, but got only one")
+ }
+
+ count := 0
+ for ; count < 1000; count++ {
+ action, _, _ := ar.Dequeue()
+ if action == arNothing {
+ break
+ }
+ }
+ if count != 3 {
+ t.Errorf("Expected 3 dequeues, but got %v", count)
+ }
+}
+
+func TestReset(t *testing.T) {
+ t.Parallel()
+ ar := newAnterooms(1)
+ ar.Enqueue(id.Zid(1), arUpdate)
+ ar.Reset()
+ action, zid, _ := ar.Dequeue()
+ if action != arReload || zid != id.Invalid {
+ t.Errorf("Expected reload & invalid Zid, but got %v/%v", action, zid)
+ }
+ ar.Reload(id.NewSet(3, 4))
+ ar.Enqueue(id.Zid(5), arUpdate)
+ ar.Enqueue(id.Zid(5), arDelete)
+ ar.Enqueue(id.Zid(5), arDelete)
+ ar.Enqueue(id.Zid(5), arUpdate)
+ if ar.first == ar.last || ar.first.next != ar.last /*|| ar.first.next.next != ar.last*/ {
+ t.Errorf("Expected 2 rooms")
+ }
+ action, zid1, _ := ar.Dequeue()
+ if action != arUpdate {
+ t.Errorf("Expected arUpdate, but got %v", action)
+ }
+ action, zid2, _ := ar.Dequeue()
+ if action != arUpdate {
+ t.Errorf("Expected arUpdate, but got %v", action)
+ }
+ if !(zid1 == id.Zid(3) && zid2 == id.Zid(4) || zid1 == id.Zid(4) && zid2 == id.Zid(3)) {
+ t.Errorf("Zids must be 3 or 4, but got %v/%v", zid1, zid2)
+ }
+ action, zid, _ = ar.Dequeue()
+ if zid != id.Zid(5) || action != arUpdate {
+ t.Errorf("Expected 5/arUpdate, but got %v/%v", zid, action)
+ }
+ action, zid, _ = ar.Dequeue()
+ if action != arNothing || zid != id.Invalid {
+ t.Errorf("Expected nothing & invalid Zid, but got %v/%v", action, zid)
+ }
+
+ ar = newAnterooms(1)
+ ar.Reload(id.NewSet(id.Zid(6)))
+ action, zid, _ = ar.Dequeue()
+ if zid != id.Zid(6) || action != arUpdate {
+ t.Errorf("Expected 6/arUpdate, but got %v/%v", zid, action)
+ }
+ action, zid, _ = ar.Dequeue()
+ if action != arNothing || zid != id.Invalid {
+ t.Errorf("Expected nothing & invalid Zid, but got %v/%v", action, zid)
+ }
+
+ ar = newAnterooms(1)
+ ar.Enqueue(id.Zid(8), arUpdate)
+ ar.Reload(nil)
+ action, zid, _ = ar.Dequeue()
+ if action != arNothing || zid != id.Invalid {
+ t.Errorf("Expected nothing & invalid Zid, but got %v/%v", action, zid)
+ }
+}
ADDED box/manager/box.go
Index: box/manager/box.go
==================================================================
--- box/manager/box.go
+++ box/manager/box.go
@@ -0,0 +1,277 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package manager coordinates the various boxes and indexes of a Zettelstore.
+package manager
+
+import (
+ "context"
+ "errors"
+ "sort"
+ "strings"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/search"
+)
+
+// Conatains all box.Box related functions
+
+// Location returns some information where the box is located.
+func (mgr *Manager) Location() string {
+ if len(mgr.boxes) <= 2 {
+ return "NONE"
+ }
+ var sb strings.Builder
+ for i := 0; i < len(mgr.boxes)-2; i++ {
+ if i > 0 {
+ sb.WriteString(", ")
+ }
+ sb.WriteString(mgr.boxes[i].Location())
+ }
+ return sb.String()
+}
+
+// CanCreateZettel returns true, if box could possibly create a new zettel.
+func (mgr *Manager) CanCreateZettel(ctx context.Context) bool {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ return mgr.started && mgr.boxes[0].CanCreateZettel(ctx)
+}
+
+// CreateZettel creates a new zettel.
+func (mgr *Manager) CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error) {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return id.Invalid, box.ErrStopped
+ }
+ return mgr.boxes[0].CreateZettel(ctx, zettel)
+}
+
+// GetZettel retrieves a specific zettel.
+func (mgr *Manager) GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error) {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return domain.Zettel{}, box.ErrStopped
+ }
+ for i, p := range mgr.boxes {
+ if z, err := p.GetZettel(ctx, zid); err != box.ErrNotFound {
+ if err == nil {
+ mgr.Enrich(ctx, z.Meta, i+1)
+ }
+ return z, err
+ }
+ }
+ return domain.Zettel{}, box.ErrNotFound
+}
+
+// GetAllZettel retrieves a specific zettel from all managed boxes.
+func (mgr *Manager) GetAllZettel(ctx context.Context, zid id.Zid) ([]domain.Zettel, error) {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return nil, box.ErrStopped
+ }
+ var result []domain.Zettel
+ for i, p := range mgr.boxes {
+ if z, err := p.GetZettel(ctx, zid); err == nil {
+ mgr.Enrich(ctx, z.Meta, i+1)
+ result = append(result, z)
+ }
+ }
+ return result, nil
+}
+
+// GetMeta retrieves just the meta data of a specific zettel.
+func (mgr *Manager) GetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error) {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return nil, box.ErrStopped
+ }
+ for i, p := range mgr.boxes {
+ if m, err := p.GetMeta(ctx, zid); err != box.ErrNotFound {
+ if err == nil {
+ mgr.Enrich(ctx, m, i+1)
+ }
+ return m, err
+ }
+ }
+ return nil, box.ErrNotFound
+}
+
+// GetAllMeta retrieves the meta data of a specific zettel from all managed boxes.
+func (mgr *Manager) GetAllMeta(ctx context.Context, zid id.Zid) ([]*meta.Meta, error) {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return nil, box.ErrStopped
+ }
+ var result []*meta.Meta
+ for i, p := range mgr.boxes {
+ if m, err := p.GetMeta(ctx, zid); err == nil {
+ mgr.Enrich(ctx, m, i+1)
+ result = append(result, m)
+ }
+ }
+ return result, nil
+}
+
+// FetchZids returns the set of all zettel identifer managed by the box.
+func (mgr *Manager) FetchZids(ctx context.Context) (result id.Set, err error) {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return nil, box.ErrStopped
+ }
+ for _, p := range mgr.boxes {
+ zids, err := p.FetchZids(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if result == nil {
+ result = zids
+ } else if len(result) <= len(zids) {
+ for zid := range result {
+ zids[zid] = true
+ }
+ result = zids
+ } else {
+ for zid := range zids {
+ result[zid] = true
+ }
+ }
+ }
+ return result, nil
+}
+
+// SelectMeta returns all zettel meta data that match the selection
+// criteria. The result is ordered by descending zettel id.
+func (mgr *Manager) SelectMeta(ctx context.Context, s *search.Search) ([]*meta.Meta, error) {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return nil, box.ErrStopped
+ }
+ var result []*meta.Meta
+ match := s.CompileMatch(mgr)
+ for _, p := range mgr.boxes {
+ selected, err := p.SelectMeta(ctx, match)
+ if err != nil {
+ return nil, err
+ }
+ sort.Slice(selected, func(i, j int) bool { return selected[i].Zid > selected[j].Zid })
+ if len(result) == 0 {
+ result = selected
+ } else {
+ result = box.MergeSorted(result, selected)
+ }
+ }
+ if s == nil {
+ return result, nil
+ }
+ return s.Sort(result), nil
+}
+
+// CanUpdateZettel returns true, if box could possibly update the given zettel.
+func (mgr *Manager) CanUpdateZettel(ctx context.Context, zettel domain.Zettel) bool {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ return mgr.started && mgr.boxes[0].CanUpdateZettel(ctx, zettel)
+}
+
+// UpdateZettel updates an existing zettel.
+func (mgr *Manager) UpdateZettel(ctx context.Context, zettel domain.Zettel) error {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return box.ErrStopped
+ }
+ // Remove all (computed) properties from metadata before storing the zettel.
+ zettel.Meta = zettel.Meta.Clone()
+ for _, p := range zettel.Meta.PairsRest(true) {
+ if mgr.propertyKeys[p.Key] {
+ zettel.Meta.Delete(p.Key)
+ }
+ }
+ return mgr.boxes[0].UpdateZettel(ctx, zettel)
+}
+
+// AllowRenameZettel returns true, if box will not disallow renaming the zettel.
+func (mgr *Manager) AllowRenameZettel(ctx context.Context, zid id.Zid) bool {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return false
+ }
+ for _, p := range mgr.boxes {
+ if !p.AllowRenameZettel(ctx, zid) {
+ return false
+ }
+ }
+ return true
+}
+
+// RenameZettel changes the current zid to a new zid.
+func (mgr *Manager) RenameZettel(ctx context.Context, curZid, newZid id.Zid) error {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return box.ErrStopped
+ }
+ for i, p := range mgr.boxes {
+ err := p.RenameZettel(ctx, curZid, newZid)
+ if err != nil && !errors.Is(err, box.ErrNotFound) {
+ for j := 0; j < i; j++ {
+ mgr.boxes[j].RenameZettel(ctx, newZid, curZid)
+ }
+ return err
+ }
+ }
+ return nil
+}
+
+// CanDeleteZettel returns true, if box could possibly delete the given zettel.
+func (mgr *Manager) CanDeleteZettel(ctx context.Context, zid id.Zid) bool {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return false
+ }
+ for _, p := range mgr.boxes {
+ if p.CanDeleteZettel(ctx, zid) {
+ return true
+ }
+ }
+ return false
+}
+
+// DeleteZettel removes the zettel from the box.
+func (mgr *Manager) DeleteZettel(ctx context.Context, zid id.Zid) error {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ if !mgr.started {
+ return box.ErrStopped
+ }
+ for _, p := range mgr.boxes {
+ err := p.DeleteZettel(ctx, zid)
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, box.ErrNotFound) && !errors.Is(err, box.ErrReadOnly) {
+ return err
+ }
+ }
+ return box.ErrNotFound
+}
ADDED box/manager/collect.go
Index: box/manager/collect.go
==================================================================
--- box/manager/collect.go
+++ box/manager/collect.go
@@ -0,0 +1,82 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package manager coordinates the various boxes and indexes of a Zettelstore.
+package manager
+
+import (
+ "strings"
+
+ "zettelstore.de/z/ast"
+ "zettelstore.de/z/box/manager/store"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/strfun"
+)
+
+type collectData struct {
+ refs id.Set
+ words store.WordSet
+ urls store.WordSet
+}
+
+func (data *collectData) initialize() {
+ data.refs = id.NewSet()
+ data.words = store.NewWordSet()
+ data.urls = store.NewWordSet()
+}
+
+func collectZettelIndexData(zn *ast.ZettelNode, data *collectData) {
+ ast.WalkBlockSlice(data, zn.Ast)
+}
+
+func collectInlineIndexData(ins ast.InlineSlice, data *collectData) {
+ ast.WalkInlineSlice(data, ins)
+}
+
+func (data *collectData) Visit(node ast.Node) ast.Visitor {
+ switch n := node.(type) {
+ case *ast.VerbatimNode:
+ for _, line := range n.Lines {
+ data.addText(line)
+ }
+ case *ast.TextNode:
+ data.addText(n.Text)
+ case *ast.TagNode:
+ data.addText(n.Tag)
+ case *ast.LinkNode:
+ data.addRef(n.Ref)
+ case *ast.ImageNode:
+ data.addRef(n.Ref)
+ case *ast.LiteralNode:
+ data.addText(n.Text)
+ }
+ return data
+}
+
+func (data *collectData) addText(s string) {
+ for _, word := range strfun.NormalizeWords(s) {
+ data.words.Add(word)
+ }
+}
+
+func (data *collectData) addRef(ref *ast.Reference) {
+ if ref == nil {
+ return
+ }
+ if ref.IsExternal() {
+ data.urls.Add(strings.ToLower(ref.Value))
+ }
+ if !ref.IsZettel() {
+ return
+ }
+ if zid, err := id.Parse(ref.URL.Path); err == nil {
+ data.refs[zid] = true
+ }
+}
ADDED box/manager/enrich.go
Index: box/manager/enrich.go
==================================================================
--- box/manager/enrich.go
+++ box/manager/enrich.go
@@ -0,0 +1,52 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package manager coordinates the various boxes and indexes of a Zettelstore.
+package manager
+
+import (
+ "context"
+ "strconv"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/domain/meta"
+)
+
+// Enrich computes additional properties and updates the given metadata.
+func (mgr *Manager) Enrich(ctx context.Context, m *meta.Meta, boxNumber int) {
+ if box.DoNotEnrich(ctx) {
+ // Enrich is called indirectly via indexer or enrichment is not requested
+ // because of other reasons -> ignore this call, do not update meta data
+ return
+ }
+ m.Set(meta.KeyBoxNumber, strconv.Itoa(boxNumber))
+ computePublished(m)
+ mgr.idxStore.Enrich(ctx, m)
+}
+
+func computePublished(m *meta.Meta) {
+ if _, ok := m.Get(meta.KeyPublished); ok {
+ return
+ }
+ if modified, ok := m.Get(meta.KeyModified); ok {
+ if _, ok = meta.TimeValue(modified); ok {
+ m.Set(meta.KeyPublished, modified)
+ return
+ }
+ }
+ zid := m.Zid.String()
+ if _, ok := meta.TimeValue(zid); ok {
+ m.Set(meta.KeyPublished, zid)
+ return
+ }
+
+ // Neither the zettel was modified nor the zettel identifer contains a valid
+ // timestamp. In this case do not set the "published" property.
+}
ADDED box/manager/indexer.go
Index: box/manager/indexer.go
==================================================================
--- box/manager/indexer.go
+++ box/manager/indexer.go
@@ -0,0 +1,227 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package manager coordinates the various boxes and indexes of a Zettelstore.
+package manager
+
+import (
+ "context"
+ "net/url"
+ "time"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/manager/store"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+ "zettelstore.de/z/parser"
+ "zettelstore.de/z/strfun"
+)
+
+// SearchEqual returns all zettel that contains the given exact word.
+// The word must be normalized through Unicode NKFD, trimmed and not empty.
+func (mgr *Manager) SearchEqual(word string) id.Set {
+ return mgr.idxStore.SearchEqual(word)
+}
+
+// SearchPrefix returns all zettel that have a word with the given prefix.
+// The prefix must be normalized through Unicode NKFD, trimmed and not empty.
+func (mgr *Manager) SearchPrefix(prefix string) id.Set {
+ return mgr.idxStore.SearchPrefix(prefix)
+}
+
+// SearchSuffix returns all zettel that have a word with the given suffix.
+// The suffix must be normalized through Unicode NKFD, trimmed and not empty.
+func (mgr *Manager) SearchSuffix(suffix string) id.Set {
+ return mgr.idxStore.SearchSuffix(suffix)
+}
+
+// SearchContains returns all zettel that contains the given string.
+// The string must be normalized through Unicode NKFD, trimmed and not empty.
+func (mgr *Manager) SearchContains(s string) id.Set {
+ return mgr.idxStore.SearchContains(s)
+}
+
+// idxIndexer runs in the background and updates the index data structures.
+// This is the main service of the idxIndexer.
+func (mgr *Manager) idxIndexer() {
+ // Something may panic. Ensure a running indexer.
+ defer func() {
+ if r := recover(); r != nil {
+ kernel.Main.LogRecover("Indexer", r)
+ go mgr.idxIndexer()
+ }
+ }()
+
+ timerDuration := 15 * time.Second
+ timer := time.NewTimer(timerDuration)
+ ctx := box.NoEnrichContext(context.Background())
+ for {
+ mgr.idxWorkService(ctx)
+ if !mgr.idxSleepService(timer, timerDuration) {
+ return
+ }
+ }
+}
+
+func (mgr *Manager) idxWorkService(ctx context.Context) {
+ var roomNum uint64
+ var start time.Time
+ for {
+ switch action, zid, arRoomNum := mgr.idxAr.Dequeue(); action {
+ case arNothing:
+ return
+ case arReload:
+ roomNum = 0
+ zids, err := mgr.FetchZids(ctx)
+ if err == nil {
+ start = time.Now()
+ if rno := mgr.idxAr.Reload(zids); rno > 0 {
+ roomNum = rno
+ }
+ mgr.idxMx.Lock()
+ mgr.idxLastReload = time.Now()
+ mgr.idxSinceReload = 0
+ mgr.idxMx.Unlock()
+ }
+ case arUpdate:
+ zettel, err := mgr.GetZettel(ctx, zid)
+ if err != nil {
+ // TODO: on some errors put the zid into a "try later" set
+ continue
+ }
+ mgr.idxMx.Lock()
+ if arRoomNum == roomNum {
+ mgr.idxDurReload = time.Since(start)
+ }
+ mgr.idxSinceReload++
+ mgr.idxMx.Unlock()
+ mgr.idxUpdateZettel(ctx, zettel)
+ case arDelete:
+ if _, err := mgr.GetMeta(ctx, zid); err == nil {
+ // Zettel was not deleted. This might occur, if zettel was
+ // deleted in secondary dirbox, but is still present in
+ // first dirbox (or vice versa). Re-index zettel in case
+ // a hidden zettel was recovered
+ mgr.idxAr.Enqueue(zid, arUpdate)
+ }
+ mgr.idxMx.Lock()
+ mgr.idxSinceReload++
+ mgr.idxMx.Unlock()
+ mgr.idxDeleteZettel(zid)
+ }
+ }
+}
+
+func (mgr *Manager) idxSleepService(timer *time.Timer, timerDuration time.Duration) bool {
+ select {
+ case _, ok := <-mgr.idxReady:
+ if !ok {
+ return false
+ }
+ case _, ok := <-timer.C:
+ if !ok {
+ return false
+ }
+ timer.Reset(timerDuration)
+ case <-mgr.done:
+ if !timer.Stop() {
+ <-timer.C
+ }
+ return false
+ }
+ return true
+}
+
+func (mgr *Manager) idxUpdateZettel(ctx context.Context, zettel domain.Zettel) {
+ m := zettel.Meta
+ if m.GetBool(meta.KeyNoIndex) {
+ // Zettel maybe in index
+ toCheck := mgr.idxStore.DeleteZettel(ctx, m.Zid)
+ mgr.idxCheckZettel(toCheck)
+ return
+ }
+
+ var cData collectData
+ cData.initialize()
+ collectZettelIndexData(parser.ParseZettel(zettel, "", mgr.rtConfig), &cData)
+ zi := store.NewZettelIndex(m.Zid)
+ mgr.idxCollectFromMeta(ctx, m, zi, &cData)
+ mgr.idxProcessData(ctx, zi, &cData)
+ toCheck := mgr.idxStore.UpdateReferences(ctx, zi)
+ mgr.idxCheckZettel(toCheck)
+}
+
+func (mgr *Manager) idxCollectFromMeta(ctx context.Context, m *meta.Meta, zi *store.ZettelIndex, cData *collectData) {
+ for _, pair := range m.Pairs(false) {
+ descr := meta.GetDescription(pair.Key)
+ if descr.IsComputed() {
+ continue
+ }
+ switch descr.Type {
+ case meta.TypeID:
+ mgr.idxUpdateValue(ctx, descr.Inverse, pair.Value, zi)
+ case meta.TypeIDSet:
+ for _, val := range meta.ListFromValue(pair.Value) {
+ mgr.idxUpdateValue(ctx, descr.Inverse, val, zi)
+ }
+ case meta.TypeZettelmarkup:
+ collectInlineIndexData(parser.ParseMetadata(pair.Value), cData)
+ case meta.TypeURL:
+ if _, err := url.Parse(pair.Value); err == nil {
+ cData.urls.Add(pair.Value)
+ }
+ default:
+ for _, word := range strfun.NormalizeWords(pair.Value) {
+ cData.words.Add(word)
+ }
+ }
+ }
+}
+
+func (mgr *Manager) idxProcessData(ctx context.Context, zi *store.ZettelIndex, cData *collectData) {
+ for ref := range cData.refs {
+ if _, err := mgr.GetMeta(ctx, ref); err == nil {
+ zi.AddBackRef(ref)
+ } else {
+ zi.AddDeadRef(ref)
+ }
+ }
+ zi.SetWords(cData.words)
+ zi.SetUrls(cData.urls)
+}
+
+func (mgr *Manager) idxUpdateValue(ctx context.Context, inverseKey, value string, zi *store.ZettelIndex) {
+ zid, err := id.Parse(value)
+ if err != nil {
+ return
+ }
+ if _, err := mgr.GetMeta(ctx, zid); err != nil {
+ zi.AddDeadRef(zid)
+ return
+ }
+ if inverseKey == "" {
+ zi.AddBackRef(zid)
+ return
+ }
+ zi.AddMetaRef(inverseKey, zid)
+}
+
+func (mgr *Manager) idxDeleteZettel(zid id.Zid) {
+ toCheck := mgr.idxStore.DeleteZettel(context.Background(), zid)
+ mgr.idxCheckZettel(toCheck)
+}
+
+func (mgr *Manager) idxCheckZettel(s id.Set) {
+ for zid := range s {
+ mgr.idxAr.Enqueue(zid, arUpdate)
+ }
+}
ADDED box/manager/manager.go
Index: box/manager/manager.go
==================================================================
--- box/manager/manager.go
+++ box/manager/manager.go
@@ -0,0 +1,314 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package manager coordinates the various boxes and indexes of a Zettelstore.
+package manager
+
+import (
+ "context"
+ "io"
+ "log"
+ "net/url"
+ "sort"
+ "sync"
+ "time"
+
+ "zettelstore.de/z/auth"
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/manager/memstore"
+ "zettelstore.de/z/box/manager/store"
+ "zettelstore.de/z/config"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+)
+
+// ConnectData contains all administration related values.
+type ConnectData struct {
+ Number int // number of the box, starting with 1.
+ Config config.Config
+ Enricher box.Enricher
+ Notify chan<- box.UpdateInfo
+}
+
+// Connect returns a handle to the specified box.
+func Connect(u *url.URL, authManager auth.BaseManager, cdata *ConnectData) (box.ManagedBox, error) {
+ if authManager.IsReadonly() {
+ rawURL := u.String()
+ // TODO: the following is wrong under some circumstances:
+ // 1. fragment is set
+ if q := u.Query(); len(q) == 0 {
+ rawURL += "?readonly"
+ } else if _, ok := q["readonly"]; !ok {
+ rawURL += "&readonly"
+ }
+ var err error
+ if u, err = url.Parse(rawURL); err != nil {
+ return nil, err
+ }
+ }
+
+ if create, ok := registry[u.Scheme]; ok {
+ return create(u, cdata)
+ }
+ return nil, &ErrInvalidScheme{u.Scheme}
+}
+
+// ErrInvalidScheme is returned if there is no box with the given scheme.
+type ErrInvalidScheme struct{ Scheme string }
+
+func (err *ErrInvalidScheme) Error() string { return "Invalid scheme: " + err.Scheme }
+
+type createFunc func(*url.URL, *ConnectData) (box.ManagedBox, error)
+
+var registry = map[string]createFunc{}
+
+// Register the encoder for later retrieval.
+func Register(scheme string, create createFunc) {
+ if _, ok := registry[scheme]; ok {
+ log.Fatalf("Box with scheme %q already registered", scheme)
+ }
+ registry[scheme] = create
+}
+
+// GetSchemes returns all registered scheme, ordered by scheme string.
+func GetSchemes() []string {
+ result := make([]string, 0, len(registry))
+ for scheme := range registry {
+ result = append(result, scheme)
+ }
+ sort.Strings(result)
+ return result
+}
+
+// Manager is a coordinating box.
+type Manager struct {
+ mgrMx sync.RWMutex
+ started bool
+ rtConfig config.Config
+ boxes []box.ManagedBox
+ observers []box.UpdateFunc
+ mxObserver sync.RWMutex
+ done chan struct{}
+ infos chan box.UpdateInfo
+ propertyKeys map[string]bool // Set of property key names
+
+ // Indexer data
+ idxStore store.Store
+ idxAr *anterooms
+ idxReady chan struct{} // Signal a non-empty anteroom to background task
+
+ // Indexer stats data
+ idxMx sync.RWMutex
+ idxLastReload time.Time
+ idxDurReload time.Duration
+ idxSinceReload uint64
+}
+
+// New creates a new managing box.
+func New(boxURIs []*url.URL, authManager auth.BaseManager, rtConfig config.Config) (*Manager, error) {
+ propertyKeys := make(map[string]bool)
+ for _, kd := range meta.GetSortedKeyDescriptions() {
+ if kd.IsProperty() {
+ propertyKeys[kd.Name] = true
+ }
+ }
+ mgr := &Manager{
+ rtConfig: rtConfig,
+ infos: make(chan box.UpdateInfo, len(boxURIs)*10),
+ propertyKeys: propertyKeys,
+
+ idxStore: memstore.New(),
+ idxAr: newAnterooms(10),
+ idxReady: make(chan struct{}, 1),
+ }
+ cdata := ConnectData{Number: 1, Config: rtConfig, Enricher: mgr, Notify: mgr.infos}
+ boxes := make([]box.ManagedBox, 0, len(boxURIs)+2)
+ for _, uri := range boxURIs {
+ p, err := Connect(uri, authManager, &cdata)
+ if err != nil {
+ return nil, err
+ }
+ if p != nil {
+ boxes = append(boxes, p)
+ cdata.Number++
+ }
+ }
+ constbox, err := registry[" const"](nil, &cdata)
+ if err != nil {
+ return nil, err
+ }
+ cdata.Number++
+ compbox, err := registry[" comp"](nil, &cdata)
+ if err != nil {
+ return nil, err
+ }
+ cdata.Number++
+ boxes = append(boxes, constbox, compbox)
+ mgr.boxes = boxes
+ return mgr, nil
+}
+
+// RegisterObserver registers an observer that will be notified
+// if a zettel was found to be changed.
+func (mgr *Manager) RegisterObserver(f box.UpdateFunc) {
+ if f != nil {
+ mgr.mxObserver.Lock()
+ mgr.observers = append(mgr.observers, f)
+ mgr.mxObserver.Unlock()
+ }
+}
+
+func (mgr *Manager) notifyObserver(ci *box.UpdateInfo) {
+ mgr.mxObserver.RLock()
+ observers := mgr.observers
+ mgr.mxObserver.RUnlock()
+ for _, ob := range observers {
+ ob(*ci)
+ }
+}
+
+func (mgr *Manager) notifier() {
+ // The call to notify may panic. Ensure a running notifier.
+ defer func() {
+ if r := recover(); r != nil {
+ kernel.Main.LogRecover("Notifier", r)
+ go mgr.notifier()
+ }
+ }()
+
+ for {
+ select {
+ case ci, ok := <-mgr.infos:
+ if ok {
+ mgr.idxEnqueue(ci.Reason, ci.Zid)
+ if ci.Box == nil {
+ ci.Box = mgr
+ }
+ mgr.notifyObserver(&ci)
+ }
+ case <-mgr.done:
+ return
+ }
+ }
+}
+
+func (mgr *Manager) idxEnqueue(reason box.UpdateReason, zid id.Zid) {
+ switch reason {
+ case box.OnReload:
+ mgr.idxAr.Reset()
+ case box.OnUpdate:
+ mgr.idxAr.Enqueue(zid, arUpdate)
+ case box.OnDelete:
+ mgr.idxAr.Enqueue(zid, arDelete)
+ default:
+ return
+ }
+ select {
+ case mgr.idxReady <- struct{}{}:
+ default:
+ }
+}
+
+// Start the box. Now all other functions of the box are allowed.
+// Starting an already started box is not allowed.
+func (mgr *Manager) Start(ctx context.Context) error {
+ mgr.mgrMx.Lock()
+ if mgr.started {
+ mgr.mgrMx.Unlock()
+ return box.ErrStarted
+ }
+ for i := len(mgr.boxes) - 1; i >= 0; i-- {
+ ssi, ok := mgr.boxes[i].(box.StartStopper)
+ if !ok {
+ continue
+ }
+ err := ssi.Start(ctx)
+ if err == nil {
+ continue
+ }
+ for j := i + 1; j < len(mgr.boxes); j++ {
+ if ssj, ok := mgr.boxes[j].(box.StartStopper); ok {
+ ssj.Stop(ctx)
+ }
+ }
+ mgr.mgrMx.Unlock()
+ return err
+ }
+ mgr.idxAr.Reset() // Ensure an initial index run
+ mgr.done = make(chan struct{})
+ go mgr.notifier()
+ go mgr.idxIndexer()
+
+ // mgr.startIndexer(mgr)
+ mgr.started = true
+ mgr.mgrMx.Unlock()
+ mgr.infos <- box.UpdateInfo{Reason: box.OnReload, Zid: id.Invalid}
+ return nil
+}
+
+// Stop the started box. Now only the Start() function is allowed.
+func (mgr *Manager) Stop(ctx context.Context) error {
+ mgr.mgrMx.Lock()
+ defer mgr.mgrMx.Unlock()
+ if !mgr.started {
+ return box.ErrStopped
+ }
+ close(mgr.done)
+ var err error
+ for _, p := range mgr.boxes {
+ if ss, ok := p.(box.StartStopper); ok {
+ if err1 := ss.Stop(ctx); err1 != nil && err == nil {
+ err = err1
+ }
+ }
+ }
+ mgr.started = false
+ return err
+}
+
+// ReadStats populates st with box statistics.
+func (mgr *Manager) ReadStats(st *box.Stats) {
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ subStats := make([]box.ManagedBoxStats, len(mgr.boxes))
+ for i, p := range mgr.boxes {
+ p.ReadStats(&subStats[i])
+ }
+
+ st.ReadOnly = true
+ sumZettel := 0
+ for _, sst := range subStats {
+ if !sst.ReadOnly {
+ st.ReadOnly = false
+ }
+ sumZettel += sst.Zettel
+ }
+ st.NumManagedBoxes = len(mgr.boxes)
+ st.ZettelTotal = sumZettel
+
+ var storeSt store.Stats
+ mgr.idxMx.RLock()
+ defer mgr.idxMx.RUnlock()
+ mgr.idxStore.ReadStats(&storeSt)
+
+ st.LastReload = mgr.idxLastReload
+ st.IndexesSinceReload = mgr.idxSinceReload
+ st.DurLastReload = mgr.idxDurReload
+ st.ZettelIndexed = storeSt.Zettel
+ st.IndexUpdates = storeSt.Updates
+ st.IndexedWords = storeSt.Words
+ st.IndexedUrls = storeSt.Urls
+}
+
+// Dump internal data structures to a Writer.
+func (mgr *Manager) Dump(w io.Writer) {
+ mgr.idxStore.Dump(w)
+}
ADDED box/manager/memstore/memstore.go
Index: box/manager/memstore/memstore.go
==================================================================
--- box/manager/memstore/memstore.go
+++ box/manager/memstore/memstore.go
@@ -0,0 +1,580 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package memstore stored the index in main memory.
+package memstore
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+ "sync"
+
+ "zettelstore.de/z/box/manager/store"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+)
+
+type metaRefs struct {
+ forward id.Slice
+ backward id.Slice
+}
+
+type zettelIndex struct {
+ dead id.Slice
+ forward id.Slice
+ backward id.Slice
+ meta map[string]metaRefs
+ words []string
+ urls []string
+}
+
+func (zi *zettelIndex) isEmpty() bool {
+ if len(zi.forward) > 0 || len(zi.backward) > 0 || len(zi.dead) > 0 || len(zi.words) > 0 {
+ return false
+ }
+ return zi.meta == nil || len(zi.meta) == 0
+}
+
+type stringRefs map[string]id.Slice
+
+type memStore struct {
+ mx sync.RWMutex
+ idx map[id.Zid]*zettelIndex
+ dead map[id.Zid]id.Slice // map dead refs where they occur
+ words stringRefs
+ urls stringRefs
+
+ // Stats
+ updates uint64
+}
+
+// New returns a new memory-based index store.
+func New() store.Store {
+ return &memStore{
+ idx: make(map[id.Zid]*zettelIndex),
+ dead: make(map[id.Zid]id.Slice),
+ words: make(stringRefs),
+ urls: make(stringRefs),
+ }
+}
+
+func (ms *memStore) Enrich(ctx context.Context, m *meta.Meta) {
+ if ms.doEnrich(ctx, m) {
+ ms.mx.Lock()
+ ms.updates++
+ ms.mx.Unlock()
+ }
+}
+
+func (ms *memStore) doEnrich(ctx context.Context, m *meta.Meta) bool {
+ ms.mx.RLock()
+ defer ms.mx.RUnlock()
+ zi, ok := ms.idx[m.Zid]
+ if !ok {
+ return false
+ }
+ var updated bool
+ if len(zi.dead) > 0 {
+ m.Set(meta.KeyDead, zi.dead.String())
+ updated = true
+ }
+ back := removeOtherMetaRefs(m, zi.backward.Copy())
+ if len(zi.backward) > 0 {
+ m.Set(meta.KeyBackward, zi.backward.String())
+ updated = true
+ }
+ if len(zi.forward) > 0 {
+ m.Set(meta.KeyForward, zi.forward.String())
+ back = remRefs(back, zi.forward)
+ updated = true
+ }
+ if len(zi.meta) > 0 {
+ for k, refs := range zi.meta {
+ if len(refs.backward) > 0 {
+ m.Set(k, refs.backward.String())
+ back = remRefs(back, refs.backward)
+ updated = true
+ }
+ }
+ }
+ if len(back) > 0 {
+ m.Set(meta.KeyBack, back.String())
+ updated = true
+ }
+ return updated
+}
+
+// SearchEqual returns all zettel that contains the given exact word.
+// The word must be normalized through Unicode NKFD, trimmed and not empty.
+func (ms *memStore) SearchEqual(word string) id.Set {
+ ms.mx.RLock()
+ defer ms.mx.RUnlock()
+ result := id.NewSet()
+ if refs, ok := ms.words[word]; ok {
+ result.AddSlice(refs)
+ }
+ if refs, ok := ms.urls[word]; ok {
+ result.AddSlice(refs)
+ }
+ zid, err := id.Parse(word)
+ if err != nil {
+ return result
+ }
+ zi, ok := ms.idx[zid]
+ if !ok {
+ return result
+ }
+
+ addBackwardZids(result, zid, zi)
+ return result
+}
+
+// SearchPrefix returns all zettel that have a word with the given prefix.
+// The prefix must be normalized through Unicode NKFD, trimmed and not empty.
+func (ms *memStore) SearchPrefix(prefix string) id.Set {
+ ms.mx.RLock()
+ defer ms.mx.RUnlock()
+ result := ms.selectWithPred(prefix, strings.HasPrefix)
+ l := len(prefix)
+ if l > 14 {
+ return result
+ }
+ minZid, err := id.Parse(prefix + "00000000000000"[:14-l])
+ if err != nil {
+ return result
+ }
+ maxZid, err := id.Parse(prefix + "99999999999999"[:14-l])
+ if err != nil {
+ return result
+ }
+ for zid, zi := range ms.idx {
+ if minZid <= zid && zid <= maxZid {
+ addBackwardZids(result, zid, zi)
+ }
+ }
+ return result
+}
+
+// SearchSuffix returns all zettel that have a word with the given suffix.
+// The suffix must be normalized through Unicode NKFD, trimmed and not empty.
+func (ms *memStore) SearchSuffix(suffix string) id.Set {
+ ms.mx.RLock()
+ defer ms.mx.RUnlock()
+ result := ms.selectWithPred(suffix, strings.HasSuffix)
+ l := len(suffix)
+ if l > 14 {
+ return result
+ }
+ val, err := id.ParseUint(suffix)
+ if err != nil {
+ return result
+ }
+ modulo := uint64(1)
+ for i := 0; i < l; i++ {
+ modulo *= 10
+ }
+ for zid, zi := range ms.idx {
+ if uint64(zid)%modulo == val {
+ addBackwardZids(result, zid, zi)
+ }
+ }
+ return result
+}
+
+// SearchContains returns all zettel that contains the given string.
+// The string must be normalized through Unicode NKFD, trimmed and not empty.
+func (ms *memStore) SearchContains(s string) id.Set {
+ ms.mx.RLock()
+ defer ms.mx.RUnlock()
+ result := ms.selectWithPred(s, strings.Contains)
+ if len(s) > 14 {
+ return result
+ }
+ if _, err := id.ParseUint(s); err != nil {
+ return result
+ }
+ for zid, zi := range ms.idx {
+ if strings.Contains(zid.String(), s) {
+ addBackwardZids(result, zid, zi)
+ }
+ }
+ return result
+}
+
+func (ms *memStore) selectWithPred(s string, pred func(string, string) bool) id.Set {
+ // Must only be called if ms.mx is read-locked!
+ result := id.NewSet()
+ for word, refs := range ms.words {
+ if !pred(word, s) {
+ continue
+ }
+ result.AddSlice(refs)
+ }
+ for u, refs := range ms.urls {
+ if !pred(u, s) {
+ continue
+ }
+ result.AddSlice(refs)
+ }
+ return result
+}
+
+func addBackwardZids(result id.Set, zid id.Zid, zi *zettelIndex) {
+ // Must only be called if ms.mx is read-locked!
+ result[zid] = true
+ result.AddSlice(zi.backward)
+ for _, mref := range zi.meta {
+ result.AddSlice(mref.backward)
+ }
+}
+
+func removeOtherMetaRefs(m *meta.Meta, back id.Slice) id.Slice {
+ for _, p := range m.PairsRest(false) {
+ switch meta.Type(p.Key) {
+ case meta.TypeID:
+ if zid, err := id.Parse(p.Value); err == nil {
+ back = remRef(back, zid)
+ }
+ case meta.TypeIDSet:
+ for _, val := range meta.ListFromValue(p.Value) {
+ if zid, err := id.Parse(val); err == nil {
+ back = remRef(back, zid)
+ }
+ }
+ }
+ }
+ return back
+}
+
+func (ms *memStore) UpdateReferences(ctx context.Context, zidx *store.ZettelIndex) id.Set {
+ ms.mx.Lock()
+ defer ms.mx.Unlock()
+ zi, ziExist := ms.idx[zidx.Zid]
+ if !ziExist || zi == nil {
+ zi = &zettelIndex{}
+ ziExist = false
+ }
+
+ // Is this zettel an old dead reference mentioned in other zettel?
+ var toCheck id.Set
+ if refs, ok := ms.dead[zidx.Zid]; ok {
+ // These must be checked later again
+ toCheck = id.NewSet(refs...)
+ delete(ms.dead, zidx.Zid)
+ }
+
+ ms.updateDeadReferences(zidx, zi)
+ ms.updateForwardBackwardReferences(zidx, zi)
+ ms.updateMetadataReferences(zidx, zi)
+ zi.words = updateWordSet(zidx.Zid, ms.words, zi.words, zidx.GetWords())
+ zi.urls = updateWordSet(zidx.Zid, ms.urls, zi.urls, zidx.GetUrls())
+
+ // Check if zi must be inserted into ms.idx
+ if !ziExist && !zi.isEmpty() {
+ ms.idx[zidx.Zid] = zi
+ }
+
+ return toCheck
+}
+
+func (ms *memStore) updateDeadReferences(zidx *store.ZettelIndex, zi *zettelIndex) {
+ // Must only be called if ms.mx is write-locked!
+ drefs := zidx.GetDeadRefs()
+ newRefs, remRefs := refsDiff(drefs, zi.dead)
+ zi.dead = drefs
+ for _, ref := range remRefs {
+ ms.dead[ref] = remRef(ms.dead[ref], zidx.Zid)
+ }
+ for _, ref := range newRefs {
+ ms.dead[ref] = addRef(ms.dead[ref], zidx.Zid)
+ }
+}
+
+func (ms *memStore) updateForwardBackwardReferences(zidx *store.ZettelIndex, zi *zettelIndex) {
+ // Must only be called if ms.mx is write-locked!
+ brefs := zidx.GetBackRefs()
+ newRefs, remRefs := refsDiff(brefs, zi.forward)
+ zi.forward = brefs
+ for _, ref := range remRefs {
+ bzi := ms.getEntry(ref)
+ bzi.backward = remRef(bzi.backward, zidx.Zid)
+ }
+ for _, ref := range newRefs {
+ bzi := ms.getEntry(ref)
+ bzi.backward = addRef(bzi.backward, zidx.Zid)
+ }
+}
+
+func (ms *memStore) updateMetadataReferences(zidx *store.ZettelIndex, zi *zettelIndex) {
+ // Must only be called if ms.mx is write-locked!
+ metarefs := zidx.GetMetaRefs()
+ for key, mr := range zi.meta {
+ if _, ok := metarefs[key]; ok {
+ continue
+ }
+ ms.removeInverseMeta(zidx.Zid, key, mr.forward)
+ }
+ if zi.meta == nil {
+ zi.meta = make(map[string]metaRefs)
+ }
+ for key, mrefs := range metarefs {
+ mr := zi.meta[key]
+ newRefs, remRefs := refsDiff(mrefs, mr.forward)
+ mr.forward = mrefs
+ zi.meta[key] = mr
+
+ for _, ref := range newRefs {
+ bzi := ms.getEntry(ref)
+ if bzi.meta == nil {
+ bzi.meta = make(map[string]metaRefs)
+ }
+ bmr := bzi.meta[key]
+ bmr.backward = addRef(bmr.backward, zidx.Zid)
+ bzi.meta[key] = bmr
+ }
+ ms.removeInverseMeta(zidx.Zid, key, remRefs)
+ }
+}
+
+func updateWordSet(zid id.Zid, srefs stringRefs, prev []string, next store.WordSet) []string {
+ // Must only be called if ms.mx is write-locked!
+ newWords, removeWords := next.Diff(prev)
+ for _, word := range newWords {
+ if refs, ok := srefs[word]; ok {
+ srefs[word] = addRef(refs, zid)
+ continue
+ }
+ srefs[word] = id.Slice{zid}
+ }
+ for _, word := range removeWords {
+ refs, ok := srefs[word]
+ if !ok {
+ continue
+ }
+ refs2 := remRef(refs, zid)
+ if len(refs2) == 0 {
+ delete(srefs, word)
+ continue
+ }
+ srefs[word] = refs2
+ }
+ return next.Words()
+}
+
+func (ms *memStore) getEntry(zid id.Zid) *zettelIndex {
+ // Must only be called if ms.mx is write-locked!
+ if zi, ok := ms.idx[zid]; ok {
+ return zi
+ }
+ zi := &zettelIndex{}
+ ms.idx[zid] = zi
+ return zi
+}
+
+func (ms *memStore) DeleteZettel(ctx context.Context, zid id.Zid) id.Set {
+ ms.mx.Lock()
+ defer ms.mx.Unlock()
+
+ zi, ok := ms.idx[zid]
+ if !ok {
+ return nil
+ }
+
+ ms.deleteDeadSources(zid, zi)
+ toCheck := ms.deleteForwardBackward(zid, zi)
+ if len(zi.meta) > 0 {
+ for key, mrefs := range zi.meta {
+ ms.removeInverseMeta(zid, key, mrefs.forward)
+ }
+ }
+ ms.deleteWords(zid, zi.words)
+ delete(ms.idx, zid)
+ return toCheck
+}
+
+func (ms *memStore) deleteDeadSources(zid id.Zid, zi *zettelIndex) {
+ // Must only be called if ms.mx is write-locked!
+ for _, ref := range zi.dead {
+ if drefs, ok := ms.dead[ref]; ok {
+ drefs = remRef(drefs, zid)
+ if len(drefs) > 0 {
+ ms.dead[ref] = drefs
+ } else {
+ delete(ms.dead, ref)
+ }
+ }
+ }
+}
+
+func (ms *memStore) deleteForwardBackward(zid id.Zid, zi *zettelIndex) id.Set {
+ // Must only be called if ms.mx is write-locked!
+ var toCheck id.Set
+ for _, ref := range zi.forward {
+ if fzi, ok := ms.idx[ref]; ok {
+ fzi.backward = remRef(fzi.backward, zid)
+ }
+ }
+ for _, ref := range zi.backward {
+ if bzi, ok := ms.idx[ref]; ok {
+ bzi.forward = remRef(bzi.forward, zid)
+ if toCheck == nil {
+ toCheck = id.NewSet()
+ }
+ toCheck[ref] = true
+ }
+ }
+ return toCheck
+}
+
+func (ms *memStore) removeInverseMeta(zid id.Zid, key string, forward id.Slice) {
+ // Must only be called if ms.mx is write-locked!
+ for _, ref := range forward {
+ bzi, ok := ms.idx[ref]
+ if !ok || bzi.meta == nil {
+ continue
+ }
+ bmr, ok := bzi.meta[key]
+ if !ok {
+ continue
+ }
+ bmr.backward = remRef(bmr.backward, zid)
+ if len(bmr.backward) > 0 || len(bmr.forward) > 0 {
+ bzi.meta[key] = bmr
+ } else {
+ delete(bzi.meta, key)
+ if len(bzi.meta) == 0 {
+ bzi.meta = nil
+ }
+ }
+ }
+}
+
+func (ms *memStore) deleteWords(zid id.Zid, words []string) {
+ // Must only be called if ms.mx is write-locked!
+ for _, word := range words {
+ refs, ok := ms.words[word]
+ if !ok {
+ continue
+ }
+ refs2 := remRef(refs, zid)
+ if len(refs2) == 0 {
+ delete(ms.words, word)
+ continue
+ }
+ ms.words[word] = refs2
+ }
+}
+
+func (ms *memStore) ReadStats(st *store.Stats) {
+ ms.mx.RLock()
+ st.Zettel = len(ms.idx)
+ st.Updates = ms.updates
+ st.Words = uint64(len(ms.words))
+ st.Urls = uint64(len(ms.urls))
+ ms.mx.RUnlock()
+}
+
+func (ms *memStore) Dump(w io.Writer) {
+ ms.mx.RLock()
+ defer ms.mx.RUnlock()
+
+ io.WriteString(w, "=== Dump\n")
+ ms.dumpIndex(w)
+ ms.dumpDead(w)
+ dumpStringRefs(w, "Words", "", "", ms.words)
+ dumpStringRefs(w, "URLs", "[[", "]]", ms.urls)
+}
+
+func (ms *memStore) dumpIndex(w io.Writer) {
+ if len(ms.idx) == 0 {
+ return
+ }
+ io.WriteString(w, "==== Zettel Index\n")
+ zids := make(id.Slice, 0, len(ms.idx))
+ for id := range ms.idx {
+ zids = append(zids, id)
+ }
+ zids.Sort()
+ for _, id := range zids {
+ fmt.Fprintln(w, "=====", id)
+ zi := ms.idx[id]
+ if len(zi.dead) > 0 {
+ fmt.Fprintln(w, "* Dead:", zi.dead)
+ }
+ dumpZids(w, "* Forward:", zi.forward)
+ dumpZids(w, "* Backward:", zi.backward)
+ for k, fb := range zi.meta {
+ fmt.Fprintln(w, "* Meta", k)
+ dumpZids(w, "** Forward:", fb.forward)
+ dumpZids(w, "** Backward:", fb.backward)
+ }
+ dumpStrings(w, "* Words", "", "", zi.words)
+ dumpStrings(w, "* URLs", "[[", "]]", zi.urls)
+ }
+}
+
+func (ms *memStore) dumpDead(w io.Writer) {
+ if len(ms.dead) == 0 {
+ return
+ }
+ fmt.Fprintf(w, "==== Dead References\n")
+ zids := make(id.Slice, 0, len(ms.dead))
+ for id := range ms.dead {
+ zids = append(zids, id)
+ }
+ zids.Sort()
+ for _, id := range zids {
+ fmt.Fprintln(w, ";", id)
+ fmt.Fprintln(w, ":", ms.dead[id])
+ }
+}
+
+func dumpZids(w io.Writer, prefix string, zids id.Slice) {
+ if len(zids) > 0 {
+ io.WriteString(w, prefix)
+ for _, zid := range zids {
+ io.WriteString(w, " ")
+ w.Write(zid.Bytes())
+ }
+ fmt.Fprintln(w)
+ }
+}
+
+func dumpStrings(w io.Writer, title, preString, postString string, slice []string) {
+ if len(slice) > 0 {
+ sl := make([]string, len(slice))
+ copy(sl, slice)
+ sort.Strings(sl)
+ fmt.Fprintln(w, title)
+ for _, s := range sl {
+ fmt.Fprintf(w, "** %s%s%s\n", preString, s, postString)
+ }
+ }
+
+}
+
+func dumpStringRefs(w io.Writer, title, preString, postString string, srefs stringRefs) {
+ if len(srefs) == 0 {
+ return
+ }
+ fmt.Fprintln(w, "====", title)
+ slice := make([]string, 0, len(srefs))
+ for s := range srefs {
+ slice = append(slice, s)
+ }
+ sort.Strings(slice)
+ for _, s := range slice {
+ fmt.Fprintf(w, "; %s%s%s\n", preString, s, postString)
+ fmt.Fprintln(w, ":", srefs[s])
+ }
+}
ADDED box/manager/memstore/refs.go
Index: box/manager/memstore/refs.go
==================================================================
--- box/manager/memstore/refs.go
+++ box/manager/memstore/refs.go
@@ -0,0 +1,101 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package memstore stored the index in main memory.
+package memstore
+
+import "zettelstore.de/z/domain/id"
+
+func refsDiff(refsN, refsO id.Slice) (newRefs, remRefs id.Slice) {
+ npos, opos := 0, 0
+ for npos < len(refsN) && opos < len(refsO) {
+ rn, ro := refsN[npos], refsO[opos]
+ if rn == ro {
+ npos++
+ opos++
+ continue
+ }
+ if rn < ro {
+ newRefs = append(newRefs, rn)
+ npos++
+ continue
+ }
+ remRefs = append(remRefs, ro)
+ opos++
+ }
+ if npos < len(refsN) {
+ newRefs = append(newRefs, refsN[npos:]...)
+ }
+ if opos < len(refsO) {
+ remRefs = append(remRefs, refsO[opos:]...)
+ }
+ return newRefs, remRefs
+}
+
+func addRef(refs id.Slice, ref id.Zid) id.Slice {
+ hi := len(refs)
+ for lo := 0; lo < hi; {
+ m := lo + (hi-lo)/2
+ if r := refs[m]; r == ref {
+ return refs
+ } else if r < ref {
+ lo = m + 1
+ } else {
+ hi = m
+ }
+ }
+ refs = append(refs, id.Invalid)
+ copy(refs[hi+1:], refs[hi:])
+ refs[hi] = ref
+ return refs
+}
+
+func remRefs(refs, rem id.Slice) id.Slice {
+ if len(refs) == 0 || len(rem) == 0 {
+ return refs
+ }
+ result := make(id.Slice, 0, len(refs))
+ rpos, dpos := 0, 0
+ for rpos < len(refs) && dpos < len(rem) {
+ rr, dr := refs[rpos], rem[dpos]
+ if rr < dr {
+ result = append(result, rr)
+ rpos++
+ continue
+ }
+ if dr < rr {
+ dpos++
+ continue
+ }
+ rpos++
+ dpos++
+ }
+ if rpos < len(refs) {
+ result = append(result, refs[rpos:]...)
+ }
+ return result
+}
+
+func remRef(refs id.Slice, ref id.Zid) id.Slice {
+ hi := len(refs)
+ for lo := 0; lo < hi; {
+ m := lo + (hi-lo)/2
+ if r := refs[m]; r == ref {
+ copy(refs[m:], refs[m+1:])
+ refs = refs[:len(refs)-1]
+ return refs
+ } else if r < ref {
+ lo = m + 1
+ } else {
+ hi = m
+ }
+ }
+ return refs
+}
ADDED box/manager/memstore/refs_test.go
Index: box/manager/memstore/refs_test.go
==================================================================
--- box/manager/memstore/refs_test.go
+++ box/manager/memstore/refs_test.go
@@ -0,0 +1,138 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package memstore stored the index in main memory.
+package memstore
+
+import (
+ "testing"
+
+ "zettelstore.de/z/domain/id"
+)
+
+func assertRefs(t *testing.T, i int, got, exp id.Slice) {
+ t.Helper()
+ if got == nil && exp != nil {
+ t.Errorf("%d: got nil, but expected %v", i, exp)
+ return
+ }
+ if got != nil && exp == nil {
+ t.Errorf("%d: expected nil, but got %v", i, got)
+ return
+ }
+ if len(got) != len(exp) {
+ t.Errorf("%d: expected len(%v)==%d, but got len(%v)==%d", i, exp, len(exp), got, len(got))
+ return
+ }
+ for p, n := range exp {
+ if got := got[p]; got != id.Zid(n) {
+ t.Errorf("%d: pos %d: expected %d, but got %d", i, p, n, got)
+ }
+ }
+}
+
+func TestRefsDiff(t *testing.T) {
+ t.Parallel()
+ testcases := []struct {
+ in1, in2 id.Slice
+ exp1, exp2 id.Slice
+ }{
+ {nil, nil, nil, nil},
+ {id.Slice{1}, nil, id.Slice{1}, nil},
+ {nil, id.Slice{1}, nil, id.Slice{1}},
+ {id.Slice{1}, id.Slice{1}, nil, nil},
+ {id.Slice{1, 2}, id.Slice{1}, id.Slice{2}, nil},
+ {id.Slice{1, 2}, id.Slice{1, 3}, id.Slice{2}, id.Slice{3}},
+ {id.Slice{1, 4}, id.Slice{1, 3}, id.Slice{4}, id.Slice{3}},
+ }
+ for i, tc := range testcases {
+ got1, got2 := refsDiff(tc.in1, tc.in2)
+ assertRefs(t, i, got1, tc.exp1)
+ assertRefs(t, i, got2, tc.exp2)
+ }
+}
+
+func TestAddRef(t *testing.T) {
+ t.Parallel()
+ testcases := []struct {
+ ref id.Slice
+ zid uint
+ exp id.Slice
+ }{
+ {nil, 5, id.Slice{5}},
+ {id.Slice{1}, 5, id.Slice{1, 5}},
+ {id.Slice{10}, 5, id.Slice{5, 10}},
+ {id.Slice{5}, 5, id.Slice{5}},
+ {id.Slice{1, 10}, 5, id.Slice{1, 5, 10}},
+ {id.Slice{1, 5, 10}, 5, id.Slice{1, 5, 10}},
+ }
+ for i, tc := range testcases {
+ got := addRef(tc.ref, id.Zid(tc.zid))
+ assertRefs(t, i, got, tc.exp)
+ }
+}
+
+func TestRemRefs(t *testing.T) {
+ t.Parallel()
+ testcases := []struct {
+ in1, in2 id.Slice
+ exp id.Slice
+ }{
+ {nil, nil, nil},
+ {nil, id.Slice{}, nil},
+ {id.Slice{}, nil, id.Slice{}},
+ {id.Slice{}, id.Slice{}, id.Slice{}},
+ {id.Slice{1}, id.Slice{5}, id.Slice{1}},
+ {id.Slice{10}, id.Slice{5}, id.Slice{10}},
+ {id.Slice{1, 5}, id.Slice{5}, id.Slice{1}},
+ {id.Slice{5, 10}, id.Slice{5}, id.Slice{10}},
+ {id.Slice{1, 10}, id.Slice{5}, id.Slice{1, 10}},
+ {id.Slice{1}, id.Slice{2, 5}, id.Slice{1}},
+ {id.Slice{10}, id.Slice{2, 5}, id.Slice{10}},
+ {id.Slice{1, 5}, id.Slice{2, 5}, id.Slice{1}},
+ {id.Slice{5, 10}, id.Slice{2, 5}, id.Slice{10}},
+ {id.Slice{1, 2, 5}, id.Slice{2, 5}, id.Slice{1}},
+ {id.Slice{2, 5, 10}, id.Slice{2, 5}, id.Slice{10}},
+ {id.Slice{1, 10}, id.Slice{2, 5}, id.Slice{1, 10}},
+ {id.Slice{1}, id.Slice{5, 9}, id.Slice{1}},
+ {id.Slice{10}, id.Slice{5, 9}, id.Slice{10}},
+ {id.Slice{1, 5}, id.Slice{5, 9}, id.Slice{1}},
+ {id.Slice{5, 10}, id.Slice{5, 9}, id.Slice{10}},
+ {id.Slice{1, 5, 9}, id.Slice{5, 9}, id.Slice{1}},
+ {id.Slice{5, 9, 10}, id.Slice{5, 9}, id.Slice{10}},
+ {id.Slice{1, 10}, id.Slice{5, 9}, id.Slice{1, 10}},
+ }
+ for i, tc := range testcases {
+ got := remRefs(tc.in1, tc.in2)
+ assertRefs(t, i, got, tc.exp)
+ }
+}
+
+func TestRemRef(t *testing.T) {
+ t.Parallel()
+ testcases := []struct {
+ ref id.Slice
+ zid uint
+ exp id.Slice
+ }{
+ {nil, 5, nil},
+ {id.Slice{}, 5, id.Slice{}},
+ {id.Slice{5}, 5, id.Slice{}},
+ {id.Slice{1}, 5, id.Slice{1}},
+ {id.Slice{10}, 5, id.Slice{10}},
+ {id.Slice{1, 5}, 5, id.Slice{1}},
+ {id.Slice{5, 10}, 5, id.Slice{10}},
+ {id.Slice{1, 5, 10}, 5, id.Slice{1, 10}},
+ }
+ for i, tc := range testcases {
+ got := remRef(tc.ref, id.Zid(tc.zid))
+ assertRefs(t, i, got, tc.exp)
+ }
+}
ADDED box/manager/store/store.go
Index: box/manager/store/store.go
==================================================================
--- box/manager/store/store.go
+++ box/manager/store/store.go
@@ -0,0 +1,59 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package store contains general index data for storing a zettel index.
+package store
+
+import (
+ "context"
+ "io"
+
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/search"
+)
+
+// Stats records statistics about the store.
+type Stats struct {
+ // Zettel is the number of zettel managed by the indexer.
+ Zettel int
+
+ // Updates count the number of metadata updates.
+ Updates uint64
+
+ // Words count the different words stored in the store.
+ Words uint64
+
+ // Urls count the different URLs stored in the store.
+ Urls uint64
+}
+
+// Store all relevant zettel data. There may be multiple implementations, i.e.
+// memory-based, file-based, based on SQLite, ...
+type Store interface {
+ search.Searcher
+
+ // Entrich metadata with data from store.
+ Enrich(ctx context.Context, m *meta.Meta)
+
+ // UpdateReferences for a specific zettel.
+ // Returns set of zettel identifier that must also be checked for changes.
+ UpdateReferences(context.Context, *ZettelIndex) id.Set
+
+ // DeleteZettel removes index data for given zettel.
+ // Returns set of zettel identifier that must also be checked for changes.
+ DeleteZettel(context.Context, id.Zid) id.Set
+
+ // ReadStats populates st with store statistics.
+ ReadStats(st *Stats)
+
+ // Dump the content to a Writer.
+ Dump(io.Writer)
+}
ADDED box/manager/store/wordset.go
Index: box/manager/store/wordset.go
==================================================================
--- box/manager/store/wordset.go
+++ box/manager/store/wordset.go
@@ -0,0 +1,61 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package store contains general index data for storing a zettel index.
+package store
+
+// WordSet contains the set of all words, with the count of their occurrences.
+type WordSet map[string]int
+
+// NewWordSet returns a new WordSet.
+func NewWordSet() WordSet { return make(WordSet) }
+
+// Add one word to the set
+func (ws WordSet) Add(s string) {
+ ws[s] = ws[s] + 1
+}
+
+// Words gives the slice of all words in the set.
+func (ws WordSet) Words() []string {
+ if len(ws) == 0 {
+ return nil
+ }
+ words := make([]string, 0, len(ws))
+ for w := range ws {
+ words = append(words, w)
+ }
+ return words
+}
+
+// Diff calculates the word slice to be added and to be removed from oldWords
+// to get the given word set.
+func (ws WordSet) Diff(oldWords []string) (newWords, removeWords []string) {
+ if len(ws) == 0 {
+ return nil, oldWords
+ }
+ if len(oldWords) == 0 {
+ return ws.Words(), nil
+ }
+ oldSet := make(WordSet, len(oldWords))
+ for _, ow := range oldWords {
+ if _, ok := ws[ow]; ok {
+ oldSet[ow] = 1
+ continue
+ }
+ removeWords = append(removeWords, ow)
+ }
+ for w := range ws {
+ if _, ok := oldSet[w]; ok {
+ continue
+ }
+ newWords = append(newWords, w)
+ }
+ return newWords, removeWords
+}
ADDED box/manager/store/wordset_test.go
Index: box/manager/store/wordset_test.go
==================================================================
--- box/manager/store/wordset_test.go
+++ box/manager/store/wordset_test.go
@@ -0,0 +1,78 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package store contains general index data for storing a zettel index.
+package store_test
+
+import (
+ "sort"
+ "testing"
+
+ "zettelstore.de/z/box/manager/store"
+)
+
+func equalWordList(exp, got []string) bool {
+ if len(exp) != len(got) {
+ return false
+ }
+ if len(got) == 0 {
+ return len(exp) == 0
+ }
+ sort.Strings(got)
+ for i, w := range exp {
+ if w != got[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func TestWordsWords(t *testing.T) {
+ t.Parallel()
+ testcases := []struct {
+ words store.WordSet
+ exp []string
+ }{
+ {nil, nil},
+ {store.WordSet{}, nil},
+ {store.WordSet{"a": 1, "b": 2}, []string{"a", "b"}},
+ }
+ for i, tc := range testcases {
+ got := tc.words.Words()
+ if !equalWordList(tc.exp, got) {
+ t.Errorf("%d: %v.Words() == %v, but got %v", i, tc.words, tc.exp, got)
+ }
+ }
+}
+
+func TestWordsDiff(t *testing.T) {
+ t.Parallel()
+ testcases := []struct {
+ cur store.WordSet
+ old []string
+ expN, expR []string
+ }{
+ {nil, nil, nil, nil},
+ {store.WordSet{}, []string{}, nil, nil},
+ {store.WordSet{"a": 1}, []string{}, []string{"a"}, nil},
+ {store.WordSet{"a": 1}, []string{"b"}, []string{"a"}, []string{"b"}},
+ {store.WordSet{}, []string{"b"}, nil, []string{"b"}},
+ {store.WordSet{"a": 1}, []string{"a"}, nil, nil},
+ }
+ for i, tc := range testcases {
+ gotN, gotR := tc.cur.Diff(tc.old)
+ if !equalWordList(tc.expN, gotN) {
+ t.Errorf("%d: %v.Diff(%v)->new %v, but got %v", i, tc.cur, tc.old, tc.expN, gotN)
+ }
+ if !equalWordList(tc.expR, gotR) {
+ t.Errorf("%d: %v.Diff(%v)->rem %v, but got %v", i, tc.cur, tc.old, tc.expR, gotR)
+ }
+ }
+}
ADDED box/manager/store/zettel.go
Index: box/manager/store/zettel.go
==================================================================
--- box/manager/store/zettel.go
+++ box/manager/store/zettel.go
@@ -0,0 +1,89 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package store contains general index data for storing a zettel index.
+package store
+
+import "zettelstore.de/z/domain/id"
+
+// ZettelIndex contains all index data of a zettel.
+type ZettelIndex struct {
+ Zid id.Zid // zid of the indexed zettel
+ backrefs id.Set // set of back references
+ metarefs map[string]id.Set // references to inverse keys
+ deadrefs id.Set // set of dead references
+ words WordSet
+ urls WordSet
+}
+
+// NewZettelIndex creates a new zettel index.
+func NewZettelIndex(zid id.Zid) *ZettelIndex {
+ return &ZettelIndex{
+ Zid: zid,
+ backrefs: id.NewSet(),
+ metarefs: make(map[string]id.Set),
+ deadrefs: id.NewSet(),
+ }
+}
+
+// AddBackRef adds a reference to a zettel where the current zettel links to
+// without any more information.
+func (zi *ZettelIndex) AddBackRef(zid id.Zid) {
+ zi.backrefs[zid] = true
+}
+
+// AddMetaRef adds a named reference to a zettel. On that zettel, the given
+// metadata key should point back to the current zettel.
+func (zi *ZettelIndex) AddMetaRef(key string, zid id.Zid) {
+ if zids, ok := zi.metarefs[key]; ok {
+ zids[zid] = true
+ return
+ }
+ zi.metarefs[key] = id.NewSet(zid)
+}
+
+// AddDeadRef adds a dead reference to a zettel.
+func (zi *ZettelIndex) AddDeadRef(zid id.Zid) {
+ zi.deadrefs[zid] = true
+}
+
+// SetWords sets the words to the given value.
+func (zi *ZettelIndex) SetWords(words WordSet) { zi.words = words }
+
+// SetUrls sets the words to the given value.
+func (zi *ZettelIndex) SetUrls(urls WordSet) { zi.urls = urls }
+
+// GetDeadRefs returns all dead references as a sorted list.
+func (zi *ZettelIndex) GetDeadRefs() id.Slice {
+ return zi.deadrefs.Sorted()
+}
+
+// GetBackRefs returns all back references as a sorted list.
+func (zi *ZettelIndex) GetBackRefs() id.Slice {
+ return zi.backrefs.Sorted()
+}
+
+// GetMetaRefs returns all meta references as a map of strings to a sorted list of references
+func (zi *ZettelIndex) GetMetaRefs() map[string]id.Slice {
+ if len(zi.metarefs) == 0 {
+ return nil
+ }
+ result := make(map[string]id.Slice, len(zi.metarefs))
+ for key, refs := range zi.metarefs {
+ result[key] = refs.Sorted()
+ }
+ return result
+}
+
+// GetWords returns a reference to the set of words. It must not be modified.
+func (zi *ZettelIndex) GetWords() WordSet { return zi.words }
+
+// GetUrls returns a reference to the set of URLs. It must not be modified.
+func (zi *ZettelIndex) GetUrls() WordSet { return zi.urls }
ADDED box/membox/membox.go
Index: box/membox/membox.go
==================================================================
--- box/membox/membox.go
+++ box/membox/membox.go
@@ -0,0 +1,200 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package membox stores zettel volatile in main memory.
+package membox
+
+import (
+ "context"
+ "net/url"
+ "sync"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/manager"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/search"
+)
+
+func init() {
+ manager.Register(
+ "mem",
+ func(u *url.URL, cdata *manager.ConnectData) (box.ManagedBox, error) {
+ return &memBox{u: u, cdata: *cdata}, nil
+ })
+}
+
+type memBox struct {
+ u *url.URL
+ cdata manager.ConnectData
+ zettel map[id.Zid]domain.Zettel
+ mx sync.RWMutex
+}
+
+func (mp *memBox) notifyChanged(reason box.UpdateReason, zid id.Zid) {
+ if chci := mp.cdata.Notify; chci != nil {
+ chci <- box.UpdateInfo{Reason: reason, Zid: zid}
+ }
+}
+
+func (mp *memBox) Location() string {
+ return mp.u.String()
+}
+
+func (mp *memBox) Start(ctx context.Context) error {
+ mp.mx.Lock()
+ mp.zettel = make(map[id.Zid]domain.Zettel)
+ mp.mx.Unlock()
+ return nil
+}
+
+func (mp *memBox) Stop(ctx context.Context) error {
+ mp.mx.Lock()
+ mp.zettel = nil
+ mp.mx.Unlock()
+ return nil
+}
+
+func (mp *memBox) CanCreateZettel(ctx context.Context) bool { return true }
+
+func (mp *memBox) CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error) {
+ mp.mx.Lock()
+ zid, err := box.GetNewZid(func(zid id.Zid) (bool, error) {
+ _, ok := mp.zettel[zid]
+ return !ok, nil
+ })
+ if err != nil {
+ mp.mx.Unlock()
+ return id.Invalid, err
+ }
+ meta := zettel.Meta.Clone()
+ meta.Zid = zid
+ zettel.Meta = meta
+ mp.zettel[zid] = zettel
+ mp.mx.Unlock()
+ mp.notifyChanged(box.OnUpdate, zid)
+ return zid, nil
+}
+
+func (mp *memBox) GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error) {
+ mp.mx.RLock()
+ zettel, ok := mp.zettel[zid]
+ mp.mx.RUnlock()
+ if !ok {
+ return domain.Zettel{}, box.ErrNotFound
+ }
+ zettel.Meta = zettel.Meta.Clone()
+ return zettel, nil
+}
+
+func (mp *memBox) GetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error) {
+ mp.mx.RLock()
+ zettel, ok := mp.zettel[zid]
+ mp.mx.RUnlock()
+ if !ok {
+ return nil, box.ErrNotFound
+ }
+ return zettel.Meta.Clone(), nil
+}
+
+func (mp *memBox) FetchZids(ctx context.Context) (id.Set, error) {
+ mp.mx.RLock()
+ result := id.NewSetCap(len(mp.zettel))
+ for zid := range mp.zettel {
+ result[zid] = true
+ }
+ mp.mx.RUnlock()
+ return result, nil
+}
+
+func (mp *memBox) SelectMeta(ctx context.Context, match search.MetaMatchFunc) ([]*meta.Meta, error) {
+ result := make([]*meta.Meta, 0, len(mp.zettel))
+ mp.mx.RLock()
+ for _, zettel := range mp.zettel {
+ m := zettel.Meta.Clone()
+ mp.cdata.Enricher.Enrich(ctx, m, mp.cdata.Number)
+ if match(m) {
+ result = append(result, m)
+ }
+ }
+ mp.mx.RUnlock()
+ return result, nil
+}
+
+func (mp *memBox) CanUpdateZettel(ctx context.Context, zettel domain.Zettel) bool {
+ return true
+}
+
+func (mp *memBox) UpdateZettel(ctx context.Context, zettel domain.Zettel) error {
+ mp.mx.Lock()
+ meta := zettel.Meta.Clone()
+ if !meta.Zid.IsValid() {
+ return &box.ErrInvalidID{Zid: meta.Zid}
+ }
+ zettel.Meta = meta
+ mp.zettel[meta.Zid] = zettel
+ mp.mx.Unlock()
+ mp.notifyChanged(box.OnUpdate, meta.Zid)
+ return nil
+}
+
+func (mp *memBox) AllowRenameZettel(ctx context.Context, zid id.Zid) bool { return true }
+
+func (mp *memBox) RenameZettel(ctx context.Context, curZid, newZid id.Zid) error {
+ mp.mx.Lock()
+ zettel, ok := mp.zettel[curZid]
+ if !ok {
+ mp.mx.Unlock()
+ return box.ErrNotFound
+ }
+
+ // Check that there is no zettel with newZid
+ if _, ok = mp.zettel[newZid]; ok {
+ mp.mx.Unlock()
+ return &box.ErrInvalidID{Zid: newZid}
+ }
+
+ meta := zettel.Meta.Clone()
+ meta.Zid = newZid
+ zettel.Meta = meta
+ mp.zettel[newZid] = zettel
+ delete(mp.zettel, curZid)
+ mp.mx.Unlock()
+ mp.notifyChanged(box.OnDelete, curZid)
+ mp.notifyChanged(box.OnUpdate, newZid)
+ return nil
+}
+
+func (mp *memBox) CanDeleteZettel(ctx context.Context, zid id.Zid) bool {
+ mp.mx.RLock()
+ _, ok := mp.zettel[zid]
+ mp.mx.RUnlock()
+ return ok
+}
+
+func (mp *memBox) DeleteZettel(ctx context.Context, zid id.Zid) error {
+ mp.mx.Lock()
+ if _, ok := mp.zettel[zid]; !ok {
+ mp.mx.Unlock()
+ return box.ErrNotFound
+ }
+ delete(mp.zettel, zid)
+ mp.mx.Unlock()
+ mp.notifyChanged(box.OnDelete, zid)
+ return nil
+}
+
+func (mp *memBox) ReadStats(st *box.ManagedBoxStats) {
+ st.ReadOnly = false
+ mp.mx.RLock()
+ st.Zettel = len(mp.zettel)
+ mp.mx.RUnlock()
+}
ADDED box/merge.go
Index: box/merge.go
==================================================================
--- box/merge.go
+++ box/merge.go
@@ -0,0 +1,46 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package box provides a generic interface to zettel boxes.
+package box
+
+import "zettelstore.de/z/domain/meta"
+
+// MergeSorted returns a merged sequence of metadata, sorted by Zid.
+// The lists first and second must be sorted descending by Zid.
+func MergeSorted(first, second []*meta.Meta) []*meta.Meta {
+ lenFirst := len(first)
+ lenSecond := len(second)
+ result := make([]*meta.Meta, 0, lenFirst+lenSecond)
+ iFirst := 0
+ iSecond := 0
+ for iFirst < lenFirst && iSecond < lenSecond {
+ zidFirst := first[iFirst].Zid
+ zidSecond := second[iSecond].Zid
+ if zidFirst > zidSecond {
+ result = append(result, first[iFirst])
+ iFirst++
+ } else if zidFirst < zidSecond {
+ result = append(result, second[iSecond])
+ iSecond++
+ } else { // zidFirst == zidSecond
+ result = append(result, first[iFirst])
+ iFirst++
+ iSecond++
+ }
+ }
+ if iFirst < lenFirst {
+ result = append(result, first[iFirst:]...)
+ } else {
+ result = append(result, second[iSecond:]...)
+ }
+
+ return result
+}
ADDED client/client.go
Index: client/client.go
==================================================================
--- client/client.go
+++ client/client.go
@@ -0,0 +1,443 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package client provides a client for accessing the Zettelstore via its API.
+package client
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "zettelstore.de/z/api"
+ "zettelstore.de/z/domain/id"
+)
+
+// Client contains all data to execute requests.
+type Client struct {
+ baseURL string
+ username string
+ password string
+ token string
+ tokenType string
+ expires time.Time
+}
+
+// NewClient create a new client.
+func NewClient(baseURL string) *Client {
+ if !strings.HasSuffix(baseURL, "/") {
+ baseURL += "/"
+ }
+ c := Client{baseURL: baseURL}
+ return &c
+}
+
+func (c *Client) newURLBuilder(key byte) *api.URLBuilder {
+ return api.NewURLBuilder(c.baseURL, key)
+}
+func (c *Client) newRequest(ctx context.Context, method string, ub *api.URLBuilder, body io.Reader) (*http.Request, error) {
+ return http.NewRequestWithContext(ctx, method, ub.String(), body)
+}
+
+func (c *Client) executeRequest(req *http.Request) (*http.Response, error) {
+ if c.token != "" {
+ req.Header.Add("Authorization", c.tokenType+" "+c.token)
+ }
+ client := http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ if resp != nil && resp.Body != nil {
+ resp.Body.Close()
+ }
+ return nil, err
+ }
+ return resp, err
+}
+
+func (c *Client) buildAndExecuteRequest(
+ ctx context.Context, method string, ub *api.URLBuilder, body io.Reader, h http.Header) (*http.Response, error) {
+ req, err := c.newRequest(ctx, method, ub, body)
+ if err != nil {
+ return nil, err
+ }
+ err = c.updateToken(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for key, val := range h {
+ req.Header[key] = append(req.Header[key], val...)
+ }
+ return c.executeRequest(req)
+}
+
+// SetAuth sets authentication data.
+func (c *Client) SetAuth(username, password string) {
+ c.username = username
+ c.password = password
+ c.token = ""
+ c.tokenType = ""
+ c.expires = time.Time{}
+}
+
+func (c *Client) executeAuthRequest(req *http.Request) error {
+ resp, err := c.executeRequest(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return errors.New(resp.Status)
+ }
+ dec := json.NewDecoder(resp.Body)
+ var tinfo api.AuthJSON
+ err = dec.Decode(&tinfo)
+ if err != nil {
+ return err
+ }
+ c.token = tinfo.Token
+ c.tokenType = tinfo.Type
+ c.expires = time.Now().Add(time.Duration(tinfo.Expires*10/9) * time.Second)
+ return nil
+}
+
+func (c *Client) updateToken(ctx context.Context) error {
+ if c.username == "" {
+ return nil
+ }
+ if time.Now().After(c.expires) {
+ return c.Authenticate(ctx)
+ }
+ return c.RefreshToken(ctx)
+}
+
+// Authenticate sets a new token by sending user name and password.
+func (c *Client) Authenticate(ctx context.Context) error {
+ authData := url.Values{"username": {c.username}, "password": {c.password}}
+ req, err := c.newRequest(ctx, http.MethodPost, c.newURLBuilder('v'), strings.NewReader(authData.Encode()))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return c.executeAuthRequest(req)
+}
+
+// RefreshToken updates the access token
+func (c *Client) RefreshToken(ctx context.Context) error {
+ req, err := c.newRequest(ctx, http.MethodPut, c.newURLBuilder('v'), nil)
+ if err != nil {
+ return err
+ }
+ return c.executeAuthRequest(req)
+}
+
+// CreateZettel creates a new zettel and returns its URL.
+func (c *Client) CreateZettel(ctx context.Context, data *api.ZettelDataJSON) (id.Zid, error) {
+ var buf bytes.Buffer
+ if err := encodeZettelData(&buf, data); err != nil {
+ return id.Invalid, err
+ }
+ ub := c.jsonZettelURLBuilder(nil)
+ resp, err := c.buildAndExecuteRequest(ctx, http.MethodPost, ub, &buf, nil)
+ if err != nil {
+ return id.Invalid, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusCreated {
+ return id.Invalid, errors.New(resp.Status)
+ }
+ dec := json.NewDecoder(resp.Body)
+ var newZid api.ZidJSON
+ err = dec.Decode(&newZid)
+ if err != nil {
+ return id.Invalid, err
+ }
+ zid, err := id.Parse(newZid.ID)
+ if err != nil {
+ return id.Invalid, err
+ }
+ return zid, nil
+}
+
+func encodeZettelData(buf *bytes.Buffer, data *api.ZettelDataJSON) error {
+ enc := json.NewEncoder(buf)
+ enc.SetEscapeHTML(false)
+ return enc.Encode(&data)
+}
+
+// ListZettel returns a list of all Zettel.
+func (c *Client) ListZettel(ctx context.Context, query url.Values) ([]api.ZettelJSON, error) {
+ ub := c.jsonZettelURLBuilder(query)
+ resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.New(resp.Status)
+ }
+ dec := json.NewDecoder(resp.Body)
+ var zl api.ZettelListJSON
+ err = dec.Decode(&zl)
+ if err != nil {
+ return nil, err
+ }
+ return zl.List, nil
+}
+
+// GetZettelJSON returns a zettel as a JSON struct.
+func (c *Client) GetZettelJSON(ctx context.Context, zid id.Zid, query url.Values) (*api.ZettelDataJSON, error) {
+ ub := c.jsonZettelURLBuilder(query).SetZid(zid)
+ resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.New(resp.Status)
+ }
+ dec := json.NewDecoder(resp.Body)
+ var out api.ZettelDataJSON
+ err = dec.Decode(&out)
+ if err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// GetEvaluatedZettel return a zettel in a defined encoding.
+func (c *Client) GetEvaluatedZettel(ctx context.Context, zid id.Zid, enc api.EncodingEnum) (string, error) {
+ ub := c.jsonZettelURLBuilder(nil).SetZid(zid)
+ ub.AppendQuery(api.QueryKeyFormat, enc.String())
+ ub.AppendQuery(api.QueryKeyPart, api.PartContent)
+ resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return "", errors.New(resp.Status)
+ }
+ content, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+ return string(content), nil
+}
+
+// GetZettelOrder returns metadata of the given zettel and, more important,
+// metadata of zettel that are referenced in a list within the first zettel.
+func (c *Client) GetZettelOrder(ctx context.Context, zid id.Zid) (*api.ZidMetaRelatedList, error) {
+ ub := c.newURLBuilder('o').SetZid(zid)
+ resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.New(resp.Status)
+ }
+ dec := json.NewDecoder(resp.Body)
+ var out api.ZidMetaRelatedList
+ err = dec.Decode(&out)
+ if err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// ContextDirection specifies how the context should be calculated.
+type ContextDirection uint8
+
+// Allowed values for ContextDirection
+const (
+ _ ContextDirection = iota
+ DirBoth
+ DirBackward
+ DirForward
+)
+
+// GetZettelContext returns metadata of the given zettel and, more important,
+// metadata of zettel that for the context of the first zettel.
+func (c *Client) GetZettelContext(
+ ctx context.Context, zid id.Zid, dir ContextDirection, depth, limit int) (
+ *api.ZidMetaRelatedList, error,
+) {
+ ub := c.newURLBuilder('x').SetZid(zid)
+ switch dir {
+ case DirBackward:
+ ub.AppendQuery(api.QueryKeyDir, api.DirBackward)
+ case DirForward:
+ ub.AppendQuery(api.QueryKeyDir, api.DirForward)
+ }
+ if depth > 0 {
+ ub.AppendQuery(api.QueryKeyDepth, strconv.Itoa(depth))
+ }
+ if limit > 0 {
+ ub.AppendQuery(api.QueryKeyLimit, strconv.Itoa(limit))
+ }
+ resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.New(resp.Status)
+ }
+ dec := json.NewDecoder(resp.Body)
+ var out api.ZidMetaRelatedList
+ err = dec.Decode(&out)
+ if err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// GetZettelLinks returns connections to ohter zettel, images, externals URLs.
+func (c *Client) GetZettelLinks(ctx context.Context, zid id.Zid) (*api.ZettelLinksJSON, error) {
+ ub := c.newURLBuilder('l').SetZid(zid)
+ resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.New(resp.Status)
+ }
+ dec := json.NewDecoder(resp.Body)
+ var out api.ZettelLinksJSON
+ err = dec.Decode(&out)
+ if err != nil {
+ return nil, err
+ }
+ return &out, nil
+}
+
+// UpdateZettel updates an existing zettel.
+func (c *Client) UpdateZettel(ctx context.Context, zid id.Zid, data *api.ZettelDataJSON) error {
+ var buf bytes.Buffer
+ if err := encodeZettelData(&buf, data); err != nil {
+ return err
+ }
+ ub := c.jsonZettelURLBuilder(nil).SetZid(zid)
+ resp, err := c.buildAndExecuteRequest(ctx, http.MethodPut, ub, &buf, nil)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusNoContent {
+ return errors.New(resp.Status)
+ }
+ return nil
+}
+
+// RenameZettel renames a zettel.
+func (c *Client) RenameZettel(ctx context.Context, oldZid, newZid id.Zid) error {
+ ub := c.jsonZettelURLBuilder(nil).SetZid(oldZid)
+ h := http.Header{
+ api.HeaderDestination: {c.jsonZettelURLBuilder(nil).SetZid(newZid).String()},
+ }
+ resp, err := c.buildAndExecuteRequest(ctx, api.MethodMove, ub, nil, h)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusNoContent {
+ return errors.New(resp.Status)
+ }
+ return nil
+}
+
+// DeleteZettel deletes a zettel with the given identifier.
+func (c *Client) DeleteZettel(ctx context.Context, zid id.Zid) error {
+ ub := c.jsonZettelURLBuilder(nil).SetZid(zid)
+ resp, err := c.buildAndExecuteRequest(ctx, http.MethodDelete, ub, nil, nil)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusNoContent {
+ return errors.New(resp.Status)
+ }
+ return nil
+}
+
+func (c *Client) jsonZettelURLBuilder(query url.Values) *api.URLBuilder {
+ ub := c.newURLBuilder('z')
+ for key, values := range query {
+ if key == api.QueryKeyFormat {
+ continue
+ }
+ for _, val := range values {
+ ub.AppendQuery(key, val)
+ }
+ }
+ return ub
+}
+
+// ListTags returns a map of all tags, together with the associated zettel containing this tag.
+func (c *Client) ListTags(ctx context.Context) (map[string][]string, error) {
+ err := c.updateToken(ctx)
+ if err != nil {
+ return nil, err
+ }
+ req, err := c.newRequest(ctx, http.MethodGet, c.newURLBuilder('t'), nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := c.executeRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.New(resp.Status)
+ }
+ dec := json.NewDecoder(resp.Body)
+ var tl api.TagListJSON
+ err = dec.Decode(&tl)
+ if err != nil {
+ return nil, err
+ }
+ return tl.Tags, nil
+}
+
+// ListRoles returns a list of all roles.
+func (c *Client) ListRoles(ctx context.Context) ([]string, error) {
+ err := c.updateToken(ctx)
+ if err != nil {
+ return nil, err
+ }
+ req, err := c.newRequest(ctx, http.MethodGet, c.newURLBuilder('r'), nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := c.executeRequest(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, errors.New(resp.Status)
+ }
+ dec := json.NewDecoder(resp.Body)
+ var rl api.RoleListJSON
+ err = dec.Decode(&rl)
+ if err != nil {
+ return nil, err
+ }
+ return rl.Roles, nil
+}
ADDED client/client_test.go
Index: client/client_test.go
==================================================================
--- client/client_test.go
+++ client/client_test.go
@@ -0,0 +1,334 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021 Detlef Stern
+//
+// This file is part of zettelstore.
+//
+// Zettelstore is licensed under the latest version of the EUPL (European Union
+// Public License). Please see file LICENSE.txt for your rights and obligations
+// under this license.
+//-----------------------------------------------------------------------------
+
+// Package client provides a client for accessing the Zettelstore via its API.
+package client_test
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "net/url"
+ "testing"
+
+ "zettelstore.de/z/api"
+ "zettelstore.de/z/client"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+)
+
+func TestCreateRenameDeleteZettel(t *testing.T) {
+ // Is not to be allowed to run in parallel with other tests.
+ c := getClient()
+ c.SetAuth("creator", "creator")
+ zid, err := c.CreateZettel(context.Background(), &api.ZettelDataJSON{
+ Meta: nil,
+ Encoding: "",
+ Content: "Example",
+ })
+ if err != nil {
+ t.Error("Cannot create zettel:", err)
+ return
+ }
+ if !zid.IsValid() {
+ t.Error("Invalid zettel ID", zid)
+ return
+ }
+ newZid := zid + 1
+ c.SetAuth("owner", "owner")
+ err = c.RenameZettel(context.Background(), zid, newZid)
+ if err != nil {
+ t.Error("Cannot rename", zid, ":", err)
+ newZid = zid
+ }
+ err = c.DeleteZettel(context.Background(), newZid)
+ if err != nil {
+ t.Error("Cannot delete", zid, ":", err)
+ return
+ }
+}
+
+func TestUpdateZettel(t *testing.T) {
+ t.Parallel()
+ c := getClient()
+ c.SetAuth("writer", "writer")
+ z, err := c.GetZettelJSON(context.Background(), id.DefaultHomeZid, nil)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if got := z.Meta[meta.KeyTitle]; got != "Home" {
+ t.Errorf("Title of zettel is not \"Home\", but %q", got)
+ return
+ }
+ newTitle := "New Home"
+ z.Meta[meta.KeyTitle] = newTitle
+ err = c.UpdateZettel(context.Background(), id.DefaultHomeZid, z)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ zt, err := c.GetZettelJSON(context.Background(), id.DefaultHomeZid, nil)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if got := zt.Meta[meta.KeyTitle]; got != newTitle {
+ t.Errorf("Title of zettel is not %q, but %q", newTitle, got)
+ }
+}
+
+func TestList(t *testing.T) {
+ testdata := []struct {
+ user string
+ exp int
+ }{
+ {"", 7},
+ {"creator", 10},
+ {"reader", 12},
+ {"writer", 12},
+ {"owner", 34},
+ }
+
+ t.Parallel()
+ c := getClient()
+ query := url.Values{api.QueryKeyFormat: {"html"}} // Client must remove "html"
+ for i, tc := range testdata {
+ t.Run(fmt.Sprintf("User %d/%q", i, tc.user), func(tt *testing.T) {
+ c.SetAuth(tc.user, tc.user)
+ l, err := c.ListZettel(context.Background(), query)
+ if err != nil {
+ tt.Error(err)
+ return
+ }
+ got := len(l)
+ if got != tc.exp {
+ tt.Errorf("List of length %d expected, but got %d\n%v", tc.exp, got, l)
+ }
+ })
+ }
+ l, err := c.ListZettel(context.Background(), url.Values{meta.KeyRole: {meta.ValueRoleConfiguration}})
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ got := len(l)
+ if got != 27 {
+ t.Errorf("List of length %d expected, but got %d\n%v", 27, got, l)
+ }
+}
+func TestGetZettel(t *testing.T) {
+ t.Parallel()
+ c := getClient()
+ c.SetAuth("owner", "owner")
+ z, err := c.GetZettelJSON(context.Background(), id.DefaultHomeZid, url.Values{api.QueryKeyPart: {api.PartContent}})
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if m := z.Meta; len(m) > 0 {
+ t.Errorf("Exptected empty meta, but got %v", z.Meta)
+ }
+ if z.Content == "" || z.Encoding != "" {
+ t.Errorf("Expect non-empty content, but empty encoding (got %q)", z.Encoding)
+ }
+}
+
+func TestGetEvaluatedZettel(t *testing.T) {
+ t.Parallel()
+ c := getClient()
+ c.SetAuth("owner", "owner")
+ encodings := []api.EncodingEnum{
+ api.EncoderDJSON,
+ api.EncoderHTML,
+ api.EncoderNative,
+ api.EncoderText,
+ }
+ for _, enc := range encodings {
+ content, err := c.GetEvaluatedZettel(context.Background(), id.DefaultHomeZid, enc)
+ if err != nil {
+ t.Error(err)
+ continue
+ }
+ if len(content) == 0 {
+ t.Errorf("Empty content for encoding %v", enc)
+ }
+ }
+}
+
+func TestGetZettelOrder(t *testing.T) {
+ t.Parallel()
+ c := getClient()
+ c.SetAuth("owner", "owner")
+ rl, err := c.GetZettelOrder(context.Background(), id.TOCNewTemplateZid)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if rl.ID != id.TOCNewTemplateZid.String() {
+ t.Errorf("Expected an Zid %v, but got %v", id.TOCNewTemplateZid, rl.ID)
+ return
+ }
+ l := rl.List
+ if got := len(l); got != 2 {
+ t.Errorf("Expected list fo length 2, got %d", got)
+ return
+ }
+ if got := l[0].ID; got != id.TemplateNewZettelZid.String() {
+ t.Errorf("Expected result[0]=%v, but got %v", id.TemplateNewZettelZid, got)
+ }
+ if got := l[1].ID; got != id.TemplateNewUserZid.String() {
+ t.Errorf("Expected result[1]=%v, but got %v", id.TemplateNewUserZid, got)
+ }
+}
+
+func TestGetZettelContext(t *testing.T) {
+ t.Parallel()
+ c := getClient()
+ c.SetAuth("owner", "owner")
+ rl, err := c.GetZettelContext(context.Background(), id.VersionZid, client.DirBoth, 0, 3)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if rl.ID != id.VersionZid.String() {
+ t.Errorf("Expected an Zid %v, but got %v", id.VersionZid, rl.ID)
+ return
+ }
+ l := rl.List
+ if got := len(l); got != 3 {
+ t.Errorf("Expected list fo length 3, got %d", got)
+ return
+ }
+ if got := l[0].ID; got != id.DefaultHomeZid.String() {
+ t.Errorf("Expected result[0]=%v, but got %v", id.DefaultHomeZid, got)
+ }
+ if got := l[1].ID; got != id.OperatingSystemZid.String() {
+ t.Errorf("Expected result[1]=%v, but got %v", id.OperatingSystemZid, got)
+ }
+ if got := l[2].ID; got != id.StartupConfigurationZid.String() {
+ t.Errorf("Expected result[2]=%v, but got %v", id.StartupConfigurationZid, got)
+ }
+
+ rl, err = c.GetZettelContext(context.Background(), id.VersionZid, client.DirBackward, 0, 0)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if rl.ID != id.VersionZid.String() {
+ t.Errorf("Expected an Zid %v, but got %v", id.VersionZid, rl.ID)
+ return
+ }
+ l = rl.List
+ if got := len(l); got != 1 {
+ t.Errorf("Expected list fo length 1, got %d", got)
+ return
+ }
+ if got := l[0].ID; got != id.DefaultHomeZid.String() {
+ t.Errorf("Expected result[0]=%v, but got %v", id.DefaultHomeZid, got)
+ }
+}
+
+func TestGetZettelLinks(t *testing.T) {
+ t.Parallel()
+ c := getClient()
+ c.SetAuth("owner", "owner")
+ zl, err := c.GetZettelLinks(context.Background(), id.DefaultHomeZid)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if zl.ID != id.DefaultHomeZid.String() {
+ t.Errorf("Expected an Zid %v, but got %v", id.DefaultHomeZid, zl.ID)
+ return
+ }
+ if len(zl.Links.Incoming) != 0 {
+ t.Error("No incomings expected", zl.Links.Incoming)
+ }
+ if got := len(zl.Links.Outgoing); got != 4 {
+ t.Errorf("Expected 4 outgoing links, got %d", got)
+ }
+ if got := len(zl.Links.Local); got != 1 {
+ t.Errorf("Expected 1 local link, got %d", got)
+ }
+ if got := len(zl.Links.External); got != 4 {
+ t.Errorf("Expected 4 external link, got %d", got)
+ }
+}
+
+func TestListTags(t *testing.T) {
+ t.Parallel()
+ c := getClient()
+ c.SetAuth("owner", "owner")
+ tm, err := c.ListTags(context.Background())
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ tags := []struct {
+ key string
+ size int
+ }{
+ {"#invisible", 1},
+ {"#user", 4},
+ {"#test", 4},
+ }
+ if len(tm) != len(tags) {
+ t.Errorf("Expected %d different tags, but got only %d (%v)", len(tags), len(tm), tm)
+ }
+ for _, tag := range tags {
+ if zl, ok := tm[tag.key]; !ok {
+ t.Errorf("No tag %v: %v", tag.key, tm)
+ } else if len(zl) != tag.size {
+ t.Errorf("Expected %d zettel with tag %v, but got %v", tag.size, tag.key, zl)
+ }
+ }
+ for i, id := range tm["#user"] {
+ if id != tm["#test"][i] {
+ t.Errorf("Tags #user and #test have different content: %v vs %v", tm["#user"], tm["#test"])
+ }
+ }
+}
+
+func TestListRoles(t *testing.T) {
+ t.Parallel()
+ c := getClient()
+ c.SetAuth("owner", "owner")
+ rl, err := c.ListRoles(context.Background())
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ exp := []string{"configuration", "user", "zettel"}
+ if len(rl) != len(exp) {
+ t.Errorf("Expected %d different tags, but got only %d (%v)", len(exp), len(rl), rl)
+ }
+ for i, id := range exp {
+ if id != rl[i] {
+ t.Errorf("Role list pos %d: expected %q, got %q", i, id, rl[i])
+ }
+ }
+}
+
+var baseURL string
+
+func init() {
+ flag.StringVar(&baseURL, "base-url", "", "Base URL")
+}
+
+func getClient() *client.Client { return client.NewClient(baseURL) }
+
+// TestMain controls whether client API tests should run or not.
+func TestMain(m *testing.M) {
+ flag.Parse()
+ if baseURL != "" {
+ m.Run()
+ }
+}
Index: cmd/cmd_file.go
==================================================================
--- cmd/cmd_file.go
+++ cmd/cmd_file.go
@@ -14,10 +14,11 @@
"flag"
"fmt"
"io"
"os"
+ "zettelstore.de/z/api"
"zettelstore.de/z/domain"
"zettelstore.de/z/domain/id"
"zettelstore.de/z/domain/meta"
"zettelstore.de/z/encoder"
"zettelstore.de/z/input"
@@ -38,11 +39,11 @@
Content: domain.NewContent(inp.Src[inp.Pos:]),
},
m.GetDefault(meta.KeySyntax, meta.ValueSyntaxZmk),
nil,
)
- enc := encoder.Create(format, &encoder.Environment{Lang: m.GetDefault(meta.KeyLang, meta.ValueLangEN)})
+ enc := encoder.Create(api.Encoder(format), &encoder.Environment{Lang: m.GetDefault(meta.KeyLang, meta.ValueLangEN)})
if enc == nil {
fmt.Fprintf(os.Stderr, "Unknown format %q\n", format)
return 2, nil
}
_, err = enc.WriteZettel(os.Stdout, z, format != "raw")
Index: cmd/cmd_run.go
==================================================================
--- cmd/cmd_run.go
+++ cmd/cmd_run.go
@@ -12,17 +12,17 @@
import (
"flag"
"net/http"
+ zsapi "zettelstore.de/z/api"
"zettelstore.de/z/auth"
+ "zettelstore.de/z/box"
"zettelstore.de/z/config"
"zettelstore.de/z/domain/meta"
"zettelstore.de/z/kernel"
- "zettelstore.de/z/place"
"zettelstore.de/z/usecase"
- "zettelstore.de/z/web/adapter"
"zettelstore.de/z/web/adapter/api"
"zettelstore.de/z/web/adapter/webui"
"zettelstore.de/z/web/server"
)
@@ -56,71 +56,81 @@
return 1, err
}
return 0, nil
}
-func setupRouting(webSrv server.Server, placeManager place.Manager, authManager auth.Manager, rtConfig config.Config) {
- protectedPlaceManager, authPolicy := authManager.PlaceWithPolicy(webSrv, placeManager, rtConfig)
- api := api.New(webSrv, authManager, authManager, webSrv, rtConfig)
- wui := webui.New(webSrv, authManager, rtConfig, authManager, placeManager, authPolicy)
-
- ucAuthenticate := usecase.NewAuthenticate(authManager, authManager, placeManager)
- ucCreateZettel := usecase.NewCreateZettel(rtConfig, protectedPlaceManager)
- ucGetMeta := usecase.NewGetMeta(protectedPlaceManager)
- ucGetZettel := usecase.NewGetZettel(protectedPlaceManager)
+func setupRouting(webSrv server.Server, boxManager box.Manager, authManager auth.Manager, rtConfig config.Config) {
+ protectedBoxManager, authPolicy := authManager.BoxWithPolicy(webSrv, boxManager, rtConfig)
+ api := api.New(webSrv, authManager, authManager, webSrv, rtConfig)
+ wui := webui.New(webSrv, authManager, rtConfig, authManager, boxManager, authPolicy)
+
+ ucAuthenticate := usecase.NewAuthenticate(authManager, authManager, boxManager)
+ ucCreateZettel := usecase.NewCreateZettel(rtConfig, protectedBoxManager)
+ ucGetMeta := usecase.NewGetMeta(protectedBoxManager)
+ ucGetAllMeta := usecase.NewGetAllMeta(protectedBoxManager)
+ ucGetZettel := usecase.NewGetZettel(protectedBoxManager)
ucParseZettel := usecase.NewParseZettel(rtConfig, ucGetZettel)
- ucListMeta := usecase.NewListMeta(protectedPlaceManager)
- ucListRoles := usecase.NewListRole(protectedPlaceManager)
- ucListTags := usecase.NewListTags(protectedPlaceManager)
- ucZettelContext := usecase.NewZettelContext(protectedPlaceManager)
-
- webSrv.Handle("/", wui.MakeGetRootHandler(protectedPlaceManager))
- webSrv.AddListRoute('a', http.MethodGet, wui.MakeGetLoginHandler())
- webSrv.AddListRoute('a', http.MethodPost, adapter.MakePostLoginHandler(
- api.MakePostLoginHandlerAPI(ucAuthenticate),
- wui.MakePostLoginHandlerHTML(ucAuthenticate)))
- webSrv.AddListRoute('a', http.MethodPut, api.MakeRenewAuthHandler())
+ ucListMeta := usecase.NewListMeta(protectedBoxManager)
+ ucListRoles := usecase.NewListRole(protectedBoxManager)
+ ucListTags := usecase.NewListTags(protectedBoxManager)
+ ucZettelContext := usecase.NewZettelContext(protectedBoxManager)
+ ucDelete := usecase.NewDeleteZettel(protectedBoxManager)
+ ucUpdate := usecase.NewUpdateZettel(protectedBoxManager)
+ ucRename := usecase.NewRenameZettel(protectedBoxManager)
+
+ webSrv.Handle("/", wui.MakeGetRootHandler(protectedBoxManager))
+
+ // Web user interface
+ webSrv.AddListRoute('a', http.MethodGet, wui.MakeGetLoginHandler())
+ webSrv.AddListRoute('a', http.MethodPost, wui.MakePostLoginHandler(ucAuthenticate))
webSrv.AddZettelRoute('a', http.MethodGet, wui.MakeGetLogoutHandler())
if !authManager.IsReadonly() {
webSrv.AddZettelRoute('b', http.MethodGet, wui.MakeGetRenameZettelHandler(ucGetMeta))
- webSrv.AddZettelRoute('b', http.MethodPost, wui.MakePostRenameZettelHandler(
- usecase.NewRenameZettel(protectedPlaceManager)))
+ webSrv.AddZettelRoute('b', http.MethodPost, wui.MakePostRenameZettelHandler(ucRename))
webSrv.AddZettelRoute('c', http.MethodGet, wui.MakeGetCopyZettelHandler(
ucGetZettel, usecase.NewCopyZettel()))
webSrv.AddZettelRoute('c', http.MethodPost, wui.MakePostCreateZettelHandler(ucCreateZettel))
webSrv.AddZettelRoute('d', http.MethodGet, wui.MakeGetDeleteZettelHandler(ucGetZettel))
- webSrv.AddZettelRoute('d', http.MethodPost, wui.MakePostDeleteZettelHandler(
- usecase.NewDeleteZettel(protectedPlaceManager)))
+ webSrv.AddZettelRoute('d', http.MethodPost, wui.MakePostDeleteZettelHandler(ucDelete))
webSrv.AddZettelRoute('e', http.MethodGet, wui.MakeEditGetZettelHandler(ucGetZettel))
- webSrv.AddZettelRoute('e', http.MethodPost, wui.MakeEditSetZettelHandler(
- usecase.NewUpdateZettel(protectedPlaceManager)))
+ webSrv.AddZettelRoute('e', http.MethodPost, wui.MakeEditSetZettelHandler(ucUpdate))
webSrv.AddZettelRoute('f', http.MethodGet, wui.MakeGetFolgeZettelHandler(
ucGetZettel, usecase.NewFolgeZettel(rtConfig)))
webSrv.AddZettelRoute('f', http.MethodPost, wui.MakePostCreateZettelHandler(ucCreateZettel))
webSrv.AddZettelRoute('g', http.MethodGet, wui.MakeGetNewZettelHandler(
ucGetZettel, usecase.NewNewZettel()))
webSrv.AddZettelRoute('g', http.MethodPost, wui.MakePostCreateZettelHandler(ucCreateZettel))
}
webSrv.AddListRoute('f', http.MethodGet, wui.MakeSearchHandler(
- usecase.NewSearch(protectedPlaceManager), ucGetMeta, ucGetZettel))
+ usecase.NewSearch(protectedBoxManager), ucGetMeta, ucGetZettel))
webSrv.AddListRoute('h', http.MethodGet, wui.MakeListHTMLMetaHandler(
ucListMeta, ucListRoles, ucListTags))
webSrv.AddZettelRoute('h', http.MethodGet, wui.MakeGetHTMLZettelHandler(
ucParseZettel, ucGetMeta))
- webSrv.AddZettelRoute('i', http.MethodGet, wui.MakeGetInfoHandler(ucParseZettel, ucGetMeta))
+ webSrv.AddZettelRoute('i', http.MethodGet, wui.MakeGetInfoHandler(
+ ucParseZettel, ucGetMeta, ucGetAllMeta))
webSrv.AddZettelRoute('j', http.MethodGet, wui.MakeZettelContextHandler(ucZettelContext))
+ // API
webSrv.AddZettelRoute('l', http.MethodGet, api.MakeGetLinksHandler(ucParseZettel))
webSrv.AddZettelRoute('o', http.MethodGet, api.MakeGetOrderHandler(
- usecase.NewZettelOrder(protectedPlaceManager, ucParseZettel)))
+ usecase.NewZettelOrder(protectedBoxManager, ucParseZettel)))
webSrv.AddListRoute('r', http.MethodGet, api.MakeListRoleHandler(ucListRoles))
webSrv.AddListRoute('t', http.MethodGet, api.MakeListTagsHandler(ucListTags))
- webSrv.AddZettelRoute('y', http.MethodGet, api.MakeZettelContextHandler(ucZettelContext))
+ webSrv.AddListRoute('v', http.MethodPost, api.MakePostLoginHandler(ucAuthenticate))
+ webSrv.AddListRoute('v', http.MethodPut, api.MakeRenewAuthHandler())
+ webSrv.AddZettelRoute('x', http.MethodGet, api.MakeZettelContextHandler(ucZettelContext))
webSrv.AddListRoute('z', http.MethodGet, api.MakeListMetaHandler(
- usecase.NewListMeta(protectedPlaceManager), ucGetMeta, ucParseZettel))
+ usecase.NewListMeta(protectedBoxManager), ucGetMeta, ucParseZettel))
webSrv.AddZettelRoute('z', http.MethodGet, api.MakeGetZettelHandler(
ucParseZettel, ucGetMeta))
+ if !authManager.IsReadonly() {
+ webSrv.AddListRoute('z', http.MethodPost, api.MakePostCreateZettelHandler(ucCreateZettel))
+ webSrv.AddZettelRoute('z', http.MethodDelete, api.MakeDeleteZettelHandler(ucDelete))
+ webSrv.AddZettelRoute('z', http.MethodPut, api.MakeUpdateZettelHandler(ucUpdate))
+ webSrv.AddZettelRoute('z', zsapi.MethodMove, api.MakeRenameZettelHandler(ucRename))
+ }
if authManager.WithAuth() {
- webSrv.SetUserRetriever(usecase.NewGetUserByZid(placeManager))
+ webSrv.SetUserRetriever(usecase.NewGetUserByZid(boxManager))
}
}
Index: cmd/command.go
==================================================================
--- cmd/command.go
+++ cmd/command.go
@@ -17,16 +17,17 @@
"zettelstore.de/z/domain/meta"
)
// Command stores information about commands / sub-commands.
type Command struct {
- Name string // command name as it appears on the command line
- Func CommandFunc // function that executes a command
- Places bool // if true then places will be set up
- Header bool // Print a heading on startup
- Flags func(*flag.FlagSet) // function to set up flag.FlagSet
- flags *flag.FlagSet // flags that belong to the command
+ Name string // command name as it appears on the command line
+ Func CommandFunc // function that executes a command
+ Boxes bool // if true then boxes will be set up
+ Header bool // Print a heading on startup
+ LineServer bool // Start admin line server
+ Flags func(*flag.FlagSet) // function to set up flag.FlagSet
+ flags *flag.FlagSet // flags that belong to the command
}
// CommandFunc is the function that executes the command.
// It accepts the parsed command line parameters.
Index: cmd/fd_limit_raise.go
==================================================================
--- cmd/fd_limit_raise.go
+++ cmd/fd_limit_raise.go
@@ -39,9 +39,9 @@
err = syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
if err != nil {
return err
}
if rLimit.Cur < minFiles {
- log.Printf("Make sure you have no more than %d files in all your places if you enabled notification\n", rLimit.Cur)
+ log.Printf("Make sure you have no more than %d files in all your boxes if you enabled notification\n", rLimit.Cur)
}
return nil
}
Index: cmd/main.go
==================================================================
--- cmd/main.go
+++ cmd/main.go
@@ -20,18 +20,18 @@
"strconv"
"strings"
"zettelstore.de/z/auth"
"zettelstore.de/z/auth/impl"
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/compbox"
+ "zettelstore.de/z/box/manager"
"zettelstore.de/z/config"
"zettelstore.de/z/domain/id"
"zettelstore.de/z/domain/meta"
"zettelstore.de/z/input"
"zettelstore.de/z/kernel"
- "zettelstore.de/z/place"
- "zettelstore.de/z/place/manager"
- "zettelstore.de/z/place/progplace"
"zettelstore.de/z/web/server"
)
const (
defConfigfile = ".zscfg"
@@ -52,20 +52,21 @@
Name: "version",
Func: func(*flag.FlagSet, *meta.Meta) (int, error) { return 0, nil },
Header: true,
})
RegisterCommand(Command{
- Name: "run",
- Func: runFunc,
- Places: true,
- Header: true,
- Flags: flgRun,
+ Name: "run",
+ Func: runFunc,
+ Boxes: true,
+ Header: true,
+ LineServer: true,
+ Flags: flgRun,
})
RegisterCommand(Command{
Name: "run-simple",
Func: runSimpleFunc,
- Places: true,
+ Boxes: true,
Header: true,
Flags: flgSimpleRun,
})
RegisterCommand(Command{
Name: "file",
@@ -111,11 +112,11 @@
if strings.HasPrefix(val, "/") {
val = "dir://" + val
} else {
val = "dir:" + val
}
- cfg.Set(keyPlaceOneURI, val)
+ cfg.Set(keyBoxOneURI, val)
case "r":
cfg.Set(keyReadOnly, flg.Value.String())
case "v":
cfg.Set(keyVerbose, flg.Value.String())
}
@@ -131,22 +132,22 @@
}
return strconv.Itoa(port), nil
}
const (
- keyAdminPort = "admin-port"
- keyDefaultDirPlaceType = "default-dir-place-type"
- keyInsecureCookie = "insecure-cookie"
- keyListenAddr = "listen-addr"
- keyOwner = "owner"
- keyPersistentCookie = "persistent-cookie"
- keyPlaceOneURI = kernel.PlaceURIs + "1"
- keyReadOnly = "read-only-mode"
- keyTokenLifetimeHTML = "token-lifetime-html"
- keyTokenLifetimeAPI = "token-lifetime-api"
- keyURLPrefix = "url-prefix"
- keyVerbose = "verbose"
+ keyAdminPort = "admin-port"
+ keyDefaultDirBoxType = "default-dir-box-type"
+ keyInsecureCookie = "insecure-cookie"
+ keyListenAddr = "listen-addr"
+ keyOwner = "owner"
+ keyPersistentCookie = "persistent-cookie"
+ keyBoxOneURI = kernel.BoxURIs + "1"
+ keyReadOnly = "read-only-mode"
+ keyTokenLifetimeHTML = "token-lifetime-html"
+ keyTokenLifetimeAPI = "token-lifetime-api"
+ keyURLPrefix = "url-prefix"
+ keyVerbose = "verbose"
)
func setServiceConfig(cfg *meta.Meta) error {
ok := setConfigValue(true, kernel.CoreService, kernel.CoreVerbose, cfg.GetBool(keyVerbose))
if val, found := cfg.Get(keyAdminPort); found {
@@ -155,21 +156,21 @@
ok = setConfigValue(ok, kernel.AuthService, kernel.AuthOwner, cfg.GetDefault(keyOwner, ""))
ok = setConfigValue(ok, kernel.AuthService, kernel.AuthReadonly, cfg.GetBool(keyReadOnly))
ok = setConfigValue(
- ok, kernel.PlaceService, kernel.PlaceDefaultDirType,
- cfg.GetDefault(keyDefaultDirPlaceType, kernel.PlaceDirTypeNotify))
- ok = setConfigValue(ok, kernel.PlaceService, kernel.PlaceURIs+"1", "dir:./zettel")
- format := kernel.PlaceURIs + "%v"
+ ok, kernel.BoxService, kernel.BoxDefaultDirType,
+ cfg.GetDefault(keyDefaultDirBoxType, kernel.BoxDirTypeNotify))
+ ok = setConfigValue(ok, kernel.BoxService, kernel.BoxURIs+"1", "dir:./zettel")
+ format := kernel.BoxURIs + "%v"
for i := 1; ; i++ {
key := fmt.Sprintf(format, i)
val, found := cfg.Get(key)
if !found {
break
}
- ok = setConfigValue(ok, kernel.PlaceService, key, val)
+ ok = setConfigValue(ok, kernel.BoxService, key, val)
}
ok = setConfigValue(
ok, kernel.WebService, kernel.WebListenAddress,
cfg.GetDefault(keyListenAddr, "127.0.0.1:23123"))
@@ -193,34 +194,34 @@
kernel.Main.Log("unable to set configuration:", key, val)
}
return ok && done
}
-func setupOperations(cfg *meta.Meta, withPlaces bool) {
- var createManager kernel.CreatePlaceManagerFunc
- if withPlaces {
+func setupOperations(cfg *meta.Meta, withBoxes bool) {
+ var createManager kernel.CreateBoxManagerFunc
+ if withBoxes {
err := raiseFdLimit()
if err != nil {
srvm := kernel.Main
srvm.Log("Raising some limitions did not work:", err)
srvm.Log("Prepare to encounter errors. Most of them can be mitigated. See the manual for details")
- srvm.SetConfig(kernel.PlaceService, kernel.PlaceDefaultDirType, kernel.PlaceDirTypeSimple)
+ srvm.SetConfig(kernel.BoxService, kernel.BoxDefaultDirType, kernel.BoxDirTypeSimple)
}
- createManager = func(placeURIs []*url.URL, authManager auth.Manager, rtConfig config.Config) (place.Manager, error) {
- progplace.Setup(cfg)
- return manager.New(placeURIs, authManager, rtConfig)
+ createManager = func(boxURIs []*url.URL, authManager auth.Manager, rtConfig config.Config) (box.Manager, error) {
+ compbox.Setup(cfg)
+ return manager.New(boxURIs, authManager, rtConfig)
}
} else {
- createManager = func([]*url.URL, auth.Manager, config.Config) (place.Manager, error) { return nil, nil }
+ createManager = func([]*url.URL, auth.Manager, config.Config) (box.Manager, error) { return nil, nil }
}
kernel.Main.SetCreators(
func(readonly bool, owner id.Zid) (auth.Manager, error) {
return impl.New(readonly, owner, cfg.GetDefault("secret", "")), nil
},
createManager,
- func(srv server.Server, plMgr place.Manager, authMgr auth.Manager, rtConfig config.Config) error {
+ func(srv server.Server, plMgr box.Manager, authMgr auth.Manager, rtConfig config.Config) error {
setupRouting(srv, plMgr, authMgr, rtConfig)
return nil
},
)
}
@@ -239,12 +240,12 @@
cfg := getConfig(fs)
if err := setServiceConfig(cfg); err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", name, err)
return 2
}
- setupOperations(cfg, command.Places)
- kernel.Main.Start(command.Header)
+ setupOperations(cfg, command.Boxes)
+ kernel.Main.Start(command.Header, command.LineServer)
exitCode, err := command.Func(fs, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", name, err)
}
kernel.Main.Shutdown(true)
Index: cmd/register.go
==================================================================
--- cmd/register.go
+++ cmd/register.go
@@ -11,10 +11,15 @@
// Package cmd provides command generic functions.
package cmd
// Mention all needed encoders, parsers and stores to have them registered.
import (
+ _ "zettelstore.de/z/box/compbox" // Allow to use computed box.
+ _ "zettelstore.de/z/box/constbox" // Allow to use global internal box.
+ _ "zettelstore.de/z/box/dirbox" // Allow to use directory box.
+ _ "zettelstore.de/z/box/filebox" // Allow to use file box.
+ _ "zettelstore.de/z/box/membox" // Allow to use in-memory box.
_ "zettelstore.de/z/encoder/htmlenc" // Allow to use HTML encoder.
_ "zettelstore.de/z/encoder/jsonenc" // Allow to use JSON encoder.
_ "zettelstore.de/z/encoder/nativeenc" // Allow to use native encoder.
_ "zettelstore.de/z/encoder/rawenc" // Allow to use raw encoder.
_ "zettelstore.de/z/encoder/textenc" // Allow to use text encoder.
@@ -23,11 +28,6 @@
_ "zettelstore.de/z/parser/blob" // Allow to use BLOB parser.
_ "zettelstore.de/z/parser/markdown" // Allow to use markdown parser.
_ "zettelstore.de/z/parser/none" // Allow to use none parser.
_ "zettelstore.de/z/parser/plain" // Allow to use plain parser.
_ "zettelstore.de/z/parser/zettelmark" // Allow to use zettelmark parser.
- _ "zettelstore.de/z/place/constplace" // Allow to use global internal place.
- _ "zettelstore.de/z/place/dirplace" // Allow to use directory place.
- _ "zettelstore.de/z/place/fileplace" // Allow to use file place.
- _ "zettelstore.de/z/place/memplace" // Allow to use memory place.
- _ "zettelstore.de/z/place/progplace" // Allow to use computed place.
)
Index: collect/collect.go
==================================================================
--- collect/collect.go
+++ collect/collect.go
@@ -9,13 +9,11 @@
//-----------------------------------------------------------------------------
// Package collect provides functions to collect items from a syntax tree.
package collect
-import (
- "zettelstore.de/z/ast"
-)
+import "zettelstore.de/z/ast"
// Summary stores the relevant parts of the syntax tree
type Summary struct {
Links []*ast.Reference // list of all referenced links
Images []*ast.Reference // list of all referenced images
@@ -22,82 +20,24 @@
Cites []*ast.CiteNode // list of all referenced citations
}
// References returns all references mentioned in the given zettel. This also
// includes references to images.
-func References(zn *ast.ZettelNode) Summary {
- lv := linkVisitor{}
- ast.NewTopDownTraverser(&lv).VisitBlockSlice(zn.Ast)
- return lv.summary
-}
-
-type linkVisitor struct {
- summary Summary
-}
-
-// VisitVerbatim does nothing.
-func (lv *linkVisitor) VisitVerbatim(vn *ast.VerbatimNode) {}
-
-// VisitRegion does nothing.
-func (lv *linkVisitor) VisitRegion(rn *ast.RegionNode) {}
-
-// VisitHeading does nothing.
-func (lv *linkVisitor) VisitHeading(hn *ast.HeadingNode) {}
-
-// VisitHRule does nothing.
-func (lv *linkVisitor) VisitHRule(hn *ast.HRuleNode) {}
-
-// VisitList does nothing.
-func (lv *linkVisitor) VisitNestedList(ln *ast.NestedListNode) {}
-
-// VisitDescriptionList does nothing.
-func (lv *linkVisitor) VisitDescriptionList(dn *ast.DescriptionListNode) {}
-
-// VisitPara does nothing.
-func (lv *linkVisitor) VisitPara(pn *ast.ParaNode) {}
-
-// VisitTable does nothing.
-func (lv *linkVisitor) VisitTable(tn *ast.TableNode) {}
-
-// VisitBLOB does nothing.
-func (lv *linkVisitor) VisitBLOB(bn *ast.BLOBNode) {}
-
-// VisitText does nothing.
-func (lv *linkVisitor) VisitText(tn *ast.TextNode) {}
-
-// VisitTag does nothing.
-func (lv *linkVisitor) VisitTag(tn *ast.TagNode) {}
-
-// VisitSpace does nothing.
-func (lv *linkVisitor) VisitSpace(sn *ast.SpaceNode) {}
-
-// VisitBreak does nothing.
-func (lv *linkVisitor) VisitBreak(bn *ast.BreakNode) {}
-
-// VisitLink collects the given link as a reference.
-func (lv *linkVisitor) VisitLink(ln *ast.LinkNode) {
- lv.summary.Links = append(lv.summary.Links, ln.Ref)
-}
-
-// VisitImage collects the image links as a reference.
-func (lv *linkVisitor) VisitImage(in *ast.ImageNode) {
- if in.Ref != nil {
- lv.summary.Images = append(lv.summary.Images, in.Ref)
- }
-}
-
-// VisitCite collects the citation.
-func (lv *linkVisitor) VisitCite(cn *ast.CiteNode) {
- lv.summary.Cites = append(lv.summary.Cites, cn)
-}
-
-// VisitFootnote does nothing.
-func (lv *linkVisitor) VisitFootnote(fn *ast.FootnoteNode) {}
-
-// VisitMark does nothing.
-func (lv *linkVisitor) VisitMark(mn *ast.MarkNode) {}
-
-// VisitFormat does nothing.
-func (lv *linkVisitor) VisitFormat(fn *ast.FormatNode) {}
-
-// VisitLiteral does nothing.
-func (lv *linkVisitor) VisitLiteral(ln *ast.LiteralNode) {}
+func References(zn *ast.ZettelNode) (s Summary) {
+ ast.WalkBlockSlice(&s, zn.Ast)
+ return s
+}
+
+// Visit all node to collect data for the summary.
+func (s *Summary) Visit(node ast.Node) ast.Visitor {
+ switch n := node.(type) {
+ case *ast.LinkNode:
+ s.Links = append(s.Links, n.Ref)
+ case *ast.ImageNode:
+ if n.Ref != nil {
+ s.Images = append(s.Images, n.Ref)
+ }
+ case *ast.CiteNode:
+ s.Cites = append(s.Cites, n)
+ }
+ return s
+}
Index: collect/collect_test.go
==================================================================
--- collect/collect_test.go
+++ collect/collect_test.go
@@ -25,10 +25,11 @@
}
return r
}
func TestLinks(t *testing.T) {
+ t.Parallel()
zn := &ast.ZettelNode{}
summary := collect.References(zn)
if summary.Links != nil || summary.Images != nil {
t.Error("No links/images expected, but got:", summary.Links, "and", summary.Images)
}
@@ -52,10 +53,11 @@
t.Error("Link count does not work. Expected: 3, got", summary.Links)
}
}
func TestImage(t *testing.T) {
+ t.Parallel()
zn := &ast.ZettelNode{
Ast: ast.BlockSlice{
&ast.ParaNode{
Inlines: ast.InlineSlice{
&ast.ImageNode{Ref: parseRef("12345678901234")},
Index: collect/order.go
==================================================================
--- collect/order.go
+++ collect/order.go
@@ -15,11 +15,11 @@
// Order of internal reference within the given zettel.
func Order(zn *ast.ZettelNode) (result []*ast.Reference) {
for _, bn := range zn.Ast {
if ln, ok := bn.(*ast.NestedListNode); ok {
- switch ln.Code {
+ switch ln.Kind {
case ast.NestedListOrdered, ast.NestedListUnordered:
for _, is := range ln.Items {
if ref := firstItemZettelReference(is); ref != nil {
result = append(result, ref)
}
Index: config/config.go
==================================================================
--- config/config.go
+++ config/config.go
@@ -54,14 +54,10 @@
GetMarkerExternal() string
// GetFooterHTML returns HTML code that should be embedded into the footer
// of each WebUI page.
GetFooterHTML() string
-
- // GetListPageSize returns the maximum length of a list to be returned in WebUI.
- // A value less or equal to zero signals no limit.
- GetListPageSize() int
}
// AuthConfig are relevant configuration values for authentication.
type AuthConfig interface {
// GetExpertMode returns the current value of the "expert-mode" key
Index: docs/manual/00001002000000.zettel
==================================================================
--- docs/manual/00001002000000.zettel
+++ docs/manual/00001002000000.zettel
@@ -13,14 +13,14 @@
: All zettel belong to you, only to you.
Zettelstore provides its services only to one person: you.
If your device is securely configured, there should be no risk that others are able to read or update your zettel.
: If you want, you can customize Zettelstore in a way that some specific or all persons are able to read some of your zettel.
; Ease of installation
-: If you want to use the Zettelstore software, all you need is to copy the executable to an appropriate place and start working.
+: If you want to use the Zettelstore software, all you need is to copy the executable to an appropriate file directory and start working.
: Upgrading the software is done just by replacing the executable with a newer one.
; Ease of operation
-: There is only one executable for Zettelstore and one directory, where your zettel are placed.
+: There is only one executable for Zettelstore and one directory, where your zettel are stored.
: If you decide to use multiple directories, you are free to configure Zettelstore appropriately.
; Multiple modes of operation
: You can use Zettelstore as a standalone software on your device, but you are not restricted to it.
: You can install the software on a central server, or you can install it on all your devices with no restrictions how to synchronize your zettel.
; Multiple user interfaces
Index: docs/manual/00001003000000.zettel
==================================================================
--- docs/manual/00001003000000.zettel
+++ docs/manual/00001003000000.zettel
@@ -7,11 +7,11 @@
=== The curious user
You just want to check out the Zettelstore software
* Grab the appropriate executable and copy it into any directory
* Start the Zettelstore software, e.g. with a double click
-* A sub-directory ""zettel"" will be created in the directory where you placed the executable.
+* A sub-directory ""zettel"" will be created in the directory where you put the executable.
It will contain your future zettel.
* Open the URI [[http://localhost:23123]] with your web browser.
It will present you a mostly empty Zettelstore.
There will be a zettel titled ""[[Home|00010000000000]]"" that contains some helpful information.
* Please read the instructions for the web-based user interface and learn about the various ways to write zettel.
@@ -41,11 +41,11 @@
--create-home --home-dir /var/lib/zettelstore \
--shell /usr/sbin/nologin \
--comment "Zettelstore server" \
zettelstore
```
-Create a systemd service file and place it into ''/etc/systemd/system/zettelstore.service'':
+Create a systemd service file and store it into ''/etc/systemd/system/zettelstore.service'':
```ini
[Unit]
Description=Zettelstore
After=network.target
Index: docs/manual/00001004010000.zettel
==================================================================
--- docs/manual/00001004010000.zettel
+++ docs/manual/00001004010000.zettel
@@ -1,15 +1,15 @@
id: 00001004010000
title: Zettelstore startup configuration
role: manual
tags: #configuration #manual #zettelstore
syntax: zmk
-modified: 20210525121644
+modified: 20210712234656
The configuration file, as specified by the ''-c CONFIGFILE'' [[command line option|00001004051000]], allows you to specify some startup options.
These options cannot be stored in a [[configuration zettel|00001004020000]] because either they are needed before Zettelstore can start or because of security reasons.
-For example, Zettelstore need to know in advance, on which network address is must listen or where zettel are placed.
+For example, Zettelstore need to know in advance, on which network address is must listen or where zettel are stored.
An attacker that is able to change the owner can do anything.
Therefore only the owner of the computer on which Zettelstore runs can change this information.
The file for startup configuration must be created via a text editor in advance.
@@ -16,18 +16,26 @@
The syntax of the configuration file is the same as for any zettel metadata.
The following keys are supported:
; [!admin-port]''admin-port''
: Specifies the TCP port through which you can reach the [[administrator console|00001004100000]].
- A value of ''0'' (the default) disables the administrators console.
+ A value of ''0'' (the default) disables the administrator console.
+ The administrator console will only be enabled if Zettelstore is started with the [[''run'' sub-command|00001004051000]].
On most operating systems, the value must be greater than ''1024'' unless you start Zettelstore with the full privileges of a system administrator (which is not recommended).
Default: ''0''
-; [!default-dir-place-type]''default-dir-place-type''
-: Specifies the default value for the (sub-) type of [[directory places|00001004011400#type]].
- Zettel are typically stored in such places.
+; [!box-uri-x]''box-uri-//X//'', where //X// is a number greater or equal to one
+: Specifies a [[box|00001004011200]] where zettel are stored.
+ During startup //X// is counted up, starting with one, until no key is found.
+ This allows to configure more than one box.
+
+ If no ''box-uri-1'' key is given, the overall effect will be the same as if only ''box-uri-1'' was specified with the value ''dir://.zettel''.
+ In this case, even a key ''box-uri-2'' will be ignored.
+; [!default-dir-box-type]''default-dir-box-type''
+: Specifies the default value for the (sub-) type of [[directory boxes|00001004011400#type]].
+ Zettel are typically stored in such boxes.
Default: ''notify''
; [!insecure-cookie]''insecure-cookie''
: Must be set to ''true'', if authentication is enabled and Zettelstore is not accessible not via HTTPS (but via HTTP).
Otherwise web browser are free to ignore the authentication cookie.
@@ -50,17 +58,10 @@
If ''true'', a persistent cookie is used.
Its lifetime exceeds the lifetime of the authentication token (see option ''token-lifetime-html'') by 30 seconds.
Default: ''false''
-; [!place-uri-X]''place-uri-//X//'', where //X// is a number greater or equal to one
-: Specifies a [[place|00001004011200]] where zettel are stored.
- During startup //X// is counted up, starting with one, until no key is found.
- This allows to configure more than one place.
-
- If no ''place-uri-1'' key is given, the overall effect will be the same as if only ''place-uri-1'' was specified with the value ''dir://.zettel''.
- In this case, even a key ''place-uri-2'' will be ignored.
; [!read-only-mode]''read-only-mode''
: Puts the Zettelstore web service into a read-only mode.
No changes are possible.
Default: false.
; [!token-lifetime-api]''token-lifetime-api'', [!token-lifetime-html]''token-lifetime-html''
@@ -80,7 +81,5 @@
This allows to use a forwarding proxy [[server|00001010090100]] in front of the Zettelstore.
; ''verbose''
: Be more verbose inf logging data.
Default: false
-
-Other keys will be ignored.
Index: docs/manual/00001004011200.zettel
==================================================================
--- docs/manual/00001004011200.zettel
+++ docs/manual/00001004011200.zettel
@@ -1,46 +1,46 @@
id: 00001004011200
-title: Zettelstore places
+title: Zettelstore boxes
role: manual
tags: #configuration #manual #zettelstore
syntax: zmk
modified: 20210525121452
A Zettelstore must store its zettel somehow and somewhere.
In most cases you want to store your zettel as files in a directory.
-Under certain circumstances you may want to store your zettel in other places.
+Under certain circumstances you may want to store your zettel elsewhere.
An example are the [[predefined zettel|00001005090000]] that come with a Zettelstore.
They are stored within the software itself.
In another situation you may want to store your zettel volatile, e.g. if you want to provide a sandbox for experimenting.
-To cope with these (and more) situations, you configure Zettelstore to use one or more places.
-This is done via the ''place-uri-X'' keys of the [[startup configuration|00001004010000#place-uri-X]] (X is a number).
-Places are specified using special [[URIs|https://en.wikipedia.org/wiki/Uniform_Resource_Identifier]], somehow similar to web addresses.
+To cope with these (and more) situations, you configure Zettelstore to use one or more //boxes//{-}.
+This is done via the ''box-uri-X'' keys of the [[startup configuration|00001004010000#box-uri-X]] (X is a number).
+Boxes are specified using special [[URIs|https://en.wikipedia.org/wiki/Uniform_Resource_Identifier]], somehow similar to web addresses.
-The following place URIs are supported:
+The following box URIs are supported:
; ''dir:\//DIR''
: Specifies a directory where zettel files are stored.
''DIR'' is the file path.
Although it is possible to use relative file paths, such as ''./zettel'' (→ URI is ''dir:\//.zettel''), it is preferable to use absolute file paths, e.g. ''/home/user/zettel''.
The directory must exist before starting the Zettelstore[^There is one exception: when Zettelstore is [[started without any parameter|00001004050000]], e.g. via double-clicking its icon, an directory called ''./zettel'' will be created.].
- It is possible to [[configure|00001004011400]] a directory place.
+ It is possible to [[configure|00001004011400]] a directory box.
; ''file:FILE.zip'' oder ''file:/\//path/to/file.zip''
: Specifies a ZIP file which contains files that store zettel.
You can create such a ZIP file, if you zip a directory full of zettel files.
- This place is always read-only.
+ This box is always read-only.
; ''mem:''
: Stores all its zettel in volatile memory.
If you stop the Zettelstore, all changes are lost.
-All places that you configure via the ''store-uri-X'' keys form a chain of places.
-If a zettel should be retrieved, a search starts in the place specified with the ''place-uri-2'' key, then ''place-uri-3'' and so on.
-If a zettel is created or changed, it is always stored in the place specified with the ''place-uri-1'' key.
-This allows to overwrite zettel from other places, e.g. the predefined zettel.
+All boxes that you configure via the ''box-uri-X'' keys form a chain of boxes.
+If a zettel should be retrieved, a search starts in the box specified with the ''box-uri-2'' key, then ''box-uri-3'' and so on.
+If a zettel is created or changed, it is always stored in the box specified with the ''box-uri-1'' key.
+This allows to overwrite zettel from other boxes, e.g. the predefined zettel.
-If you use the ''mem:'' place, where zettel are stored in volatile memory, it makes only sense if you configure it as ''place-uri-1''.
-Such a place will be empty when Zettelstore starts and only the first place will receive updates.
+If you use the ''mem:'' box, where zettel are stored in volatile memory, it makes only sense if you configure it as ''box-uri-1''.
+Such a box will be empty when Zettelstore starts and only the first box will receive updates.
You must make sure that your computer has enough RAM to store all zettel.
Index: docs/manual/00001004011400.zettel
==================================================================
--- docs/manual/00001004011400.zettel
+++ docs/manual/00001004011400.zettel
@@ -1,29 +1,29 @@
id: 00001004011400
-title: Configure file directory places
+title: Configure file directory boxes
role: manual
tags: #configuration #manual #zettelstore
syntax: zmk
modified: 20210525121232
-Under certain circumstances, it is preferable to further configure a file directory place.
-This is done by appending query parameters after the base place URI ''dir:\//DIR''.
+Under certain circumstances, it is preferable to further configure a file directory box.
+This is done by appending query parameters after the base box URI ''dir:\//DIR''.
The following parameters are supported:
|= Parameter:|Description|Default value:|
-|type|(Sub-) Type of the directory service|(value of ''[[default-dir-place-type|00001004010000#default-dir-place-type]]'')
+|type|(Sub-) Type of the directory service|(value of ''[[default-dir-box-type|00001004010000#default-dir-box-type]]'')
|rescan|Time (in seconds) after which the directory should be scanned fully|600
|worker|Number of worker that can access the directory in parallel|(depends on type)
|readonly|Allow only operations that do not change a zettel or create a new zettel|n/a
=== Type
On some operating systems, Zettelstore tries to detect changes to zettel files outside of Zettelstore's control[^This includes Linux, Windows, and macOS.].
On other operating systems, this may be not possible, due to technical limitations.
-Automatic detection of external changes is also not possible, if zettel files are placed on an external service, such as a file server accessed via SMD/CIFS or NFS.
+Automatic detection of external changes is also not possible, if zettel files are put on an external service, such as a file server accessed via SMD/CIFS or NFS.
-To cope with this uncertainty, Zettelstore provides various internal implementations of a directory place.
+To cope with this uncertainty, Zettelstore provides various internal implementations of a directory box.
The default values should match the needs of different users, as explained in the [[installation part|00001003000000]] of this manual.
The following values are supported:
; simple
: Is not able to detect external changes.
@@ -39,44 +39,44 @@
Under certain circumstances it is possible that Zettelstore does not detect a change done by another software.
To cope with this unlikely, but still possible event, Zettelstore re-scans periodically the file directory.
The time interval is configured by the ''rescan'' parameter, e.g.
```
-place-uri-1: dir:///home/zettel?rescan=300
+box-uri-1: dir:///home/zettel?rescan=300
```
This makes Zettelstore to re-scan the directory ''/home/zettel/'' every 300 seconds, i.e. 5 minutes.
For a Zettelstore with many zettel, re-scanning the directory may take a while, especially if it is stored on a remote computer (e.g. via CIFS/SMB or NFS).
In this case, you should adjust the parameter value.
Please note that a directory re-scan invalidates all internal data of a Zettelstore.
It might trigger a re-build of the backlink database (and other internal databases).
-Therefore a large value if preferred.
+Therefore a large value is preferred.
-This value is ignored for other directory place type, such as ""simple"".
+This value is ignored for other directory box types, such as ""simple"".
=== Worker
Internally, Zettelstore parallels concurrent requests for a zettel or its metadata.
The number of parallel activities is configured by the ''worker'' parameter.
A computer contains a limited number of internal processing units (CPU).
Its number ranges from 1 to (currently) 128, e.g. in bigger server environments.
Zettelstore typically runs on a system with 1 to 8 CPUs.
Access to zettel file is ultimately managed by the underlying operating system.
-Depending on the hardware and on the type of the directory place, only a limited number of parallel accesses are desirable.
+Depending on the hardware and on the type of the directory box, only a limited number of parallel accesses are desirable.
On smaller hardware[^In comparison to a normal desktop or laptop computer], such as the [[Raspberry Zero|https://www.raspberrypi.org/products/raspberry-pi-zero/]], a smaller value might be appropriate.
Every worker needs some amount of main memory (RAM) and some amount of processing power.
On bigger hardware, with some fast file services, a bigger value could result in higher performance, if needed.
-For a directory place of type ""notify"", the default value is: 7.
-The directory place type ""simple"" limits the value to a maximum of 1, i.e. no concurrency is possible with this type of directory place.
+For a directory box of type ""notify"", the default value is: 7.
+The directory box type ""simple"" limits the value to a maximum of 1, i.e. no concurrency is possible with this type of directory box.
For various reasons, the value should be a prime number, with a maximum value of 1499.
=== Readonly
-Sometimes you may want to provide zettel from a file directory place, but you want to disallow any changes.
-If you provide the query parameter ''readonly'' (with or without a corresponding value), the place will disallow any changes.
+Sometimes you may want to provide zettel from a file directory box, but you want to disallow any changes.
+If you provide the query parameter ''readonly'' (with or without a corresponding value), the box will disallow any changes.
```
-place-uri-1: dir:///home/zettel?readonly
+box-uri-1: dir:///home/zettel?readonly
```
-If you put the whole Zettelstore in [[read-only|00001004010000]] [[mode|00001004051000]], all configured file directory places will be in read-only mode too, even if not explicitly configured.
+If you put the whole Zettelstore in [[read-only|00001004010000]] [[mode|00001004051000]], all configured file directory boxes will be in read-only mode too, even if not explicitly configured.
Index: docs/manual/00001004020000.zettel
==================================================================
--- docs/manual/00001004020000.zettel
+++ docs/manual/00001004020000.zettel
@@ -1,10 +1,11 @@
id: 00001004020000
title: Configure the running Zettelstore
role: manual
tags: #configuration #manual #zettelstore
syntax: zmk
+modified: 20210611213730
You can configure a running Zettelstore by modifying the special zettel with the ID [[00000000000100]].
This zettel is called ""configuration zettel"".
The following metadata keys change the appearance / behavior of Zettelstore:
@@ -52,14 +53,10 @@
: Specifies the identifier of the zettel, that should be presented for the default view / home view.
If not given or if the identifier does not identify a zettel, the zettel with the identifier ''00010000000000'' is shown.
; [!marker-external]''marker-external''
: Some HTML code that is displayed after a reference to external material.
Default: ''&\#10138;'', to display a ""➚"" sign.
-; [!list-page-size]''list-page-size''
-: If set to a value greater than zero, specifies the number of items shown in WebUI lists.
- Basically, this is the list of all zettel (possibly restricted) and the list of search results.
- Default: ''0''.
; [!site-name]''site-name''
: Name of the Zettelstore instance.
Will be used when displaying some lists.
Default: ''Zettelstore''.
; [!yaml-header]''yaml-header''
Index: docs/manual/00001004050200.zettel
==================================================================
--- docs/manual/00001004050200.zettel
+++ docs/manual/00001004050200.zettel
@@ -1,21 +1,20 @@
id: 00001004050200
title: The ''help'' sub-command
role: manual
tags: #command #configuration #manual #zettelstore
syntax: zmk
-precursor: 00001004050000
+modified: 20210712233414
Lists all implemented sub-commands.
Example:
```
# zettelstore help
Available commands:
-- "config"
- "file"
- "help"
- "password"
- "run"
- "run-simple"
- "version"
```
Index: docs/manual/00001004050400.zettel
==================================================================
--- docs/manual/00001004050400.zettel
+++ docs/manual/00001004050400.zettel
@@ -1,28 +1,27 @@
id: 00001004050400
title: The ''version'' sub-command
role: manual
tags: #command #configuration #manual #zettelstore
syntax: zmk
-precursor: 00001004050000
+modified: 20210712234031
Emits some information about the Zettelstore's version.
This allows you to check, whether your installed Zettelstore is
-The name of the software (""Zettelstore"") and the build version information is given, as well as the compiler version, the name of the computer running the Zettelstore, and an indication about the operating system and the processor architecture of that computer.
-
-The build version information is a string like ''v1.0.2-34-gf567a3''.
-The part ""v1.0.2"" is the release version.
-The string ""34"" specifies the number of internal patches, after the release was published.
-""gf567a3"" is a code uniquely identify the version to the developer.
-
-Everything after the release version is optional, eg. ""v1.4.3"" is a valid build version information too.
+The name of the software (""Zettelstore"") and the build version information is given, as well as the compiler version, and an indication about the operating system and the processor architecture of that computer.
+
+The build version information is a string like ''1.0.2+351ae138b4''.
+The part ""1.0.2"" is the release version.
+""+351ae138b4"" is a code uniquely identifying the version to the developer.
+
+Everything after the release version is optional, eg. ""1.4.3"" is a valid build version information too.
Example:
```
# zettelstore version
-Zettelstore (v0.0.4/go1.15) running on mycomputer (linux/amd64)
+Zettelstore 1.0.2+351ae138b4 (go1.16.5@linux/amd64)
+Licensed under the latest version of the EUPL (European Union Public License)
```
-In this example, Zettelstore is running in the released version ""v.0.0.4"" and was compiled using [[Go, version 1.15|https://golang.org/doc/go1.15]].
-It runs on a computer named ""mycomputer"".
+In this example, Zettelstore is running in the released version ""1.0.2"" and was compiled using [[Go, version 1.16.5|https://golang.org/doc/go1.16]].
The software was build for running under a Linux operating system with an ""amd64"" processor.
Index: docs/manual/00001004051000.zettel
==================================================================
--- docs/manual/00001004051000.zettel
+++ docs/manual/00001004051000.zettel
@@ -1,18 +1,17 @@
id: 00001004051000
title: The ''run'' sub-command
role: manual
tags: #command #configuration #manual #zettelstore
syntax: zmk
-modified: 20210510153318
-precursor: 00001004050000
+modified: 20210712234419
=== ``zettelstore run``
This starts the web service.
```
-zettelstore run [-c CONFIGFILE] [-d DIR] [-p PORT] [-r] [-v]
+zettelstore run [-a PORT] [-c CONFIGFILE] [-d DIR] [-debug] [-p PORT] [-r] [-v]
```
; [!a]''-a PORT''
: Specifies the TCP port through which you can reach the [[administrator console|00001004100000]].
See the explanation of [[''admin-port''|00001004010000#admin-port]] for more details.
Index: docs/manual/00001004051100.zettel
==================================================================
--- docs/manual/00001004051100.zettel
+++ docs/manual/00001004051100.zettel
@@ -1,23 +1,24 @@
id: 00001004051100
title: The ''run-simple'' sub-command
role: manual
tags: #command #configuration #manual #zettelstore
syntax: zmk
-precursor: 00001004050000
+modified: 20210712234203
=== ``zettelstore run-simple``
This sub-command is implicitly called, when an user starts Zettelstore by double-clicking on its GUI icon.
It is s simplified variant of the [[''run'' sub-command|00001004051000]].
It allows only to specify a zettel directory.
The directory will be created automatically, if it does not exist.
-This is the only difference to the ''run'' sub-command, where the directory must exists.
+This is a difference to the ''run'' sub-command, where the directory must exists.
+In contrast to the ''run'' sub-command, other command line parameter are not allowed.
```
zettelstore run-simple [-d DIR]
```
; [!d]''-d DIR''
: Specifies ''DIR'' as the directory that contains all zettel.
Default is ''./zettel'' (''.\\zettel'' on Windows), where ''.'' denotes the ""current directory"".
Index: docs/manual/00001004051200.zettel
==================================================================
--- docs/manual/00001004051200.zettel
+++ docs/manual/00001004051200.zettel
@@ -1,11 +1,11 @@
id: 00001004051200
title: The ''file'' sub-command
role: manual
tags: #command #configuration #manual #zettelstore
syntax: zmk
-precursor: 00001004050000
+modified: 20210712234222
Reads zettel data from a file (or from standard input / stdin) and renders it to standard output / stdout.
This allows Zettelstore to render files manually.
```
zettelstore file [-t FORMAT] [file-1 [file-2]]
Index: docs/manual/00001004051400.zettel
==================================================================
--- docs/manual/00001004051400.zettel
+++ docs/manual/00001004051400.zettel
@@ -1,11 +1,11 @@
id: 00001004051400
title: The ''password'' sub-command
role: manual
tags: #command #configuration #manual #zettelstore
syntax: zmk
-precursor: 00001004050000
+modified: 20210712234305
This sub-command is used to create a hashed password for to be authenticated users.
It reads a password from standard input (two times, both must be equal) and writes the hashed password to standard output.
@@ -15,11 +15,11 @@
```
``IDENT`` is the identification for the user that should be authenticated.
``ZETTEL-ID`` is the [[identifier of the zettel|00001006050000]] that later acts as a user zettel.
-See [[Creating an user zettel|00001010040200]] for background.
+See [[Creating an user zettel|00001010040200]] for some background information.
An example:
```
# zettelstore password bob 20200911115600
Index: docs/manual/00001004101000.zettel
==================================================================
--- docs/manual/00001004101000.zettel
+++ docs/manual/00001004101000.zettel
@@ -58,14 +58,14 @@
: Sets a single configuration value for the next configuration of a given service.
It will become effective if the service is restarted.
If the key specifies a list value, all other list values with a number greater than the given key are deleted.
You can use the special number ""0"" to delete all values.
- E.g. ``set-config place place-uri-0 any_text`` will remove all values of the list //place-uri-//.
+ E.g. ``set-config box box-uri-0 any_text`` will remove all values of the list //box-uri-//.
; ''shutdown''
: Terminate the Zettelstore itself (and closes the connection to the administrator console).
; ''start SERVICE''
: Start the given bservice and all dependent services.
; ''stat SERVICE''
: Display some statistical values for the given service.
; ''stop SERVICE''
: Stop the given service and all other that depend on this.
Index: docs/manual/00001005000000.zettel
==================================================================
--- docs/manual/00001005000000.zettel
+++ docs/manual/00001005000000.zettel
@@ -1,10 +1,11 @@
id: 00001005000000
title: Structure of Zettelstore
role: manual
tags: #design #manual #zettelstore
syntax: zmk
+modified: 20210614165848
Zettelstore is a software that manages your zettel.
Since every zettel must be readable without any special tool, most zettel has to be stored as ordinary files within specific directories.
Typically, file names and file content must comply to specific rules so that Zettelstore can manage them.
If you add, delete, or change zettel files with other tools, e.g. a text editor, Zettelstore will monitor these actions.
@@ -18,11 +19,11 @@
Zettelstore becomes extensible by external software.
For example, a more sophisticated web interface could be build, or an application for your mobile device that allows you to send content to your Zettelstore as new zettel.
=== Where zettel are stored
-Your zettel are stored as files in a specific directory.
+Your zettel are stored typically as files in a specific directory.
If you have not explicitly specified the directory, a default directory will be used.
The directory has to be specified at [[startup time|00001004010000]].
Nested directories are not supported (yet).
Every file in this directory that should be monitored by Zettelstore must have a file name that begins with 14 digits (0-9), the [[zettel identifier|00001006050000]].
@@ -46,24 +47,24 @@
The solution is a ''.meta'' file with the same zettel identifier.
Zettelstore recognizes this situation and reads in both files for the one zettel containing the figure.
It maintains this relationship as long as theses files exists.
In case of some textual zettel content you do not want to store the metadata and the zettel content in two different files.
-Here the ''.zettel'' extension will signal that the metadata and the zettel content will be placed in the same file, separated by an empty line or a line with three dashes (""''-\-\-''"", also known as ""YAML separator"").
+Here the ''.zettel'' extension will signal that the metadata and the zettel content will be put in the same file, separated by an empty line or a line with three dashes (""''-\-\-''"", also known as ""YAML separator"").
=== Predefined zettel
Zettelstore contains some [[predefined zettel|00001005090000]] to work properly.
The [[configuration zettel|00001004020000]] is one example.
To render the builtin web interface, some templates are used, as well as a layout specification in CSS.
-The icon that visualizes an external link is a predefined SVG image.
+The icon that visualizes a broken image is a predefined GIF image.
All of these are visible to the Zettelstore as zettel.
One reason for this is to allow you to modify these zettel to adapt Zettelstore to your needs and visual preferences.
Where are these zettel stored?
-They are stored within the Zettelstore software itself, because one design goal was to have just one file to use Zettelstore.
+They are stored within the Zettelstore software itself, because one design goal was to have just one executable file to use Zettelstore.
But data stored within an executable programm cannot be changed later[^Well, it can, but it is a very bad idea to allow this. Mostly for security reasons.].
To allow changing predefined zettel, both the file store and the internal zettel store are internally chained together.
If you change a zettel, it will be always stored as a file.
If a zettel is requested, Zettelstore will first try to read that zettel from a file.
@@ -72,5 +73,17 @@
Therefore, the file store ""shadows"" the internal zettel store.
If you want to read the original zettel, you either have to delete the zettel (which removes it from the file directory), or you have to rename it to another zettel identifier.
Now we have two places where zettel are stored: in the specific directory and within the Zettelstore software.
* [[List of predefined zettel|00001005090000]]
+
+=== Boxes: other ways to store zettel
+As described above, a zettel may be stored as a file inside a directory or inside the Zettelstore software itself.
+Zettelstore allows other ways to store zettel by providing an abstraction called //box//.[^Formerly, zettel were stored physically in boxes, often made of wood.]
+
+A file directory which stores zettel is called a ""directory box"".
+But zettel may be also stored in a ZIP file, which is called ""file box"".
+For testing purposes, zettel may be stored in volatile memeory (called //RAM//).
+This way is called ""memory box"".
+
+Other types of boxes could be added to Zettelstore.
+What about a ""remote Zettelstore box""?
Index: docs/manual/00001005090000.zettel
==================================================================
--- docs/manual/00001005090000.zettel
+++ docs/manual/00001005090000.zettel
@@ -1,11 +1,11 @@
id: 00001005090000
title: List of predefined zettel
role: manual
tags: #manual #reference #zettelstore
syntax: zmk
-modified: 20210511180816
+modified: 20210622124647
The following table lists all predefined zettel with their purpose.
|= Identifier :|= Title | Purpose
| [[00000000000001]] | Zettelstore Version | Contains the version string of the running Zettelstore
@@ -12,11 +12,11 @@
| [[00000000000002]] | Zettelstore Host | Contains the name of the computer running the Zettelstore
| [[00000000000003]] | Zettelstore Operating System | Contains the operating system and CPU architecture of the computer running the Zettelstore
| [[00000000000004]] | Zettelstore License | Lists the license of Zettelstore
| [[00000000000005]] | Zettelstore Contributors | Lists all contributors of Zettelstore
| [[00000000000006]] | Zettelstore Dependencies | Lists all licensed content
-| [[00000000000020]] | Zettelstore Place Manager | Contains some statistics about zettel places and the the index process
+| [[00000000000020]] | Zettelstore Box Manager | Contains some statistics about zettel boxes and the the index process
| [[00000000000090]] | Zettelstore Supported Metadata Keys | Contains all supported metadata keys, their [[types|00001006030000]], and more
| [[00000000000096]] | Zettelstore Startup Configuration | Contains the effective values of the [[startup configuration|00001004010000]]
| [[00000000000100]] | Zettelstore Runtime Configuration | Allows to [[configure Zettelstore at runtime|00001004020000]]
| [[00000000010100]] | Zettelstore Base HTML Template | Contains the general layout of the HTML view
| [[00000000010200]] | Zettelstore Login Form HTML Template | Layout of the login form, when authentication is [[enabled|00001010040100]]
@@ -26,15 +26,16 @@
| [[00000000010403]] | Zettelstore Form HTML Template | Form that is used to create a new or to change an existing zettel that contains text
| [[00000000010404]] | Zettelstore Rename Form HTML Template | View that is displayed to change the [[zettel identifier|00001006050000]]
| [[00000000010405]] | Zettelstore Delete HTML Template | View to confirm the deletion of a zettel
| [[00000000010500]] | Zettelstore List Roles HTML Template | Layout for listing all roles
| [[00000000010600]] | Zettelstore List Tags HTML Template | Layout of tags lists
-| [[00000000020001]] | Zettelstore Base CSS | CSS file that is included by the [[Base HTML Template|00000000010100]]
+| [[00000000020001]] | Zettelstore Base CSS | System-defined CSS file that is included by the [[Base HTML Template|00000000010100]]
+| [[00000000025001]] | Zettelstore User CSS | User-defined CSS file that is included by the [[Base HTML Template|00000000010100]]
| [[00000000040001]] | Generic Emoji | Image that is shown if original image reference is invalid
| [[00000000090000]] | New Menu | Contains items that should contain in the zettel template menu
| [[00000000090001]] | New Zettel | Template for a new zettel with role ""[[zettel|00001006020100]]""
| [[00000000090002]] | New User | Template for a new zettel with role ""[[user|00001006020100#user]]""
| [[00010000000000]] | Home | Default home zettel, contains some welcome information
If a zettel is not linked, it is not accessible for the current user.
**Important:** All identifier may change until a stable version of the software is released.
Index: docs/manual/00001006020000.zettel
==================================================================
--- docs/manual/00001006020000.zettel
+++ docs/manual/00001006020000.zettel
@@ -1,10 +1,11 @@
id: 00001006020000
title: Supported Metadata Keys
role: manual
tags: #manual #meta #reference #zettel #zettelstore
syntax: zmk
+modified: 20210709162756
Although you are free to define your own metadata, by using any key (according to the [[syntax|00001006010000]]), some keys have a special meaning that is enforced by Zettelstore.
See the [[computed list of supported metadata keys|00000000000090]] for details.
Most keys conform to a [[type|00001006030000]].
@@ -46,20 +47,23 @@
This is a computed value.
There is no need to set it via Zettelstore.
; [!no-index]''no-index''
: If set to true, the zettel will not be indexed and therefore not be found in full-text searches.
+; [!box-number]''box-number''
+: Is a computed value and contains the number of the box where the zettel was found.
+ For all but the [[predefined zettel|00001005090000]], this number is equal to the number //X// specified in startup configuration key [[''box-uri-//X//''|00001004010000#box-uri-x]].
; [!precursor]''precursor''
: References zettel for which this zettel is a ""Folgezettel"" / follow-up zettel.
Basically the inverse of key [[''folge''|#folge]].
; [!published]''published''
: This property contains the timestamp of the mast modification / creation of the zettel.
- If [[''modified''|#modified]]is set, it contains the same value.
+ If [[''modified''|#modified]] is set, it contains the same value.
Otherwise, if the zettel identifier contains a valid timestamp, the identifier is used.
In all other cases, this property is not set.
- It can be used for [[sorting|00001012051800#sort]] zettel based on their publication date.
+ It can be used for [[sorting|00001012052000]] zettel based on their publication date.
It is a computed value.
There is no need to set it via Zettelstore.
; [!read-only]''read-only''
: Marks a zettel as read-only.
Index: docs/manual/00001006020400.zettel
==================================================================
--- docs/manual/00001006020400.zettel
+++ docs/manual/00001006020400.zettel
@@ -14,11 +14,11 @@
If there is no metadata value for key ''read-only'' or if its [[boolean value|00001006030500]]
is interpreted as ""false"", anybody can modify the zettel.
If the metadata value is something else (the value ""true"" is recommended),
the user cannot modify the zettel through the web interface.
-However, if the zettel is stored as a file in a [[directory place|00001004011400]],
+However, if the zettel is stored as a file in a [[directory box|00001004011400]],
the zettel could be modified using an external editor.
=== Authentication enabled
If there is no metadata value for key ''read-only'' or if its [[boolean value|00001006030500]]
is interpreted as ""false"", anybody can modify the zettel.
@@ -33,8 +33,8 @@
: Neither an unauthenticated user, nor users with roles ""reader"" or ""writer"" are allowed to modify the zettel.
Only the owner of the Zettelstore can modify the zettel.
If the metadata value is something else (one of the values ""true"" or ""owner"" is recommended),
no user is allowed modify the zettel through the web interface.
-However, if the zettel is accessible as a file in a [[directory place|00001004011400]],
+However, if the zettel is accessible as a file in a [[directory box|00001004011400]],
the zettel could be modified using an external editor.
Typically the owner of a Zettelstore have such an access.
Index: docs/manual/00001006030000.zettel
==================================================================
--- docs/manual/00001006030000.zettel
+++ docs/manual/00001006030000.zettel
@@ -1,15 +1,21 @@
id: 00001006030000
title: Supported Key Types
role: manual
tags: #manual #meta #reference #zettel #zettelstore
syntax: zmk
+modified: 20210627170437
-Most [[supported metadata keys|00001006020000]] conform to a type.
+All [[supported metadata keys|00001006020000]] conform to a type.
-Every metadata key should conform to a type.
-User-defined metadata keys are of type EString.
+User-defined metadata keys conform also to a type, based on the suffix of the key.
+|=Suffix|Type
+| ''-number'' | [[Number|00001006033000]]
+| ''-url'' | [[URL|00001006035000]]
+| ''-zid'' | [[Identifier|00001006032000]]
+| any other suffix | [[EString|00001006031500]]
+
The name of the metadata key is bound to the key type
Every key type has an associated validation rule to check values of the given type.
There is also a rule how values are matched, e.g. against a search term when selecting some zettel.
And there is a rule, how values compare for sorting.
Index: docs/manual/00001006050000.zettel
==================================================================
--- docs/manual/00001006050000.zettel
+++ docs/manual/00001006050000.zettel
@@ -1,10 +1,11 @@
id: 00001006050000
title: Zettel identifier
+role: manual
tags: #design #manual #zettelstore
syntax: zmk
-role: manual
+modified: 20210721123222
Each zettel is given a unique identifier.
To some degree, the zettel identifier is part of the metadata.
Basically, the identifier is given by the [[Zettelstore|00001005000000]] software.
@@ -17,11 +18,12 @@
In most cases the zettel identifier is the timestamp when the zettel was created.
However, the Zettelstore software just checks for exactly 14 digits.
Anybody is free to assign a ""non-timestamp"" identifier to a zettel, e.g. with
a month part of ""35"" or with ""99"" as the last two digits.
-In fact, all identifiers of zettel initially provided by an empty Zettelstore
-begin with ""000000"", except the home zettel ''00010000000000''.
+
+Some zettel identifier are [[reserved|00001006055000]] and should not be used otherwise.
+All identifiers of zettel initially provided by an empty Zettelstore begin with ""000000"", except the home zettel ''00010000000000''.
Zettel identifier of this manual have be chosen to begin with ""000010"".
A zettel can have any identifier that contains 14 digits and that is not in use
by another zettel managed by the same Zettelstore.
ADDED docs/manual/00001006055000.zettel
Index: docs/manual/00001006055000.zettel
==================================================================
--- docs/manual/00001006055000.zettel
+++ docs/manual/00001006055000.zettel
@@ -0,0 +1,43 @@
+id: 00001006055000
+title: Reserved zettel identifier
+role: manual
+tags: #design #manual #zettelstore
+syntax: zmk
+modified: 20210721125518
+
+[[Zettel identifier|00001006050000]] are typically created by examine the current date and time.
+By renaming a zettel, you are able to provide any sequence of 14 digits.
+If no other zettel has the same identifier, you are allowed to rename a zettel.
+
+To make things easier, you normally should not use zettel identifier that begin with four zeroes (''0000'').
+
+All zettel provided by an empty zettelstore begin with six zeroes[^Exception: the predefined home zettel ''00010000000000''. But you can [[configure|00001004020000#home-zettel]] another zettel with another identifier as the new home zettel.].
+Zettel identifier of this manual have be chosen to begin with ''000010''.
+
+However, some external applications may need a range of specific zettel identifier to work properly.
+Identifier that begin with ''00009'' can be used for such purpose.
+To request a reservation, please send an email to the maintainer of Zettelstore.
+The request must include the following data:
+; Title
+: Title of you application
+; Description
+: A brief description what the application is used for and why you need to reserve some zettel identifier
+; Number
+: Specify the amount of zettel identifier you are planning to use.
+ Minimum size is 100.
+ If you need more than 10.000, your justification will contain more words.
+
+=== Reserved Zettel Identifier
+
+|= From | To | Description
+| 00000000000000 | 0000000000000 | This is an invalid zettel identifier
+| 00000000000001 | 0000009999999 | [[Predefined zettel|00001005090000]]
+| 00000100000000 | 0000019999999 | Zettelstore manual
+| 00000200000000 | 0000899999999 | Reserved for future use
+| 00009000000000 | 0000999999999 | Reserved for applications
+
+This list may change in the future.
+
+==== External Applications
+|= From | To | Description
+| 00009000001000 | 00009000001000 | ZS Slides, an application to display zettel as a HTML-based slideshow
Index: docs/manual/00001007010000.zettel
==================================================================
--- docs/manual/00001007010000.zettel
+++ docs/manual/00001007010000.zettel
@@ -22,28 +22,28 @@
With some exceptions, two identical non-space characters begins a formatting range that is ended with the same two characters.
Exceptions are: links, images, edits, comments, and both the ""en-dash"" and the ""horizontal ellipsis"".
A link is given with ``[[...]]``{=zmk}, an images with ``{{...}}``{=zmk}, and an edit formatting with ``((...))``{=zmk}.
An inline comment, beginning with the sequence ``%%``{=zmk}, always ends at the end of the line where it begins.
-The ""en-dash"" (""--"") is specified as ``--``{=zmk}, the ""horizontal ellipsis"" (""..."") as ``...``{=zmk}[^If placed at the end of non-space text.].
+The ""en-dash"" (""--"") is specified as ``--``{=zmk}, the ""horizontal ellipsis"" (""..."") as ``...``{=zmk}[^If put at the end of non-space text.].
Some inline elements do not follow the rule of two identical character, especially to specify footnotes, citation keys, and local marks.
These elements begin with one opening square bracket (""``[``""), use a character for specifying the kind of the inline, typically allow to specify some content, and end with one closing square bracket (""``]``"").
One inline element that does not begin with two characters is the ""entity"".
It allows to specify any Unicode character.
-The specification of that character is placed between an ampersand character and a semicolon: ``&...;``{=zmk}.
+The specification of that character is put between an ampersand character and a semicolon: ``&...;``{=zmk}.
For exmple, an ""n-dash"" could also be specified as ``–``{==zmk}.
The backslash character (""``\\``"") possibly gives the next character a special meaning.
This allows to resolve some left ambiguities.
For example, a list of depth 2 will begin a line with ``** Item 2.2``{=zmk}.
An inline element to strongly emphasize some text begin with a space will be specified as ``** Text**``{=zmk}.
To force the inline element formatting at the beginning of a line, ``**\\ Text**``{=zmk} should better be specified.
Many block and inline elements can be refined by additional attributes.
-Attributes resemble roughly HTML attributes and are placed near the corresponding elements by using the syntax ``{...}``{=zmk}.
+Attributes resemble roughly HTML attributes and are put near the corresponding elements by using the syntax ``{...}``{=zmk}.
One example is to make space characters visible inside a inline literal element: ``1 + 2 = 3``{-} was specified by using the default attribute: ``\`\`1 + 2 = 3\`\`{-}``.
To summarize:
* With some exceptions, blocks-structural elements begins at the for position of a line with three identical characters.
Index: docs/manual/00001007030200.zettel
==================================================================
--- docs/manual/00001007030200.zettel
+++ docs/manual/00001007030200.zettel
@@ -88,11 +88,11 @@
* C
:::
Please note that two lists cannot be separated by an empty line.
-Instead you should place a horizonal rule (""thematic break"") between them.
+Instead you should put a horizonal rule (""thematic break"") between them.
You could also use a mark element or a hard line break to separate the two lists:
```zmk
# One
# Two
[!sep]
Index: docs/manual/00001007031000.zettel
==================================================================
--- docs/manual/00001007031000.zettel
+++ docs/manual/00001007031000.zettel
@@ -91,17 +91,17 @@
=== Rows to be ignored
A line that begins with the sequence ''|%'' (vertical bar character (""''|''"", ''U+007C''), followed by a percent sign character (“%”, U+0025)) will be ignored.
This allows to specify a horizontal rule that is not rendered.
Such tables are emitted by some commands of the [[administrator console|00001004100000]].
-For example, the command ``get-config place`` will emit
+For example, the command ``get-config box`` will emit
```
|=Key | Value | Description
-|%-----------+--------+-----------------------------
-| defdirtype | notify | Default directory place type
+|%-----------+--------+---------------------------
+| defdirtype | notify | Default directory box type
```
This is rendered in HTML as:
:::example
|=Key | Value | Description
-|%-----------+--------+-----------------------------
-| defdirtype | notify | Default directory place type
+|%-----------+--------+---------------------------
+| defdirtype | notify | Default directory box type
:::
Index: docs/manual/00001007040000.zettel
==================================================================
--- docs/manual/00001007040000.zettel
+++ docs/manual/00001007040000.zettel
@@ -45,17 +45,17 @@
==== Entities & more
Sometimes it is not easy to enter special characters.
If you know the Unicode code point of that character, or its name according to the [[HTML standard|https://html.spec.whatwg.org/multipage/named-characters.html]], you can enter it by number or by name.
Regardless which method you use, an entity always begins with an ampersand character (""''&''"", ''U+0026'') and ends with a semicolon character (""'';''"", ''U+003B'').
-If you know the HTML name of the character you want to enter, place it between these two character.
+If you know the HTML name of the character you want to enter, put it between these two character.
Example: ``&`` is rendered as ::&::{=example}.
If you want to enter its numeric code point, a number sign character must follow the ampersand character, followed by digits to base 10.
Example: ``&`` is rendered in HTML as ::&::{=example}.
-You also can enter its numeric code point as a hex number, if you place the letter ""x"" after the numeric sign character.
+You also can enter its numeric code point as a hex number, if you put the letter ""x"" after the numeric sign character.
Example: ``&`` is rendered in HTML as ::&::{=example}.
Since some Unicode character are used quite often, a special notation is introduced for them:
* Two consecutive hyphen-minus characters result in an //en-dash// character.
Index: docs/manual/00001008000000.zettel
==================================================================
--- docs/manual/00001008000000.zettel
+++ docs/manual/00001008000000.zettel
@@ -1,11 +1,11 @@
id: 00001008000000
title: Other Markup Languages
role: manual
tags: #manual #zettelstore
syntax: zmk
-modified: 20210523194915
+modified: 20210705111758
[[Zettelmarkup|00001007000000]] is not the only markup language you can use to define your content.
Zettelstore is quite agnostic with respect to markup languages.
Of course, Zettelmarkup plays an important role.
However, with the exception of zettel titles, you can use any (markup) language that is supported:
@@ -23,16 +23,22 @@
; [!css]''css''
: A [[Cascading Style Sheet|https://www.w3.org/Style/CSS/]], to be used when rendering a zettel as HTML.
; [!gif]''gif''; [!jpeg]''jpeg''; [!jpg]''jpg''; [!png]''png''
: The formats for pixel graphics.
Typically the data is stored in a separate file and the syntax is given in the ''.meta'' file.
+; [!html]''html''
+: Hypertext Markup Language, will not be parsed further.
+ Instead, it is treated as [[text|#text]], but will be encoded differently for [[HTML format|00001012920510]] (same for the [[web user interface|00001014000000]]).
+
+ For security reasons, equivocal elements will not be encoded in the HTML format / web user interface, e.g. the ``\nokay\n",
"html": "\n
\n",
- "example": 273,
- "start_line": 4928,
- "end_line": 4938,
+ "example": 303,
+ "start_line": 5287,
+ "end_line": 5297,
"section": "Lists"
},
{
"markdown": "The number of windows in my house is\n14. The number of doors is 6.\n",
"html": "
The number of windows in my house is\n14. The number of doors is 6.
\n",
- "example": 274,
- "start_line": 5005,
- "end_line": 5011,
+ "example": 304,
+ "start_line": 5364,
+ "end_line": 5370,
"section": "Lists"
},
{
"markdown": "The number of windows in my house is\n1. The number of doors is 6.\n",
"html": "
\n",
- "example": 299,
- "start_line": 5523,
- "end_line": 5527,
- "section": "Backslash escapes"
- },
- {
- "markdown": "\\*not emphasized*\n\\ not a tag\n\\[not a link](/foo)\n\\`not code`\n1\\. not a list\n\\* not a list\n\\# not a heading\n\\[foo]: /url \"not a reference\"\n\\ö not a character entity\n",
- "html": "
*not emphasized*\n<br/> not a tag\n[not a link](/foo)\n`not code`\n1. not a list\n* not a list\n# not a heading\n[foo]: /url "not a reference"\nö not a character entity