Index: .fossil-settings/ignore-glob
==================================================================
--- .fossil-settings/ignore-glob
+++ .fossil-settings/ignore-glob
@@ -1,2 +1,3 @@
bin/*
releases/*
+parser/pikchr/*.out
Index: Makefile
==================================================================
--- Makefile
+++ Makefile
@@ -5,27 +5,27 @@
##
## 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.
-.PHONY: check relcheck api version build release clean
+.PHONY: check relcheck api build release clean
check:
- go run tools/check/check.go
+ go run tools/build.go check
relcheck:
- go run tools/check/check.go -r
+ go run tools/build.go relcheck
api:
- go run tools/testapi/testapi.go
+ go run tools/build.go testapi
version:
- @echo $(shell go run tools/build/build.go version)
+ @echo $(shell go run tools/build.go version)
build:
- go run tools/build/build.go build
+ go run tools/build.go build
release:
- go run tools/build/build.go release
+ go run tools/build.go release
clean:
- go run tools/clean/clean.go
+ go run tools/build.go clean
Index: README.md
==================================================================
--- README.md
+++ README.md
@@ -11,16 +11,17 @@
To get an initial impression, take a look at the
[manual](https://zettelstore.de/manual/). It is a live example of the
zettelstore software, running in read-only mode.
-[Zettelstore Client](https://t73f.de/r/zsc) provides client software to access
-Zettelstore via its API more easily, [Zettelstore
+[Zettelstore Client](https://zettelstore.de/client) provides client
+software to access Zettelstore via its API more easily, [Zettelstore
Contrib](https://zettelstore.de/contrib) contains contributed software, which
often connects to Zettelstore via its API. Some of the software packages may be
experimental.
The software, including the manual, is licensed
under the [European Union Public License 1.2 (or
later)](https://zettelstore.de/home/file?name=LICENSE.txt&ci=trunk).
-[Stay tuned](https://mastodon.social/tags/Zettelstore) …
+[Stay](https://twitter.com/zettelstore)
+tuned …
Index: VERSION
==================================================================
--- VERSION
+++ VERSION
@@ -1,1 +1,1 @@
-0.21.0-dev
+0.11.0
ADDED ast/ast.go
Index: ast/ast.go
==================================================================
--- /dev/null
+++ ast/ast.go
@@ -0,0 +1,89 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 ast provides the abstract syntax tree for parsed zettel content.
+package ast
+
+import (
+ "net/url"
+
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+)
+
+// ZettelNode is the root node of the abstract syntax tree.
+// It is *not* part of the visitor pattern.
+type ZettelNode struct {
+ Meta *meta.Meta // Original metadata
+ Content domain.Content // Original content
+ Zid id.Zid // Zettel identification.
+ InhMeta *meta.Meta // Metadata of the zettel, with inherited values.
+ Ast BlockSlice // Zettel abstract syntax tree is a sequence of block nodes.
+ Syntax string // Syntax / parser that produced the Ast
+}
+
+// Node is the interface, all nodes must implement.
+type Node interface {
+ WalkChildren(v Visitor)
+}
+
+// BlockNode is the interface that all block nodes must implement.
+type BlockNode interface {
+ Node
+ blockNode()
+}
+
+// ItemNode is a node that can occur as a list item.
+type ItemNode interface {
+ BlockNode
+ itemNode()
+}
+
+// ItemSlice is a slice of ItemNodes.
+type ItemSlice []ItemNode
+
+// DescriptionNode is a node that contains just textual description.
+type DescriptionNode interface {
+ ItemNode
+ descriptionNode()
+}
+
+// DescriptionSlice is a slice of DescriptionNodes.
+type DescriptionSlice []DescriptionNode
+
+// InlineNode is the interface that all inline nodes must implement.
+type InlineNode interface {
+ Node
+ inlineNode()
+}
+
+// Reference is a reference to external or internal material.
+type Reference struct {
+ URL *url.URL
+ Value string
+ State RefState
+}
+
+// RefState indicates the state of the reference.
+type RefState int
+
+// Constants for RefState
+const (
+ RefStateInvalid RefState = iota // Invalid Reference
+ RefStateZettel // Reference to an internal zettel
+ RefStateSelf // Reference to same zettel with a fragment
+ RefStateFound // Reference to an existing internal zettel, URL is ajusted
+ RefStateBroken // Reference to a non-existing internal zettel
+ RefStateHosted // Reference to local hosted non-Zettel, without URL change
+ RefStateBased // Reference to local non-Zettel, to be prefixed
+ RefStateQuery // Reference to a zettel query
+ RefStateExternal // Reference to external material
+)
ADDED ast/block.go
Index: ast/block.go
==================================================================
--- /dev/null
+++ ast/block.go
@@ -0,0 +1,292 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 ast
+
+import "zettelstore.de/c/attrs"
+
+// Definition of Block nodes.
+
+// BlockSlice is a slice of BlockNodes.
+type BlockSlice []BlockNode
+
+func (*BlockSlice) blockNode() { /* Just a marker */ }
+
+// WalkChildren walks down to the descriptions.
+func (bs *BlockSlice) WalkChildren(v Visitor) {
+ if bs != nil {
+ for _, bn := range *bs {
+ Walk(v, bn)
+ }
+ }
+}
+
+// FirstParagraphInlines returns the inline list of the first paragraph that
+// contains a inline list.
+func (bs BlockSlice) FirstParagraphInlines() InlineSlice {
+ for _, bn := range bs {
+ pn, ok := bn.(*ParaNode)
+ if !ok {
+ continue
+ }
+ if inl := pn.Inlines; len(inl) > 0 {
+ return inl
+ }
+ }
+ return nil
+}
+
+//--------------------------------------------------------------------------
+
+// ParaNode contains just a sequence of inline elements.
+// Another name is "paragraph".
+type ParaNode struct {
+ Inlines InlineSlice
+}
+
+func (*ParaNode) blockNode() { /* Just a marker */ }
+func (*ParaNode) itemNode() { /* Just a marker */ }
+func (*ParaNode) descriptionNode() { /* Just a marker */ }
+
+// CreateParaNode creates a parameter block from inline nodes.
+func CreateParaNode(nodes ...InlineNode) *ParaNode { return &ParaNode{Inlines: nodes} }
+
+// WalkChildren walks down the inline elements.
+func (pn *ParaNode) WalkChildren(v Visitor) { Walk(v, &pn.Inlines) }
+
+//--------------------------------------------------------------------------
+
+// VerbatimNode contains uninterpreted text
+type VerbatimNode struct {
+ Kind VerbatimKind
+ Attrs attrs.Attributes
+ Content []byte
+}
+
+// VerbatimKind specifies the format that is applied to code inline nodes.
+type VerbatimKind int
+
+// Constants for VerbatimCode
+const (
+ _ VerbatimKind = iota
+ VerbatimZettel // Zettel content
+ VerbatimProg // Program code
+ VerbatimEval // Code to be externally interpreted. Syntax is stored in default attribute.
+ VerbatimComment // Block comment
+ VerbatimHTML // Block HTML, e.g. for Markdown
+ VerbatimMath // Block math mode
+)
+
+func (*VerbatimNode) blockNode() { /* Just a marker */ }
+func (*VerbatimNode) itemNode() { /* Just a marker */ }
+
+// WalkChildren does nothing.
+func (*VerbatimNode) WalkChildren(Visitor) { /* No children*/ }
+
+//--------------------------------------------------------------------------
+
+// RegionNode encapsulates a region of block nodes.
+type RegionNode struct {
+ Kind RegionKind
+ Attrs attrs.Attributes
+ Blocks BlockSlice
+ Inlines InlineSlice // Optional text at the end of the region
+}
+
+// RegionKind specifies the actual region type.
+type RegionKind int
+
+// Values for RegionCode
+const (
+ _ RegionKind = iota
+ RegionSpan // Just a span of blocks
+ RegionQuote // A longer quotation
+ RegionVerse // Line breaks matter
+)
+
+func (*RegionNode) blockNode() { /* Just a marker */ }
+func (*RegionNode) itemNode() { /* Just a marker */ }
+
+// WalkChildren walks down the blocks and the text.
+func (rn *RegionNode) WalkChildren(v Visitor) {
+ Walk(v, &rn.Blocks)
+ Walk(v, &rn.Inlines)
+}
+
+//--------------------------------------------------------------------------
+
+// HeadingNode stores the heading text and level.
+type HeadingNode struct {
+ Level int
+ Attrs attrs.Attributes
+ Slug string // Heading text, normalized
+ Fragment string // Heading text, suitable to be used as an unique URL fragment
+ Inlines InlineSlice // Heading text, possibly formatted
+}
+
+func (*HeadingNode) blockNode() { /* Just a marker */ }
+func (*HeadingNode) itemNode() { /* Just a marker */ }
+
+// WalkChildren walks the heading text.
+func (hn *HeadingNode) WalkChildren(v Visitor) { Walk(v, &hn.Inlines) }
+
+//--------------------------------------------------------------------------
+
+// HRuleNode specifies a horizontal rule.
+type HRuleNode struct {
+ Attrs attrs.Attributes
+}
+
+func (*HRuleNode) blockNode() { /* Just a marker */ }
+func (*HRuleNode) itemNode() { /* Just a marker */ }
+
+// WalkChildren does nothing.
+func (*HRuleNode) WalkChildren(Visitor) { /* No children*/ }
+
+//--------------------------------------------------------------------------
+
+// NestedListNode specifies a nestable list, either ordered or unordered.
+type NestedListNode struct {
+ Kind NestedListKind
+ Items []ItemSlice
+ Attrs attrs.Attributes
+}
+
+// NestedListKind specifies the actual list type.
+type NestedListKind uint8
+
+// Values for ListCode
+const (
+ _ NestedListKind = iota
+ NestedListOrdered // Ordered list.
+ NestedListUnordered // Unordered list.
+ NestedListQuote // Quote list.
+)
+
+func (*NestedListNode) blockNode() { /* Just a marker */ }
+func (*NestedListNode) itemNode() { /* Just a marker */ }
+
+// WalkChildren walks down the items.
+func (ln *NestedListNode) WalkChildren(v Visitor) {
+ if items := ln.Items; items != nil {
+ for _, item := range items {
+ WalkItemSlice(v, item)
+ }
+ }
+}
+
+//--------------------------------------------------------------------------
+
+// DescriptionListNode specifies a description list.
+type DescriptionListNode struct {
+ Descriptions []Description
+}
+
+// Description is one element of a description list.
+type Description struct {
+ Term InlineSlice
+ Descriptions []DescriptionSlice
+}
+
+func (*DescriptionListNode) blockNode() { /* Just a marker */ }
+
+// WalkChildren walks down to the descriptions.
+func (dn *DescriptionListNode) WalkChildren(v Visitor) {
+ if descrs := dn.Descriptions; descrs != nil {
+ for i, desc := range descrs {
+ if len(desc.Term) > 0 {
+ Walk(v, &descrs[i].Term) // Otherwise, changes in desc.Term will not go back into AST
+ }
+ if dss := desc.Descriptions; dss != nil {
+ for _, dns := range dss {
+ WalkDescriptionSlice(v, dns)
+ }
+ }
+ }
+ }
+}
+
+//--------------------------------------------------------------------------
+
+// TableNode specifies a full table
+type TableNode struct {
+ Header TableRow // The header row
+ Align []Alignment // Default column alignment
+ Rows []TableRow // The slice of cell rows
+}
+
+// TableCell contains the data for one table cell
+type TableCell struct {
+ Align Alignment // Cell alignment
+ Inlines InlineSlice // Cell content
+}
+
+// TableRow is a slice of cells.
+type TableRow []*TableCell
+
+// Alignment specifies text alignment.
+// Currently only for tables.
+type Alignment int
+
+// Constants for Alignment.
+const (
+ _ Alignment = iota
+ AlignDefault // Default alignment, inherited
+ AlignLeft // Left alignment
+ AlignCenter // Center the content
+ AlignRight // Right alignment
+)
+
+func (*TableNode) blockNode() { /* Just a marker */ }
+
+// WalkChildren walks down to the cells.
+func (tn *TableNode) WalkChildren(v Visitor) {
+ if header := tn.Header; header != nil {
+ for i := range header {
+ Walk(v, &header[i].Inlines) // Otherwise changes will not go back
+ }
+ }
+ if rows := tn.Rows; rows != nil {
+ for _, row := range rows {
+ for i := range row {
+ Walk(v, &row[i].Inlines) // Otherwise changes will not go back
+ }
+ }
+ }
+}
+
+//--------------------------------------------------------------------------
+
+// TranscludeNode specifies block content from other zettel to embedded in
+// current zettel
+type TranscludeNode struct {
+ Attrs attrs.Attributes
+ Ref *Reference
+}
+
+func (*TranscludeNode) blockNode() { /* Just a marker */ }
+
+// WalkChildren does nothing.
+func (*TranscludeNode) WalkChildren(Visitor) { /* No children*/ }
+
+//--------------------------------------------------------------------------
+
+// BLOBNode contains just binary data that must be interpreted according to
+// a syntax.
+type BLOBNode struct {
+ Description InlineSlice
+ Syntax string
+ Blob []byte
+}
+
+func (*BLOBNode) blockNode() { /* Just a marker */ }
+
+// WalkChildren does nothing.
+func (*BLOBNode) WalkChildren(Visitor) { /* No children*/ }
ADDED ast/inline.go
Index: ast/inline.go
==================================================================
--- /dev/null
+++ ast/inline.go
@@ -0,0 +1,240 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 ast
+
+import (
+ "unicode/utf8"
+
+ "zettelstore.de/c/attrs"
+)
+
+// Definitions of inline nodes.
+
+// InlineSlice is a list of BlockNodes.
+type InlineSlice []InlineNode
+
+func (*InlineSlice) inlineNode() { /* Just a marker */ }
+
+// CreateInlineSliceFromWords makes a new inline list from words,
+// that will be space-separated.
+func CreateInlineSliceFromWords(words ...string) InlineSlice {
+ inl := make(InlineSlice, 0, 2*len(words)-1)
+ for i, word := range words {
+ if i > 0 {
+ inl = append(inl, &SpaceNode{Lexeme: " "})
+ }
+ inl = append(inl, &TextNode{Text: word})
+ }
+ return inl
+}
+
+// WalkChildren walks down to the list.
+func (is *InlineSlice) WalkChildren(v Visitor) {
+ for _, in := range *is {
+ Walk(v, in)
+ }
+}
+
+// --------------------------------------------------------------------------
+
+// TextNode just contains some text.
+type TextNode struct {
+ Text string // The text itself.
+}
+
+func (*TextNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren does nothing.
+func (*TextNode) WalkChildren(Visitor) { /* No children*/ }
+
+// --------------------------------------------------------------------------
+
+// SpaceNode tracks inter-word space characters.
+type SpaceNode struct {
+ Lexeme string
+}
+
+func (*SpaceNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren does nothing.
+func (*SpaceNode) WalkChildren(Visitor) { /* No children*/ }
+
+// Count returns the number of space runes.
+func (sn *SpaceNode) Count() int {
+ return utf8.RuneCountInString(sn.Lexeme)
+}
+
+// --------------------------------------------------------------------------
+
+// BreakNode signals a new line that must / should be interpreted as a new line break.
+type BreakNode struct {
+ Hard bool // Hard line break?
+}
+
+func (*BreakNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren does nothing.
+func (*BreakNode) WalkChildren(Visitor) { /* No children*/ }
+
+// --------------------------------------------------------------------------
+
+// LinkNode contains the specified link.
+type LinkNode struct {
+ Attrs attrs.Attributes // Optional attributes
+ Ref *Reference
+ Inlines InlineSlice // The text associated with the link.
+}
+
+func (*LinkNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren walks to the link text.
+func (ln *LinkNode) WalkChildren(v Visitor) {
+ if len(ln.Inlines) > 0 {
+ Walk(v, &ln.Inlines)
+ }
+}
+
+// --------------------------------------------------------------------------
+
+// EmbedRefNode contains the specified embedded reference material.
+type EmbedRefNode struct {
+ Attrs attrs.Attributes // Optional attributes
+ Ref *Reference // The reference to be embedded.
+ Syntax string // Syntax of referenced material, if known
+ Inlines InlineSlice // Optional text associated with the image.
+}
+
+func (*EmbedRefNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren walks to the text that describes the embedded material.
+func (en *EmbedRefNode) WalkChildren(v Visitor) { Walk(v, &en.Inlines) }
+
+// --------------------------------------------------------------------------
+
+// EmbedBLOBNode contains the specified embedded BLOB material.
+type EmbedBLOBNode struct {
+ Attrs attrs.Attributes // Optional attributes
+ Syntax string // Syntax of Blob
+ Blob []byte // BLOB data itself.
+ Inlines InlineSlice // Optional text associated with the image.
+}
+
+func (*EmbedBLOBNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren walks to the text that describes the embedded material.
+func (en *EmbedBLOBNode) WalkChildren(v Visitor) { Walk(v, &en.Inlines) }
+
+// --------------------------------------------------------------------------
+
+// CiteNode contains the specified citation.
+type CiteNode struct {
+ Attrs attrs.Attributes // Optional attributes
+ Key string // The citation key
+ Inlines InlineSlice // Optional text associated with the citation.
+}
+
+func (*CiteNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren walks to the cite text.
+func (cn *CiteNode) WalkChildren(v Visitor) { Walk(v, &cn.Inlines) }
+
+// --------------------------------------------------------------------------
+
+// MarkNode contains the specified merked position.
+// It is a BlockNode too, because although it is typically parsed during inline
+// mode, it is moved into block mode afterwards.
+type MarkNode struct {
+ Mark string // The mark text itself
+ Slug string // Slugified form of Mark
+ Fragment string // Unique form of Slug
+ Inlines InlineSlice // Marked inline content
+}
+
+func (*MarkNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren does nothing.
+func (mn *MarkNode) WalkChildren(v Visitor) {
+ if len(mn.Inlines) > 0 {
+ Walk(v, &mn.Inlines)
+ }
+}
+
+// --------------------------------------------------------------------------
+
+// FootnoteNode contains the specified footnote.
+type FootnoteNode struct {
+ Attrs attrs.Attributes // Optional attributes
+ Inlines InlineSlice // The footnote text.
+}
+
+func (*FootnoteNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren walks to the footnote text.
+func (fn *FootnoteNode) WalkChildren(v Visitor) { Walk(v, &fn.Inlines) }
+
+// --------------------------------------------------------------------------
+
+// FormatNode specifies some inline formatting.
+type FormatNode struct {
+ Kind FormatKind
+ Attrs attrs.Attributes // Optional attributes.
+ Inlines InlineSlice
+}
+
+// FormatKind specifies the format that is applied to the inline nodes.
+type FormatKind int
+
+// Constants for FormatCode
+const (
+ _ FormatKind = iota
+ FormatEmph // Emphasized text.
+ FormatStrong // Strongly emphasized text.
+ FormatInsert // Inserted text.
+ FormatDelete // Deleted text.
+ FormatSuper // Superscripted text.
+ FormatSub // SubscriptedText.
+ FormatQuote // Quoted text.
+ FormatSpan // Generic inline container.
+)
+
+func (*FormatNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren walks to the formatted text.
+func (fn *FormatNode) WalkChildren(v Visitor) { Walk(v, &fn.Inlines) }
+
+// --------------------------------------------------------------------------
+
+// LiteralNode specifies some uninterpreted text.
+type LiteralNode struct {
+ Kind LiteralKind
+ Attrs attrs.Attributes // Optional attributes.
+ Content []byte
+}
+
+// LiteralKind specifies the format that is applied to code inline nodes.
+type LiteralKind int
+
+// Constants for LiteralCode
+const (
+ _ LiteralKind = iota
+ LiteralZettel // Zettel content
+ LiteralProg // Inline program code
+ LiteralInput // Computer input, e.g. Keyboard strokes
+ LiteralOutput // Computer output
+ LiteralComment // Inline comment
+ LiteralHTML // Inline HTML, e.g. for Markdown
+ LiteralMath // Inline math mode
+)
+
+func (*LiteralNode) inlineNode() { /* Just a marker */ }
+
+// WalkChildren does nothing.
+func (*LiteralNode) WalkChildren(Visitor) { /* No children*/ }
ADDED ast/ref.go
Index: ast/ref.go
==================================================================
--- /dev/null
+++ ast/ref.go
@@ -0,0 +1,105 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 ast
+
+import (
+ "net/url"
+ "strings"
+
+ "zettelstore.de/z/domain/id"
+)
+
+// QueryPrefix is the prefix that denotes a query expression.
+const QueryPrefix = "query:"
+
+// ParseReference parses a string and returns a reference.
+func ParseReference(s string) *Reference {
+ if invalidReference(s) {
+ return &Reference{URL: nil, Value: s, State: RefStateInvalid}
+ }
+ if strings.HasPrefix(s, QueryPrefix) {
+ return &Reference{URL: nil, Value: s[len(QueryPrefix):], State: RefStateQuery}
+ }
+ if state, ok := localState(s); ok {
+ if state == RefStateBased {
+ s = s[1:]
+ }
+ u, err := url.Parse(s)
+ if err == nil {
+ return &Reference{URL: u, Value: s, State: state}
+ }
+ }
+ u, err := url.Parse(s)
+ if err != nil {
+ return &Reference{URL: nil, Value: s, State: RefStateInvalid}
+ }
+ if !externalURL(u) {
+ if _, err = id.Parse(u.Path); err == nil {
+ return &Reference{URL: u, Value: s, State: RefStateZettel}
+ }
+ if u.Path == "" && u.Fragment != "" {
+ return &Reference{URL: u, Value: s, State: RefStateSelf}
+ }
+ }
+ return &Reference{URL: u, Value: s, State: RefStateExternal}
+}
+
+func invalidReference(s string) bool { return s == "" || s == "00000000000000" }
+func externalURL(u *url.URL) bool {
+ return u.Scheme != "" || u.Opaque != "" || u.Host != "" || u.User != nil
+}
+
+func localState(path string) (RefState, bool) {
+ if len(path) > 0 && path[0] == '/' {
+ if len(path) > 1 && path[1] == '/' {
+ return RefStateBased, true
+ }
+ return RefStateHosted, true
+ }
+ if len(path) > 1 && path[0] == '.' {
+ if len(path) > 2 && path[1] == '.' && path[2] == '/' {
+ return RefStateHosted, true
+ }
+ return RefStateHosted, path[1] == '/'
+ }
+ return RefStateInvalid, false
+}
+
+// String returns the string representation of a reference.
+func (r Reference) String() string {
+ if r.URL != nil {
+ return r.URL.String()
+ }
+ if r.State == RefStateQuery {
+ return QueryPrefix + r.Value
+ }
+ return r.Value
+}
+
+// IsValid returns true if reference is valid
+func (r *Reference) IsValid() bool { return r.State != RefStateInvalid }
+
+// IsZettel returns true if it is a referencen to a local zettel.
+func (r *Reference) IsZettel() bool {
+ switch r.State {
+ case RefStateZettel, RefStateSelf, RefStateFound, RefStateBroken:
+ return true
+ }
+ return false
+}
+
+// IsLocal returns true if reference is local
+func (r *Reference) IsLocal() bool {
+ return r.State == RefStateHosted || r.State == RefStateBased
+}
+
+// IsExternal returns true if it is a referencen to external material.
+func (r *Reference) IsExternal() bool { return r.State == RefStateExternal }
ADDED ast/ref_test.go
Index: ast/ref_test.go
==================================================================
--- /dev/null
+++ ast/ref_test.go
@@ -0,0 +1,95 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 ast_test
+
+import (
+ "testing"
+
+ "zettelstore.de/z/ast"
+)
+
+func TestParseReference(t *testing.T) {
+ t.Parallel()
+ testcases := []struct {
+ link string
+ err bool
+ exp string
+ }{
+ {"", true, ""},
+ {"123", false, "123"},
+ {",://", true, ""},
+ }
+
+ for i, tc := range testcases {
+ got := ast.ParseReference(tc.link)
+ if got.IsValid() == tc.err {
+ t.Errorf(
+ "TC=%d, expected parse error of %q: %v, but got %q", i, tc.link, tc.err, got)
+ }
+ if got.IsValid() && got.String() != tc.exp {
+ t.Errorf("TC=%d, Reference of %q is %q, but got %q", i, tc.link, tc.exp, got)
+ }
+ }
+}
+
+func TestReferenceIsZettelMaterial(t *testing.T) {
+ t.Parallel()
+ testcases := []struct {
+ link string
+ isZettel bool
+ isExternal bool
+ isLocal bool
+ }{
+ {"", false, false, false},
+ {"00000000000000", false, false, false},
+ {"http://zettelstore.de/z/ast", false, true, false},
+ {"12345678901234", true, false, false},
+ {"12345678901234#local", true, false, false},
+ {"http://12345678901234", false, true, false},
+ {"http://zettelstore.de/z/12345678901234", false, true, false},
+ {"http://zettelstore.de/12345678901234", false, true, false},
+ {"/12345678901234", false, false, true},
+ {"//12345678901234", false, false, true},
+ {"./12345678901234", false, false, true},
+ {"../12345678901234", false, false, true},
+ {".../12345678901234", false, true, false},
+ }
+
+ for i, tc := range testcases {
+ ref := ast.ParseReference(tc.link)
+ isZettel := ref.IsZettel()
+ if isZettel != tc.isZettel {
+ t.Errorf(
+ "TC=%d, Reference %q isZettel=%v expected, but got %v",
+ i,
+ tc.link,
+ tc.isZettel,
+ isZettel)
+ }
+ isLocal := ref.IsLocal()
+ if isLocal != tc.isLocal {
+ t.Errorf(
+ "TC=%d, Reference %q isLocal=%v expected, but got %v",
+ i,
+ tc.link,
+ tc.isLocal, isLocal)
+ }
+ isExternal := ref.IsExternal()
+ if isExternal != tc.isExternal {
+ t.Errorf(
+ "TC=%d, Reference %q isExternal=%v expected, but got %v",
+ i,
+ tc.link,
+ tc.isExternal,
+ isExternal)
+ }
+ }
+}
ADDED ast/walk.go
Index: ast/walk.go
==================================================================
--- /dev/null
+++ ast/walk.go
@@ -0,0 +1,45 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 ast
+
+// Visitor is a visitor for walking the AST.
+type Visitor interface {
+ Visit(node Node) Visitor
+}
+
+// Walk traverses the AST.
+func Walk(v Visitor, node Node) {
+ if v = v.Visit(node); v == nil {
+ return
+ }
+
+ // Implementation note:
+ // It is much faster to use interface dispatching than to use a switch statement.
+ // On my "cpu: Intel(R) Core(TM) i7-6820HQ CPU @ 2.70GHz", a switch statement
+ // implementation tooks approx 940-980 ns/op. Interface dispatching is in the
+ // range of 900-930 ns/op.
+ node.WalkChildren(v)
+ v.Visit(nil)
+}
+
+// WalkItemSlice traverses an item slice.
+func WalkItemSlice(v Visitor, ins ItemSlice) {
+ for _, in := range ins {
+ Walk(v, in)
+ }
+}
+
+// WalkDescriptionSlice traverses an item slice.
+func WalkDescriptionSlice(v Visitor, dns DescriptionSlice) {
+ for _, dn := range dns {
+ Walk(v, dn)
+ }
+}
ADDED ast/walk_test.go
Index: ast/walk_test.go
==================================================================
--- /dev/null
+++ ast/walk_test.go
@@ -0,0 +1,71 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 ast_test
+
+import (
+ "testing"
+
+ "zettelstore.de/c/attrs"
+ "zettelstore.de/z/ast"
+)
+
+func BenchmarkWalk(b *testing.B) {
+ root := ast.BlockSlice{
+ &ast.HeadingNode{
+ Inlines: ast.CreateInlineSliceFromWords("A", "Simple", "Heading"),
+ },
+ &ast.ParaNode{
+ Inlines: ast.CreateInlineSliceFromWords("This", "is", "the", "introduction."),
+ },
+ &ast.NestedListNode{
+ Kind: ast.NestedListUnordered,
+ Items: []ast.ItemSlice{
+ []ast.ItemNode{
+ &ast.ParaNode{
+ Inlines: ast.CreateInlineSliceFromWords("Item", "1"),
+ },
+ },
+ []ast.ItemNode{
+ &ast.ParaNode{
+ Inlines: ast.CreateInlineSliceFromWords("Item", "2"),
+ },
+ },
+ },
+ },
+ &ast.ParaNode{
+ Inlines: ast.CreateInlineSliceFromWords("This", "is", "some", "intermediate", "text."),
+ },
+ ast.CreateParaNode(
+ &ast.FormatNode{
+ Kind: ast.FormatEmph,
+ Attrs: attrs.Attributes(map[string]string{
+ "": "class",
+ "color": "green",
+ }),
+ Inlines: ast.CreateInlineSliceFromWords("This", "is", "some", "emphasized", "text."),
+ },
+ &ast.SpaceNode{Lexeme: " "},
+ &ast.LinkNode{
+ Ref: &ast.Reference{Value: "http://zettelstore.de"},
+ Inlines: ast.CreateInlineSliceFromWords("URL", "text."),
+ },
+ ),
+ }
+ v := benchVisitor{}
+ b.ResetTimer()
+ for n := 0; n < b.N; n++ {
+ ast.Walk(&v, &root)
+ }
+}
+
+type benchVisitor struct{}
+
+func (bv *benchVisitor) Visit(ast.Node) ast.Visitor { return bv }
ADDED auth/auth.go
Index: auth/auth.go
==================================================================
--- /dev/null
+++ auth/auth.go
@@ -0,0 +1,103 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 auth provides services for authentification / authorization.
+package auth
+
+import (
+ "time"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/config"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+)
+
+// BaseManager allows to check some base auth modes.
+type BaseManager interface {
+ // IsReadonly returns true, if the systems is configured to run in read-only-mode.
+ IsReadonly() bool
+}
+
+// TokenManager provides methods to create authentication
+type TokenManager interface {
+
+ // GetToken produces a authentication token.
+ GetToken(ident *meta.Meta, d time.Duration, kind TokenKind) ([]byte, error)
+
+ // CheckToken checks the validity of the token and returns relevant data.
+ CheckToken(token []byte, k TokenKind) (TokenData, error)
+}
+
+// TokenKind specifies for which application / usage a token is/was requested.
+type TokenKind int
+
+// Allowed values of token kind
+const (
+ _ TokenKind = iota
+ KindJSON
+ KindHTML
+)
+
+// TokenData contains some important elements from a token.
+type TokenData struct {
+ Token []byte
+ Now time.Time
+ Issued time.Time
+ Expires time.Time
+ Ident string
+ Zid id.Zid
+}
+
+// AuthzManager provides methods for authorization.
+type AuthzManager interface {
+ BaseManager
+
+ // Owner returns the zettel identifier of the owner.
+ Owner() id.Zid
+
+ // IsOwner returns true, if the given zettel identifier is that of the owner.
+ IsOwner(zid id.Zid) bool
+
+ // Returns true if authentication is enabled.
+ WithAuth() bool
+
+ // GetUserRole role returns the user role of the given user zettel.
+ GetUserRole(user *meta.Meta) meta.UserRole
+}
+
+// Manager is the main interface for providing the service.
+type Manager interface {
+ TokenManager
+ AuthzManager
+
+ BoxWithPolicy(unprotectedBox box.Box, rtConfig config.Config) (box.Box, Policy)
+}
+
+// Policy is an interface for checking access authorization.
+type Policy interface {
+ // User is allowed to create a new zettel.
+ CanCreate(user, newMeta *meta.Meta) bool
+
+ // User is allowed to read zettel
+ CanRead(user, m *meta.Meta) bool
+
+ // User is allowed to write zettel.
+ CanWrite(user, oldMeta, newMeta *meta.Meta) bool
+
+ // User is allowed to rename zettel
+ CanRename(user, m *meta.Meta) bool
+
+ // User is allowed to delete zettel.
+ CanDelete(user, m *meta.Meta) bool
+
+ // User is allowed to refresh box data.
+ CanRefresh(user *meta.Meta) bool
+}
ADDED auth/cred/cred.go
Index: auth/cred/cred.go
==================================================================
--- /dev/null
+++ auth/cred/cred.go
@@ -0,0 +1,53 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 cred provides some function for handling credentials.
+package cred
+
+import (
+ "bytes"
+
+ "golang.org/x/crypto/bcrypt"
+ "zettelstore.de/z/domain/id"
+)
+
+// HashCredential returns a hashed vesion of the given credential
+func HashCredential(zid id.Zid, ident, credential string) (string, error) {
+ fullCredential := createFullCredential(zid, ident, credential)
+ res, err := bcrypt.GenerateFromPassword(fullCredential, bcrypt.DefaultCost)
+ if err != nil {
+ return "", err
+ }
+ return string(res), nil
+}
+
+// CompareHashAndCredential checks, whether the hashed credential is a possible
+// value when hashing the credential.
+func CompareHashAndCredential(hashed string, zid id.Zid, ident, credential string) (bool, error) {
+ fullCredential := createFullCredential(zid, ident, credential)
+ err := bcrypt.CompareHashAndPassword([]byte(hashed), fullCredential)
+ if err == nil {
+ return true, nil
+ }
+ if err == bcrypt.ErrMismatchedHashAndPassword {
+ return false, nil
+ }
+ return false, err
+}
+
+func createFullCredential(zid id.Zid, ident, credential string) []byte {
+ var buf bytes.Buffer
+ buf.WriteString(zid.String())
+ buf.WriteByte(' ')
+ buf.WriteString(ident)
+ buf.WriteByte(' ')
+ buf.WriteString(credential)
+ return buf.Bytes()
+}
ADDED auth/impl/impl.go
Index: auth/impl/impl.go
==================================================================
--- /dev/null
+++ auth/impl/impl.go
@@ -0,0 +1,178 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 impl provides services for authentification / authorization.
+package impl
+
+import (
+ "errors"
+ "hash/fnv"
+ "io"
+ "time"
+
+ "github.com/pascaldekloe/jwt"
+
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/auth"
+ "zettelstore.de/z/auth/policy"
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/config"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+)
+
+type myAuth struct {
+ readonly bool
+ owner id.Zid
+ secret []byte
+}
+
+// New creates a new auth object.
+func New(readonly bool, owner id.Zid, extSecret string) auth.Manager {
+ return &myAuth{
+ readonly: readonly,
+ owner: owner,
+ secret: calcSecret(extSecret),
+ }
+}
+
+var configKeys = []string{
+ kernel.CoreProgname,
+ kernel.CoreGoVersion,
+ kernel.CoreHostname,
+ kernel.CoreGoOS,
+ kernel.CoreGoArch,
+ kernel.CoreVersion,
+}
+
+func calcSecret(extSecret string) []byte {
+ h := fnv.New128()
+ if extSecret != "" {
+ io.WriteString(h, extSecret)
+ }
+ for _, key := range configKeys {
+ io.WriteString(h, kernel.Main.GetConfig(kernel.CoreService, key).(string))
+ }
+ return h.Sum(nil)
+}
+
+// IsReadonly returns true, if the systems is configured to run in read-only-mode.
+func (a *myAuth) IsReadonly() bool { return a.readonly }
+
+const reqHash = jwt.HS512
+
+// ErrNoIdent signals that the 'ident' key is missing.
+var ErrNoIdent = errors.New("auth: missing ident")
+
+// ErrOtherKind signals that the token was defined for another token kind.
+var ErrOtherKind = errors.New("auth: wrong token kind")
+
+// ErrNoZid signals that the 'zid' key is missing.
+var ErrNoZid = errors.New("auth: missing zettel id")
+
+// GetToken returns a token to be used for authentification.
+func (a *myAuth) GetToken(ident *meta.Meta, d time.Duration, kind auth.TokenKind) ([]byte, error) {
+ subject, ok := ident.Get(api.KeyUserID)
+ if !ok || subject == "" {
+ return nil, ErrNoIdent
+ }
+
+ now := time.Now().Round(time.Second)
+ claims := jwt.Claims{
+ Registered: jwt.Registered{
+ Subject: subject,
+ Expires: jwt.NewNumericTime(now.Add(d)),
+ Issued: jwt.NewNumericTime(now),
+ },
+ Set: map[string]interface{}{
+ "zid": ident.Zid.String(),
+ "_tk": int(kind),
+ },
+ }
+ token, err := claims.HMACSign(reqHash, a.secret)
+ if err != nil {
+ return nil, err
+ }
+ return token, nil
+}
+
+// ErrTokenExpired signals an exired token
+var ErrTokenExpired = errors.New("auth: token expired")
+
+// CheckToken checks the validity of the token and returns relevant data.
+func (a *myAuth) CheckToken(token []byte, k auth.TokenKind) (auth.TokenData, error) {
+ h, err := jwt.NewHMAC(reqHash, a.secret)
+ if err != nil {
+ return auth.TokenData{}, err
+ }
+ claims, err := h.Check(token)
+ if err != nil {
+ return auth.TokenData{}, err
+ }
+ now := time.Now().Round(time.Second)
+ expires := claims.Expires.Time()
+ if expires.Before(now) {
+ return auth.TokenData{}, ErrTokenExpired
+ }
+ ident := claims.Subject
+ if ident == "" {
+ return auth.TokenData{}, ErrNoIdent
+ }
+ if zidS, ok := claims.Set["zid"].(string); ok {
+ if zid, err2 := id.Parse(zidS); err2 == nil {
+ if kind, ok2 := claims.Set["_tk"].(float64); ok2 {
+ if auth.TokenKind(kind) == k {
+ return auth.TokenData{
+ Token: token,
+ Now: now,
+ Issued: claims.Issued.Time(),
+ Expires: expires,
+ Ident: ident,
+ Zid: zid,
+ }, nil
+ }
+ }
+ return auth.TokenData{}, ErrOtherKind
+ }
+ }
+ return auth.TokenData{}, ErrNoZid
+}
+
+func (a *myAuth) Owner() id.Zid { return a.owner }
+
+func (a *myAuth) IsOwner(zid id.Zid) bool {
+ return zid.IsValid() && zid == a.owner
+}
+
+func (a *myAuth) WithAuth() bool { return a.owner != id.Invalid }
+
+// GetUserRole role returns the user role of the given user zettel.
+func (a *myAuth) GetUserRole(user *meta.Meta) meta.UserRole {
+ if user == nil {
+ if a.WithAuth() {
+ return meta.UserRoleUnknown
+ }
+ return meta.UserRoleOwner
+ }
+ if a.IsOwner(user.Zid) {
+ return meta.UserRoleOwner
+ }
+ if val, ok := user.Get(api.KeyUserRole); ok {
+ if ur := meta.GetUserRole(val); ur != meta.UserRoleUnknown {
+ return ur
+ }
+ }
+ return meta.UserRoleReader
+}
+
+func (a *myAuth) BoxWithPolicy(unprotectedBox box.Box, rtConfig config.Config) (box.Box, auth.Policy) {
+ return policy.BoxWithPolicy(a, unprotectedBox, rtConfig)
+}
ADDED auth/policy/anon.go
Index: auth/policy/anon.go
==================================================================
--- /dev/null
+++ auth/policy/anon.go
@@ -0,0 +1,56 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 policy
+
+import (
+ "zettelstore.de/z/auth"
+ "zettelstore.de/z/config"
+ "zettelstore.de/z/domain/meta"
+)
+
+type anonPolicy struct {
+ authConfig config.AuthConfig
+ pre auth.Policy
+}
+
+func (ap *anonPolicy) CanCreate(user, newMeta *meta.Meta) bool {
+ return ap.pre.CanCreate(user, newMeta)
+}
+
+func (ap *anonPolicy) CanRead(user, m *meta.Meta) bool {
+ return ap.pre.CanRead(user, m) && ap.checkVisibility(m)
+}
+
+func (ap *anonPolicy) CanWrite(user, oldMeta, newMeta *meta.Meta) bool {
+ return ap.pre.CanWrite(user, oldMeta, newMeta) && ap.checkVisibility(oldMeta)
+}
+
+func (ap *anonPolicy) CanRename(user, m *meta.Meta) bool {
+ return ap.pre.CanRename(user, m) && ap.checkVisibility(m)
+}
+
+func (ap *anonPolicy) CanDelete(user, m *meta.Meta) bool {
+ return ap.pre.CanDelete(user, m) && ap.checkVisibility(m)
+}
+
+func (ap *anonPolicy) CanRefresh(user *meta.Meta) bool {
+ if ap.authConfig.GetExpertMode() || ap.authConfig.GetSimpleMode() {
+ return true
+ }
+ return ap.pre.CanRefresh(user)
+}
+
+func (ap *anonPolicy) checkVisibility(m *meta.Meta) bool {
+ if ap.authConfig.GetVisibility(m) == meta.VisibilityExpert {
+ return ap.authConfig.GetExpertMode()
+ }
+ return true
+}
ADDED auth/policy/box.go
Index: auth/policy/box.go
==================================================================
--- /dev/null
+++ auth/policy/box.go
@@ -0,0 +1,168 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 policy
+
+import (
+ "context"
+
+ "zettelstore.de/z/auth"
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/config"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/query"
+ "zettelstore.de/z/web/server"
+)
+
+// BoxWithPolicy wraps the given box inside a policy box.
+func BoxWithPolicy(
+ manager auth.AuthzManager,
+ box box.Box,
+ authConfig config.AuthConfig,
+) (box.Box, auth.Policy) {
+ pol := newPolicy(manager, authConfig)
+ return newBox(box, pol), pol
+}
+
+// polBox implements a policy box.
+type polBox struct {
+ box box.Box
+ policy auth.Policy
+}
+
+// newBox creates a new policy box.
+func newBox(box box.Box, policy auth.Policy) box.Box {
+ return &polBox{
+ box: box,
+ policy: policy,
+ }
+}
+
+func (pp *polBox) Location() string {
+ return pp.box.Location()
+}
+
+func (pp *polBox) CanCreateZettel(ctx context.Context) bool {
+ return pp.box.CanCreateZettel(ctx)
+}
+
+func (pp *polBox) CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error) {
+ user := server.GetUser(ctx)
+ if pp.policy.CanCreate(user, zettel.Meta) {
+ return pp.box.CreateZettel(ctx, zettel)
+ }
+ return id.Invalid, box.NewErrNotAllowed("Create", user, id.Invalid)
+}
+
+func (pp *polBox) GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error) {
+ zettel, err := pp.box.GetZettel(ctx, zid)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ user := server.GetUser(ctx)
+ if pp.policy.CanRead(user, zettel.Meta) {
+ return zettel, nil
+ }
+ return domain.Zettel{}, box.NewErrNotAllowed("GetZettel", user, zid)
+}
+
+func (pp *polBox) GetAllZettel(ctx context.Context, zid id.Zid) ([]domain.Zettel, error) {
+ return pp.box.GetAllZettel(ctx, zid)
+}
+
+func (pp *polBox) GetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error) {
+ m, err := pp.box.GetMeta(ctx, zid)
+ if err != nil {
+ return nil, err
+ }
+ user := server.GetUser(ctx)
+ if pp.policy.CanRead(user, m) {
+ return m, nil
+ }
+ return nil, box.NewErrNotAllowed("GetMeta", user, zid)
+}
+
+func (pp *polBox) GetAllMeta(ctx context.Context, zid id.Zid) ([]*meta.Meta, error) {
+ return pp.box.GetAllMeta(ctx, zid)
+}
+
+func (pp *polBox) FetchZids(ctx context.Context) (id.Set, error) {
+ return nil, box.NewErrNotAllowed("fetch-zids", server.GetUser(ctx), id.Invalid)
+}
+
+func (pp *polBox) SelectMeta(ctx context.Context, q *query.Query) ([]*meta.Meta, error) {
+ user := server.GetUser(ctx)
+ canRead := pp.policy.CanRead
+ q = q.SetPreMatch(func(m *meta.Meta) bool { return canRead(user, m) })
+ return pp.box.SelectMeta(ctx, q)
+}
+
+func (pp *polBox) CanUpdateZettel(ctx context.Context, zettel domain.Zettel) bool {
+ return pp.box.CanUpdateZettel(ctx, zettel)
+}
+
+func (pp *polBox) UpdateZettel(ctx context.Context, zettel domain.Zettel) error {
+ zid := zettel.Meta.Zid
+ user := server.GetUser(ctx)
+ if !zid.IsValid() {
+ return &box.ErrInvalidID{Zid: zid}
+ }
+ // Write existing zettel
+ oldMeta, err := pp.box.GetMeta(ctx, zid)
+ if err != nil {
+ return err
+ }
+ if pp.policy.CanWrite(user, oldMeta, zettel.Meta) {
+ return pp.box.UpdateZettel(ctx, zettel)
+ }
+ return box.NewErrNotAllowed("Write", user, zid)
+}
+
+func (pp *polBox) AllowRenameZettel(ctx context.Context, zid id.Zid) bool {
+ return pp.box.AllowRenameZettel(ctx, zid)
+}
+
+func (pp *polBox) RenameZettel(ctx context.Context, curZid, newZid id.Zid) error {
+ meta, err := pp.box.GetMeta(ctx, curZid)
+ if err != nil {
+ return err
+ }
+ user := server.GetUser(ctx)
+ if pp.policy.CanRename(user, meta) {
+ return pp.box.RenameZettel(ctx, curZid, newZid)
+ }
+ return box.NewErrNotAllowed("Rename", user, curZid)
+}
+
+func (pp *polBox) CanDeleteZettel(ctx context.Context, zid id.Zid) bool {
+ return pp.box.CanDeleteZettel(ctx, zid)
+}
+
+func (pp *polBox) DeleteZettel(ctx context.Context, zid id.Zid) error {
+ meta, err := pp.box.GetMeta(ctx, zid)
+ if err != nil {
+ return err
+ }
+ user := server.GetUser(ctx)
+ if pp.policy.CanDelete(user, meta) {
+ return pp.box.DeleteZettel(ctx, zid)
+ }
+ return box.NewErrNotAllowed("Delete", user, zid)
+}
+
+func (pp *polBox) Refresh(ctx context.Context) error {
+ user := server.GetUser(ctx)
+ if pp.policy.CanRefresh(user) {
+ return pp.box.Refresh(ctx)
+ }
+ return box.NewErrNotAllowed("Refresh", user, id.Invalid)
+}
ADDED auth/policy/default.go
Index: auth/policy/default.go
==================================================================
--- /dev/null
+++ auth/policy/default.go
@@ -0,0 +1,57 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 policy
+
+import (
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/auth"
+ "zettelstore.de/z/domain/meta"
+)
+
+type defaultPolicy struct {
+ manager auth.AuthzManager
+}
+
+func (*defaultPolicy) CanCreate(_, _ *meta.Meta) bool { return true }
+func (*defaultPolicy) CanRead(_, _ *meta.Meta) bool { return true }
+func (d *defaultPolicy) CanWrite(user, oldMeta, _ *meta.Meta) bool {
+ return d.canChange(user, oldMeta)
+}
+func (d *defaultPolicy) CanRename(user, m *meta.Meta) bool { return d.canChange(user, m) }
+func (d *defaultPolicy) CanDelete(user, m *meta.Meta) bool { return d.canChange(user, m) }
+
+func (*defaultPolicy) CanRefresh(user *meta.Meta) bool { return user != nil }
+
+func (d *defaultPolicy) canChange(user, m *meta.Meta) bool {
+ metaRo, ok := m.Get(api.KeyReadOnly)
+ if !ok {
+ return true
+ }
+ if user == nil {
+ // If we are here, there is no authentication.
+ // See owner.go:CanWrite.
+
+ // No authentication: check for owner-like restriction, because the user
+ // acts as an owner
+ return metaRo != api.ValueUserRoleOwner && !meta.BoolValue(metaRo)
+ }
+
+ userRole := d.manager.GetUserRole(user)
+ switch metaRo {
+ case api.ValueUserRoleReader:
+ return userRole > meta.UserRoleReader
+ case api.ValueUserRoleWriter:
+ return userRole > meta.UserRoleWriter
+ case api.ValueUserRoleOwner:
+ return userRole > meta.UserRoleOwner
+ }
+ return !meta.BoolValue(metaRo)
+}
ADDED auth/policy/owner.go
Index: auth/policy/owner.go
==================================================================
--- /dev/null
+++ auth/policy/owner.go
@@ -0,0 +1,163 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 policy
+
+import (
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/auth"
+ "zettelstore.de/z/config"
+ "zettelstore.de/z/domain/meta"
+)
+
+type ownerPolicy struct {
+ manager auth.AuthzManager
+ authConfig config.AuthConfig
+ pre auth.Policy
+}
+
+func (o *ownerPolicy) CanCreate(user, newMeta *meta.Meta) bool {
+ if user == nil || !o.pre.CanCreate(user, newMeta) {
+ return false
+ }
+ return o.userIsOwner(user) || o.userCanCreate(user, newMeta)
+}
+
+func (o *ownerPolicy) userCanCreate(user, newMeta *meta.Meta) bool {
+ if o.manager.GetUserRole(user) == meta.UserRoleReader {
+ return false
+ }
+ if _, ok := newMeta.Get(api.KeyUserID); ok {
+ return false
+ }
+ return true
+}
+
+func (o *ownerPolicy) CanRead(user, m *meta.Meta) bool {
+ // No need to call o.pre.CanRead(user, meta), because it will always return true.
+ // Both the default and the readonly policy allow to read a zettel.
+ vis := o.authConfig.GetVisibility(m)
+ if res, ok := o.checkVisibility(user, vis); ok {
+ return res
+ }
+ return o.userIsOwner(user) || o.userCanRead(user, m, vis)
+}
+
+func (o *ownerPolicy) userCanRead(user, m *meta.Meta, vis meta.Visibility) bool {
+ switch vis {
+ case meta.VisibilityOwner, meta.VisibilityExpert:
+ return false
+ case meta.VisibilityPublic:
+ return true
+ }
+ if user == nil {
+ return false
+ }
+ if _, ok := m.Get(api.KeyUserID); ok {
+ // Only the user can read its own zettel
+ return user.Zid == m.Zid
+ }
+ switch o.manager.GetUserRole(user) {
+ case meta.UserRoleReader, meta.UserRoleWriter, meta.UserRoleOwner:
+ return true
+ case meta.UserRoleCreator:
+ return vis == meta.VisibilityCreator
+ default:
+ return false
+ }
+}
+
+var noChangeUser = []string{
+ api.KeyID,
+ api.KeyRole,
+ api.KeyUserID,
+ api.KeyUserRole,
+}
+
+func (o *ownerPolicy) CanWrite(user, oldMeta, newMeta *meta.Meta) bool {
+ if user == nil || !o.pre.CanWrite(user, oldMeta, newMeta) {
+ return false
+ }
+ vis := o.authConfig.GetVisibility(oldMeta)
+ if res, ok := o.checkVisibility(user, vis); ok {
+ return res
+ }
+ if o.userIsOwner(user) {
+ return true
+ }
+ if !o.userCanRead(user, oldMeta, vis) {
+ return false
+ }
+ if _, ok := oldMeta.Get(api.KeyUserID); ok {
+ // Here we know, that user.Zid == newMeta.Zid (because of userCanRead) and
+ // user.Zid == newMeta.Zid (because oldMeta.Zid == newMeta.Zid)
+ for _, key := range noChangeUser {
+ if oldMeta.GetDefault(key, "") != newMeta.GetDefault(key, "") {
+ return false
+ }
+ }
+ return true
+ }
+ switch userRole := o.manager.GetUserRole(user); userRole {
+ case meta.UserRoleReader, meta.UserRoleCreator:
+ return false
+ }
+ return o.userCanCreate(user, newMeta)
+}
+
+func (o *ownerPolicy) CanRename(user, m *meta.Meta) bool {
+ if user == nil || !o.pre.CanRename(user, m) {
+ return false
+ }
+ if res, ok := o.checkVisibility(user, o.authConfig.GetVisibility(m)); ok {
+ return res
+ }
+ return o.userIsOwner(user)
+}
+
+func (o *ownerPolicy) CanDelete(user, m *meta.Meta) bool {
+ if user == nil || !o.pre.CanDelete(user, m) {
+ return false
+ }
+ if res, ok := o.checkVisibility(user, o.authConfig.GetVisibility(m)); ok {
+ return res
+ }
+ return o.userIsOwner(user)
+}
+
+func (o *ownerPolicy) CanRefresh(user *meta.Meta) bool {
+ switch userRole := o.manager.GetUserRole(user); userRole {
+ case meta.UserRoleUnknown:
+ return o.authConfig.GetSimpleMode()
+ case meta.UserRoleCreator:
+ return o.authConfig.GetExpertMode() || o.authConfig.GetSimpleMode()
+ }
+ return true
+}
+
+func (o *ownerPolicy) checkVisibility(user *meta.Meta, vis meta.Visibility) (bool, bool) {
+ if vis == meta.VisibilityExpert {
+ return o.userIsOwner(user) && o.authConfig.GetExpertMode(), true
+ }
+ return false, false
+}
+
+func (o *ownerPolicy) userIsOwner(user *meta.Meta) bool {
+ if user == nil {
+ return false
+ }
+ if o.manager.IsOwner(user.Zid) {
+ return true
+ }
+ if val, ok := user.Get(api.KeyUserRole); ok && val == api.ValueUserRoleOwner {
+ return true
+ }
+ return false
+}
ADDED auth/policy/policy.go
Index: auth/policy/policy.go
==================================================================
--- /dev/null
+++ auth/policy/policy.go
@@ -0,0 +1,70 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 policy provides some interfaces and implementation for authorizsation policies.
+package policy
+
+import (
+ "zettelstore.de/z/auth"
+ "zettelstore.de/z/config"
+ "zettelstore.de/z/domain/meta"
+)
+
+// newPolicy creates a policy based on given constraints.
+func newPolicy(manager auth.AuthzManager, authConfig config.AuthConfig) auth.Policy {
+ var pol auth.Policy
+ if manager.IsReadonly() {
+ pol = &roPolicy{}
+ } else {
+ pol = &defaultPolicy{manager}
+ }
+ if manager.WithAuth() {
+ pol = &ownerPolicy{
+ manager: manager,
+ authConfig: authConfig,
+ pre: pol,
+ }
+ } else {
+ pol = &anonPolicy{
+ authConfig: authConfig,
+ pre: pol,
+ }
+ }
+ return &prePolicy{pol}
+}
+
+type prePolicy struct {
+ post auth.Policy
+}
+
+func (p *prePolicy) CanCreate(user, newMeta *meta.Meta) bool {
+ return newMeta != nil && p.post.CanCreate(user, newMeta)
+}
+
+func (p *prePolicy) CanRead(user, m *meta.Meta) bool {
+ return m != nil && p.post.CanRead(user, m)
+}
+
+func (p *prePolicy) CanWrite(user, oldMeta, newMeta *meta.Meta) bool {
+ return oldMeta != nil && newMeta != nil && oldMeta.Zid == newMeta.Zid &&
+ p.post.CanWrite(user, oldMeta, newMeta)
+}
+
+func (p *prePolicy) CanRename(user, m *meta.Meta) bool {
+ return m != nil && p.post.CanRename(user, m)
+}
+
+func (p *prePolicy) CanDelete(user, m *meta.Meta) bool {
+ return m != nil && p.post.CanDelete(user, m)
+}
+
+func (p *prePolicy) CanRefresh(user *meta.Meta) bool {
+ return p.post.CanRefresh(user)
+}
ADDED auth/policy/policy_test.go
Index: auth/policy/policy_test.go
==================================================================
--- /dev/null
+++ auth/policy/policy_test.go
@@ -0,0 +1,717 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 policy
+
+import (
+ "fmt"
+ "testing"
+
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/auth"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+)
+
+func TestPolicies(t *testing.T) {
+ t.Parallel()
+ testScene := []struct {
+ readonly bool
+ withAuth bool
+ expert bool
+ simple bool
+ }{
+ {true, true, true, true},
+ {true, true, true, false},
+ {true, true, false, true},
+ {true, true, false, false},
+ {true, false, true, true},
+ {true, false, true, false},
+ {true, false, false, true},
+ {true, false, false, false},
+ {false, true, true, true},
+ {false, true, true, false},
+ {false, true, false, true},
+ {false, true, false, false},
+ {false, false, true, true},
+ {false, false, true, false},
+ {false, false, false, true},
+ {false, false, false, false},
+ }
+ for _, ts := range testScene {
+ pol := newPolicy(
+ &testAuthzManager{readOnly: ts.readonly, withAuth: ts.withAuth},
+ &authConfig{simple: ts.simple, expert: ts.expert},
+ )
+ name := fmt.Sprintf("readonly=%v/withauth=%v/expert=%v/simple=%v",
+ ts.readonly, ts.withAuth, ts.expert, ts.simple)
+ t.Run(name, func(tt *testing.T) {
+ testCreate(tt, pol, ts.withAuth, ts.readonly)
+ testRead(tt, pol, ts.withAuth, ts.expert)
+ testWrite(tt, pol, ts.withAuth, ts.readonly, ts.expert)
+ testRename(tt, pol, ts.withAuth, ts.readonly, ts.expert)
+ testDelete(tt, pol, ts.withAuth, ts.readonly, ts.expert)
+ testRefresh(tt, pol, ts.withAuth, ts.expert, ts.simple)
+ })
+ }
+}
+
+type testAuthzManager struct {
+ readOnly bool
+ withAuth bool
+}
+
+func (a *testAuthzManager) IsReadonly() bool { return a.readOnly }
+func (*testAuthzManager) Owner() id.Zid { return ownerZid }
+func (*testAuthzManager) IsOwner(zid id.Zid) bool { return zid == ownerZid }
+
+func (a *testAuthzManager) WithAuth() bool { return a.withAuth }
+
+func (a *testAuthzManager) GetUserRole(user *meta.Meta) meta.UserRole {
+ if user == nil {
+ if a.WithAuth() {
+ return meta.UserRoleUnknown
+ }
+ return meta.UserRoleOwner
+ }
+ if a.IsOwner(user.Zid) {
+ return meta.UserRoleOwner
+ }
+ if val, ok := user.Get(api.KeyUserRole); ok {
+ if ur := meta.GetUserRole(val); ur != meta.UserRoleUnknown {
+ return ur
+ }
+ }
+ return meta.UserRoleReader
+}
+
+type authConfig struct{ simple, expert bool }
+
+func (ac *authConfig) GetSimpleMode() bool { return ac.simple }
+func (ac *authConfig) GetExpertMode() bool { return ac.expert }
+
+func (*authConfig) GetVisibility(m *meta.Meta) meta.Visibility {
+ if vis, ok := m.Get(api.KeyVisibility); ok {
+ return meta.GetVisibility(vis)
+ }
+ return meta.VisibilityLogin
+}
+
+func testCreate(t *testing.T, pol auth.Policy, withAuth, readonly bool) {
+ t.Helper()
+ anonUser := newAnon()
+ creator := newCreator()
+ reader := newReader()
+ writer := newWriter()
+ owner := newOwner()
+ owner2 := newOwner2()
+ zettel := newZettel()
+ userZettel := newUserZettel()
+ testCases := []struct {
+ user *meta.Meta
+ meta *meta.Meta
+ exp bool
+ }{
+ // No meta
+ {anonUser, nil, false},
+ {creator, nil, false},
+ {reader, nil, false},
+ {writer, nil, false},
+ {owner, nil, false},
+ {owner2, nil, false},
+ // Ordinary zettel
+ {anonUser, zettel, !withAuth && !readonly},
+ {creator, zettel, !readonly},
+ {reader, zettel, !withAuth && !readonly},
+ {writer, zettel, !readonly},
+ {owner, zettel, !readonly},
+ {owner2, zettel, !readonly},
+ // User zettel
+ {anonUser, userZettel, !withAuth && !readonly},
+ {creator, userZettel, !withAuth && !readonly},
+ {reader, userZettel, !withAuth && !readonly},
+ {writer, userZettel, !withAuth && !readonly},
+ {owner, userZettel, !readonly},
+ {owner2, userZettel, !readonly},
+ }
+ for _, tc := range testCases {
+ t.Run("Create", func(tt *testing.T) {
+ got := pol.CanCreate(tc.user, tc.meta)
+ if tc.exp != got {
+ tt.Errorf("exp=%v, but got=%v", tc.exp, got)
+ }
+ })
+ }
+}
+
+func testRead(t *testing.T, pol auth.Policy, withAuth, expert bool) {
+ t.Helper()
+ anonUser := newAnon()
+ creator := newCreator()
+ reader := newReader()
+ writer := newWriter()
+ owner := newOwner()
+ owner2 := newOwner2()
+ zettel := newZettel()
+ publicZettel := newPublicZettel()
+ creatorZettel := newCreatorZettel()
+ loginZettel := newLoginZettel()
+ ownerZettel := newOwnerZettel()
+ expertZettel := newExpertZettel()
+ userZettel := newUserZettel()
+ testCases := []struct {
+ user *meta.Meta
+ meta *meta.Meta
+ exp bool
+ }{
+ // No meta
+ {anonUser, nil, false},
+ {creator, nil, false},
+ {reader, nil, false},
+ {writer, nil, false},
+ {owner, nil, false},
+ {owner2, nil, false},
+ // Ordinary zettel
+ {anonUser, zettel, !withAuth},
+ {creator, zettel, !withAuth},
+ {reader, zettel, true},
+ {writer, zettel, true},
+ {owner, zettel, true},
+ {owner2, zettel, true},
+ // Public zettel
+ {anonUser, publicZettel, true},
+ {creator, publicZettel, true},
+ {reader, publicZettel, true},
+ {writer, publicZettel, true},
+ {owner, publicZettel, true},
+ {owner2, publicZettel, true},
+ // Creator zettel
+ {anonUser, creatorZettel, !withAuth},
+ {creator, creatorZettel, true},
+ {reader, creatorZettel, true},
+ {writer, creatorZettel, true},
+ {owner, creatorZettel, true},
+ {owner2, creatorZettel, true},
+ // Login zettel
+ {anonUser, loginZettel, !withAuth},
+ {creator, loginZettel, !withAuth},
+ {reader, loginZettel, true},
+ {writer, loginZettel, true},
+ {owner, loginZettel, true},
+ {owner2, loginZettel, true},
+ // Owner zettel
+ {anonUser, ownerZettel, !withAuth},
+ {creator, ownerZettel, !withAuth},
+ {reader, ownerZettel, !withAuth},
+ {writer, ownerZettel, !withAuth},
+ {owner, ownerZettel, true},
+ {owner2, ownerZettel, true},
+ // Expert zettel
+ {anonUser, expertZettel, !withAuth && expert},
+ {creator, expertZettel, !withAuth && expert},
+ {reader, expertZettel, !withAuth && expert},
+ {writer, expertZettel, !withAuth && expert},
+ {owner, expertZettel, expert},
+ {owner2, expertZettel, expert},
+ // Other user zettel
+ {anonUser, userZettel, !withAuth},
+ {creator, userZettel, !withAuth},
+ {reader, userZettel, !withAuth},
+ {writer, userZettel, !withAuth},
+ {owner, userZettel, true},
+ {owner2, userZettel, true},
+ // Own user zettel
+ {creator, creator, true},
+ {reader, reader, true},
+ {writer, writer, true},
+ {owner, owner, true},
+ {owner, owner2, true},
+ {owner2, owner, true},
+ {owner2, owner2, true},
+ }
+ for _, tc := range testCases {
+ t.Run("Read", func(tt *testing.T) {
+ got := pol.CanRead(tc.user, tc.meta)
+ if tc.exp != got {
+ tt.Errorf("exp=%v, but got=%v", tc.exp, got)
+ }
+ })
+ }
+}
+
+func testWrite(t *testing.T, pol auth.Policy, withAuth, readonly, expert bool) {
+ t.Helper()
+ anonUser := newAnon()
+ creator := newCreator()
+ reader := newReader()
+ writer := newWriter()
+ owner := newOwner()
+ owner2 := newOwner2()
+ zettel := newZettel()
+ publicZettel := newPublicZettel()
+ loginZettel := newLoginZettel()
+ ownerZettel := newOwnerZettel()
+ expertZettel := newExpertZettel()
+ userZettel := newUserZettel()
+ writerNew := writer.Clone()
+ writerNew.Set(api.KeyUserRole, owner.GetDefault(api.KeyUserRole, ""))
+ roFalse := newRoFalseZettel()
+ roTrue := newRoTrueZettel()
+ roReader := newRoReaderZettel()
+ roWriter := newRoWriterZettel()
+ roOwner := newRoOwnerZettel()
+ notAuthNotReadonly := !withAuth && !readonly
+ testCases := []struct {
+ user *meta.Meta
+ old *meta.Meta
+ new *meta.Meta
+ exp bool
+ }{
+ // No old and new meta
+ {anonUser, nil, nil, false},
+ {creator, nil, nil, false},
+ {reader, nil, nil, false},
+ {writer, nil, nil, false},
+ {owner, nil, nil, false},
+ {owner2, nil, nil, false},
+ // No old meta
+ {anonUser, nil, zettel, false},
+ {creator, nil, zettel, false},
+ {reader, nil, zettel, false},
+ {writer, nil, zettel, false},
+ {owner, nil, zettel, false},
+ {owner2, nil, zettel, false},
+ // No new meta
+ {anonUser, zettel, nil, false},
+ {creator, zettel, nil, false},
+ {reader, zettel, nil, false},
+ {writer, zettel, nil, false},
+ {owner, zettel, nil, false},
+ {owner2, zettel, nil, false},
+ // Old an new zettel have different zettel identifier
+ {anonUser, zettel, publicZettel, false},
+ {creator, zettel, publicZettel, false},
+ {reader, zettel, publicZettel, false},
+ {writer, zettel, publicZettel, false},
+ {owner, zettel, publicZettel, false},
+ {owner2, zettel, publicZettel, false},
+ // Overwrite a normal zettel
+ {anonUser, zettel, zettel, notAuthNotReadonly},
+ {creator, zettel, zettel, notAuthNotReadonly},
+ {reader, zettel, zettel, notAuthNotReadonly},
+ {writer, zettel, zettel, !readonly},
+ {owner, zettel, zettel, !readonly},
+ {owner2, zettel, zettel, !readonly},
+ // Public zettel
+ {anonUser, publicZettel, publicZettel, notAuthNotReadonly},
+ {creator, publicZettel, publicZettel, notAuthNotReadonly},
+ {reader, publicZettel, publicZettel, notAuthNotReadonly},
+ {writer, publicZettel, publicZettel, !readonly},
+ {owner, publicZettel, publicZettel, !readonly},
+ {owner2, publicZettel, publicZettel, !readonly},
+ // Login zettel
+ {anonUser, loginZettel, loginZettel, notAuthNotReadonly},
+ {creator, loginZettel, loginZettel, notAuthNotReadonly},
+ {reader, loginZettel, loginZettel, notAuthNotReadonly},
+ {writer, loginZettel, loginZettel, !readonly},
+ {owner, loginZettel, loginZettel, !readonly},
+ {owner2, loginZettel, loginZettel, !readonly},
+ // Owner zettel
+ {anonUser, ownerZettel, ownerZettel, notAuthNotReadonly},
+ {creator, ownerZettel, ownerZettel, notAuthNotReadonly},
+ {reader, ownerZettel, ownerZettel, notAuthNotReadonly},
+ {writer, ownerZettel, ownerZettel, notAuthNotReadonly},
+ {owner, ownerZettel, ownerZettel, !readonly},
+ {owner2, ownerZettel, ownerZettel, !readonly},
+ // Expert zettel
+ {anonUser, expertZettel, expertZettel, notAuthNotReadonly && expert},
+ {creator, expertZettel, expertZettel, notAuthNotReadonly && expert},
+ {reader, expertZettel, expertZettel, notAuthNotReadonly && expert},
+ {writer, expertZettel, expertZettel, notAuthNotReadonly && expert},
+ {owner, expertZettel, expertZettel, !readonly && expert},
+ {owner2, expertZettel, expertZettel, !readonly && expert},
+ // Other user zettel
+ {anonUser, userZettel, userZettel, notAuthNotReadonly},
+ {creator, userZettel, userZettel, notAuthNotReadonly},
+ {reader, userZettel, userZettel, notAuthNotReadonly},
+ {writer, userZettel, userZettel, notAuthNotReadonly},
+ {owner, userZettel, userZettel, !readonly},
+ {owner2, userZettel, userZettel, !readonly},
+ // Own user zettel
+ {creator, creator, creator, !readonly},
+ {reader, reader, reader, !readonly},
+ {writer, writer, writer, !readonly},
+ {owner, owner, owner, !readonly},
+ {owner2, owner2, owner2, !readonly},
+ // Writer cannot change importand metadata of its own user zettel
+ {writer, writer, writerNew, notAuthNotReadonly},
+ // No r/o zettel
+ {anonUser, roFalse, roFalse, notAuthNotReadonly},
+ {creator, roFalse, roFalse, notAuthNotReadonly},
+ {reader, roFalse, roFalse, notAuthNotReadonly},
+ {writer, roFalse, roFalse, !readonly},
+ {owner, roFalse, roFalse, !readonly},
+ {owner2, roFalse, roFalse, !readonly},
+ // Reader r/o zettel
+ {anonUser, roReader, roReader, false},
+ {creator, roReader, roReader, false},
+ {reader, roReader, roReader, false},
+ {writer, roReader, roReader, !readonly},
+ {owner, roReader, roReader, !readonly},
+ {owner2, roReader, roReader, !readonly},
+ // Writer r/o zettel
+ {anonUser, roWriter, roWriter, false},
+ {creator, roWriter, roWriter, false},
+ {reader, roWriter, roWriter, false},
+ {writer, roWriter, roWriter, false},
+ {owner, roWriter, roWriter, !readonly},
+ {owner2, roWriter, roWriter, !readonly},
+ // Owner r/o zettel
+ {anonUser, roOwner, roOwner, false},
+ {creator, roOwner, roOwner, false},
+ {reader, roOwner, roOwner, false},
+ {writer, roOwner, roOwner, false},
+ {owner, roOwner, roOwner, false},
+ {owner2, roOwner, roOwner, false},
+ // r/o = true zettel
+ {anonUser, roTrue, roTrue, false},
+ {creator, roTrue, roTrue, false},
+ {reader, roTrue, roTrue, false},
+ {writer, roTrue, roTrue, false},
+ {owner, roTrue, roTrue, false},
+ {owner2, roTrue, roTrue, false},
+ }
+ for _, tc := range testCases {
+ t.Run("Write", func(tt *testing.T) {
+ got := pol.CanWrite(tc.user, tc.old, tc.new)
+ if tc.exp != got {
+ tt.Errorf("exp=%v, but got=%v", tc.exp, got)
+ }
+ })
+ }
+}
+
+func testRename(t *testing.T, pol auth.Policy, withAuth, readonly, expert bool) {
+ t.Helper()
+ anonUser := newAnon()
+ creator := newCreator()
+ reader := newReader()
+ writer := newWriter()
+ owner := newOwner()
+ owner2 := newOwner2()
+ zettel := newZettel()
+ expertZettel := newExpertZettel()
+ roFalse := newRoFalseZettel()
+ roTrue := newRoTrueZettel()
+ roReader := newRoReaderZettel()
+ roWriter := newRoWriterZettel()
+ roOwner := newRoOwnerZettel()
+ notAuthNotReadonly := !withAuth && !readonly
+ testCases := []struct {
+ user *meta.Meta
+ meta *meta.Meta
+ exp bool
+ }{
+ // No meta
+ {anonUser, nil, false},
+ {creator, nil, false},
+ {reader, nil, false},
+ {writer, nil, false},
+ {owner, nil, false},
+ {owner2, nil, false},
+ // Any zettel
+ {anonUser, zettel, notAuthNotReadonly},
+ {creator, zettel, notAuthNotReadonly},
+ {reader, zettel, notAuthNotReadonly},
+ {writer, zettel, notAuthNotReadonly},
+ {owner, zettel, !readonly},
+ {owner2, zettel, !readonly},
+ // Expert zettel
+ {anonUser, expertZettel, notAuthNotReadonly && expert},
+ {creator, expertZettel, notAuthNotReadonly && expert},
+ {reader, expertZettel, notAuthNotReadonly && expert},
+ {writer, expertZettel, notAuthNotReadonly && expert},
+ {owner, expertZettel, !readonly && expert},
+ {owner2, expertZettel, !readonly && expert},
+ // No r/o zettel
+ {anonUser, roFalse, notAuthNotReadonly},
+ {creator, roFalse, notAuthNotReadonly},
+ {reader, roFalse, notAuthNotReadonly},
+ {writer, roFalse, notAuthNotReadonly},
+ {owner, roFalse, !readonly},
+ {owner2, roFalse, !readonly},
+ // Reader r/o zettel
+ {anonUser, roReader, false},
+ {creator, roReader, false},
+ {reader, roReader, false},
+ {writer, roReader, notAuthNotReadonly},
+ {owner, roReader, !readonly},
+ {owner2, roReader, !readonly},
+ // Writer r/o zettel
+ {anonUser, roWriter, false},
+ {creator, roWriter, false},
+ {reader, roWriter, false},
+ {writer, roWriter, false},
+ {owner, roWriter, !readonly},
+ {owner2, roWriter, !readonly},
+ // Owner r/o zettel
+ {anonUser, roOwner, false},
+ {creator, roOwner, false},
+ {reader, roOwner, false},
+ {writer, roOwner, false},
+ {owner, roOwner, false},
+ {owner2, roOwner, false},
+ // r/o = true zettel
+ {anonUser, roTrue, false},
+ {creator, roTrue, false},
+ {reader, roTrue, false},
+ {writer, roTrue, false},
+ {owner, roTrue, false},
+ {owner2, roTrue, false},
+ }
+ for _, tc := range testCases {
+ t.Run("Rename", func(tt *testing.T) {
+ got := pol.CanRename(tc.user, tc.meta)
+ if tc.exp != got {
+ tt.Errorf("exp=%v, but got=%v", tc.exp, got)
+ }
+ })
+ }
+}
+
+func testDelete(t *testing.T, pol auth.Policy, withAuth, readonly, expert bool) {
+ t.Helper()
+ anonUser := newAnon()
+ creator := newCreator()
+ reader := newReader()
+ writer := newWriter()
+ owner := newOwner()
+ owner2 := newOwner2()
+ zettel := newZettel()
+ expertZettel := newExpertZettel()
+ roFalse := newRoFalseZettel()
+ roTrue := newRoTrueZettel()
+ roReader := newRoReaderZettel()
+ roWriter := newRoWriterZettel()
+ roOwner := newRoOwnerZettel()
+ notAuthNotReadonly := !withAuth && !readonly
+ testCases := []struct {
+ user *meta.Meta
+ meta *meta.Meta
+ exp bool
+ }{
+ // No meta
+ {anonUser, nil, false},
+ {creator, nil, false},
+ {reader, nil, false},
+ {writer, nil, false},
+ {owner, nil, false},
+ {owner2, nil, false},
+ // Any zettel
+ {anonUser, zettel, notAuthNotReadonly},
+ {creator, zettel, notAuthNotReadonly},
+ {reader, zettel, notAuthNotReadonly},
+ {writer, zettel, notAuthNotReadonly},
+ {owner, zettel, !readonly},
+ {owner2, zettel, !readonly},
+ // Expert zettel
+ {anonUser, expertZettel, notAuthNotReadonly && expert},
+ {creator, expertZettel, notAuthNotReadonly && expert},
+ {reader, expertZettel, notAuthNotReadonly && expert},
+ {writer, expertZettel, notAuthNotReadonly && expert},
+ {owner, expertZettel, !readonly && expert},
+ {owner2, expertZettel, !readonly && expert},
+ // No r/o zettel
+ {anonUser, roFalse, notAuthNotReadonly},
+ {creator, roFalse, notAuthNotReadonly},
+ {reader, roFalse, notAuthNotReadonly},
+ {writer, roFalse, notAuthNotReadonly},
+ {owner, roFalse, !readonly},
+ {owner2, roFalse, !readonly},
+ // Reader r/o zettel
+ {anonUser, roReader, false},
+ {creator, roReader, false},
+ {reader, roReader, false},
+ {writer, roReader, notAuthNotReadonly},
+ {owner, roReader, !readonly},
+ {owner2, roReader, !readonly},
+ // Writer r/o zettel
+ {anonUser, roWriter, false},
+ {creator, roWriter, false},
+ {reader, roWriter, false},
+ {writer, roWriter, false},
+ {owner, roWriter, !readonly},
+ {owner2, roWriter, !readonly},
+ // Owner r/o zettel
+ {anonUser, roOwner, false},
+ {creator, roOwner, false},
+ {reader, roOwner, false},
+ {writer, roOwner, false},
+ {owner, roOwner, false},
+ {owner2, roOwner, false},
+ // r/o = true zettel
+ {anonUser, roTrue, false},
+ {creator, roTrue, false},
+ {reader, roTrue, false},
+ {writer, roTrue, false},
+ {owner, roTrue, false},
+ {owner2, roTrue, false},
+ }
+ for _, tc := range testCases {
+ t.Run("Delete", func(tt *testing.T) {
+ got := pol.CanDelete(tc.user, tc.meta)
+ if tc.exp != got {
+ tt.Errorf("exp=%v, but got=%v", tc.exp, got)
+ }
+ })
+ }
+}
+
+func testRefresh(t *testing.T, pol auth.Policy, withAuth, expert, simple bool) {
+ t.Helper()
+ testCases := []struct {
+ user *meta.Meta
+ exp bool
+ }{
+ {newAnon(), (!withAuth && expert) || simple},
+ {newCreator(), !withAuth || expert || simple},
+ {newReader(), true},
+ {newWriter(), true},
+ {newOwner(), true},
+ {newOwner2(), true},
+ }
+ for _, tc := range testCases {
+ t.Run("Refresh", func(tt *testing.T) {
+ got := pol.CanRefresh(tc.user)
+ if tc.exp != got {
+ tt.Errorf("exp=%v, but got=%v", tc.exp, got)
+ }
+ })
+ }
+}
+
+const (
+ creatorZid = id.Zid(1013)
+ readerZid = id.Zid(1013)
+ writerZid = id.Zid(1015)
+ ownerZid = id.Zid(1017)
+ owner2Zid = id.Zid(1019)
+ zettelZid = id.Zid(1021)
+ visZid = id.Zid(1023)
+ userZid = id.Zid(1025)
+)
+
+func newAnon() *meta.Meta { return nil }
+func newCreator() *meta.Meta {
+ user := meta.New(creatorZid)
+ user.Set(api.KeyTitle, "Creator")
+ user.Set(api.KeyUserID, "ceator")
+ user.Set(api.KeyUserRole, api.ValueUserRoleCreator)
+ return user
+}
+func newReader() *meta.Meta {
+ user := meta.New(readerZid)
+ user.Set(api.KeyTitle, "Reader")
+ user.Set(api.KeyUserID, "reader")
+ user.Set(api.KeyUserRole, api.ValueUserRoleReader)
+ return user
+}
+func newWriter() *meta.Meta {
+ user := meta.New(writerZid)
+ user.Set(api.KeyTitle, "Writer")
+ user.Set(api.KeyUserID, "writer")
+ user.Set(api.KeyUserRole, api.ValueUserRoleWriter)
+ return user
+}
+func newOwner() *meta.Meta {
+ user := meta.New(ownerZid)
+ user.Set(api.KeyTitle, "Owner")
+ user.Set(api.KeyUserID, "owner")
+ user.Set(api.KeyUserRole, api.ValueUserRoleOwner)
+ return user
+}
+func newOwner2() *meta.Meta {
+ user := meta.New(owner2Zid)
+ user.Set(api.KeyTitle, "Owner 2")
+ user.Set(api.KeyUserID, "owner-2")
+ user.Set(api.KeyUserRole, api.ValueUserRoleOwner)
+ return user
+}
+func newZettel() *meta.Meta {
+ m := meta.New(zettelZid)
+ m.Set(api.KeyTitle, "Any Zettel")
+ return m
+}
+func newPublicZettel() *meta.Meta {
+ m := meta.New(visZid)
+ m.Set(api.KeyTitle, "Public Zettel")
+ m.Set(api.KeyVisibility, api.ValueVisibilityPublic)
+ return m
+}
+func newCreatorZettel() *meta.Meta {
+ m := meta.New(visZid)
+ m.Set(api.KeyTitle, "Creator Zettel")
+ m.Set(api.KeyVisibility, api.ValueVisibilityCreator)
+ return m
+}
+func newLoginZettel() *meta.Meta {
+ m := meta.New(visZid)
+ m.Set(api.KeyTitle, "Login Zettel")
+ m.Set(api.KeyVisibility, api.ValueVisibilityLogin)
+ return m
+}
+func newOwnerZettel() *meta.Meta {
+ m := meta.New(visZid)
+ m.Set(api.KeyTitle, "Owner Zettel")
+ m.Set(api.KeyVisibility, api.ValueVisibilityOwner)
+ return m
+}
+func newExpertZettel() *meta.Meta {
+ m := meta.New(visZid)
+ m.Set(api.KeyTitle, "Expert Zettel")
+ m.Set(api.KeyVisibility, api.ValueVisibilityExpert)
+ return m
+}
+func newRoFalseZettel() *meta.Meta {
+ m := meta.New(zettelZid)
+ m.Set(api.KeyTitle, "No r/o Zettel")
+ m.Set(api.KeyReadOnly, api.ValueFalse)
+ return m
+}
+func newRoTrueZettel() *meta.Meta {
+ m := meta.New(zettelZid)
+ m.Set(api.KeyTitle, "A r/o Zettel")
+ m.Set(api.KeyReadOnly, api.ValueTrue)
+ return m
+}
+func newRoReaderZettel() *meta.Meta {
+ m := meta.New(zettelZid)
+ m.Set(api.KeyTitle, "Reader r/o Zettel")
+ m.Set(api.KeyReadOnly, api.ValueUserRoleReader)
+ return m
+}
+func newRoWriterZettel() *meta.Meta {
+ m := meta.New(zettelZid)
+ m.Set(api.KeyTitle, "Writer r/o Zettel")
+ m.Set(api.KeyReadOnly, api.ValueUserRoleWriter)
+ return m
+}
+func newRoOwnerZettel() *meta.Meta {
+ m := meta.New(zettelZid)
+ m.Set(api.KeyTitle, "Owner r/o Zettel")
+ m.Set(api.KeyReadOnly, api.ValueUserRoleOwner)
+ return m
+}
+func newUserZettel() *meta.Meta {
+ m := meta.New(userZid)
+ m.Set(api.KeyTitle, "Any User")
+ m.Set(api.KeyUserID, "any")
+ return m
+}
ADDED auth/policy/readonly.go
Index: auth/policy/readonly.go
==================================================================
--- /dev/null
+++ auth/policy/readonly.go
@@ -0,0 +1,22 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 policy
+
+import "zettelstore.de/z/domain/meta"
+
+type roPolicy struct{}
+
+func (*roPolicy) CanCreate(_, _ *meta.Meta) bool { return false }
+func (*roPolicy) CanRead(_, _ *meta.Meta) bool { return true }
+func (*roPolicy) CanWrite(_, _, _ *meta.Meta) bool { return false }
+func (*roPolicy) CanRename(_, _ *meta.Meta) bool { return false }
+func (*roPolicy) CanDelete(_, _ *meta.Meta) bool { return false }
+func (*roPolicy) CanRefresh(user *meta.Meta) bool { return user != nil }
ADDED box/box.go
Index: box/box.go
==================================================================
--- /dev/null
+++ box/box.go
@@ -0,0 +1,316 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "time"
+
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/query"
+)
+
+// BaseBox is implemented by all Zettel boxes.
+type BaseBox interface {
+ // Location returns some information where the box is located.
+ // Format is dependent of the box.
+ Location() string
+
+ // CanCreateZettel returns true, if box could possibly create a new zettel.
+ CanCreateZettel(ctx context.Context) bool
+
+ // CreateZettel creates a new zettel.
+ // Returns the new zettel id (and an error indication).
+ CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error)
+
+ // GetZettel retrieves a specific zettel.
+ GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error)
+
+ // GetMeta retrieves just the meta data of a specific zettel.
+ GetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error)
+
+ // CanUpdateZettel returns true, if box could possibly update the given zettel.
+ CanUpdateZettel(ctx context.Context, zettel domain.Zettel) bool
+
+ // UpdateZettel updates an existing zettel.
+ UpdateZettel(ctx context.Context, zettel domain.Zettel) error
+
+ // AllowRenameZettel returns true, if box will not disallow renaming the zettel.
+ AllowRenameZettel(ctx context.Context, zid id.Zid) bool
+
+ // RenameZettel changes the current Zid to a new Zid.
+ RenameZettel(ctx context.Context, curZid, newZid id.Zid) error
+
+ // CanDeleteZettel returns true, if box could possibly delete the given zettel.
+ CanDeleteZettel(ctx context.Context, zid id.Zid) bool
+
+ // DeleteZettel removes the zettel from the box.
+ DeleteZettel(ctx context.Context, zid id.Zid) error
+}
+
+// ZidFunc is a function that processes identifier of a zettel.
+type ZidFunc func(id.Zid)
+
+// MetaFunc is a function that processes metadata of a zettel.
+type MetaFunc func(*meta.Meta)
+
+// ManagedBox is the interface of managed boxes.
+type ManagedBox interface {
+ BaseBox
+
+ // Apply identifier of every zettel to the given function, if predicate returns true.
+ ApplyZid(context.Context, ZidFunc, query.RetrievePredicate) error
+
+ // Apply metadata of every zettel to the given function, if predicate returns true.
+ ApplyMeta(context.Context, MetaFunc, query.RetrievePredicate) error
+
+ // ReadStats populates st with box statistics
+ ReadStats(st *ManagedBoxStats)
+}
+
+// ManagedBoxStats records statistics about the box.
+type ManagedBoxStats struct {
+ // ReadOnly indicates that the content of a box cannot change.
+ ReadOnly bool
+
+ // Zettel is the number of zettel managed by the box.
+ Zettel int
+}
+
+// StartState enumerates the possible states of starting and stopping a box.
+//
+// StartStateStopped -> StartStateStarting -> StartStateStarted -> StateStateStopping -> StartStateStopped.
+//
+// Other transitions are also possible.
+type StartState uint8
+
+// Constant values of StartState
+const (
+ StartStateStopped StartState = iota
+ StartStateStarting
+ StartStateStarted
+ StartStateStopping
+)
+
+// StartStopper performs simple lifecycle management.
+type StartStopper interface {
+ // State the current status of the box.
+ State() StartState
+
+ // Start the box. Now all other functions of the box are allowed.
+ // Starting a box, which is not in state StartStateStopped is not allowed.
+ Start(ctx context.Context) error
+
+ // Stop the started box. Now only the Start() function is allowed.
+ Stop(ctx context.Context)
+}
+
+// Refresher allow to refresh their internal data.
+type Refresher interface {
+ // Refresh the box data.
+ Refresh(context.Context)
+}
+
+// Box is to be used outside the box package and its descendants.
+type Box interface {
+ BaseBox
+
+ // FetchZids returns the set of all zettel identifer managed by the box.
+ FetchZids(ctx context.Context) (id.Set, error)
+
+ // SelectMeta returns a list of metadata that comply to the given selection criteria.
+ SelectMeta(ctx context.Context, q *query.Query) ([]*meta.Meta, error)
+
+ // GetAllZettel retrieves a specific zettel from all managed boxes.
+ GetAllZettel(ctx context.Context, zid id.Zid) ([]domain.Zettel, error)
+
+ // GetAllMeta retrieves the meta data of a specific zettel from all managed boxes.
+ GetAllMeta(ctx context.Context, zid id.Zid) ([]*meta.Meta, error)
+
+ // Refresh the data from the box and from its managed sub-boxes.
+ Refresh(context.Context) error
+}
+
+// Stats record stattistics about a box.
+type Stats struct {
+ // ReadOnly indicates that boxes cannot be modified.
+ ReadOnly bool
+
+ // NumManagedBoxes is the number of boxes managed.
+ NumManagedBoxes int
+
+ // Zettel is the number of zettel managed by the box, including
+ // duplicates across managed boxes.
+ ZettelTotal int
+
+ // LastReload stores the timestamp when a full re-index was done.
+ LastReload time.Time
+
+ // DurLastReload is the duration of the last full re-index run.
+ DurLastReload time.Duration
+
+ // IndexesSinceReload counts indexing a zettel since the full re-index.
+ IndexesSinceReload uint64
+
+ // ZettelIndexed is the number of zettel managed by the indexer.
+ ZettelIndexed int
+
+ // IndexUpdates count the number of metadata updates.
+ IndexUpdates uint64
+
+ // IndexedWords count the different words indexed.
+ IndexedWords uint64
+
+ // IndexedUrls count the different URLs indexed.
+ IndexedUrls uint64
+}
+
+// Manager is a box-managing box.
+type Manager interface {
+ Box
+ StartStopper
+ Subject
+
+ // ReadStats populates st with box statistics
+ ReadStats(st *Stats)
+
+ // Dump internal data to a Writer.
+ Dump(w io.Writer)
+}
+
+// UpdateReason gives an indication, why the ObserverFunc was called.
+type UpdateReason uint8
+
+// Values for Reason
+const (
+ _ UpdateReason = iota
+ OnReady // Box is started and fully operational
+ OnReload // Box was reloaded
+ OnZettel // Something with a zettel happened
+)
+
+// UpdateInfo contains all the data about a changed zettel.
+type UpdateInfo struct {
+ Box BaseBox
+ Reason UpdateReason
+ Zid id.Zid
+}
+
+// UpdateFunc is a function to be called when a change is detected.
+type UpdateFunc func(UpdateInfo)
+
+// Subject is a box that notifies observers about changes.
+type Subject interface {
+ // RegisterObserver registers an observer that will be notified
+ // if one or all zettel are found to be changed.
+ RegisterObserver(UpdateFunc)
+}
+
+// Enricher is used to update metadata by adding new properties.
+type Enricher interface {
+ // Enrich computes additional properties and updates the given metadata.
+ // It is typically called by zettel reading methods.
+ Enrich(ctx context.Context, m *meta.Meta, boxNumber int)
+}
+
+// NoEnrichContext will signal an enricher that nothing has to be done.
+// This is useful for an Indexer, but also for some box.Box calls, when
+// just the plain metadata is needed.
+func NoEnrichContext(ctx context.Context) context.Context {
+ return context.WithValue(ctx, ctxNoEnrichKey, &ctxNoEnrichKey)
+}
+
+type ctxNoEnrichType struct{}
+
+var ctxNoEnrichKey ctxNoEnrichType
+
+// DoNotEnrich determines if the context is marked to not enrich metadata.
+func DoNotEnrich(ctx context.Context) bool {
+ _, ok := ctx.Value(ctxNoEnrichKey).(*ctxNoEnrichType)
+ return ok
+}
+
+// NoEnrichQuery provides a context that signals not to enrich, if the query does not need this.
+func NoEnrichQuery(ctx context.Context, q *query.Query) context.Context {
+ if q.EnrichNeeded() {
+ return ctx
+ }
+ return NoEnrichContext(ctx)
+}
+
+// ErrNotAllowed is returned if the caller is not allowed to perform the operation.
+type ErrNotAllowed struct {
+ Op string
+ User *meta.Meta
+ Zid id.Zid
+}
+
+// NewErrNotAllowed creates an new authorization error.
+func NewErrNotAllowed(op string, user *meta.Meta, zid id.Zid) error {
+ return &ErrNotAllowed{
+ Op: op,
+ User: user,
+ Zid: zid,
+ }
+}
+
+func (err *ErrNotAllowed) Error() string {
+ if err.User == nil {
+ if err.Zid.IsValid() {
+ return fmt.Sprintf(
+ "operation %q on zettel %v not allowed for not authorized user",
+ err.Op, err.Zid)
+ }
+ return fmt.Sprintf("operation %q not allowed for not authorized user", err.Op)
+ }
+ if err.Zid.IsValid() {
+ return fmt.Sprintf(
+ "operation %q on zettel %v not allowed for user %v/%v",
+ err.Op, err.Zid, err.User.GetDefault(api.KeyUserID, "?"), err.User.Zid)
+ }
+ return fmt.Sprintf(
+ "operation %q not allowed for user %v/%v",
+ err.Op, err.User.GetDefault(api.KeyUserID, "?"), err.User.Zid)
+}
+
+// Is return true, if the error is of type ErrNotAllowed.
+func (*ErrNotAllowed) Is(error) bool { return true }
+
+// ErrStarted is returned when trying to start an already started box.
+var ErrStarted = errors.New("box is already started")
+
+// ErrStopped is returned if calling methods on a box that was not started.
+var ErrStopped = errors.New("box is stopped")
+
+// ErrReadOnly is returned if there is an attepmt to write to a read-only box.
+var ErrReadOnly = errors.New("read-only box")
+
+// ErrNotFound is returned if a zettel was not found in the box.
+var ErrNotFound = errors.New("zettel not found")
+
+// ErrConflict is returned if a box operation detected a conflict..
+// One example: if calculating a new zettel identifier takes too long.
+var ErrConflict = errors.New("conflict")
+
+// ErrCapacity is returned if a box has reached its capacity.
+var ErrCapacity = errors.New("capacity exceeded")
+
+// ErrInvalidID is returned if the zettel id is not appropriate for the box operation.
+type ErrInvalidID struct{ Zid id.Zid }
+
+func (err *ErrInvalidID) Error() string { return "invalid Zettel id: " + err.Zid.String() }
ADDED box/compbox/compbox.go
Index: box/compbox/compbox.go
==================================================================
--- /dev/null
+++ box/compbox/compbox.go
@@ -0,0 +1,193 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 compbox provides zettel that have computed content.
+package compbox
+
+import (
+ "context"
+ "net/url"
+
+ "zettelstore.de/c/api"
+ "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/kernel"
+ "zettelstore.de/z/logger"
+ "zettelstore.de/z/query"
+)
+
+func init() {
+ manager.Register(
+ " comp",
+ func(u *url.URL, cdata *manager.ConnectData) (box.ManagedBox, error) {
+ return getCompBox(cdata.Number, cdata.Enricher), nil
+ })
+}
+
+type compBox struct {
+ log *logger.Logger
+ number int
+ enricher box.Enricher
+}
+
+var myConfig *meta.Meta
+var myZettel = map[id.Zid]struct {
+ meta func(id.Zid) *meta.Meta
+ content func(*meta.Meta) []byte
+}{
+ id.MustParse(api.ZidVersion): {genVersionBuildM, genVersionBuildC},
+ id.MustParse(api.ZidHost): {genVersionHostM, genVersionHostC},
+ id.MustParse(api.ZidOperatingSystem): {genVersionOSM, genVersionOSC},
+ id.MustParse(api.ZidLog): {genLogM, genLogC},
+ id.MustParse(api.ZidBoxManager): {genManagerM, genManagerC},
+ id.MustParse(api.ZidMetadataKey): {genKeysM, genKeysC},
+ id.MustParse(api.ZidParser): {genParserM, genParserC},
+ id.MustParse(api.ZidStartupConfiguration): {genConfigZettelM, genConfigZettelC},
+}
+
+// Get returns the one program box.
+func getCompBox(boxNumber int, mf box.Enricher) box.ManagedBox {
+ return &compBox{
+ log: kernel.Main.GetLogger(kernel.BoxService).Clone().
+ Str("box", "comp").Int("boxnum", int64(boxNumber)).Child(),
+ number: boxNumber,
+ enricher: mf,
+ }
+}
+
+// Setup remembers important values.
+func Setup(cfg *meta.Meta) { myConfig = cfg.Clone() }
+
+func (*compBox) Location() string { return "" }
+
+func (*compBox) CanCreateZettel(context.Context) bool { return false }
+
+func (cb *compBox) CreateZettel(context.Context, domain.Zettel) (id.Zid, error) {
+ cb.log.Trace().Err(box.ErrReadOnly).Msg("CreateZettel")
+ return id.Invalid, box.ErrReadOnly
+}
+
+func (cb *compBox) GetZettel(_ context.Context, zid id.Zid) (domain.Zettel, error) {
+ if gen, ok := myZettel[zid]; ok && gen.meta != nil {
+ if m := gen.meta(zid); m != nil {
+ updateMeta(m)
+ if genContent := gen.content; genContent != nil {
+ cb.log.Trace().Msg("GetMeta/Content")
+ return domain.Zettel{
+ Meta: m,
+ Content: domain.NewContent(genContent(m)),
+ }, nil
+ }
+ cb.log.Trace().Msg("GetMeta/NoContent")
+ return domain.Zettel{Meta: m}, nil
+ }
+ }
+ cb.log.Trace().Err(box.ErrNotFound).Msg("GetZettel/Err")
+ return domain.Zettel{}, box.ErrNotFound
+}
+
+func (cb *compBox) GetMeta(_ context.Context, zid id.Zid) (*meta.Meta, error) {
+ if gen, ok := myZettel[zid]; ok {
+ if genMeta := gen.meta; genMeta != nil {
+ if m := genMeta(zid); m != nil {
+ updateMeta(m)
+ cb.log.Trace().Msg("GetMeta")
+ return m, nil
+ }
+ }
+ }
+ cb.log.Trace().Err(box.ErrNotFound).Msg("GetMeta/Err")
+ return nil, box.ErrNotFound
+}
+
+func (cb *compBox) ApplyZid(_ context.Context, handle box.ZidFunc, constraint query.RetrievePredicate) error {
+ cb.log.Trace().Int("entries", int64(len(myZettel))).Msg("ApplyMeta")
+ for zid, gen := range myZettel {
+ if !constraint(zid) {
+ continue
+ }
+ if genMeta := gen.meta; genMeta != nil {
+ if genMeta(zid) != nil {
+ handle(zid)
+ }
+ }
+ }
+ return nil
+}
+
+func (cb *compBox) ApplyMeta(ctx context.Context, handle box.MetaFunc, constraint query.RetrievePredicate) error {
+ cb.log.Trace().Int("entries", int64(len(myZettel))).Msg("ApplyMeta")
+ for zid, gen := range myZettel {
+ if !constraint(zid) {
+ continue
+ }
+ if genMeta := gen.meta; genMeta != nil {
+ if m := genMeta(zid); m != nil {
+ updateMeta(m)
+ cb.enricher.Enrich(ctx, m, cb.number)
+ handle(m)
+ }
+ }
+ }
+ return nil
+}
+
+func (*compBox) CanUpdateZettel(context.Context, domain.Zettel) bool { return false }
+
+func (cb *compBox) UpdateZettel(context.Context, domain.Zettel) error {
+ cb.log.Trace().Err(box.ErrReadOnly).Msg("UpdateZettel")
+ return box.ErrReadOnly
+}
+
+func (*compBox) AllowRenameZettel(_ context.Context, zid id.Zid) bool {
+ _, ok := myZettel[zid]
+ return !ok
+}
+
+func (cb *compBox) RenameZettel(_ context.Context, curZid, _ id.Zid) error {
+ err := box.ErrNotFound
+ if _, ok := myZettel[curZid]; ok {
+ err = box.ErrReadOnly
+ }
+ cb.log.Trace().Err(err).Msg("RenameZettel")
+ return err
+}
+
+func (*compBox) CanDeleteZettel(context.Context, id.Zid) bool { return false }
+
+func (cb *compBox) DeleteZettel(_ context.Context, zid id.Zid) error {
+ err := box.ErrNotFound
+ if _, ok := myZettel[zid]; ok {
+ err = box.ErrReadOnly
+ }
+ cb.log.Trace().Err(err).Msg("DeleteZettel")
+ return err
+}
+
+func (cb *compBox) ReadStats(st *box.ManagedBoxStats) {
+ st.ReadOnly = true
+ st.Zettel = len(myZettel)
+ cb.log.Trace().Int("zettel", int64(st.Zettel)).Msg("ReadStats")
+}
+
+func updateMeta(m *meta.Meta) {
+ if _, ok := m.Get(api.KeySyntax); !ok {
+ m.Set(api.KeySyntax, meta.SyntaxZmk)
+ }
+ m.Set(api.KeyRole, api.ValueRoleConfiguration)
+ m.Set(api.KeyLang, api.ValueLangEN)
+ m.Set(api.KeyReadOnly, api.ValueTrue)
+ if _, ok := m.Get(api.KeyVisibility); !ok {
+ m.Set(api.KeyVisibility, api.ValueVisibilityExpert)
+ }
+}
ADDED box/compbox/config.go
Index: box/compbox/config.go
==================================================================
--- /dev/null
+++ box/compbox/config.go
@@ -0,0 +1,54 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 compbox
+
+import (
+ "bytes"
+
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+)
+
+func genConfigZettelM(zid id.Zid) *meta.Meta {
+ if myConfig == nil {
+ return nil
+ }
+ m := meta.New(zid)
+ m.Set(api.KeyTitle, "Zettelstore Startup Configuration")
+ m.Set(api.KeyCreated, kernel.Main.GetConfig(kernel.CoreService, kernel.CoreStarted).(string))
+ m.Set(api.KeyVisibility, api.ValueVisibilityExpert)
+ return m
+}
+
+func genConfigZettelC(*meta.Meta) []byte {
+ var buf bytes.Buffer
+ for i, p := range myConfig.Pairs() {
+ if i > 0 {
+ buf.WriteByte('\n')
+ }
+ buf.WriteString("; ''")
+ buf.WriteString(p.Key)
+ buf.WriteString("''")
+ if p.Value != "" {
+ buf.WriteString("\n: ``")
+ for _, r := range p.Value {
+ if r == '`' {
+ buf.WriteByte('\\')
+ }
+ buf.WriteRune(r)
+ }
+ buf.WriteString("``")
+ }
+ }
+ return buf.Bytes()
+}
ADDED box/compbox/keys.go
Index: box/compbox/keys.go
==================================================================
--- /dev/null
+++ box/compbox/keys.go
@@ -0,0 +1,40 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 compbox
+
+import (
+ "bytes"
+ "fmt"
+
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+)
+
+func genKeysM(zid id.Zid) *meta.Meta {
+ m := meta.New(zid)
+ m.Set(api.KeyTitle, "Zettelstore Supported Metadata Keys")
+ m.Set(api.KeyCreated, kernel.Main.GetConfig(kernel.CoreService, kernel.CoreVTime).(string))
+ m.Set(api.KeyVisibility, api.ValueVisibilityLogin)
+ return m
+}
+
+func genKeysC(*meta.Meta) []byte {
+ keys := meta.GetSortedKeyDescriptions()
+ var buf bytes.Buffer
+ buf.WriteString("|=Name<|=Type<|=Computed?:|=Property?:\n")
+ for _, kd := range keys {
+ fmt.Fprintf(&buf,
+ "|%v|%v|%v|%v\n", kd.Name, kd.Type.Name, kd.IsComputed(), kd.IsProperty())
+ }
+ return buf.Bytes()
+}
ADDED box/compbox/log.go
Index: box/compbox/log.go
==================================================================
--- /dev/null
+++ box/compbox/log.go
@@ -0,0 +1,50 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 compbox
+
+import (
+ "bytes"
+
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+)
+
+func genLogM(zid id.Zid) *meta.Meta {
+ m := meta.New(zid)
+ m.Set(api.KeyTitle, "Zettelstore Log")
+ m.Set(api.KeySyntax, meta.SyntaxText)
+ m.Set(api.KeyCreated, kernel.Main.GetConfig(kernel.CoreService, kernel.CoreStarted).(string))
+ m.Set(api.KeyModified, kernel.Main.GetLastLogTime().Local().Format(id.ZidLayout))
+ return m
+}
+
+func genLogC(*meta.Meta) []byte {
+ const tsFormat = "2006-01-02 15:04:05.999999"
+ entries := kernel.Main.RetrieveLogEntries()
+ var buf bytes.Buffer
+ for _, entry := range entries {
+ ts := entry.TS.Format(tsFormat)
+ buf.WriteString(ts)
+ for j := len(ts); j < len(tsFormat); j++ {
+ buf.WriteByte('0')
+ }
+ buf.WriteByte(' ')
+ buf.WriteString(entry.Level.Format())
+ buf.WriteByte(' ')
+ buf.WriteString(entry.Prefix)
+ buf.WriteByte(' ')
+ buf.WriteString(entry.Message)
+ buf.WriteByte('\n')
+ }
+ return buf.Bytes()
+}
ADDED box/compbox/manager.go
Index: box/compbox/manager.go
==================================================================
--- /dev/null
+++ box/compbox/manager.go
@@ -0,0 +1,41 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 compbox
+
+import (
+ "bytes"
+ "fmt"
+
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+)
+
+func genManagerM(zid id.Zid) *meta.Meta {
+ m := meta.New(zid)
+ m.Set(api.KeyTitle, "Zettelstore Box Manager")
+ m.Set(api.KeyCreated, kernel.Main.GetConfig(kernel.CoreService, kernel.CoreStarted).(string))
+ return m
+}
+
+func genManagerC(*meta.Meta) []byte {
+ kvl := kernel.Main.GetServiceStatistics(kernel.BoxService)
+ if len(kvl) == 0 {
+ return nil
+ }
+ var buf bytes.Buffer
+ buf.WriteString("|=Name|=Value>\n")
+ for _, kv := range kvl {
+ fmt.Fprintf(&buf, "| %v | %v\n", kv.Key, kv.Value)
+ }
+ return buf.Bytes()
+}
ADDED box/compbox/parser.go
Index: box/compbox/parser.go
==================================================================
--- /dev/null
+++ box/compbox/parser.go
@@ -0,0 +1,51 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 compbox
+
+import (
+ "bytes"
+ "fmt"
+ "sort"
+ "strings"
+
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+ "zettelstore.de/z/parser"
+)
+
+func genParserM(zid id.Zid) *meta.Meta {
+ m := meta.New(zid)
+ m.Set(api.KeyTitle, "Zettelstore Supported Parser")
+ m.Set(api.KeyCreated, kernel.Main.GetConfig(kernel.CoreService, kernel.CoreVTime).(string))
+ m.Set(api.KeyVisibility, api.ValueVisibilityLogin)
+ return m
+}
+
+func genParserC(*meta.Meta) []byte {
+ var buf bytes.Buffer
+ buf.WriteString("|=Syntax<|=Alt. Value(s):|=Text Parser?:|=Text Format?:|=Image Format?:\n")
+ syntaxes := parser.GetSyntaxes()
+ sort.Strings(syntaxes)
+ for _, syntax := range syntaxes {
+ info := parser.Get(syntax)
+ if info.Name != syntax {
+ continue
+ }
+ altNames := info.AltNames
+ sort.Strings(altNames)
+ fmt.Fprintf(
+ &buf, "|%v|%v|%v|%v|%v\n",
+ syntax, strings.Join(altNames, ", "), info.IsASTParser, info.IsTextFormat, info.IsImageFormat)
+ }
+ return buf.Bytes()
+}
ADDED box/compbox/version.go
Index: box/compbox/version.go
==================================================================
--- /dev/null
+++ box/compbox/version.go
@@ -0,0 +1,58 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 compbox
+
+import (
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+)
+
+func getVersionMeta(zid id.Zid, title string) *meta.Meta {
+ m := meta.New(zid)
+ m.Set(api.KeyTitle, title)
+ m.Set(api.KeyVisibility, api.ValueVisibilityExpert)
+ return m
+}
+
+func genVersionBuildM(zid id.Zid) *meta.Meta {
+ m := getVersionMeta(zid, "Zettelstore Version")
+ m.Set(api.KeyCreated, kernel.Main.GetConfig(kernel.CoreService, kernel.CoreVTime).(string))
+ m.Set(api.KeyVisibility, api.ValueVisibilityLogin)
+ return m
+}
+func genVersionBuildC(*meta.Meta) []byte {
+ return []byte(kernel.Main.GetConfig(kernel.CoreService, kernel.CoreVersion).(string))
+}
+
+func genVersionHostM(zid id.Zid) *meta.Meta {
+ m := getVersionMeta(zid, "Zettelstore Host")
+ m.Set(api.KeyCreated, kernel.Main.GetConfig(kernel.CoreService, kernel.CoreStarted).(string))
+ return m
+}
+func genVersionHostC(*meta.Meta) []byte {
+ return []byte(kernel.Main.GetConfig(kernel.CoreService, kernel.CoreHostname).(string))
+}
+
+func genVersionOSM(zid id.Zid) *meta.Meta {
+ m := getVersionMeta(zid, "Zettelstore Operating System")
+ m.Set(api.KeyCreated, kernel.Main.GetConfig(kernel.CoreService, kernel.CoreStarted).(string))
+ return m
+}
+func genVersionOSC(*meta.Meta) []byte {
+ goOS := kernel.Main.GetConfig(kernel.CoreService, kernel.CoreGoOS).(string)
+ goArch := kernel.Main.GetConfig(kernel.CoreService, kernel.CoreGoArch).(string)
+ result := make([]byte, 0, len(goOS)+len(goArch)+1)
+ result = append(result, goOS...)
+ result = append(result, '/')
+ return append(result, goArch...)
+}
ADDED box/constbox/base.css
Index: box/constbox/base.css
==================================================================
--- /dev/null
+++ box/constbox/base.css
@@ -0,0 +1,241 @@
+*,*::before,*::after {
+ box-sizing: border-box;
+ }
+ html {
+ font-size: 1rem;
+ font-family: serif;
+ scroll-behavior: smooth;
+ height: 100%;
+ }
+ body {
+ margin: 0;
+ min-height: 100vh;
+ line-height: 1.4;
+ background-color: #f8f8f8 ;
+ height: 100%;
+ }
+ nav.zs-menu {
+ background-color: hsl(210, 28%, 90%);
+ overflow: auto;
+ white-space: nowrap;
+ font-family: sans-serif;
+ padding-left: .5rem;
+ }
+ nav.zs-menu > a {
+ float:left;
+ display: block;
+ text-align: center;
+ padding:.41rem .5rem;
+ text-decoration: none;
+ color:black;
+ }
+ nav.zs-menu > a:hover, .zs-dropdown:hover button { background-color: hsl(210, 28%, 80%) }
+ nav.zs-menu form { float: right }
+ nav.zs-menu form input[type=text] {
+ padding: .12rem;
+ border: none;
+ margin-top: .25rem;
+ margin-right: .5rem;
+ }
+ .zs-dropdown {
+ float: left;
+ overflow: hidden;
+ }
+ .zs-dropdown > button {
+ font-size: 16px;
+ border: none;
+ outline: none;
+ color: black;
+ padding:.41rem .5rem;
+ background-color: inherit;
+ font-family: inherit;
+ margin: 0;
+ }
+ .zs-dropdown-content {
+ display: none;
+ position: absolute;
+ background-color: #f9f9f9;
+ min-width: 160px;
+ box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
+ z-index: 1;
+ }
+ .zs-dropdown-content > a {
+ float: none;
+ color: black;
+ padding:.41rem .5rem;
+ text-decoration: none;
+ display: block;
+ text-align: left;
+ }
+ .zs-dropdown-content > a:hover { background-color: hsl(210, 28%, 75%) }
+ .zs-dropdown:hover > .zs-dropdown-content { display: block }
+ main { padding: 0 1rem }
+ article > * + * { margin-top: .5rem }
+ article header {
+ padding: 0;
+ margin: 0;
+ }
+ h1,h2,h3,h4,h5,h6 { font-family:sans-serif; font-weight:normal }
+ h1 { font-size:1.5rem; margin:.65rem 0 }
+ h2 { font-size:1.25rem; margin:.70rem 0 }
+ h3 { font-size:1.15rem; margin:.75rem 0 }
+ h4 { font-size:1.05rem; margin:.8rem 0; font-weight: bold }
+ h5 { font-size:1.05rem; margin:.8rem 0 }
+ h6 { font-size:1.05rem; margin:.8rem 0; font-weight: lighter }
+ p { margin: .5rem 0 0 0 }
+ li,figure,figcaption,dl { margin: 0 }
+ dt { margin: .5rem 0 0 0 }
+ dt+dd { margin-top: 0 }
+ dd { margin: .5rem 0 0 2rem }
+ dd > p:first-child { margin: 0 0 0 0 }
+ blockquote {
+ border-left: 0.5rem solid lightgray;
+ padding-left: 1rem;
+ margin-left: 1rem;
+ margin-right: 2rem;
+ font-style: italic;
+ }
+ blockquote p { margin-bottom: .5rem }
+ blockquote cite { font-style: normal }
+ table {
+ border-collapse: collapse;
+ border-spacing: 0;
+ max-width: 100%;
+ }
+ thead>tr>td { border-bottom: 2px solid hsl(0, 0%, 70%); font-weight: bold }
+ tfoot>tr>td { border-top: 2px solid hsl(0, 0%, 70%); font-weight: bold }
+ td {
+ text-align: left;
+ padding: .25rem .5rem;
+ border-bottom: 1px solid hsl(0, 0%, 85%)
+ }
+ main form {
+ padding: 0 .5em;
+ margin: .5em 0 0 0;
+ }
+ main form:after {
+ content: ".";
+ display: block;
+ height: 0;
+ clear: both;
+ visibility: hidden;
+ }
+ main form div { margin: .5em 0 0 0 }
+ input { font-family: monospace }
+ input[type="submit"],button,select { font: inherit }
+ label { font-family: sans-serif; font-size:.9rem }
+ textarea {
+ font-family: monospace;
+ resize: vertical;
+ width: 100%;
+ }
+ .zs-input {
+ padding: .5em;
+ display:block;
+ border:none;
+ border-bottom:1px solid #ccc;
+ width:100%;
+ }
+ input.zs-primary { float:right }
+ input.zs-secondary { float:left }
+ input.zs-upload {
+ padding-left: 1em;
+ padding-right: 1em;
+ }
+ a:not([class]) { text-decoration-skip-ink: auto }
+ a.broken { text-decoration: line-through }
+ a.external::after { content: "➚"; display: inline-block }
+ img { max-width: 100% }
+ img.right { float: right }
+ ol.zs-endnotes {
+ padding-top: .5rem;
+ border-top: 1px solid;
+ }
+ kbd { font-family:monospace }
+ code,pre {
+ font-family: monospace;
+ font-size: 85%;
+ }
+ code {
+ padding: .1rem .2rem;
+ background: #f0f0f0;
+ border: 1px solid #ccc;
+ border-radius: .25rem;
+ }
+ pre {
+ padding: .5rem .7rem;
+ max-width: 100%;
+ overflow: auto;
+ border: 1px solid #ccc;
+ border-radius: .5rem;
+ background: #f0f0f0;
+ }
+ pre code {
+ font-size: 95%;
+ position: relative;
+ padding: 0;
+ border: none;
+ }
+ div.zs-indication {
+ padding: .5rem .7rem;
+ max-width: 100%;
+ border-radius: .5rem;
+ border: 1px solid black;
+ }
+ div.zs-indication p:first-child { margin-top: 0 }
+ span.zs-indication {
+ border: 1px solid black;
+ border-radius: .25rem;
+ padding: .1rem .2rem;
+ font-size: 95%;
+ }
+ .zs-info {
+ background-color: lightblue;
+ padding: .5rem 1rem;
+ }
+ .zs-warning {
+ background-color: lightyellow;
+ padding: .5rem 1rem;
+ }
+ .zs-error {
+ background-color: lightpink;
+ border-style: none !important;
+ font-weight: bold;
+ }
+ td.left { text-align:left }
+ td.center { text-align:center }
+ td.right { text-align:right }
+ .zs-font-size-0 { font-size:75% }
+ .zs-font-size-1 { font-size:83% }
+ .zs-font-size-2 { font-size:100% }
+ .zs-font-size-3 { font-size:117% }
+ .zs-font-size-4 { font-size:150% }
+ .zs-font-size-5 { font-size:200% }
+ .zs-deprecated { border-style: dashed; padding: .2rem }
+ .zs-meta {
+ font-size:.75rem;
+ color:#444;
+ margin-bottom:1rem;
+ }
+ .zs-meta a { color:#444 }
+ h1+.zs-meta { margin-top:-1rem }
+ nav > details { margin-top:1rem }
+ details > summary {
+ width: 100%;
+ background-color: #eee;
+ font-family:sans-serif;
+ }
+ details > ul {
+ margin-top:0;
+ padding-left:2rem;
+ background-color: #eee;
+ }
+ footer { padding: 0 1rem }
+ @media (prefers-reduced-motion: reduce) {
+ * {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+ }
ADDED box/constbox/base.mustache
Index: box/constbox/base.mustache
==================================================================
--- /dev/null
+++ box/constbox/base.mustache
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+{{{MetaHeader}}}
+
+
+{{#CSSRoleURL}} {{/CSSRoleURL}}
+{{Title}}
+
+
+
+
+{{{Content}}}
+
+{{#FooterHTML}}{{/FooterHTML}}
+{{#DebugMode}}WARNING: Debug mode is enabled. DO NOT USE IN PRODUCTION!
{{/DebugMode}}
+
+
ADDED box/constbox/constbox.go
Index: box/constbox/constbox.go
==================================================================
--- /dev/null
+++ box/constbox/constbox.go
@@ -0,0 +1,408 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 constbox puts zettel inside the executable.
+package constbox
+
+import (
+ "context"
+ _ "embed" // Allow to embed file content
+ "net/url"
+
+ "zettelstore.de/c/api"
+ "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/kernel"
+ "zettelstore.de/z/logger"
+ "zettelstore.de/z/query"
+)
+
+func init() {
+ manager.Register(
+ " const",
+ func(u *url.URL, cdata *manager.ConnectData) (box.ManagedBox, error) {
+ return &constBox{
+ log: kernel.Main.GetLogger(kernel.BoxService).Clone().
+ Str("box", "const").Int("boxnum", int64(cdata.Number)).Child(),
+ number: cdata.Number,
+ zettel: constZettelMap,
+ enricher: cdata.Enricher,
+ }, nil
+ })
+}
+
+type constHeader map[string]string
+
+type constZettel struct {
+ header constHeader
+ content domain.Content
+}
+
+type constBox struct {
+ log *logger.Logger
+ number int
+ zettel map[id.Zid]constZettel
+ enricher box.Enricher
+}
+
+func (*constBox) Location() string { return "const:" }
+
+func (*constBox) CanCreateZettel(context.Context) bool { return false }
+
+func (cb *constBox) CreateZettel(context.Context, domain.Zettel) (id.Zid, error) {
+ cb.log.Trace().Err(box.ErrReadOnly).Msg("CreateZettel")
+ return id.Invalid, box.ErrReadOnly
+}
+
+func (cb *constBox) GetZettel(_ context.Context, zid id.Zid) (domain.Zettel, error) {
+ if z, ok := cb.zettel[zid]; ok {
+ cb.log.Trace().Msg("GetZettel")
+ return domain.Zettel{Meta: meta.NewWithData(zid, z.header), Content: z.content}, nil
+ }
+ cb.log.Trace().Err(box.ErrNotFound).Msg("GetZettel")
+ return domain.Zettel{}, box.ErrNotFound
+}
+
+func (cb *constBox) GetMeta(_ context.Context, zid id.Zid) (*meta.Meta, error) {
+ if z, ok := cb.zettel[zid]; ok {
+ cb.log.Trace().Msg("GetMeta")
+ return meta.NewWithData(zid, z.header), nil
+ }
+ cb.log.Trace().Err(box.ErrNotFound).Msg("GetMeta")
+ return nil, box.ErrNotFound
+}
+
+func (cb *constBox) ApplyZid(_ context.Context, handle box.ZidFunc, constraint query.RetrievePredicate) error {
+ cb.log.Trace().Int("entries", int64(len(cb.zettel))).Msg("ApplyZid")
+ for zid := range cb.zettel {
+ if constraint(zid) {
+ handle(zid)
+ }
+ }
+ return nil
+}
+
+func (cb *constBox) ApplyMeta(ctx context.Context, handle box.MetaFunc, constraint query.RetrievePredicate) error {
+ cb.log.Trace().Int("entries", int64(len(cb.zettel))).Msg("ApplyMeta")
+ for zid, zettel := range cb.zettel {
+ if constraint(zid) {
+ m := meta.NewWithData(zid, zettel.header)
+ cb.enricher.Enrich(ctx, m, cb.number)
+ handle(m)
+ }
+ }
+ return nil
+}
+
+func (*constBox) CanUpdateZettel(context.Context, domain.Zettel) bool { return false }
+
+func (cb *constBox) UpdateZettel(context.Context, domain.Zettel) error {
+ cb.log.Trace().Err(box.ErrReadOnly).Msg("UpdateZettel")
+ return box.ErrReadOnly
+}
+
+func (cb *constBox) AllowRenameZettel(_ context.Context, zid id.Zid) bool {
+ _, ok := cb.zettel[zid]
+ return !ok
+}
+
+func (cb *constBox) RenameZettel(_ context.Context, curZid, _ id.Zid) error {
+ err := box.ErrNotFound
+ if _, ok := cb.zettel[curZid]; ok {
+ err = box.ErrReadOnly
+ }
+ cb.log.Trace().Err(err).Msg("RenameZettel")
+ return err
+}
+
+func (*constBox) CanDeleteZettel(context.Context, id.Zid) bool { return false }
+
+func (cb *constBox) DeleteZettel(_ context.Context, zid id.Zid) error {
+ err := box.ErrNotFound
+ if _, ok := cb.zettel[zid]; ok {
+ err = box.ErrReadOnly
+ }
+ cb.log.Trace().Err(err).Msg("DeleteZettel")
+ return err
+}
+
+func (cb *constBox) ReadStats(st *box.ManagedBoxStats) {
+ st.ReadOnly = true
+ st.Zettel = len(cb.zettel)
+ cb.log.Trace().Int("zettel", int64(st.Zettel)).Msg("ReadStats")
+}
+
+var constZettelMap = map[id.Zid]constZettel{
+ id.ConfigurationZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Runtime Configuration",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxNone,
+ api.KeyCreated: "20200804111624",
+ api.KeyVisibility: api.ValueVisibilityOwner,
+ },
+ domain.NewContent(nil)},
+ id.MustParse(api.ZidLicense): {
+ constHeader{
+ api.KeyTitle: "Zettelstore License",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxText,
+ api.KeyCreated: "20210504135842",
+ api.KeyLang: api.ValueLangEN,
+ api.KeyModified: "20220131153422",
+ api.KeyReadOnly: api.ValueTrue,
+ api.KeyVisibility: api.ValueVisibilityPublic,
+ },
+ domain.NewContent(contentLicense)},
+ id.MustParse(api.ZidAuthors): {
+ constHeader{
+ api.KeyTitle: "Zettelstore Contributors",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxZmk,
+ api.KeyCreated: "20210504135842",
+ api.KeyLang: api.ValueLangEN,
+ api.KeyReadOnly: api.ValueTrue,
+ api.KeyVisibility: api.ValueVisibilityLogin,
+ },
+ domain.NewContent(contentContributors)},
+ id.MustParse(api.ZidDependencies): {
+ constHeader{
+ api.KeyTitle: "Zettelstore Dependencies",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxZmk,
+ api.KeyLang: api.ValueLangEN,
+ api.KeyReadOnly: api.ValueTrue,
+ api.KeyVisibility: api.ValueVisibilityLogin,
+ api.KeyCreated: "20210504135842",
+ api.KeyModified: "20221013105100",
+ },
+ domain.NewContent(contentDependencies)},
+ id.BaseTemplateZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Base HTML Template",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxMustache,
+ api.KeyCreated: "20210504135842",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(contentBaseMustache)},
+ id.LoginTemplateZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Login Form HTML Template",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxMustache,
+ api.KeyCreated: "20200804111624",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(contentLoginMustache)},
+ id.ZettelTemplateZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Zettel HTML Template",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxMustache,
+ api.KeyCreated: "20200804111624",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(contentZettelMustache)},
+ id.InfoTemplateZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Info HTML Template",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxMustache,
+ api.KeyCreated: "20200804111624",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(contentInfoMustache)},
+ id.ContextTemplateZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Context HTML Template",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxMustache,
+ api.KeyCreated: "20210218181140",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(contentContextMustache)},
+ id.FormTemplateZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Form HTML Template",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxMustache,
+ api.KeyCreated: "20200804111624",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(contentFormMustache)},
+ id.RenameTemplateZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Rename Form HTML Template",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxMustache,
+ api.KeyCreated: "20200804111624",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(contentRenameMustache)},
+ id.DeleteTemplateZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Delete HTML Template",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxMustache,
+ api.KeyCreated: "20200804111624",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(contentDeleteMustache)},
+ id.ListTemplateZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore List Zettel HTML Template",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxMustache,
+ api.KeyCreated: "20200804111624",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(contentListZettelMustache)},
+ id.ErrorTemplateZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Error HTML Template",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxMustache,
+ api.KeyCreated: "20210305133215",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(contentErrorMustache)},
+ id.MustParse(api.ZidBaseCSS): {
+ constHeader{
+ api.KeyTitle: "Zettelstore Base CSS",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxCSS,
+ api.KeyCreated: "20200804111624",
+ api.KeyVisibility: api.ValueVisibilityPublic,
+ },
+ domain.NewContent(contentBaseCSS)},
+ id.MustParse(api.ZidUserCSS): {
+ constHeader{
+ api.KeyTitle: "Zettelstore User CSS",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxCSS,
+ api.KeyCreated: "20210622110143",
+ api.KeyVisibility: api.ValueVisibilityPublic,
+ },
+ domain.NewContent([]byte("/* User-defined CSS */"))},
+ id.RoleCSSMapZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Role to CSS Map",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxNone,
+ api.KeyCreated: "20220321183214",
+ api.KeyVisibility: api.ValueVisibilityExpert,
+ },
+ domain.NewContent(nil)},
+ id.EmojiZid: {
+ constHeader{
+ api.KeyTitle: "Zettelstore Generic Emoji",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxGif,
+ api.KeyReadOnly: api.ValueTrue,
+ api.KeyCreated: "20210504175807",
+ api.KeyVisibility: api.ValueVisibilityPublic,
+ },
+ domain.NewContent(contentEmoji)},
+ id.TOCNewTemplateZid: {
+ constHeader{
+ api.KeyTitle: "New Menu",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxZmk,
+ api.KeyLang: api.ValueLangEN,
+ api.KeyCreated: "20210217161829",
+ api.KeyVisibility: api.ValueVisibilityCreator,
+ },
+ domain.NewContent(contentNewTOCZettel)},
+ id.MustParse(api.ZidTemplateNewZettel): {
+ constHeader{
+ api.KeyTitle: "New Zettel",
+ api.KeyRole: api.ValueRoleZettel,
+ api.KeySyntax: meta.SyntaxZmk,
+ api.KeyCreated: "20201028185209",
+ api.KeyVisibility: api.ValueVisibilityCreator,
+ },
+ domain.NewContent(nil)},
+ id.MustParse(api.ZidTemplateNewUser): {
+ constHeader{
+ api.KeyTitle: "New User",
+ api.KeyRole: api.ValueRoleConfiguration,
+ api.KeySyntax: meta.SyntaxNone,
+ api.KeyCreated: "20201028185209",
+ meta.NewPrefix + api.KeyCredential: "",
+ meta.NewPrefix + api.KeyUserID: "",
+ meta.NewPrefix + api.KeyUserRole: api.ValueUserRoleReader,
+ api.KeyVisibility: api.ValueVisibilityOwner,
+ },
+ domain.NewContent(nil)},
+ id.DefaultHomeZid: {
+ constHeader{
+ api.KeyTitle: "Home",
+ api.KeyRole: api.ValueRoleZettel,
+ api.KeySyntax: meta.SyntaxZmk,
+ api.KeyLang: api.ValueLangEN,
+ api.KeyCreated: "20210210190757",
+ },
+ domain.NewContent(contentHomeZettel)},
+}
+
+//go:embed license.txt
+var contentLicense []byte
+
+//go:embed contributors.zettel
+var contentContributors []byte
+
+//go:embed dependencies.zettel
+var contentDependencies []byte
+
+//go:embed base.mustache
+var contentBaseMustache []byte
+
+//go:embed login.mustache
+var contentLoginMustache []byte
+
+//go:embed zettel.mustache
+var contentZettelMustache []byte
+
+//go:embed info.mustache
+var contentInfoMustache []byte
+
+//go:embed context.mustache
+var contentContextMustache []byte
+
+//go:embed form.mustache
+var contentFormMustache []byte
+
+//go:embed rename.mustache
+var contentRenameMustache []byte
+
+//go:embed delete.mustache
+var contentDeleteMustache []byte
+
+//go:embed listzettel.mustache
+var contentListZettelMustache []byte
+
+//go:embed error.mustache
+var contentErrorMustache []byte
+
+//go:embed base.css
+var contentBaseCSS []byte
+
+//go:embed emoji_spin.gif
+var contentEmoji []byte
+
+//go:embed newtoc.zettel
+var contentNewTOCZettel []byte
+
+//go:embed home.zettel
+var contentHomeZettel []byte
ADDED box/constbox/context.mustache
Index: box/constbox/context.mustache
==================================================================
--- /dev/null
+++ box/constbox/context.mustache
@@ -0,0 +1,13 @@
+
+
+{{{Content}}}
+
ADDED box/constbox/contributors.zettel
Index: box/constbox/contributors.zettel
==================================================================
--- /dev/null
+++ 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
==================================================================
--- /dev/null
+++ box/constbox/delete.mustache
@@ -0,0 +1,42 @@
+
+
+Do you really want to delete this zettel?
+{{#HasShadows}}
+
+
Infomation
+
If you delete this zettel, the previoulsy shadowed zettel from overlayed box {{ShadowedBox}} becomes available.
+
+{{/HasShadows}}
+{{#Incoming.Has}}
+
+
Warning!
+
If you delete this zettel, incoming references from the following zettel will become invalid.
+
+{{#Incoming.Links}}
+{{Text}}
+{{/Incoming.Links}}
+
+
+{{/Incoming.Has}}
+{{#HasUselessFiles}}
+
+
Warning!
+
Deleting this zettel will also delete the following files, so that they will not be interpreted as content for this zettel.
+
+{{#UselessFiles}}
+{{{.}}}
+{{/UselessFiles}}
+
+
+{{/HasUselessFiles}}
+
+{{#MetaPairs}}
+{{Key}}: {{Value}}
+{{/MetaPairs}}
+
+
+
ADDED box/constbox/dependencies.zettel
Index: box/constbox/dependencies.zettel
==================================================================
--- /dev/null
+++ box/constbox/dependencies.zettel
@@ -0,0 +1,176 @@
+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 licenses.
+
+=== 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.
+```
+
+=== ASCIIToSVG
+; URL
+: [[https://github.com/asciitosvg/asciitosvg]]
+; License
+: MIT
+; Remarks
+: ASCIIToSVG was incorporated into the source code of Zettelstore, moving it into package ''zettelstore.de/z/parser/draw''.
+ Later, the source code was changed substantially to adapt it to the needs of Zettelstore.
+```
+Copyright (c) 2015 The ASCIIToSVG Contributors
+
+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.
+```
+
+=== Fsnotify
+; URL
+: [[https://fsnotify.org/]]
+; License
+: BSD 3-Clause "New" or "Revised" License
+; Source
+: [[https://github.com/fsnotify/fsnotify]]
+```
+Copyright © 2012 The Go Authors. All rights reserved.
+Copyright © 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
==================================================================
--- /dev/null
+++ box/constbox/emoji_spin.gif
cannot compute difference between binary files
ADDED box/constbox/error.mustache
Index: box/constbox/error.mustache
==================================================================
--- /dev/null
+++ box/constbox/error.mustache
@@ -0,0 +1,6 @@
+
+
+{{ErrorText}}
+
ADDED box/constbox/form.mustache
Index: box/constbox/form.mustache
==================================================================
--- /dev/null
+++ box/constbox/form.mustache
@@ -0,0 +1,55 @@
+
+
+
+
ADDED box/constbox/home.zettel
Index: box/constbox/home.zettel
==================================================================
--- /dev/null
+++ box/constbox/home.zettel
@@ -0,0 +1,43 @@
+=== 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.
+
+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]]: {{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
==================================================================
--- /dev/null
+++ box/constbox/info.mustache
@@ -0,0 +1,74 @@
+
+
+Information for Zettel {{Zid}}
+Web
+· Context
+{{#CanWrite}} · Edit {{/CanWrite}}
+{{#CanCopy}} · Copy {{/CanCopy}}
+{{#CanVersion}} · Version {{/CanVersion}}
+{{#CanFolge}} · Folge {{/CanFolge}}
+{{#CanRename}}· Rename {{/CanRename}}
+{{#CanDelete}}· Delete {{/CanDelete}}
+
+Interpreted Metadata
+
+{{#MetaData}}{{Key}} {{{Value}}}
+{{/MetaData}}
+References
+{{#HasLocLinks}}
+Local
+
+{{#LocLinks}}
+{{#Valid}}{{Zid}} {{/Valid}}
+{{^Valid}}{{Zid}} {{/Valid}}
+{{/LocLinks}}
+
+{{/HasLocLinks}}
+{{#QueryLinks.Has}}
+Queries
+
+{{#QueryLinks.Links}}
+{{Text}}
+{{/QueryLinks.Links}}
+
+{{/QueryLinks.Has}}
+{{#HasExtLinks}}
+External
+
+{{#ExtLinks}}
+{{.}}
+{{/ExtLinks}}
+
+{{/HasExtLinks}}
+Unlinked
+{{{UnLinksContent}}}
+
+Search Phrase
+
+
+Parts and encodings
+
+{{#EvalMatrix}}
+
+{{Header}}
+{{#Elements}}{{Text}}
+{{/Elements}}
+
+{{/EvalMatrix}}
+
+Parsed (not evaluated)
+
+{{#ParseMatrix}}
+
+{{Header}}
+{{#Elements}}{{Text}}
+{{/Elements}}
+
+{{/ParseMatrix}}
+
+{{#HasShadowLinks}}
+Shadowed Boxes
+{{#ShadowLinks}}{{.}} {{/ShadowLinks}}
+{{/HasShadowLinks}}
+{{#Endnotes}}{{{Endnotes}}}{{/Endnotes}}
+
ADDED box/constbox/license.txt
Index: box/constbox/license.txt
==================================================================
--- /dev/null
+++ box/constbox/license.txt
@@ -0,0 +1,295 @@
+Copyright (c) 2020-present Detlef Stern
+
+ Licensed under the EUPL
+
+Zettelstore is licensed under the European Union Public License, version 1.2 or
+later (EUPL v. 1.2). The license is available in the official languages of the
+EU. The English version is included here. Please see
+https://joinup.ec.europa.eu/community/eupl/og_page/eupl for official
+translations of the other languages.
+
+
+-------------------------------------------------------------------------------
+
+
+EUROPEAN UNION PUBLIC LICENCE v. 1.2
+EUPL © the European Union 2007, 2016
+
+This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined
+below) which is provided under the terms of this Licence. Any use of the Work,
+other than as authorised under this Licence is prohibited (to the extent such
+use is covered by a right of the copyright holder of the Work).
+
+The Work is provided under the terms of this Licence when the Licensor (as
+defined below) has placed the following notice immediately following the
+copyright notice for the Work:
+
+ Licensed under the EUPL
+
+or has expressed by any other means his willingness to license under the EUPL.
+
+1. Definitions
+
+In this Licence, the following terms have the following meaning:
+
+— ‘The Licence’: this Licence.
+— ‘The Original Work’: the work or software distributed or communicated by the
+ Licensor under this Licence, available as Source Code and also as Executable
+ Code as the case may be.
+— ‘Derivative Works’: the works or software that could be created by the
+ Licensee, based upon the Original Work or modifications thereof. This Licence
+ does not define the extent of modification or dependence on the Original Work
+ required in order to classify a work as a Derivative Work; this extent is
+ determined by copyright law applicable in the country mentioned in Article
+ 15.
+— ‘The Work’: the Original Work or its Derivative Works.
+— ‘The Source Code’: the human-readable form of the Work which is the most
+ convenient for people to study and modify.
+— ‘The Executable Code’: any code which has generally been compiled and which
+ is meant to be interpreted by a computer as a program.
+— ‘The Licensor’: the natural or legal person that distributes or communicates
+ the Work under the Licence.
+— ‘Contributor(s)’: any natural or legal person who modifies the Work under the
+ Licence, or otherwise contributes to the creation of a Derivative Work.
+— ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of
+ the Work under the terms of the Licence.
+— ‘Distribution’ or ‘Communication’: any act of selling, giving, lending,
+ renting, distributing, communicating, transmitting, or otherwise making
+ available, online or offline, copies of the Work or providing access to its
+ essential functionalities at the disposal of any other natural or legal
+ person.
+
+2. Scope of the rights granted by the Licence
+
+The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
+sublicensable licence to do the following, for the duration of copyright vested
+in the Original Work:
+
+— use the Work in any circumstance and for all usage,
+— reproduce the Work,
+— modify the Work, and make Derivative Works based upon the Work,
+— communicate to the public, including the right to make available or display
+ the Work or copies thereof to the public and perform publicly, as the case
+ may be, the Work,
+— distribute the Work or copies thereof,
+— lend and rent the Work or copies thereof,
+— sublicense rights in the Work or copies thereof.
+
+Those rights can be exercised on any media, supports and formats, whether now
+known or later invented, as far as the applicable law permits so.
+
+In the countries where moral rights apply, the Licensor waives his right to
+exercise his moral right to the extent allowed by law in order to make
+effective the licence of the economic rights here above listed.
+
+The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
+any patents held by the Licensor, to the extent necessary to make use of the
+rights granted on the Work under this Licence.
+
+3. Communication of the Source Code
+
+The Licensor may provide the Work either in its Source Code form, or as
+Executable Code. If the Work is provided as Executable Code, the Licensor
+provides in addition a machine-readable copy of the Source Code of the Work
+along with each copy of the Work that the Licensor distributes or indicates, in
+a notice following the copyright notice attached to the Work, a repository
+where the Source Code is easily and freely accessible for as long as the
+Licensor continues to distribute or communicate the Work.
+
+4. Limitations on copyright
+
+Nothing in this Licence is intended to deprive the Licensee of the benefits
+from any exception or limitation to the exclusive rights of the rights owners
+in the Work, of the exhaustion of those rights or of other applicable
+limitations thereto.
+
+5. Obligations of the Licensee
+
+The grant of the rights mentioned above is subject to some restrictions and
+obligations imposed on the Licensee. Those obligations are the following:
+
+Attribution right: The Licensee shall keep intact all copyright, patent or
+trademarks notices and all notices that refer to the Licence and to the
+disclaimer of warranties. The Licensee must include a copy of such notices and
+a copy of the Licence with every copy of the Work he/she distributes or
+communicates. The Licensee must cause any Derivative Work to carry prominent
+notices stating that the Work has been modified and the date of modification.
+
+Copyleft clause: If the Licensee distributes or communicates copies of the
+Original Works or Derivative Works, this Distribution or Communication will be
+done under the terms of this Licence or of a later version of this Licence
+unless the Original Work is expressly distributed only under this version of
+the Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee
+(becoming Licensor) cannot offer or impose any additional terms or conditions
+on the Work or Derivative Work that alter or restrict the terms of the Licence.
+
+Compatibility clause: If the Licensee Distributes or Communicates Derivative
+Works or copies thereof based upon both the Work and another work licensed
+under a Compatible Licence, this Distribution or Communication can be done
+under the terms of this Compatible Licence. For the sake of this clause,
+‘Compatible Licence’ refers to the licences listed in the appendix attached to
+this Licence. Should the Licensee's obligations under the Compatible Licence
+conflict with his/her obligations under this Licence, the obligations of the
+Compatible Licence shall prevail.
+
+Provision of Source Code: When distributing or communicating copies of the
+Work, the Licensee will provide a machine-readable copy of the Source Code or
+indicate a repository where this Source will be easily and freely available for
+as long as the Licensee continues to distribute or communicate the Work.
+
+Legal Protection: This Licence does not grant permission to use the trade
+names, trademarks, service marks, or names of the Licensor, except as required
+for reasonable and customary use in describing the origin of the Work and
+reproducing the content of the copyright notice.
+
+6. Chain of Authorship
+
+The original Licensor warrants that the copyright in the Original Work granted
+hereunder is owned by him/her or licensed to him/her and that he/she has the
+power and authority to grant the Licence.
+
+Each Contributor warrants that the copyright in the modifications he/she brings
+to the Work are owned by him/her or licensed to him/her and that he/she has the
+power and authority to grant the Licence.
+
+Each time You accept the Licence, the original Licensor and subsequent
+Contributors grant You a licence to their contributions to the Work, under the
+terms of this Licence.
+
+7. Disclaimer of Warranty
+
+The Work is a work in progress, which is continuously improved by numerous
+Contributors. It is not a finished work and may therefore contain defects or
+‘bugs’ inherent to this type of development.
+
+For the above reason, the Work is provided under the Licence on an ‘as is’
+basis and without warranties of any kind concerning the Work, including without
+limitation merchantability, fitness for a particular purpose, absence of
+defects or errors, accuracy, non-infringement of intellectual property rights
+other than copyright as stated in Article 6 of this Licence.
+
+This disclaimer of warranty is an essential part of the Licence and a condition
+for the grant of any rights to the Work.
+
+8. Disclaimer of Liability
+
+Except in the cases of wilful misconduct or damages directly caused to natural
+persons, the Licensor will in no event be liable for any direct or indirect,
+material or moral, damages of any kind, arising out of the Licence or of the
+use of the Work, including without limitation, damages for loss of goodwill,
+work stoppage, computer failure or malfunction, loss of data or any commercial
+damage, even if the Licensor has been advised of the possibility of such
+damage. However, the Licensor will be liable under statutory product liability
+laws as far such laws apply to the Work.
+
+9. Additional agreements
+
+While distributing the Work, You may choose to conclude an additional
+agreement, defining obligations or services consistent with this Licence.
+However, if accepting obligations, You may act only on your own behalf and on
+your sole responsibility, not on behalf of the original Licensor or any other
+Contributor, and only if You agree to indemnify, defend, and hold each
+Contributor harmless for any liability incurred by, or claims asserted against
+such Contributor by the fact You have accepted any warranty or additional
+liability.
+
+10. Acceptance of the Licence
+
+The provisions of this Licence can be accepted by clicking on an icon ‘I agree’
+placed under the bottom of a window displaying the text of this Licence or by
+affirming consent in any other similar way, in accordance with the rules of
+applicable law. Clicking on that icon indicates your clear and irrevocable
+acceptance of this Licence and all of its terms and conditions.
+
+Similarly, you irrevocably accept this Licence and all of its terms and
+conditions by exercising any rights granted to You by Article 2 of this
+Licence, such as the use of the Work, the creation by You of a Derivative Work
+or the Distribution or Communication by You of the Work or copies thereof.
+
+11. Information to the public
+
+In case of any Distribution or Communication of the Work by means of electronic
+communication by You (for example, by offering to download the Work from
+a remote location) the distribution channel or media (for example, a website)
+must at least provide to the public the information requested by the applicable
+law regarding the Licensor, the Licence and the way it may be accessible,
+concluded, stored and reproduced by the Licensee.
+
+12. Termination of the Licence
+
+The Licence and the rights granted hereunder will terminate automatically upon
+any breach by the Licensee of the terms of the Licence.
+
+Such a termination will not terminate the licences of any person who has
+received the Work from the Licensee under the Licence, provided such persons
+remain in full compliance with the Licence.
+
+13. Miscellaneous
+
+Without prejudice of Article 9 above, the Licence represents the complete
+agreement between the Parties as to the Work.
+
+If any provision of the Licence is invalid or unenforceable under applicable
+law, this will not affect the validity or enforceability of the Licence as
+a whole. Such provision will be construed or reformed so as necessary to make
+it valid and enforceable.
+
+The European Commission may publish other linguistic versions or new versions
+of this Licence or updated versions of the Appendix, so far this is required
+and reasonable, without reducing the scope of the rights granted by the
+Licence. New versions of the Licence will be published with a unique version
+number.
+
+All linguistic versions of this Licence, approved by the European Commission,
+have identical value. Parties can take advantage of the linguistic version of
+their choice.
+
+14. Jurisdiction
+
+Without prejudice to specific agreement between parties,
+
+— any litigation resulting from the interpretation of this License, arising
+ between the European Union institutions, bodies, offices or agencies, as
+ a Licensor, and any Licensee, will be subject to the jurisdiction of the
+ Court of Justice of the European Union, as laid down in article 272 of the
+ Treaty on the Functioning of the European Union,
+— any litigation arising between other parties and resulting from the
+ interpretation of this License, will be subject to the exclusive jurisdiction
+ of the competent court where the Licensor resides or conducts its primary
+ business.
+
+15. Applicable Law
+
+Without prejudice to specific agreement between parties,
+
+— this Licence shall be governed by the law of the European Union Member State
+ where the Licensor has his seat, resides or has his registered office,
+— this licence shall be governed by Belgian law if the Licensor has no seat,
+ residence or registered office inside a European Union Member State.
+
+
+ Appendix
+
+
+‘Compatible Licences’ according to Article 5 EUPL are:
+
+— GNU General Public License (GPL) v. 2, v. 3
+— GNU Affero General Public License (AGPL) v. 3
+— Open Software License (OSL) v. 2.1, v. 3.0
+— Eclipse Public License (EPL) v. 1.0
+— CeCILL v. 2.0, v. 2.1
+— Mozilla Public Licence (MPL) v. 2
+— GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
+— Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
+ works other than software
+— European Union Public Licence (EUPL) v. 1.1, v. 1.2
+— Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
+ Reciprocity (LiLiQ-R+)
+
+The European Commission may update this Appendix to later versions of the above
+licences without producing a new version of the EUPL, as long as they provide
+the rights granted in Article 2 of this Licence and protect the covered Source
+Code from exclusive appropriation.
+
+All other changes or additions to this Appendix require the production of a new
+EUPL version.
ADDED box/constbox/listzettel.mustache
Index: box/constbox/listzettel.mustache
==================================================================
--- /dev/null
+++ box/constbox/listzettel.mustache
@@ -0,0 +1,14 @@
+
+
+
+
+
+{{{Content}}}
+{{#CanCreate}}
+
+
+
+ {{/CanCreate}}
+
ADDED box/constbox/login.mustache
Index: box/constbox/login.mustache
==================================================================
--- /dev/null
+++ box/constbox/login.mustache
@@ -0,0 +1,19 @@
+
+
+{{#Retry}}
+Wrong user name / password. Try again.
+{{/Retry}}
+
+
+User name:
+
+
+
+Password:
+
+
+
+
+
ADDED box/constbox/newtoc.zettel
Index: box/constbox/newtoc.zettel
==================================================================
--- /dev/null
+++ 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
==================================================================
--- /dev/null
+++ box/constbox/rename.mustache
@@ -0,0 +1,41 @@
+
+
+Do you really want to rename this zettel?
+{{#Incoming.Has}}
+
+
Warning!
+
If you rename this zettel, incoming references from the following zettel will become invalid.
+
+{{#Incoming.Links}}
+{{Text}}
+{{/Incoming.Links}}
+
+
+{{/Incoming.Has}}
+{{#HasUselessFiles}}
+
+
Warning!
+
Renaming this zettel will also delete the following files, so that they will not be interpreted as content for a zettel with identifier {{Zid}}.
+
+{{#UselessFiles}}
+{{{.}}}
+{{/UselessFiles}}
+
+
+{{/HasUselessFiles}}
+
+
+New zettel id
+
+
+
+
+
+
+{{#MetaPairs}}
+{{Key}}: {{Value}}
+{{/MetaPairs}}
+
+
ADDED box/constbox/zettel.mustache
Index: box/constbox/zettel.mustache
==================================================================
--- /dev/null
+++ box/constbox/zettel.mustache
@@ -0,0 +1,52 @@
+
+
+{{{Content}}}
+
+{{#NeedBottomNav}}{{/NeedBottomNav}}
+{{#FolgeLinks.Has}}
+
+Folgezettel
+
+{{#FolgeLinks.Links}}
+{{Text}}
+{{/FolgeLinks.Links}}
+
+
+{{/FolgeLinks.Has}}
+{{#BackLinks.Has}}
+
+Incoming
+
+{{#BackLinks.Links}}
+{{Text}}
+{{/BackLinks.Links}}
+
+
+{{/BackLinks.Has}}
+{{#SuccessorLinks.Has}}
+
+Successors
+
+{{#SuccessorLinks.Links}}
+{{Text}}
+{{/SuccessorLinks.Links}}
+
+
+{{/SuccessorLinks.Has}}
+{{#NeedBottomNav}} {{/NeedBottomNav}}
ADDED box/dirbox/dirbox.go
Index: box/dirbox/dirbox.go
==================================================================
--- /dev/null
+++ box/dirbox/dirbox.go
@@ -0,0 +1,418 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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"
+ "sync"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/manager"
+ "zettelstore.de/z/box/notify"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+ "zettelstore.de/z/logger"
+ "zettelstore.de/z/query"
+)
+
+func init() {
+ manager.Register("dir", func(u *url.URL, cdata *manager.ConnectData) (box.ManagedBox, error) {
+ var log *logger.Logger
+ if krnl := kernel.Main; krnl != nil {
+ log = krnl.GetLogger(kernel.BoxService).Clone().Str("box", "dir").Int("boxnum", int64(cdata.Number)).Child()
+ }
+ path := getDirPath(u)
+ if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
+ return nil, err
+ }
+ dp := dirBox{
+ log: log,
+ number: cdata.Number,
+ location: u.String(),
+ readonly: box.GetQueryBool(u, "readonly"),
+ cdata: *cdata,
+ dir: path,
+ notifySpec: getDirSrvInfo(log, u.Query().Get("type")),
+ fSrvs: makePrime(uint32(box.GetQueryInt(u, "worker", 1, 7, 1499))),
+ }
+ return &dp, nil
+ })
+}
+
+func makePrime(n uint32) uint32 {
+ for !isPrime(n) {
+ n++
+ }
+ return n
+}
+
+func isPrime(n uint32) bool {
+ if n == 0 {
+ return false
+ }
+ if n <= 3 {
+ return true
+ }
+ if n%2 == 0 {
+ return false
+ }
+ for i := uint32(3); i*i <= n; i += 2 {
+ if n%i == 0 {
+ return false
+ }
+ }
+ return true
+}
+
+type notifyTypeSpec int
+
+const (
+ _ notifyTypeSpec = iota
+ dirNotifyAny
+ dirNotifySimple
+ dirNotifyFS
+)
+
+func getDirSrvInfo(log *logger.Logger, notifyType string) notifyTypeSpec {
+ for count := 0; count < 2; count++ {
+ switch notifyType {
+ case kernel.BoxDirTypeNotify:
+ return dirNotifyFS
+ case kernel.BoxDirTypeSimple:
+ return dirNotifySimple
+ default:
+ notifyType = kernel.Main.GetConfig(kernel.BoxService, kernel.BoxDefaultDirType).(string)
+ }
+ }
+ log.Error().Str("notifyType", notifyType).Msg("Unable to set notify type, using a default")
+ return dirNotifySimple
+}
+
+func getDirPath(u *url.URL) string {
+ if u.Opaque != "" {
+ return filepath.Clean(u.Opaque)
+ }
+ return filepath.Clean(u.Path)
+}
+
+// dirBox uses a directory to store zettel as files.
+type dirBox struct {
+ log *logger.Logger
+ number int
+ location string
+ readonly bool
+ cdata manager.ConnectData
+ dir string
+ notifySpec notifyTypeSpec
+ dirSrv *notify.DirService
+ fSrvs uint32
+ fCmds []chan fileCmd
+ mxCmds sync.RWMutex
+}
+
+func (dp *dirBox) Location() string {
+ return dp.location
+}
+
+func (dp *dirBox) State() box.StartState {
+ if ds := dp.dirSrv; ds != nil {
+ switch ds.State() {
+ case notify.DsCreated:
+ return box.StartStateStopped
+ case notify.DsStarting:
+ return box.StartStateStarting
+ case notify.DsWorking:
+ return box.StartStateStarted
+ case notify.DsMissing:
+ return box.StartStateStarted
+ case notify.DsStopping:
+ return box.StartStateStopping
+ }
+ }
+ return box.StartStateStopped
+}
+
+func (dp *dirBox) Start(context.Context) error {
+ dp.mxCmds.Lock()
+ defer dp.mxCmds.Unlock()
+ dp.fCmds = make([]chan fileCmd, 0, dp.fSrvs)
+ for i := uint32(0); i < dp.fSrvs; i++ {
+ cc := make(chan fileCmd)
+ go fileService(i, dp.log.Clone().Str("sub", "file").Uint("fn", uint64(i)).Child(), dp.dir, cc)
+ dp.fCmds = append(dp.fCmds, cc)
+ }
+
+ var notifier notify.Notifier
+ var err error
+ switch dp.notifySpec {
+ case dirNotifySimple:
+ notifier, err = notify.NewSimpleDirNotifier(dp.log.Clone().Str("notify", "simple").Child(), dp.dir)
+ default:
+ notifier, err = notify.NewFSDirNotifier(dp.log.Clone().Str("notify", "fs").Child(), dp.dir)
+ }
+ if err != nil {
+ dp.log.Fatal().Err(err).Msg("Unable to create directory supervisor")
+ dp.stopFileServices()
+ return err
+ }
+ dp.dirSrv = notify.NewDirService(
+ dp,
+ dp.log.Clone().Str("sub", "dirsrv").Child(),
+ notifier,
+ dp.cdata.Notify,
+ )
+ dp.dirSrv.Start()
+ return nil
+}
+
+func (dp *dirBox) Refresh(_ context.Context) {
+ dp.dirSrv.Refresh()
+ dp.log.Trace().Msg("Refresh")
+}
+
+func (dp *dirBox) Stop(_ context.Context) {
+ dirSrv := dp.dirSrv
+ dp.dirSrv = nil
+ if dirSrv != nil {
+ dirSrv.Stop()
+ }
+ dp.stopFileServices()
+}
+
+func (dp *dirBox) stopFileServices() {
+ for _, c := range dp.fCmds {
+ close(c)
+ }
+}
+
+func (dp *dirBox) notifyChanged(zid id.Zid) {
+ if chci := dp.cdata.Notify; chci != nil {
+ dp.log.Trace().Zid(zid).Msg("notifyChanged")
+ chci <- box.UpdateInfo{Reason: box.OnZettel, 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(_ 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
+ }
+
+ newZid, err := dp.dirSrv.SetNewDirEntry()
+ if err != nil {
+ return id.Invalid, err
+ }
+ meta := zettel.Meta
+ meta.Zid = newZid
+ entry := notify.DirEntry{Zid: newZid}
+ dp.updateEntryFromMetaContent(&entry, meta, zettel.Content)
+
+ err = dp.srvSetZettel(ctx, &entry, zettel)
+ if err == nil {
+ err = dp.dirSrv.UpdateDirEntry(&entry)
+ }
+ dp.notifyChanged(meta.Zid)
+ dp.log.Trace().Err(err).Zid(meta.Zid).Msg("CreateZettel")
+ return meta.Zid, err
+}
+
+func (dp *dirBox) GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error) {
+ entry := dp.dirSrv.GetDirEntry(zid)
+ if !entry.IsValid() {
+ return domain.Zettel{}, box.ErrNotFound
+ }
+ m, c, err := dp.srvGetMetaContent(ctx, entry, zid)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ zettel := domain.Zettel{Meta: m, Content: domain.NewContent(c)}
+ dp.log.Trace().Zid(zid).Msg("GetZettel")
+ return zettel, nil
+}
+
+func (dp *dirBox) GetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error) {
+ m, err := dp.doGetMeta(ctx, zid)
+ dp.log.Trace().Zid(zid).Err(err).Msg("GetMeta")
+ return m, err
+}
+func (dp *dirBox) doGetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error) {
+ entry := dp.dirSrv.GetDirEntry(zid)
+ if !entry.IsValid() {
+ return nil, box.ErrNotFound
+ }
+ m, err := dp.srvGetMeta(ctx, entry, zid)
+ if err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+func (dp *dirBox) ApplyZid(_ context.Context, handle box.ZidFunc, constraint query.RetrievePredicate) error {
+ entries := dp.dirSrv.GetDirEntries(constraint)
+ dp.log.Trace().Int("entries", int64(len(entries))).Msg("ApplyZid")
+ for _, entry := range entries {
+ handle(entry.Zid)
+ }
+ return nil
+}
+
+func (dp *dirBox) ApplyMeta(ctx context.Context, handle box.MetaFunc, constraint query.RetrievePredicate) error {
+ entries := dp.dirSrv.GetDirEntries(constraint)
+ dp.log.Trace().Int("entries", int64(len(entries))).Msg("ApplyMeta")
+
+ // The following loop could be parallelized if needed for performance.
+ for _, entry := range entries {
+ m, err := dp.srvGetMeta(ctx, entry, entry.Zid)
+ if err != nil {
+ dp.log.Trace().Err(err).Msg("ApplyMeta/getMeta")
+ return err
+ }
+ dp.cdata.Enricher.Enrich(ctx, m, dp.number)
+ handle(m)
+ }
+ return nil
+}
+
+func (dp *dirBox) CanUpdateZettel(context.Context, 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
+ zid := meta.Zid
+ if !zid.IsValid() {
+ return &box.ErrInvalidID{Zid: zid}
+ }
+ entry := dp.dirSrv.GetDirEntry(zid)
+ if !entry.IsValid() {
+ // Existing zettel, but new in this box.
+ entry = ¬ify.DirEntry{Zid: zid}
+ }
+ dp.updateEntryFromMetaContent(entry, meta, zettel.Content)
+ dp.dirSrv.UpdateDirEntry(entry)
+ err := dp.srvSetZettel(ctx, entry, zettel)
+ if err == nil {
+ dp.notifyChanged(zid)
+ }
+ dp.log.Trace().Zid(zid).Err(err).Msg("UpdateZettel")
+ return err
+}
+
+func (dp *dirBox) updateEntryFromMetaContent(entry *notify.DirEntry, m *meta.Meta, content domain.Content) {
+ entry.SetupFromMetaContent(m, content, dp.cdata.Config.GetZettelFileSyntax)
+}
+
+func (dp *dirBox) AllowRenameZettel(context.Context, id.Zid) bool {
+ return !dp.readonly
+}
+
+func (dp *dirBox) RenameZettel(ctx context.Context, curZid, newZid id.Zid) error {
+ if curZid == newZid {
+ return nil
+ }
+ curEntry := dp.dirSrv.GetDirEntry(curZid)
+ if !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.doGetMeta(ctx, newZid); err == nil {
+ return &box.ErrInvalidID{Zid: newZid}
+ }
+
+ oldMeta, oldContent, err := dp.srvGetMetaContent(ctx, curEntry, curZid)
+ if err != nil {
+ return err
+ }
+
+ newEntry, err := dp.dirSrv.RenameDirEntry(curEntry, newZid)
+ if err != nil {
+ return err
+ }
+ oldMeta.Zid = newZid
+ newZettel := domain.Zettel{Meta: oldMeta, Content: domain.NewContent(oldContent)}
+ if err = dp.srvSetZettel(ctx, &newEntry, newZettel); err != nil {
+ // "Rollback" rename. No error checking...
+ dp.dirSrv.RenameDirEntry(&newEntry, curZid)
+ return err
+ }
+ err = dp.srvDeleteZettel(ctx, curEntry, curZid)
+ if err == nil {
+ dp.notifyChanged(curZid)
+ dp.notifyChanged(newZid)
+ }
+ dp.log.Trace().Zid(curZid).Zid(newZid).Err(err).Msg("RenameZettel")
+ return err
+}
+
+func (dp *dirBox) CanDeleteZettel(_ context.Context, zid id.Zid) bool {
+ if dp.readonly {
+ return false
+ }
+ entry := dp.dirSrv.GetDirEntry(zid)
+ return entry.IsValid()
+}
+
+func (dp *dirBox) DeleteZettel(ctx context.Context, zid id.Zid) error {
+ if dp.readonly {
+ return box.ErrReadOnly
+ }
+
+ entry := dp.dirSrv.GetDirEntry(zid)
+ if !entry.IsValid() {
+ return box.ErrNotFound
+ }
+ err := dp.dirSrv.DeleteDirEntry(zid)
+ if err != nil {
+ return nil
+ }
+ err = dp.srvDeleteZettel(ctx, entry, zid)
+ if err == nil {
+ dp.notifyChanged(zid)
+ }
+ dp.log.Trace().Zid(zid).Err(err).Msg("DeleteZettel")
+ return err
+}
+
+func (dp *dirBox) ReadStats(st *box.ManagedBoxStats) {
+ st.ReadOnly = dp.readonly
+ st.Zettel = dp.dirSrv.NumDirEntries()
+ dp.log.Trace().Int("zettel", int64(st.Zettel)).Msg("ReadStats")
+}
ADDED box/dirbox/dirbox_test.go
Index: box/dirbox/dirbox_test.go
==================================================================
--- /dev/null
+++ box/dirbox/dirbox_test.go
@@ -0,0 +1,50 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+import "testing"
+
+func TestIsPrime(t *testing.T) {
+ testcases := []struct {
+ n uint32
+ exp bool
+ }{
+ {0, false}, {1, true}, {2, true}, {3, true}, {4, false}, {5, true},
+ {6, false}, {7, true}, {8, false}, {9, false}, {10, false},
+ {11, true}, {12, false}, {13, true}, {14, false}, {15, false},
+ {17, true}, {19, true}, {21, false}, {23, true}, {25, false},
+ {27, false}, {29, true}, {31, true}, {33, false}, {35, false},
+ }
+ for _, tc := range testcases {
+ got := isPrime(tc.n)
+ if got != tc.exp {
+ t.Errorf("isPrime(%d)=%v, but got %v", tc.n, tc.exp, got)
+ }
+ }
+}
+
+func TestMakePrime(t *testing.T) {
+ for i := uint32(0); i < 1500; i++ {
+ np := makePrime(i)
+ if np < i {
+ t.Errorf("makePrime(%d) < %d", i, np)
+ continue
+ }
+ if !isPrime(np) {
+ t.Errorf("makePrime(%d) == %d is not prime", i, np)
+ continue
+ }
+ if isPrime(i) && i != np {
+ t.Errorf("%d is already prime, but got %d as next prime", i, np)
+ continue
+ }
+ }
+}
ADDED box/dirbox/service.go
Index: box/dirbox/service.go
==================================================================
--- /dev/null
+++ box/dirbox/service.go
@@ -0,0 +1,387 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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
+
+import (
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+
+ "zettelstore.de/z/box/filebox"
+ "zettelstore.de/z/box/notify"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/input"
+ "zettelstore.de/z/kernel"
+ "zettelstore.de/z/logger"
+)
+
+func fileService(i uint32, log *logger.Logger, dirPath string, cmds <-chan fileCmd) {
+ // Something may panic. Ensure a running service.
+ defer func() {
+ if r := recover(); r != nil {
+ kernel.Main.LogRecover("FileService", r)
+ go fileService(i, log, dirPath, cmds)
+ }
+ }()
+
+ log.Trace().Uint("i", uint64(i)).Str("dirpath", dirPath).Msg("File service started")
+ for cmd := range cmds {
+ cmd.run(log, dirPath)
+ }
+ log.Trace().Uint("i", uint64(i)).Str("dirpath", dirPath).Msg("File service stopped")
+}
+
+type fileCmd interface {
+ run(*logger.Logger, string)
+}
+
+const serviceTimeout = 5 * time.Second // must be shorter than the web servers timeout values for reading+writing.
+
+// COMMAND: srvGetMeta ----------------------------------------
+//
+// Retrieves the meta data from a zettel.
+
+func (dp *dirBox) srvGetMeta(ctx context.Context, entry *notify.DirEntry, zid id.Zid) (*meta.Meta, error) {
+ rc := make(chan resGetMeta, 1)
+ dp.getFileChan(zid) <- &fileGetMeta{entry, rc}
+ ctx, cancel := context.WithTimeout(ctx, serviceTimeout)
+ defer cancel()
+ select {
+ case res := <-rc:
+ return res.meta, res.err
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
+
+type fileGetMeta struct {
+ entry *notify.DirEntry
+ rc chan<- resGetMeta
+}
+type resGetMeta struct {
+ meta *meta.Meta
+ err error
+}
+
+func (cmd *fileGetMeta) run(log *logger.Logger, dirPath string) {
+ var m *meta.Meta
+ var err error
+
+ entry := cmd.entry
+ zid := entry.Zid
+ if metaName := entry.MetaName; metaName == "" {
+ contentName := entry.ContentName
+ contentExt := entry.ContentExt
+ if contentName == "" || contentExt == "" {
+ log.Panic().Zid(zid).Msg("No meta, no content in getMeta")
+ }
+ if entry.HasMetaInContent() {
+ m, _, err = parseMetaContentFile(zid, filepath.Join(dirPath, contentName))
+ } else {
+ m = filebox.CalcDefaultMeta(zid, contentExt)
+ }
+ } else {
+ m, err = parseMetaFile(zid, filepath.Join(dirPath, metaName))
+ }
+ if err == nil {
+ cmdCleanupMeta(m, entry)
+ }
+ cmd.rc <- resGetMeta{m, err}
+}
+
+// COMMAND: srvGetMetaContent ----------------------------------------
+//
+// Retrieves the meta data and the content of a zettel.
+
+func (dp *dirBox) srvGetMetaContent(ctx context.Context, entry *notify.DirEntry, zid id.Zid) (*meta.Meta, []byte, error) {
+ rc := make(chan resGetMetaContent, 1)
+ dp.getFileChan(zid) <- &fileGetMetaContent{entry, rc}
+ ctx, cancel := context.WithTimeout(ctx, serviceTimeout)
+ defer cancel()
+ select {
+ case res := <-rc:
+ return res.meta, res.content, res.err
+ case <-ctx.Done():
+ return nil, nil, ctx.Err()
+ }
+}
+
+type fileGetMetaContent struct {
+ entry *notify.DirEntry
+ rc chan<- resGetMetaContent
+}
+type resGetMetaContent struct {
+ meta *meta.Meta
+ content []byte
+ err error
+}
+
+func (cmd *fileGetMetaContent) run(log *logger.Logger, dirPath string) {
+ var m *meta.Meta
+ var content []byte
+ var err error
+
+ entry := cmd.entry
+ zid := entry.Zid
+ contentName := entry.ContentName
+ contentExt := entry.ContentExt
+ contentPath := filepath.Join(dirPath, contentName)
+ if metaName := entry.MetaName; metaName == "" {
+ if contentName == "" || contentExt == "" {
+ log.Panic().Zid(zid).Msg("No meta, no content in getMetaContent")
+ }
+ if entry.HasMetaInContent() {
+ m, content, err = parseMetaContentFile(zid, contentPath)
+ } else {
+ m = filebox.CalcDefaultMeta(zid, contentExt)
+ content, err = os.ReadFile(contentPath)
+ }
+ } else {
+ m, err = parseMetaFile(zid, filepath.Join(dirPath, metaName))
+ if contentName != "" {
+ var err1 error
+ content, err1 = os.ReadFile(contentPath)
+ if err == nil {
+ err = err1
+ }
+ }
+ }
+ if err == nil {
+ cmdCleanupMeta(m, entry)
+ }
+ cmd.rc <- resGetMetaContent{m, content, err}
+}
+
+// COMMAND: srvSetZettel ----------------------------------------
+//
+// Writes a new or exsting zettel.
+
+func (dp *dirBox) srvSetZettel(ctx context.Context, entry *notify.DirEntry, zettel domain.Zettel) error {
+ rc := make(chan resSetZettel, 1)
+ dp.getFileChan(zettel.Meta.Zid) <- &fileSetZettel{entry, zettel, rc}
+ ctx, cancel := context.WithTimeout(ctx, serviceTimeout)
+ defer cancel()
+ select {
+ case err := <-rc:
+ return err
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+type fileSetZettel struct {
+ entry *notify.DirEntry
+ zettel domain.Zettel
+ rc chan<- resSetZettel
+}
+type resSetZettel = error
+
+func (cmd *fileSetZettel) run(log *logger.Logger, dirPath string) {
+ entry := cmd.entry
+ zid := entry.Zid
+ contentName := entry.ContentName
+ m := cmd.zettel.Meta
+ content := cmd.zettel.Content.AsBytes()
+ metaName := entry.MetaName
+ if metaName == "" {
+ if contentName == "" {
+ log.Panic().Zid(zid).Msg("No meta, no content in setZettel")
+ }
+ contentPath := filepath.Join(dirPath, contentName)
+ if entry.HasMetaInContent() {
+ err := writeZettelFile(contentPath, m, content)
+ cmd.rc <- err
+ return
+ }
+ err := writeFileContent(contentPath, content)
+ cmd.rc <- err
+ return
+ }
+
+ err := writeMetaFile(filepath.Join(dirPath, metaName), m)
+ if err == nil && contentName != "" {
+ err = writeFileContent(filepath.Join(dirPath, contentName), content)
+ }
+ cmd.rc <- err
+}
+
+func writeMetaFile(metaPath string, m *meta.Meta) error {
+ metaFile, err := openFileWrite(metaPath)
+ if err != nil {
+ return err
+ }
+ err = writeFileZid(metaFile, m.Zid)
+ if err == nil {
+ _, err = m.WriteComputed(metaFile)
+ }
+ if err1 := metaFile.Close(); err == nil {
+ err = err1
+ }
+ return err
+}
+
+func writeZettelFile(contentPath string, m *meta.Meta, content []byte) error {
+ zettelFile, err := openFileWrite(contentPath)
+ if err != nil {
+ return err
+ }
+ if err == nil {
+ err = writeMetaHeader(zettelFile, m)
+ }
+ if err == nil {
+ _, err = zettelFile.Write(content)
+ }
+ if err1 := zettelFile.Close(); err == nil {
+ err = err1
+ }
+ return err
+}
+
+var (
+ newline = []byte{'\n'}
+ yamlSep = []byte{'-', '-', '-', '\n'}
+)
+
+func writeMetaHeader(w io.Writer, m *meta.Meta) (err error) {
+ if m.YamlSep {
+ _, err = w.Write(yamlSep)
+ if err != nil {
+ return err
+ }
+ }
+ err = writeFileZid(w, m.Zid)
+ if err != nil {
+ return err
+ }
+ _, err = m.WriteComputed(w)
+ if err != nil {
+ return err
+ }
+ if m.YamlSep {
+ _, err = w.Write(yamlSep)
+ } else {
+ _, err = w.Write(newline)
+ }
+ return err
+}
+
+// COMMAND: srvDeleteZettel ----------------------------------------
+//
+// Deletes an existing zettel.
+
+func (dp *dirBox) srvDeleteZettel(ctx context.Context, entry *notify.DirEntry, zid id.Zid) error {
+ rc := make(chan resDeleteZettel, 1)
+ dp.getFileChan(zid) <- &fileDeleteZettel{entry, rc}
+ ctx, cancel := context.WithTimeout(ctx, serviceTimeout)
+ defer cancel()
+ select {
+ case err := <-rc:
+ return err
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+type fileDeleteZettel struct {
+ entry *notify.DirEntry
+ rc chan<- resDeleteZettel
+}
+type resDeleteZettel = error
+
+func (cmd *fileDeleteZettel) run(log *logger.Logger, dirPath string) {
+ var err error
+
+ entry := cmd.entry
+ contentName := entry.ContentName
+ contentPath := filepath.Join(dirPath, contentName)
+ if metaName := entry.MetaName; metaName == "" {
+ if contentName == "" {
+ log.Panic().Zid(entry.Zid).Msg("No meta, no content in getMetaContent")
+ }
+ err = os.Remove(contentPath)
+ } else {
+ if contentName != "" {
+ err = os.Remove(contentPath)
+ }
+ err1 := os.Remove(filepath.Join(dirPath, metaName))
+ if err == nil {
+ err = err1
+ }
+ }
+ for _, dupName := range entry.UselessFiles {
+ err1 := os.Remove(filepath.Join(dirPath, dupName))
+ if err == nil {
+ err = err1
+ }
+ }
+ cmd.rc <- err
+}
+
+// Utility functions ----------------------------------------
+
+func parseMetaFile(zid id.Zid, path string) (*meta.Meta, error) {
+ src, err := os.ReadFile(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, []byte, error) {
+ src, err := os.ReadFile(path)
+ if err != nil {
+ return nil, nil, err
+ }
+ inp := input.NewInput(src)
+ meta := meta.NewFromInput(zid, inp)
+ return meta, src[inp.Pos:], nil
+}
+
+func cmdCleanupMeta(m *meta.Meta, entry *notify.DirEntry) {
+ filebox.CleanupMeta(
+ m,
+ entry.Zid,
+ entry.ContentExt,
+ entry.MetaName != "",
+ entry.UselessFiles,
+ )
+}
+
+func openFileWrite(path string) (*os.File, error) {
+ return os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
+}
+
+func writeFileZid(w io.Writer, zid id.Zid) error {
+ _, err := io.WriteString(w, "id: ")
+ if err == nil {
+ _, err = w.Write(zid.Bytes())
+ if err == nil {
+ _, err = io.WriteString(w, "\n")
+ }
+ }
+ return err
+}
+
+func writeFileContent(path string, content []byte) error {
+ f, err := openFileWrite(path)
+ if err == nil {
+ _, err = f.Write(content)
+ if err1 := f.Close(); err == nil {
+ err = err1
+ }
+ }
+ return err
+}
ADDED box/filebox/filebox.go
Index: box/filebox/filebox.go
==================================================================
--- /dev/null
+++ box/filebox/filebox.go
@@ -0,0 +1,94 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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/c/api"
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/manager"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+)
+
+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{
+ log: kernel.Main.GetLogger(kernel.BoxService).Clone().
+ Str("box", "zip").Int("boxnum", int64(cdata.Number)).Child(),
+ number: cdata.Number,
+ name: path,
+ enricher: cdata.Enricher,
+ notify: cdata.Notify,
+ }, 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(api.KeySyntax, calculateSyntax(ext))
+ return m
+}
+
+// CleanupMeta enhances the given metadata.
+func CleanupMeta(m *meta.Meta, zid id.Zid, ext string, inMeta bool, uselessFiles []string) {
+ if inMeta {
+ if syntax, ok := m.Get(api.KeySyntax); !ok || syntax == "" {
+ dm := CalcDefaultMeta(zid, ext)
+ syntax, ok = dm.Get(api.KeySyntax)
+ if !ok {
+ panic("Default meta must contain syntax")
+ }
+ m.Set(api.KeySyntax, syntax)
+ }
+ }
+
+ if len(uselessFiles) > 0 {
+ m.Set(api.KeyUselessFiles, strings.Join(uselessFiles, " "))
+ }
+}
ADDED box/filebox/zipbox.go
Index: box/filebox/zipbox.go
==================================================================
--- /dev/null
+++ box/filebox/zipbox.go
@@ -0,0 +1,271 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+import (
+ "archive/zip"
+ "context"
+ "io"
+ "strings"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/box/notify"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/input"
+ "zettelstore.de/z/logger"
+ "zettelstore.de/z/query"
+)
+
+type zipBox struct {
+ log *logger.Logger
+ number int
+ name string
+ enricher box.Enricher
+ notify chan<- box.UpdateInfo
+ dirSrv *notify.DirService
+}
+
+func (zb *zipBox) Location() string {
+ if strings.HasPrefix(zb.name, "/") {
+ return "file://" + zb.name
+ }
+ return "file:" + zb.name
+}
+
+func (zb *zipBox) State() box.StartState {
+ if ds := zb.dirSrv; ds != nil {
+ switch ds.State() {
+ case notify.DsCreated:
+ return box.StartStateStopped
+ case notify.DsStarting:
+ return box.StartStateStarting
+ case notify.DsWorking:
+ return box.StartStateStarted
+ case notify.DsMissing:
+ return box.StartStateStarted
+ case notify.DsStopping:
+ return box.StartStateStopping
+ }
+ }
+ return box.StartStateStopped
+}
+
+func (zb *zipBox) Start(context.Context) error {
+ reader, err := zip.OpenReader(zb.name)
+ if err != nil {
+ return err
+ }
+ reader.Close()
+ zipNotifier := notify.NewSimpleZipNotifier(zb.log, zb.name)
+ zb.dirSrv = notify.NewDirService(zb, zb.log, zipNotifier, zb.notify)
+ zb.dirSrv.Start()
+ return nil
+}
+
+func (zb *zipBox) Refresh(_ context.Context) {
+ zb.dirSrv.Refresh()
+ zb.log.Trace().Msg("Refresh")
+}
+
+func (zb *zipBox) Stop(context.Context) {
+ zb.dirSrv.Stop()
+ zb.dirSrv = nil
+}
+
+func (*zipBox) CanCreateZettel(context.Context) bool { return false }
+
+func (zb *zipBox) CreateZettel(context.Context, domain.Zettel) (id.Zid, error) {
+ err := box.ErrReadOnly
+ zb.log.Trace().Err(err).Msg("CreateZettel")
+ return id.Invalid, err
+}
+
+func (zb *zipBox) GetZettel(_ context.Context, zid id.Zid) (domain.Zettel, error) {
+ entry := zb.dirSrv.GetDirEntry(zid)
+ if !entry.IsValid() {
+ return domain.Zettel{}, box.ErrNotFound
+ }
+ reader, err := zip.OpenReader(zb.name)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ defer reader.Close()
+
+ var m *meta.Meta
+ var src []byte
+ var inMeta bool
+
+ contentName := entry.ContentName
+ if metaName := entry.MetaName; metaName == "" {
+ if contentName == "" {
+ zb.log.Panic().Zid(zid).Msg("No meta, no content in zipBox.GetZettel")
+ }
+ src, err = readZipFileContent(reader, entry.ContentName)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ if entry.HasMetaInContent() {
+ inp := input.NewInput(src)
+ m = meta.NewFromInput(zid, inp)
+ src = src[inp.Pos:]
+ } else {
+ m = CalcDefaultMeta(zid, entry.ContentExt)
+ }
+ } else {
+ m, err = readZipMetaFile(reader, zid, metaName)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ inMeta = true
+ if contentName != "" {
+ src, err = readZipFileContent(reader, entry.ContentName)
+ if err != nil {
+ return domain.Zettel{}, err
+ }
+ }
+ }
+
+ CleanupMeta(m, zid, entry.ContentExt, inMeta, entry.UselessFiles)
+ zb.log.Trace().Zid(zid).Msg("GetZettel")
+ return domain.Zettel{Meta: m, Content: domain.NewContent(src)}, nil
+}
+
+func (zb *zipBox) GetMeta(_ context.Context, zid id.Zid) (*meta.Meta, error) {
+ entry := zb.dirSrv.GetDirEntry(zid)
+ if !entry.IsValid() {
+ return nil, box.ErrNotFound
+ }
+ reader, err := zip.OpenReader(zb.name)
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+ m, err := zb.readZipMeta(reader, zid, entry)
+ zb.log.Trace().Err(err).Zid(zid).Msg("GetMeta")
+ return m, err
+}
+
+func (zb *zipBox) ApplyZid(_ context.Context, handle box.ZidFunc, constraint query.RetrievePredicate) error {
+ entries := zb.dirSrv.GetDirEntries(constraint)
+ zb.log.Trace().Int("entries", int64(len(entries))).Msg("ApplyZid")
+ for _, entry := range entries {
+ handle(entry.Zid)
+ }
+ return nil
+}
+
+func (zb *zipBox) ApplyMeta(ctx context.Context, handle box.MetaFunc, constraint query.RetrievePredicate) error {
+ reader, err := zip.OpenReader(zb.name)
+ if err != nil {
+ return err
+ }
+ defer reader.Close()
+ entries := zb.dirSrv.GetDirEntries(constraint)
+ zb.log.Trace().Int("entries", int64(len(entries))).Msg("ApplyMeta")
+ for _, entry := range entries {
+ if !constraint(entry.Zid) {
+ continue
+ }
+ m, err2 := zb.readZipMeta(reader, entry.Zid, entry)
+ if err2 != nil {
+ continue
+ }
+ zb.enricher.Enrich(ctx, m, zb.number)
+ handle(m)
+ }
+ return nil
+}
+
+func (*zipBox) CanUpdateZettel(context.Context, domain.Zettel) bool { return false }
+
+func (zb *zipBox) UpdateZettel(context.Context, domain.Zettel) error {
+ err := box.ErrReadOnly
+ zb.log.Trace().Err(err).Msg("UpdateZettel")
+ return err
+}
+
+func (zb *zipBox) AllowRenameZettel(_ context.Context, zid id.Zid) bool {
+ entry := zb.dirSrv.GetDirEntry(zid)
+ return !entry.IsValid()
+}
+
+func (zb *zipBox) RenameZettel(_ context.Context, curZid, newZid id.Zid) error {
+ err := box.ErrReadOnly
+ if curZid == newZid {
+ err = nil
+ }
+ curEntry := zb.dirSrv.GetDirEntry(curZid)
+ if !curEntry.IsValid() {
+ err = box.ErrNotFound
+ }
+ zb.log.Trace().Err(err).Msg("RenameZettel")
+ return err
+}
+
+func (*zipBox) CanDeleteZettel(context.Context, id.Zid) bool { return false }
+
+func (zb *zipBox) DeleteZettel(_ context.Context, zid id.Zid) error {
+ err := box.ErrReadOnly
+ entry := zb.dirSrv.GetDirEntry(zid)
+ if !entry.IsValid() {
+ err = box.ErrNotFound
+ }
+ zb.log.Trace().Err(err).Msg("DeleteZettel")
+ return err
+}
+
+func (zb *zipBox) ReadStats(st *box.ManagedBoxStats) {
+ st.ReadOnly = true
+ st.Zettel = zb.dirSrv.NumDirEntries()
+ zb.log.Trace().Int("zettel", int64(st.Zettel)).Msg("ReadStats")
+}
+
+func (zb *zipBox) readZipMeta(reader *zip.ReadCloser, zid id.Zid, entry *notify.DirEntry) (m *meta.Meta, err error) {
+ var inMeta bool
+ if metaName := entry.MetaName; metaName == "" {
+ contentName := entry.ContentName
+ contentExt := entry.ContentExt
+ if contentName == "" || contentExt == "" {
+ zb.log.Panic().Zid(zid).Msg("No meta, no content in getMeta")
+ }
+ if entry.HasMetaInContent() {
+ m, err = readZipMetaFile(reader, zid, contentName)
+ } else {
+ m = CalcDefaultMeta(zid, contentExt)
+ }
+ } else {
+ m, err = readZipMetaFile(reader, zid, metaName)
+ }
+ if err == nil {
+ CleanupMeta(m, zid, entry.ContentExt, inMeta, entry.UselessFiles)
+ }
+ 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) ([]byte, error) {
+ f, err := reader.Open(name)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ return io.ReadAll(f)
+}
ADDED box/helper.go
Index: box/helper.go
==================================================================
--- /dev/null
+++ box/helper.go
@@ -0,0 +1,63 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+import (
+ "net/url"
+ "strconv"
+ "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
+}
+
+// GetQueryBool is a helper function to extract bool values from a box URI.
+func GetQueryBool(u *url.URL, key string) bool {
+ _, ok := u.Query()[key]
+ return ok
+}
+
+// GetQueryInt is a helper function to extract int values of a specified range from a box URI.
+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
+}
ADDED box/manager/anteroom.go
Index: box/manager/anteroom.go
==================================================================
--- /dev/null
+++ box/manager/anteroom.go
@@ -0,0 +1,152 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+import (
+ "sync"
+
+ "zettelstore.de/z/domain/id"
+)
+
+type arAction int
+
+const (
+ arNothing arAction = iota
+ arReload
+ arZettel
+)
+
+type anteroom struct {
+ num uint64
+ next *anteroom
+ waiting id.Set
+ 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) EnqueueZettel(zid id.Zid) {
+ if !zid.IsValid() {
+ return
+ }
+ ar.mx.Lock()
+ defer ar.mx.Unlock()
+ if ar.first == nil {
+ ar.first = ar.makeAnteroom(zid)
+ 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
+ }
+ if _, ok := room.waiting[zid]; ok {
+ // Zettel is already waiting. Nothing to do.
+ return
+ }
+ }
+ if room := ar.last; !room.reload && (ar.maxLoad == 0 || room.curLoad < ar.maxLoad) {
+ room.waiting.Zid(zid)
+ room.curLoad++
+ return
+ }
+ room := ar.makeAnteroom(zid)
+ ar.last.next = room
+ ar.last = room
+}
+
+func (ar *anterooms) makeAnteroom(zid id.Zid) *anteroom {
+ ar.nextNum++
+ if zid == id.Invalid {
+ return &anteroom{num: ar.nextNum, next: nil, waiting: nil, curLoad: 0, reload: true}
+ }
+ c := ar.maxLoad
+ if c == 0 {
+ c = 100
+ }
+ waiting := id.NewSetCap(ar.maxLoad, zid)
+ 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)
+ ar.last = ar.first
+}
+
+func (ar *anterooms) Reload(newZids id.Set) uint64 {
+ ar.mx.Lock()
+ defer ar.mx.Unlock()
+ ar.deleteReloadedRooms()
+
+ if ns := len(newZids); ns > 0 {
+ ar.nextNum++
+ ar.first = &anteroom{num: ar.nextNum, next: ar.first, waiting: newZids, curLoad: ns, reload: true}
+ if ar.first.next == nil {
+ ar.last = ar.first
+ }
+ return ar.nextNum
+ }
+
+ ar.first = nil
+ ar.last = nil
+ return 0
+}
+
+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
+ }
+ roomNo := ar.first.num
+ if ar.first.waiting == nil {
+ ar.removeFirst()
+ return arReload, id.Invalid, roomNo
+ }
+ for zid := range ar.first.waiting {
+ delete(ar.first.waiting, zid)
+ if len(ar.first.waiting) == 0 {
+ ar.removeFirst()
+ }
+ return arZettel, zid, roomNo
+ }
+ ar.removeFirst()
+ return arNothing, id.Invalid, 0
+}
+
+func (ar *anterooms) removeFirst() {
+ ar.first = ar.first.next
+ if ar.first == nil {
+ ar.last = nil
+ }
+}
ADDED box/manager/anteroom_test.go
Index: box/manager/anteroom_test.go
==================================================================
--- /dev/null
+++ box/manager/anteroom_test.go
@@ -0,0 +1,106 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+import (
+ "testing"
+
+ "zettelstore.de/z/domain/id"
+)
+
+func TestSimple(t *testing.T) {
+ t.Parallel()
+ ar := newAnterooms(2)
+ ar.EnqueueZettel(id.Zid(1))
+ action, zid, rno := ar.Dequeue()
+ if zid != id.Zid(1) || action != arZettel || rno != 1 {
+ t.Errorf("Expected arZettel/1/1, but got %v/%v/%v", action, zid, rno)
+ }
+ _, zid, _ = ar.Dequeue()
+ if zid != id.Invalid {
+ t.Errorf("Expected invalid Zid, but got %v", zid)
+ }
+ ar.EnqueueZettel(id.Zid(1))
+ ar.EnqueueZettel(id.Zid(2))
+ if ar.first != ar.last {
+ t.Errorf("Expected one room, but got more")
+ }
+ ar.EnqueueZettel(id.Zid(3))
+ 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.EnqueueZettel(id.Zid(1))
+ 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.EnqueueZettel(id.Zid(5))
+ ar.EnqueueZettel(id.Zid(5))
+ 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 != arZettel {
+ t.Errorf("Expected arZettel, but got %v", action)
+ }
+ action, zid2, _ := ar.Dequeue()
+ if action != arZettel {
+ t.Errorf("Expected arZettel, 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 != arZettel {
+ t.Errorf("Expected 5/arZettel, 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 != arZettel {
+ t.Errorf("Expected 6/arZettel, 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.EnqueueZettel(id.Zid(8))
+ 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
==================================================================
--- /dev/null
+++ box/manager/box.go
@@ -0,0 +1,296 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/query"
+)
+
+// 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.State() == box.StartStateStarted && mgr.boxes[0].CanCreateZettel(ctx)
+}
+
+// CreateZettel creates a new zettel.
+func (mgr *Manager) CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error) {
+ mgr.mgrLog.Debug().Msg("CreateZettel")
+ if mgr.State() != box.StartStateStarted {
+ return id.Invalid, box.ErrStopped
+ }
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ 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.mgrLog.Debug().Zid(zid).Msg("GetZettel")
+ if mgr.State() != box.StartStateStarted {
+ return domain.Zettel{}, box.ErrStopped
+ }
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ 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.mgrLog.Debug().Zid(zid).Msg("GetAllZettel")
+ if mgr.State() != box.StartStateStarted {
+ return nil, box.ErrStopped
+ }
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ 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.mgrLog.Debug().Zid(zid).Msg("GetMeta")
+ if mgr.State() != box.StartStateStarted {
+ return nil, box.ErrStopped
+ }
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ return mgr.doGetMeta(ctx, zid)
+}
+
+func (mgr *Manager) doGetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error) {
+ 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.mgrLog.Debug().Zid(zid).Msg("GetAllMeta")
+ if mgr.State() != box.StartStateStarted {
+ return nil, box.ErrStopped
+ }
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ 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) (id.Set, error) {
+ mgr.mgrLog.Debug().Msg("FetchZids")
+ if mgr.State() != box.StartStateStarted {
+ return nil, box.ErrStopped
+ }
+ result := id.Set{}
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ for _, p := range mgr.boxes {
+ err := p.ApplyZid(ctx, func(zid id.Zid) { result.Zid(zid) }, func(id.Zid) bool { return true })
+ if err != nil {
+ return nil, err
+ }
+ }
+ return result, nil
+}
+
+type metaMap map[id.Zid]*meta.Meta
+
+// 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, q *query.Query) ([]*meta.Meta, error) {
+ if msg := mgr.mgrLog.Debug(); msg.Enabled() {
+ msg.Str("query", q.String()).Msg("SelectMeta")
+ }
+ if mgr.State() != box.StartStateStarted {
+ return nil, box.ErrStopped
+ }
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+
+ compSearch := q.RetrieveAndCompile(mgr)
+ selected := metaMap{}
+ for _, term := range compSearch.Terms {
+ rejected := id.Set{}
+ handleMeta := func(m *meta.Meta) {
+ zid := m.Zid
+ if rejected.Contains(zid) {
+ mgr.mgrLog.Trace().Zid(zid).Msg("SelectMeta/alreadyRejected")
+ return
+ }
+ if _, ok := selected[zid]; ok {
+ mgr.mgrLog.Trace().Zid(zid).Msg("SelectMeta/alreadySelected")
+ return
+ }
+ if compSearch.PreMatch(m) && term.Match(m) {
+ selected[zid] = m
+ mgr.mgrLog.Trace().Zid(zid).Msg("SelectMeta/match")
+ } else {
+ rejected.Zid(zid)
+ mgr.mgrLog.Trace().Zid(zid).Msg("SelectMeta/reject")
+ }
+ }
+ for _, p := range mgr.boxes {
+ if err := p.ApplyMeta(ctx, handleMeta, term.Retrieve); err != nil {
+ return nil, err
+ }
+ }
+ }
+ result := make([]*meta.Meta, 0, len(selected))
+ for _, m := range selected {
+ result = append(result, m)
+ }
+ return q.AfterSearch(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.State() == box.StartStateStarted && mgr.boxes[0].CanUpdateZettel(ctx, zettel)
+}
+
+// UpdateZettel updates an existing zettel.
+func (mgr *Manager) UpdateZettel(ctx context.Context, zettel domain.Zettel) error {
+ mgr.mgrLog.Debug().Zid(zettel.Meta.Zid).Msg("UpdateZettel")
+ if mgr.State() != box.StartStateStarted {
+ return box.ErrStopped
+ }
+ // Remove all (computed) properties from metadata before storing the zettel.
+ zettel.Meta = zettel.Meta.Clone()
+ for _, p := range zettel.Meta.ComputedPairsRest() {
+ if mgr.propertyKeys.Has(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 {
+ if mgr.State() != box.StartStateStarted {
+ return false
+ }
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ 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.mgrLog.Debug().Zid(curZid).Zid(newZid).Msg("RenameZettel")
+ if mgr.State() != box.StartStateStarted {
+ return box.ErrStopped
+ }
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ 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 {
+ if mgr.State() != box.StartStateStarted {
+ return false
+ }
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ 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.mgrLog.Debug().Zid(zid).Msg("DeleteZettel")
+ if mgr.State() != box.StartStateStarted {
+ return box.ErrStopped
+ }
+ mgr.mgrMx.RLock()
+ defer mgr.mgrMx.RUnlock()
+ 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
==================================================================
--- /dev/null
+++ box/manager/collect.go
@@ -0,0 +1,81 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+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.Walk(data, &zn.Ast)
+}
+
+func collectInlineIndexData(is *ast.InlineSlice, data *collectData) {
+ ast.Walk(data, is)
+}
+
+func (data *collectData) Visit(node ast.Node) ast.Visitor {
+ switch n := node.(type) {
+ case *ast.VerbatimNode:
+ data.addText(string(n.Content))
+ case *ast.TranscludeNode:
+ data.addRef(n.Ref)
+ case *ast.TextNode:
+ data.addText(n.Text)
+ case *ast.LinkNode:
+ data.addRef(n.Ref)
+ case *ast.EmbedRefNode:
+ data.addRef(n.Ref)
+ case *ast.CiteNode:
+ data.addText(n.Key)
+ case *ast.LiteralNode:
+ data.addText(string(n.Content))
+ }
+ 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(zid)
+ }
+}
ADDED box/manager/enrich.go
Index: box/manager/enrich.go
==================================================================
--- /dev/null
+++ box/manager/enrich.go
@@ -0,0 +1,130 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+import (
+ "context"
+ "strconv"
+
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/domain/id"
+ "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) {
+
+ // Calculate computed, but stored values.
+ if _, ok := m.Get(api.KeyCreated); !ok {
+ m.Set(api.KeyCreated, computeCreated(m.Zid))
+ }
+
+ 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 metadata
+ return
+ }
+ computePublished(m)
+ m.Set(api.KeyBoxNumber, strconv.Itoa(boxNumber))
+ mgr.idxStore.Enrich(ctx, m)
+}
+
+func computeCreated(zid id.Zid) string {
+ if zid <= 10101000000 {
+ // A year 0000 is not allowed and therefore an artificaial Zid.
+ // In the year 0001, the month must be > 0.
+ // In the month 000101, the day must be > 0.
+ return "00010101000000"
+ }
+ seconds := zid % 100
+ if seconds > 59 {
+ seconds = 59
+ }
+ zid /= 100
+ minutes := zid % 100
+ if minutes > 59 {
+ minutes = 59
+ }
+ zid /= 100
+ hours := zid % 100
+ if hours > 23 {
+ hours = 23
+ }
+ zid /= 100
+ day := zid % 100
+ zid /= 100
+ month := zid % 100
+ year := zid / 100
+ month, day = sanitizeMonthDay(year, month, day)
+ created := ((((year*100+month)*100+day)*100+hours)*100+minutes)*100 + seconds
+ return created.String()
+}
+
+func sanitizeMonthDay(year, month, day id.Zid) (id.Zid, id.Zid) {
+ if day < 1 {
+ day = 1
+ }
+ if month < 1 {
+ month = 1
+ }
+ if month > 12 {
+ month = 12
+ }
+
+ switch month {
+ case 1, 3, 5, 7, 8, 10, 12:
+ if day > 31 {
+ day = 31
+ }
+ case 4, 6, 9, 11:
+ if day > 30 {
+ day = 30
+ }
+ case 2:
+ if year%4 != 0 || (year%100 == 0 && year%400 != 0) {
+ if day > 28 {
+ day = 28
+ }
+ } else {
+ if day > 29 {
+ day = 29
+ }
+ }
+ }
+ return month, day
+}
+
+func computePublished(m *meta.Meta) {
+ if _, ok := m.Get(api.KeyPublished); ok {
+ return
+ }
+ if modified, ok := m.Get(api.KeyModified); ok {
+ if _, ok = meta.TimeValue(modified); ok {
+ m.Set(api.KeyPublished, modified)
+ return
+ }
+ }
+ if created, ok := m.Get(api.KeyCreated); ok {
+ if _, ok = meta.TimeValue(created); ok {
+ m.Set(api.KeyPublished, created)
+ return
+ }
+ }
+ zid := m.Zid.String()
+ if _, ok := meta.TimeValue(zid); ok {
+ m.Set(api.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
==================================================================
--- /dev/null
+++ box/manager/indexer.go
@@ -0,0 +1,238 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+import (
+ "context"
+ "fmt"
+ "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 {
+ found := mgr.idxStore.SearchEqual(word)
+ mgr.idxLog.Debug().Str("word", word).Int("found", int64(len(found))).Msg("SearchEqual")
+ if msg := mgr.idxLog.Trace(); msg.Enabled() {
+ msg.Str("ids", fmt.Sprint(found)).Msg("IDs")
+ }
+ return found
+}
+
+// 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 {
+ found := mgr.idxStore.SearchPrefix(prefix)
+ mgr.idxLog.Debug().Str("prefix", prefix).Int("found", int64(len(found))).Msg("SearchPrefix")
+ if msg := mgr.idxLog.Trace(); msg.Enabled() {
+ msg.Str("ids", fmt.Sprint(found)).Msg("IDs")
+ }
+ return found
+}
+
+// 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 {
+ found := mgr.idxStore.SearchSuffix(suffix)
+ mgr.idxLog.Debug().Str("suffix", suffix).Int("found", int64(len(found))).Msg("SearchSuffix")
+ if msg := mgr.idxLog.Trace(); msg.Enabled() {
+ msg.Str("ids", fmt.Sprint(found)).Msg("IDs")
+ }
+ return found
+}
+
+// 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 {
+ found := mgr.idxStore.SearchContains(s)
+ mgr.idxLog.Debug().Str("s", s).Int("found", int64(len(found))).Msg("SearchContains")
+ if msg := mgr.idxLog.Trace(); msg.Enabled() {
+ msg.Str("ids", fmt.Sprint(found)).Msg("IDs")
+ }
+ return found
+}
+
+// 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:
+ mgr.idxLog.Debug().Msg("reload")
+ 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().Local()
+ mgr.idxSinceReload = 0
+ mgr.idxMx.Unlock()
+ }
+ case arZettel:
+ mgr.idxLog.Debug().Zid(zid).Msg("zettel")
+ zettel, err := mgr.GetZettel(ctx, zid)
+ if err != nil {
+ // Zettel was deleted or is not accessible b/c of other reasons
+ mgr.idxLog.Trace().Zid(zid).Msg("delete")
+ mgr.idxMx.Lock()
+ mgr.idxSinceReload++
+ mgr.idxMx.Unlock()
+ mgr.idxDeleteZettel(zid)
+ continue
+ }
+ mgr.idxLog.Trace().Zid(zid).Msg("update")
+ mgr.idxMx.Lock()
+ if arRoomNum == roomNum {
+ mgr.idxDurReload = time.Since(start)
+ }
+ mgr.idxSinceReload++
+ mgr.idxMx.Unlock()
+ mgr.idxUpdateZettel(ctx, zettel)
+ }
+ }
+}
+
+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) {
+ var cData collectData
+ cData.initialize()
+ collectZettelIndexData(parser.ParseZettel(ctx, zettel, "", mgr.rtConfig), &cData)
+
+ m := zettel.Meta
+ 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.ComputedPairs() {
+ descr := meta.GetDescription(pair.Key)
+ if descr.IsProperty() {
+ 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:
+ is := parser.ParseMetadata(pair.Value)
+ collectInlineIndexData(&is, 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.EnqueueZettel(zid)
+ }
+}
ADDED box/manager/manager.go
Index: box/manager/manager.go
==================================================================
--- /dev/null
+++ box/manager/manager.go
@@ -0,0 +1,396 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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"
+ "net/url"
+ "sync"
+ "time"
+
+ "zettelstore.de/c/maps"
+ "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"
+ "zettelstore.de/z/logger"
+ "zettelstore.de/z/strfun"
+)
+
+// 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 {
+ panic(scheme)
+ }
+ registry[scheme] = create
+}
+
+// GetSchemes returns all registered scheme, ordered by scheme string.
+func GetSchemes() []string { return maps.Keys(registry) }
+
+// Manager is a coordinating box.
+type Manager struct {
+ mgrLog *logger.Logger
+ stateMx sync.RWMutex
+ state box.StartState
+ mgrMx sync.RWMutex
+ rtConfig config.Config
+ boxes []box.ManagedBox
+ observers []box.UpdateFunc
+ mxObserver sync.RWMutex
+ done chan struct{}
+ infos chan box.UpdateInfo
+ propertyKeys strfun.Set // Set of property key names
+
+ // Indexer data
+ idxLog *logger.Logger
+ 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
+}
+
+func (mgr *Manager) setState(newState box.StartState) {
+ mgr.stateMx.Lock()
+ mgr.state = newState
+ mgr.stateMx.Unlock()
+}
+
+func (mgr *Manager) State() box.StartState {
+ mgr.stateMx.RLock()
+ state := mgr.state
+ mgr.stateMx.RUnlock()
+ return state
+}
+
+// New creates a new managing box.
+func New(boxURIs []*url.URL, authManager auth.BaseManager, rtConfig config.Config) (*Manager, error) {
+ descrs := meta.GetSortedKeyDescriptions()
+ propertyKeys := make(strfun.Set, len(descrs))
+ for _, kd := range descrs {
+ if kd.IsProperty() {
+ propertyKeys.Set(kd.Name)
+ }
+ }
+ boxLog := kernel.Main.GetLogger(kernel.BoxService)
+ mgr := &Manager{
+ mgrLog: boxLog.Clone().Str("box", "manager").Child(),
+ rtConfig: rtConfig,
+ infos: make(chan box.UpdateInfo, len(boxURIs)*10),
+ propertyKeys: propertyKeys,
+
+ idxLog: boxLog.Clone().Str("box", "index").Child(),
+ idxStore: memstore.New(),
+ idxAr: newAnterooms(1000),
+ 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) 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()
+ }
+ }()
+
+ tsLastEvent := time.Now()
+ cache := destutterCache{}
+ for {
+ select {
+ case ci, ok := <-mgr.infos:
+ if ok {
+ now := time.Now()
+ if len(cache) > 1 && tsLastEvent.Add(10*time.Second).Before(now) {
+ // Cache contains entries and is definitely outdated
+ mgr.mgrLog.Trace().Msg("clean destutter cache")
+ cache = destutterCache{}
+ }
+ tsLastEvent = now
+
+ reason, zid := ci.Reason, ci.Zid
+ mgr.mgrLog.Debug().Uint("reason", uint64(reason)).Zid(zid).Msg("notifier")
+ if ignoreUpdate(cache, now, reason, zid) {
+ mgr.mgrLog.Trace().Uint("reason", uint64(reason)).Zid(zid).Msg("notifier ignored")
+ continue
+ }
+
+ mgr.idxEnqueue(reason, zid)
+ if ci.Box == nil {
+ ci.Box = mgr
+ }
+ if mgr.State() == box.StartStateStarted {
+ mgr.notifyObserver(&ci)
+ }
+ }
+ case <-mgr.done:
+ return
+ }
+ }
+}
+
+type destutterData struct {
+ deadAt time.Time
+ reason box.UpdateReason
+}
+type destutterCache = map[id.Zid]destutterData
+
+func ignoreUpdate(cache destutterCache, now time.Time, reason box.UpdateReason, zid id.Zid) bool {
+ if dsd, found := cache[zid]; found {
+ if dsd.reason == reason && dsd.deadAt.After(now) {
+ return true
+ }
+ }
+ cache[zid] = destutterData{
+ deadAt: now.Add(500 * time.Millisecond),
+ reason: reason,
+ }
+ return false
+}
+
+func (mgr *Manager) idxEnqueue(reason box.UpdateReason, zid id.Zid) {
+ switch reason {
+ case box.OnReady:
+ return
+ case box.OnReload:
+ mgr.idxAr.Reset()
+ case box.OnZettel:
+ mgr.idxAr.EnqueueZettel(zid)
+ default:
+ mgr.mgrLog.Warn().Uint("reason", uint64(reason)).Zid(zid).Msg("Unknown notification reason")
+ return
+ }
+ select {
+ case mgr.idxReady <- struct{}{}:
+ default:
+ }
+}
+
+func (mgr *Manager) notifyObserver(ci *box.UpdateInfo) {
+ mgr.mxObserver.RLock()
+ observers := mgr.observers
+ mgr.mxObserver.RUnlock()
+ for _, ob := range observers {
+ ob(*ci)
+ }
+}
+
+// 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()
+ defer mgr.mgrMx.Unlock()
+ if mgr.State() != box.StartStateStopped {
+ return box.ErrStarted
+ }
+ mgr.setState(box.StartStateStarting)
+ 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
+ }
+ mgr.setState(box.StartStateStopping)
+ for j := i + 1; j < len(mgr.boxes); j++ {
+ if ssj, ok2 := mgr.boxes[j].(box.StartStopper); ok2 {
+ ssj.Stop(ctx)
+ }
+ }
+ mgr.setState(box.StartStateStopped)
+ return err
+ }
+ mgr.idxAr.Reset() // Ensure an initial index run
+ mgr.done = make(chan struct{})
+ go mgr.notifier()
+
+ for !mgr.allBoxesStarted() {
+ mgr.mgrLog.Trace().Msg("Wait for boxes to start")
+ time.Sleep(time.Second)
+ }
+ mgr.setState(box.StartStateStarted)
+ mgr.notifyObserver(&box.UpdateInfo{Box: mgr, Reason: box.OnReady})
+
+ go mgr.idxIndexer()
+ return nil
+}
+
+func (mgr *Manager) allBoxesStarted() bool {
+ for _, bx := range mgr.boxes {
+ if b, ok := bx.(box.StartStopper); ok && b.State() != box.StartStateStarted {
+ return false
+ }
+ }
+ return true
+}
+
+// Stop the started box. Now only the Start() function is allowed.
+func (mgr *Manager) Stop(ctx context.Context) {
+ mgr.mgrMx.Lock()
+ defer mgr.mgrMx.Unlock()
+ if mgr.State() != box.StartStateStarted {
+ return
+ }
+ mgr.setState(box.StartStateStopping)
+ close(mgr.done)
+ for _, p := range mgr.boxes {
+ if ss, ok := p.(box.StartStopper); ok {
+ ss.Stop(ctx)
+ }
+ }
+ mgr.setState(box.StartStateStopped)
+}
+
+// Refresh internal box data.
+func (mgr *Manager) Refresh(ctx context.Context) error {
+ mgr.mgrLog.Debug().Msg("Refresh")
+ if mgr.State() != box.StartStateStarted {
+ return box.ErrStopped
+ }
+ mgr.mgrMx.Lock()
+ defer mgr.mgrMx.Unlock()
+ mgr.infos <- box.UpdateInfo{Reason: box.OnReload, Zid: id.Invalid}
+ for _, bx := range mgr.boxes {
+ if rb, ok := bx.(box.Refresher); ok {
+ rb.Refresh(ctx)
+ }
+ }
+ return nil
+}
+
+// ReadStats populates st with box statistics.
+func (mgr *Manager) ReadStats(st *box.Stats) {
+ mgr.mgrLog.Debug().Msg("ReadStats")
+ 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
==================================================================
--- /dev/null
+++ box/manager/memstore/memstore.go
@@ -0,0 +1,580 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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/c/api"
+ "zettelstore.de/c/maps"
+ "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 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(_ context.Context, m *meta.Meta) {
+ if ms.doEnrich(m) {
+ ms.mx.Lock()
+ ms.updates++
+ ms.mx.Unlock()
+ }
+}
+
+func (ms *memStore) doEnrich(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(api.KeyDead, zi.dead.String())
+ updated = true
+ }
+ back := removeOtherMetaRefs(m, zi.backward.Copy())
+ if len(zi.backward) > 0 {
+ m.Set(api.KeyBackward, zi.backward.String())
+ updated = true
+ }
+ if len(zi.forward) > 0 {
+ m.Set(api.KeyForward, zi.forward.String())
+ back = remRefs(back, zi.forward)
+ updated = true
+ }
+ 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(api.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
+ }
+ maxZid, err := id.Parse(prefix + "99999999999999"[:14-l])
+ if err != nil {
+ return result
+ }
+ var minZid id.Zid
+ if l < 14 && prefix == "0000000000000"[:l] {
+ minZid = id.Zid(1)
+ } else {
+ minZid, err = id.Parse(prefix + "00000000000000"[: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(zid)
+ 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() {
+ 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(_ 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(_ 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.Zid(ref)
+ }
+ }
+ 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)
+ for _, s := range maps.Keys(srefs) {
+ 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
==================================================================
--- /dev/null
+++ box/manager/memstore/refs.go
@@ -0,0 +1,100 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+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
==================================================================
--- /dev/null
+++ box/manager/memstore/refs_test.go
@@ -0,0 +1,137 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+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
==================================================================
--- /dev/null
+++ box/manager/store/store.go
@@ -0,0 +1,59 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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/query"
+)
+
+// 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 {
+ query.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
==================================================================
--- /dev/null
+++ box/manager/store/wordset.go
@@ -0,0 +1,60 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+// 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
==================================================================
--- /dev/null
+++ box/manager/store/wordset_test.go
@@ -0,0 +1,77 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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_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
==================================================================
--- /dev/null
+++ box/manager/store/zettel.go
@@ -0,0 +1,88 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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
+
+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(zid)
+}
+
+// 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(zid)
+ 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(zid)
+}
+
+// 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
==================================================================
--- /dev/null
+++ box/membox/membox.go
@@ -0,0 +1,266 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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/kernel"
+ "zettelstore.de/z/logger"
+ "zettelstore.de/z/query"
+)
+
+func init() {
+ manager.Register(
+ "mem",
+ func(u *url.URL, cdata *manager.ConnectData) (box.ManagedBox, error) {
+ return &memBox{
+ log: kernel.Main.GetLogger(kernel.BoxService).Clone().
+ Str("box", "mem").Int("boxnum", int64(cdata.Number)).Child(),
+ u: u,
+ cdata: *cdata,
+ maxZettel: box.GetQueryInt(u, "max-zettel", 0, 127, 65535),
+ maxBytes: box.GetQueryInt(u, "max-bytes", 0, 65535, (1024*1024*1024)-1),
+ }, nil
+ })
+}
+
+type memBox struct {
+ log *logger.Logger
+ u *url.URL
+ cdata manager.ConnectData
+ maxZettel int
+ maxBytes int
+ mx sync.RWMutex // Protects the following fields
+ zettel map[id.Zid]domain.Zettel
+ curBytes int
+}
+
+func (mb *memBox) notifyChanged(zid id.Zid) {
+ if chci := mb.cdata.Notify; chci != nil {
+ chci <- box.UpdateInfo{Box: mb, Reason: box.OnZettel, Zid: zid}
+ }
+}
+
+func (mb *memBox) Location() string {
+ return mb.u.String()
+}
+
+func (mb *memBox) State() box.StartState {
+ mb.mx.RLock()
+ defer mb.mx.RUnlock()
+ if mb.zettel == nil {
+ return box.StartStateStopped
+ }
+ return box.StartStateStarted
+}
+
+func (mb *memBox) Start(context.Context) error {
+ mb.mx.Lock()
+ mb.zettel = make(map[id.Zid]domain.Zettel)
+ mb.curBytes = 0
+ mb.mx.Unlock()
+ mb.log.Trace().Int("max-zettel", int64(mb.maxZettel)).Int("max-bytes", int64(mb.maxBytes)).Msg("Start Box")
+ return nil
+}
+
+func (mb *memBox) Stop(context.Context) {
+ mb.mx.Lock()
+ mb.zettel = nil
+ mb.mx.Unlock()
+}
+
+func (mb *memBox) CanCreateZettel(context.Context) bool {
+ mb.mx.RLock()
+ defer mb.mx.RUnlock()
+ return len(mb.zettel) < mb.maxZettel
+}
+
+func (mb *memBox) CreateZettel(_ context.Context, zettel domain.Zettel) (id.Zid, error) {
+ mb.mx.Lock()
+ newBytes := mb.curBytes + zettel.Length()
+ if mb.maxZettel < len(mb.zettel) || mb.maxBytes < newBytes {
+ mb.mx.Unlock()
+ return id.Invalid, box.ErrCapacity
+ }
+ zid, err := box.GetNewZid(func(zid id.Zid) (bool, error) {
+ _, ok := mb.zettel[zid]
+ return !ok, nil
+ })
+ if err != nil {
+ mb.mx.Unlock()
+ return id.Invalid, err
+ }
+ meta := zettel.Meta.Clone()
+ meta.Zid = zid
+ zettel.Meta = meta
+ mb.zettel[zid] = zettel
+ mb.curBytes = newBytes
+ mb.mx.Unlock()
+ mb.notifyChanged(zid)
+ mb.log.Trace().Zid(zid).Msg("CreateZettel")
+ return zid, nil
+}
+
+func (mb *memBox) GetZettel(_ context.Context, zid id.Zid) (domain.Zettel, error) {
+ mb.mx.RLock()
+ zettel, ok := mb.zettel[zid]
+ mb.mx.RUnlock()
+ if !ok {
+ return domain.Zettel{}, box.ErrNotFound
+ }
+ zettel.Meta = zettel.Meta.Clone()
+ mb.log.Trace().Msg("GetZettel")
+ return zettel, nil
+}
+
+func (mb *memBox) GetMeta(_ context.Context, zid id.Zid) (*meta.Meta, error) {
+ mb.mx.RLock()
+ zettel, ok := mb.zettel[zid]
+ mb.mx.RUnlock()
+ if !ok {
+ return nil, box.ErrNotFound
+ }
+ mb.log.Trace().Msg("GetMeta")
+ return zettel.Meta.Clone(), nil
+}
+
+func (mb *memBox) ApplyZid(_ context.Context, handle box.ZidFunc, constraint query.RetrievePredicate) error {
+ mb.mx.RLock()
+ defer mb.mx.RUnlock()
+ mb.log.Trace().Int("entries", int64(len(mb.zettel))).Msg("ApplyZid")
+ for zid := range mb.zettel {
+ if constraint(zid) {
+ handle(zid)
+ }
+ }
+ return nil
+}
+
+func (mb *memBox) ApplyMeta(ctx context.Context, handle box.MetaFunc, constraint query.RetrievePredicate) error {
+ mb.mx.RLock()
+ defer mb.mx.RUnlock()
+ mb.log.Trace().Int("entries", int64(len(mb.zettel))).Msg("ApplyMeta")
+ for zid, zettel := range mb.zettel {
+ if constraint(zid) {
+ m := zettel.Meta.Clone()
+ mb.cdata.Enricher.Enrich(ctx, m, mb.cdata.Number)
+ handle(m)
+ }
+ }
+ return nil
+}
+
+func (mb *memBox) CanUpdateZettel(_ context.Context, zettel domain.Zettel) bool {
+ mb.mx.RLock()
+ defer mb.mx.RUnlock()
+ zid := zettel.Meta.Zid
+ if !zid.IsValid() {
+ return false
+ }
+
+ newBytes := mb.curBytes + zettel.Length()
+ if prevZettel, found := mb.zettel[zid]; found {
+ newBytes -= prevZettel.Length()
+ }
+ return newBytes < mb.maxBytes
+}
+
+func (mb *memBox) UpdateZettel(_ context.Context, zettel domain.Zettel) error {
+ m := zettel.Meta.Clone()
+ if !m.Zid.IsValid() {
+ return &box.ErrInvalidID{Zid: m.Zid}
+ }
+
+ mb.mx.Lock()
+ newBytes := mb.curBytes + zettel.Length()
+ if prevZettel, found := mb.zettel[m.Zid]; found {
+ newBytes -= prevZettel.Length()
+ }
+ if mb.maxBytes < newBytes {
+ mb.mx.Unlock()
+ return box.ErrCapacity
+ }
+
+ zettel.Meta = m
+ mb.zettel[m.Zid] = zettel
+ mb.curBytes = newBytes
+ mb.mx.Unlock()
+ mb.notifyChanged(m.Zid)
+ mb.log.Trace().Msg("UpdateZettel")
+ return nil
+}
+
+func (*memBox) AllowRenameZettel(context.Context, id.Zid) bool { return true }
+
+func (mb *memBox) RenameZettel(_ context.Context, curZid, newZid id.Zid) error {
+ mb.mx.Lock()
+ zettel, ok := mb.zettel[curZid]
+ if !ok {
+ mb.mx.Unlock()
+ return box.ErrNotFound
+ }
+
+ // Check that there is no zettel with newZid
+ if _, ok = mb.zettel[newZid]; ok {
+ mb.mx.Unlock()
+ return &box.ErrInvalidID{Zid: newZid}
+ }
+
+ meta := zettel.Meta.Clone()
+ meta.Zid = newZid
+ zettel.Meta = meta
+ mb.zettel[newZid] = zettel
+ delete(mb.zettel, curZid)
+ mb.mx.Unlock()
+ mb.notifyChanged(curZid)
+ mb.notifyChanged(newZid)
+ mb.log.Trace().Msg("RenameZettel")
+ return nil
+}
+
+func (mb *memBox) CanDeleteZettel(_ context.Context, zid id.Zid) bool {
+ mb.mx.RLock()
+ _, ok := mb.zettel[zid]
+ mb.mx.RUnlock()
+ return ok
+}
+
+func (mb *memBox) DeleteZettel(_ context.Context, zid id.Zid) error {
+ mb.mx.Lock()
+ oldZettel, found := mb.zettel[zid]
+ if !found {
+ mb.mx.Unlock()
+ return box.ErrNotFound
+ }
+ delete(mb.zettel, zid)
+ mb.curBytes -= oldZettel.Length()
+ mb.mx.Unlock()
+ mb.notifyChanged(zid)
+ mb.log.Trace().Msg("DeleteZettel")
+ return nil
+}
+
+func (mb *memBox) ReadStats(st *box.ManagedBoxStats) {
+ st.ReadOnly = false
+ mb.mx.RLock()
+ st.Zettel = len(mb.zettel)
+ mb.mx.RUnlock()
+ mb.log.Trace().Int("zettel", int64(st.Zettel)).Msg("ReadStats")
+}
ADDED box/notify/directory.go
Index: box/notify/directory.go
==================================================================
--- /dev/null
+++ box/notify/directory.go
@@ -0,0 +1,610 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 notify
+
+import (
+ "errors"
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/kernel"
+ "zettelstore.de/z/logger"
+ "zettelstore.de/z/parser"
+ "zettelstore.de/z/query"
+ "zettelstore.de/z/strfun"
+)
+
+type entrySet map[id.Zid]*DirEntry
+
+// DirServiceState signal the internal state of the service.
+//
+// The following state transitions are possible:
+// --newDirService--> dsCreated
+// dsCreated --Start--> dsStarting
+// dsStarting --last list notification--> dsWorking
+// dsWorking --directory missing--> dsMissing
+// dsMissing --last list notification--> dsWorking
+// --Stop--> dsStopping
+type DirServiceState uint8
+
+const (
+ DsCreated DirServiceState = iota
+ DsStarting // Reading inital scan
+ DsWorking // Initial scan complete, fully operational
+ DsMissing // Directory is missing
+ DsStopping // Service is shut down
+)
+
+// DirService specifies a directory service for file based zettel.
+type DirService struct {
+ box box.ManagedBox
+ log *logger.Logger
+ dirPath string
+ notifier Notifier
+ infos chan<- box.UpdateInfo
+ mx sync.RWMutex // protects status, entries
+ state DirServiceState
+ entries entrySet
+}
+
+// ErrNoDirectory signals missing directory data.
+var ErrNoDirectory = errors.New("unable to retrieve zettel directory information")
+
+// NewDirService creates a new directory service.
+func NewDirService(box box.ManagedBox, log *logger.Logger, notifier Notifier, chci chan<- box.UpdateInfo) *DirService {
+ return &DirService{
+ box: box,
+ log: log,
+ notifier: notifier,
+ infos: chci,
+ state: DsCreated,
+ }
+}
+
+// State the current service state.
+func (ds *DirService) State() DirServiceState {
+ ds.mx.RLock()
+ state := ds.state
+ ds.mx.RUnlock()
+ return state
+}
+
+// Start the directory service.
+func (ds *DirService) Start() {
+ ds.mx.Lock()
+ ds.state = DsStarting
+ ds.mx.Unlock()
+ var newEntries entrySet
+ go ds.updateEvents(newEntries)
+}
+
+// Refresh the directory entries.
+func (ds *DirService) Refresh() {
+ ds.notifier.Refresh()
+}
+
+// Stop the directory service.
+func (ds *DirService) Stop() {
+ ds.mx.Lock()
+ ds.state = DsStopping
+ ds.mx.Unlock()
+ ds.notifier.Close()
+}
+
+func (ds *DirService) logMissingEntry(action string) error {
+ err := ErrNoDirectory
+ ds.log.Info().Err(err).Str("action", action).Msg("Unable to get directory information")
+ return err
+}
+
+// NumDirEntries returns the number of entries in the directory.
+func (ds *DirService) NumDirEntries() int {
+ ds.mx.RLock()
+ defer ds.mx.RUnlock()
+ if ds.entries == nil {
+ return 0
+ }
+ return len(ds.entries)
+}
+
+// GetDirEntries returns a list of directory entries, which satisfy the given constraint.
+func (ds *DirService) GetDirEntries(constraint query.RetrievePredicate) []*DirEntry {
+ ds.mx.RLock()
+ defer ds.mx.RUnlock()
+ if ds.entries == nil {
+ return nil
+ }
+ result := make([]*DirEntry, 0, len(ds.entries))
+ for zid, entry := range ds.entries {
+ if constraint(zid) {
+ copiedEntry := *entry
+ result = append(result, &copiedEntry)
+ }
+ }
+ return result
+}
+
+// GetDirEntry returns a directory entry with the given zid, or nil if not found.
+func (ds *DirService) GetDirEntry(zid id.Zid) *DirEntry {
+ ds.mx.RLock()
+ defer ds.mx.RUnlock()
+ if ds.entries == nil {
+ return nil
+ }
+ foundEntry := ds.entries[zid]
+ if foundEntry == nil {
+ return nil
+ }
+ result := *foundEntry
+ return &result
+}
+
+// SetNewDirEntry calculates an empty directory entry with an unused identifier and
+// stores it in the directory.
+func (ds *DirService) SetNewDirEntry() (id.Zid, error) {
+ ds.mx.Lock()
+ defer ds.mx.Unlock()
+ if ds.entries == nil {
+ return id.Invalid, ds.logMissingEntry("new")
+ }
+ zid, err := box.GetNewZid(func(zid id.Zid) (bool, error) {
+ _, found := ds.entries[zid]
+ return !found, nil
+ })
+ if err != nil {
+ return id.Invalid, err
+ }
+ ds.entries[zid] = &DirEntry{Zid: zid}
+ return zid, nil
+}
+
+// UpdateDirEntry updates an directory entry in place.
+func (ds *DirService) UpdateDirEntry(updatedEntry *DirEntry) error {
+ entry := *updatedEntry
+ ds.mx.Lock()
+ defer ds.mx.Unlock()
+ if ds.entries == nil {
+ return ds.logMissingEntry("update")
+ }
+ ds.entries[entry.Zid] = &entry
+ return nil
+}
+
+// RenameDirEntry replaces an existing directory entry with a new one.
+func (ds *DirService) RenameDirEntry(oldEntry *DirEntry, newZid id.Zid) (DirEntry, error) {
+ ds.mx.Lock()
+ defer ds.mx.Unlock()
+ if ds.entries == nil {
+ return DirEntry{}, ds.logMissingEntry("rename")
+ }
+ if _, found := ds.entries[newZid]; found {
+ return DirEntry{}, &box.ErrInvalidID{Zid: newZid}
+ }
+ oldZid := oldEntry.Zid
+ newEntry := DirEntry{
+ Zid: newZid,
+ MetaName: renameFilename(oldEntry.MetaName, oldZid, newZid),
+ ContentName: renameFilename(oldEntry.ContentName, oldZid, newZid),
+ ContentExt: oldEntry.ContentExt,
+ // Duplicates must not be set, because duplicates will be deleted
+ }
+ delete(ds.entries, oldZid)
+ ds.entries[newZid] = &newEntry
+ return newEntry, nil
+}
+
+func renameFilename(name string, curID, newID id.Zid) string {
+ if cur := curID.String(); strings.HasPrefix(name, cur) {
+ name = newID.String() + name[len(cur):]
+ }
+ return name
+}
+
+// DeleteDirEntry removes a entry from the directory.
+func (ds *DirService) DeleteDirEntry(zid id.Zid) error {
+ ds.mx.Lock()
+ defer ds.mx.Unlock()
+ if ds.entries == nil {
+ return ds.logMissingEntry("delete")
+ }
+ delete(ds.entries, zid)
+ return nil
+}
+
+func (ds *DirService) updateEvents(newEntries entrySet) {
+ // Something may panic. Ensure a running service.
+ defer func() {
+ if r := recover(); r != nil {
+ kernel.Main.LogRecover("DirectoryService", r)
+ go ds.updateEvents(newEntries)
+ }
+ }()
+
+ for ev := range ds.notifier.Events() {
+ e, ok := ds.handleEvent(ev, newEntries)
+ if !ok {
+ break
+ }
+ newEntries = e
+ }
+}
+func (ds *DirService) handleEvent(ev Event, newEntries entrySet) (entrySet, bool) {
+ ds.mx.RLock()
+ state := ds.state
+ ds.mx.RUnlock()
+
+ if msg := ds.log.Trace(); msg.Enabled() {
+ msg.Uint("state", uint64(state)).Str("op", ev.Op.String()).Str("name", ev.Name).Msg("notifyEvent")
+ }
+ if state == DsStopping {
+ return nil, false
+ }
+
+ switch ev.Op {
+ case Error:
+ newEntries = nil
+ if state != DsMissing {
+ ds.log.Warn().Err(ev.Err).Msg("Notifier confused")
+ }
+ case Make:
+ newEntries = make(entrySet)
+ case List:
+ if ev.Name == "" {
+ zids := getNewZids(newEntries)
+ ds.mx.Lock()
+ fromMissing := ds.state == DsMissing
+ prevEntries := ds.entries
+ ds.entries = newEntries
+ ds.state = DsWorking
+ ds.mx.Unlock()
+ ds.onCreateDirectory(zids, prevEntries)
+ if fromMissing {
+ ds.log.Info().Str("path", ds.dirPath).Msg("Zettel directory found")
+ }
+ return nil, true
+ }
+ if newEntries != nil {
+ ds.onUpdateFileEvent(newEntries, ev.Name)
+ }
+ case Destroy:
+ ds.onDestroyDirectory()
+ ds.log.Error().Str("path", ds.dirPath).Msg("Zettel directory missing")
+ return nil, true
+ case Update:
+ ds.mx.Lock()
+ zid := ds.onUpdateFileEvent(ds.entries, ev.Name)
+ ds.mx.Unlock()
+ if zid != id.Invalid {
+ ds.notifyChange(zid)
+ }
+ case Delete:
+ ds.mx.Lock()
+ zid := ds.onDeleteFileEvent(ds.entries, ev.Name)
+ ds.mx.Unlock()
+ if zid != id.Invalid {
+ ds.notifyChange(zid)
+ }
+ default:
+ ds.log.Warn().Str("event", fmt.Sprintf("%v", ev)).Msg("Unknown zettel notification event")
+ }
+ return newEntries, true
+}
+
+func getNewZids(entries entrySet) id.Slice {
+ zids := make(id.Slice, 0, len(entries))
+ for zid := range entries {
+ zids = append(zids, zid)
+ }
+ return zids
+}
+
+func (ds *DirService) onCreateDirectory(zids id.Slice, prevEntries entrySet) {
+ for _, zid := range zids {
+ ds.notifyChange(zid)
+ delete(prevEntries, zid)
+ }
+
+ // These were previously stored, by are not found now.
+ // Notify system that these were deleted, e.g. for updating the index.
+ for zid := range prevEntries {
+ ds.notifyChange(zid)
+ }
+}
+
+func (ds *DirService) onDestroyDirectory() {
+ ds.mx.Lock()
+ entries := ds.entries
+ ds.entries = nil
+ ds.state = DsMissing
+ ds.mx.Unlock()
+ for zid := range entries {
+ ds.notifyChange(zid)
+ }
+}
+
+var validFileName = regexp.MustCompile(`^(\d{14})`)
+
+func matchValidFileName(name string) []string {
+ return validFileName.FindStringSubmatch(name)
+}
+
+func seekZid(name string) id.Zid {
+ match := matchValidFileName(name)
+ if len(match) == 0 {
+ return id.Invalid
+ }
+ zid, err := id.Parse(match[1])
+ if err != nil {
+ return id.Invalid
+ }
+ return zid
+}
+
+func fetchdirEntry(entries entrySet, zid id.Zid) *DirEntry {
+ if entry, found := entries[zid]; found {
+ return entry
+ }
+ entry := &DirEntry{Zid: zid}
+ entries[zid] = entry
+ return entry
+}
+
+func (ds *DirService) onUpdateFileEvent(entries entrySet, name string) id.Zid {
+ if entries == nil {
+ return id.Invalid
+ }
+ zid := seekZid(name)
+ if zid == id.Invalid {
+ return id.Invalid
+ }
+ entry := fetchdirEntry(entries, zid)
+ dupName1, dupName2 := ds.updateEntry(entry, name)
+ if dupName1 != "" {
+ ds.log.Warn().Str("name", dupName1).Msg("Duplicate content (is ignored)")
+ if dupName2 != "" {
+ ds.log.Warn().Str("name", dupName2).Msg("Duplicate content (is ignored)")
+ }
+ return id.Invalid
+ }
+ return zid
+}
+
+func (ds *DirService) onDeleteFileEvent(entries entrySet, name string) id.Zid {
+ if entries == nil {
+ return id.Invalid
+ }
+ zid := seekZid(name)
+ if zid == id.Invalid {
+ return id.Invalid
+ }
+ entry, found := entries[zid]
+ if !found {
+ return zid
+ }
+ for i, dupName := range entry.UselessFiles {
+ if dupName == name {
+ removeDuplicate(entry, i)
+ return zid
+ }
+ }
+ if name == entry.ContentName {
+ entry.ContentName = ""
+ entry.ContentExt = ""
+ ds.replayUpdateUselessFiles(entry)
+ } else if name == entry.MetaName {
+ entry.MetaName = ""
+ ds.replayUpdateUselessFiles(entry)
+ }
+ if entry.ContentName == "" && entry.MetaName == "" {
+ delete(entries, zid)
+ }
+ return zid
+}
+
+func removeDuplicate(entry *DirEntry, i int) {
+ if len(entry.UselessFiles) == 1 {
+ entry.UselessFiles = nil
+ return
+ }
+ entry.UselessFiles = entry.UselessFiles[:i+copy(entry.UselessFiles[i:], entry.UselessFiles[i+1:])]
+}
+
+func (ds *DirService) replayUpdateUselessFiles(entry *DirEntry) {
+ uselessFiles := entry.UselessFiles
+ if len(uselessFiles) == 0 {
+ return
+ }
+ entry.UselessFiles = make([]string, 0, len(uselessFiles))
+ for _, name := range uselessFiles {
+ ds.updateEntry(entry, name)
+ }
+ if len(uselessFiles) == len(entry.UselessFiles) {
+ return
+ }
+loop:
+ for _, prevName := range uselessFiles {
+ for _, newName := range entry.UselessFiles {
+ if prevName == newName {
+ continue loop
+ }
+ }
+ ds.log.Info().Str("name", prevName).Msg("Previous duplicate file becomes useful")
+ }
+}
+
+func (ds *DirService) updateEntry(entry *DirEntry, name string) (string, string) {
+ ext := onlyExt(name)
+ if !extIsMetaAndContent(entry.ContentExt) {
+ if ext == "" {
+ return updateEntryMeta(entry, name), ""
+ }
+ if entry.MetaName == "" {
+ if nameWithoutExt(name, ext) == entry.ContentName {
+ // We have marked a file as content file, but it is a metadata file,
+ // because it is the same as the new file without extension.
+ entry.MetaName = entry.ContentName
+ entry.ContentName = ""
+ entry.ContentExt = ""
+ ds.replayUpdateUselessFiles(entry)
+ } else if entry.ContentName != "" && nameWithoutExt(entry.ContentName, entry.ContentExt) == name {
+ // We have already a valid content file, and new file should serve as metadata file,
+ // because it is the same as the content file without extension.
+ entry.MetaName = name
+ return "", ""
+ }
+ }
+ }
+ return updateEntryContent(entry, name, ext)
+}
+
+func nameWithoutExt(name, ext string) string {
+ return name[0 : len(name)-len(ext)-1]
+}
+
+func updateEntryMeta(entry *DirEntry, name string) string {
+ metaName := entry.MetaName
+ if metaName == "" {
+ entry.MetaName = name
+ return ""
+ }
+ if metaName == name {
+ return ""
+ }
+ if newNameIsBetter(metaName, name) {
+ entry.MetaName = name
+ return addUselessFile(entry, metaName)
+ }
+ return addUselessFile(entry, name)
+}
+
+func updateEntryContent(entry *DirEntry, name, ext string) (string, string) {
+ contentName := entry.ContentName
+ if contentName == "" {
+ entry.ContentName = name
+ entry.ContentExt = ext
+ return "", ""
+ }
+ if contentName == name {
+ return "", ""
+ }
+ contentExt := entry.ContentExt
+ if contentExt == ext {
+ if newNameIsBetter(contentName, name) {
+ entry.ContentName = name
+ return addUselessFile(entry, contentName), ""
+ }
+ return addUselessFile(entry, name), ""
+ }
+ if contentExt == extZettel {
+ return addUselessFile(entry, name), ""
+ }
+ if ext == extZettel {
+ entry.ContentName = name
+ entry.ContentExt = ext
+ contentName = addUselessFile(entry, contentName)
+ if metaName := entry.MetaName; metaName != "" {
+ metaName = addUselessFile(entry, metaName)
+ entry.MetaName = ""
+ return contentName, metaName
+ }
+ return contentName, ""
+ }
+ if newExtIsBetter(contentExt, ext) {
+ entry.ContentName = name
+ entry.ContentExt = ext
+ return addUselessFile(entry, contentName), ""
+ }
+ return addUselessFile(entry, name), ""
+}
+func addUselessFile(entry *DirEntry, name string) string {
+ for _, dupName := range entry.UselessFiles {
+ if name == dupName {
+ return ""
+ }
+ }
+ entry.UselessFiles = append(entry.UselessFiles, name)
+ return name
+}
+
+func onlyExt(name string) string {
+ ext := filepath.Ext(name)
+ if ext == "" || ext[0] != '.' {
+ return ext
+ }
+ return ext[1:]
+}
+
+func newNameIsBetter(oldName, newName string) bool {
+ if len(oldName) < len(newName) {
+ return false
+ }
+ return oldName > newName
+}
+
+var supportedSyntax, primarySyntax strfun.Set
+
+func init() {
+ syntaxList := parser.GetSyntaxes()
+ supportedSyntax = strfun.NewSet(syntaxList...)
+ primarySyntax = make(map[string]struct{}, len(syntaxList))
+ for _, syntax := range syntaxList {
+ if parser.Get(syntax).Name == syntax {
+ primarySyntax.Set(syntax)
+ }
+ }
+}
+func newExtIsBetter(oldExt, newExt string) bool {
+ oldSyntax := supportedSyntax.Has(oldExt)
+ if oldSyntax != supportedSyntax.Has(newExt) {
+ return !oldSyntax
+ }
+ if oldSyntax {
+ if oldExt == "zmk" {
+ return false
+ }
+ if newExt == "zmk" {
+ return true
+ }
+ oldInfo := parser.Get(oldExt)
+ newInfo := parser.Get(newExt)
+ if oldASTParser := oldInfo.IsASTParser; oldASTParser != newInfo.IsASTParser {
+ return !oldASTParser
+ }
+ if oldTextFormat := oldInfo.IsTextFormat; oldTextFormat != newInfo.IsTextFormat {
+ return !oldTextFormat
+ }
+ if oldImageFormat := oldInfo.IsImageFormat; oldImageFormat != newInfo.IsImageFormat {
+ return oldImageFormat
+ }
+ if oldPrimary := primarySyntax.Has(oldExt); oldPrimary != primarySyntax.Has(newExt) {
+ return !oldPrimary
+ }
+ }
+
+ oldLen := len(oldExt)
+ newLen := len(newExt)
+ if oldLen != newLen {
+ return newLen < oldLen
+ }
+ return newExt < oldExt
+}
+
+func (ds *DirService) notifyChange(zid id.Zid) {
+ if chci := ds.infos; chci != nil {
+ ds.log.Trace().Zid(zid).Msg("notifyChange")
+ chci <- box.UpdateInfo{Box: ds.box, Reason: box.OnZettel, Zid: zid}
+ }
+}
ADDED box/notify/directory_test.go
Index: box/notify/directory_test.go
==================================================================
--- /dev/null
+++ box/notify/directory_test.go
@@ -0,0 +1,78 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2022-present 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 notify
+
+import (
+ "testing"
+
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ _ "zettelstore.de/z/parser/blob" // Allow to use BLOB parser.
+ _ "zettelstore.de/z/parser/draw" // Allow to use draw 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.
+)
+
+func TestSeekZid(t *testing.T) {
+ testcases := []struct {
+ name string
+ zid id.Zid
+ }{
+ {"", id.Invalid},
+ {"1", id.Invalid},
+ {"1234567890123", id.Invalid},
+ {" 12345678901234", id.Invalid},
+ {"12345678901234", id.Zid(12345678901234)},
+ {"12345678901234.ext", id.Zid(12345678901234)},
+ {"12345678901234 abc.ext", id.Zid(12345678901234)},
+ {"12345678901234.abc.ext", id.Zid(12345678901234)},
+ {"12345678901234 def", id.Zid(12345678901234)},
+ }
+ for _, tc := range testcases {
+ gotZid := seekZid(tc.name)
+ if gotZid != tc.zid {
+ t.Errorf("seekZid(%q) == %v, but got %v", tc.name, tc.zid, gotZid)
+ }
+ }
+}
+
+func TestNewExtIsBetter(t *testing.T) {
+ extVals := []string{
+ // Main Formats
+ meta.SyntaxZmk, meta.SyntaxDraw, meta.SyntaxMarkdown, meta.SyntaxMD,
+ // Other supported text formats
+ meta.SyntaxCSS, meta.SyntaxTxt, meta.SyntaxHTML,
+ meta.SyntaxMustache, meta.SyntaxText, meta.SyntaxPlain,
+ // Supported text graphics formats
+ meta.SyntaxSVG,
+ meta.SyntaxNone,
+ // Supported binary graphic formats
+ meta.SyntaxGif, meta.SyntaxPNG, meta.SyntaxJPEG, meta.SyntaxWebp, meta.SyntaxJPG,
+
+ // Unsupported syntax values
+ "gz", "cpp", "tar", "cppc",
+ }
+ for oldI, oldExt := range extVals {
+ for newI, newExt := range extVals {
+ if oldI <= newI {
+ continue
+ }
+ if !newExtIsBetter(oldExt, newExt) {
+ t.Errorf("newExtIsBetter(%q, %q) == true, but got false", oldExt, newExt)
+ }
+ if newExtIsBetter(newExt, oldExt) {
+ t.Errorf("newExtIsBetter(%q, %q) == false, but got true", newExt, oldExt)
+ }
+ }
+ }
+}
ADDED box/notify/entry.go
Index: box/notify/entry.go
==================================================================
--- /dev/null
+++ box/notify/entry.go
@@ -0,0 +1,120 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 notify
+
+import (
+ "path/filepath"
+
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/parser"
+)
+
+const (
+ extZettel = "zettel" // file contains metadata and content
+ extBin = "bin" // file contains binary content
+ extTxt = "txt" // file contains non-binary content
+)
+
+func extIsMetaAndContent(ext string) bool { return ext == extZettel }
+
+// DirEntry stores everything for a directory entry.
+type DirEntry struct {
+ Zid id.Zid
+ MetaName string // file name of meta information
+ ContentName string // file name of zettel content
+ ContentExt string // (normalized) file extension of zettel content
+ UselessFiles []string // list of other content files
+}
+
+// IsValid checks whether the entry is valid.
+func (e *DirEntry) IsValid() bool {
+ return e != nil && e.Zid.IsValid()
+}
+
+// HasMetaInContent returns true, if metadata will be stored in the content file.
+func (e *DirEntry) HasMetaInContent() bool {
+ return e.IsValid() && extIsMetaAndContent(e.ContentExt)
+}
+
+// SetupFromMetaContent fills entry data based on metadata and zettel content.
+func (e *DirEntry) SetupFromMetaContent(m *meta.Meta, content domain.Content, getZettelFileSyntax func() []string) {
+ if e.Zid != m.Zid {
+ panic("Zid differ")
+ }
+ if contentName := e.ContentName; contentName != "" {
+ if !extIsMetaAndContent(e.ContentExt) && e.MetaName == "" {
+ e.MetaName = e.calcBaseName(contentName)
+ }
+ return
+ }
+
+ syntax := m.GetDefault(api.KeySyntax, "")
+ ext := calcContentExt(syntax, m.YamlSep, getZettelFileSyntax)
+ metaName := e.MetaName
+ eimc := extIsMetaAndContent(ext)
+ if eimc {
+ if metaName != "" {
+ ext = contentExtWithMeta(syntax, content)
+ }
+ e.ContentName = e.calcBaseName(metaName) + "." + ext
+ e.ContentExt = ext
+ } else {
+ if len(content.AsBytes()) > 0 {
+ e.ContentName = e.calcBaseName(metaName) + "." + ext
+ e.ContentExt = ext
+ }
+ if metaName == "" {
+ e.MetaName = e.calcBaseName(e.ContentName)
+ }
+ }
+}
+
+func contentExtWithMeta(syntax string, content domain.Content) string {
+ p := parser.Get(syntax)
+ if content.IsBinary() {
+ if p.IsImageFormat {
+ return syntax
+ }
+ return extBin
+ }
+ if p.IsImageFormat {
+ return extTxt
+ }
+ return syntax
+}
+
+func calcContentExt(syntax string, yamlSep bool, getZettelFileSyntax func() []string) string {
+ if yamlSep {
+ return extZettel
+ }
+ switch syntax {
+ case meta.SyntaxNone, meta.SyntaxZmk:
+ return extZettel
+ }
+ for _, s := range getZettelFileSyntax() {
+ if s == syntax {
+ return extZettel
+ }
+ }
+ return syntax
+
+}
+
+func (e *DirEntry) calcBaseName(name string) string {
+ if name == "" {
+ return e.Zid.String()
+ }
+ return name[0 : len(name)-len(filepath.Ext(name))]
+
+}
ADDED box/notify/fsdir.go
Index: box/notify/fsdir.go
==================================================================
--- /dev/null
+++ box/notify/fsdir.go
@@ -0,0 +1,232 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 notify
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/fsnotify/fsnotify"
+ "zettelstore.de/z/logger"
+)
+
+type fsdirNotifier struct {
+ log *logger.Logger
+ events chan Event
+ done chan struct{}
+ refresh chan struct{}
+ base *fsnotify.Watcher
+ path string
+ fetcher EntryFetcher
+ parent string
+}
+
+// NewFSDirNotifier creates a directory based notifier that receives notifications
+// from the file system.
+func NewFSDirNotifier(log *logger.Logger, path string) (Notifier, error) {
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ log.Debug().Err(err).Str("path", path).Msg("Unable to create absolute path")
+ return nil, err
+ }
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ log.Debug().Err(err).Str("absPath", absPath).Msg("Unable to create watcher")
+ return nil, err
+ }
+ absParentDir := filepath.Dir(absPath)
+ errParent := watcher.Add(absParentDir)
+ err = watcher.Add(absPath)
+ if errParent != nil {
+ if err != nil {
+ log.Error().
+ Str("parentDir", absParentDir).Err(errParent).
+ Str("path", absPath).Err(err).
+ Msg("Unable to access Zettel directory and its parent directory")
+ watcher.Close()
+ return nil, err
+ }
+ log.Warn().
+ Str("parentDir", absParentDir).Err(errParent).
+ Msg("Parent of Zettel directory cannot be supervised")
+ log.Warn().Str("path", absPath).
+ Msg("Zettelstore might not detect a deletion or movement of the Zettel directory")
+ } else if err != nil {
+ // Not a problem, if container is not available. It might become available later.
+ log.Warn().Err(err).Str("path", absPath).Msg("Zettel directory not available")
+ }
+
+ fsdn := &fsdirNotifier{
+ log: log,
+ events: make(chan Event),
+ refresh: make(chan struct{}),
+ done: make(chan struct{}),
+ base: watcher,
+ path: absPath,
+ fetcher: newDirPathFetcher(absPath),
+ parent: absParentDir,
+ }
+ go fsdn.eventLoop()
+ return fsdn, nil
+}
+
+func (fsdn *fsdirNotifier) Events() <-chan Event {
+ return fsdn.events
+}
+
+func (fsdn *fsdirNotifier) Refresh() {
+ fsdn.refresh <- struct{}{}
+}
+
+func (fsdn *fsdirNotifier) eventLoop() {
+ defer fsdn.base.Close()
+ defer close(fsdn.events)
+ defer close(fsdn.refresh)
+ if !listDirElements(fsdn.log, fsdn.fetcher, fsdn.events, fsdn.done) {
+ return
+ }
+
+ for fsdn.readAndProcessEvent() {
+ }
+}
+
+func (fsdn *fsdirNotifier) readAndProcessEvent() bool {
+ select {
+ case <-fsdn.done:
+ fsdn.traceDone(1)
+ return false
+ default:
+ }
+ select {
+ case <-fsdn.done:
+ fsdn.traceDone(2)
+ return false
+ case <-fsdn.refresh:
+ fsdn.log.Trace().Msg("refresh")
+ listDirElements(fsdn.log, fsdn.fetcher, fsdn.events, fsdn.done)
+ case err, ok := <-fsdn.base.Errors:
+ fsdn.log.Trace().Err(err).Bool("ok", ok).Msg("got errors")
+ if !ok {
+ return false
+ }
+ select {
+ case fsdn.events <- Event{Op: Error, Err: err}:
+ case <-fsdn.done:
+ fsdn.traceDone(3)
+ return false
+ }
+ case ev, ok := <-fsdn.base.Events:
+ fsdn.log.Trace().Str("name", ev.Name).Str("op", ev.Op.String()).Bool("ok", ok).Msg("file event")
+ if !ok {
+ return false
+ }
+ if !fsdn.processEvent(&ev) {
+ return false
+ }
+ }
+ return true
+}
+
+func (fsdn *fsdirNotifier) traceDone(pos int64) {
+ fsdn.log.Trace().Int("i", pos).Msg("done with read and process events")
+}
+
+func (fsdn *fsdirNotifier) processEvent(ev *fsnotify.Event) bool {
+ if strings.HasPrefix(ev.Name, fsdn.path) {
+ if len(ev.Name) == len(fsdn.path) {
+ return fsdn.processDirEvent(ev)
+ }
+ return fsdn.processFileEvent(ev)
+ }
+ fsdn.log.Trace().Str("path", fsdn.path).Str("name", ev.Name).Str("op", ev.Op.String()).Msg("event does not match")
+ return true
+}
+
+func (fsdn *fsdirNotifier) processDirEvent(ev *fsnotify.Event) bool {
+ if ev.Has(fsnotify.Remove) || ev.Has(fsnotify.Rename) {
+ fsdn.log.Debug().Str("name", fsdn.path).Msg("Directory removed")
+ fsdn.base.Remove(fsdn.path)
+ select {
+ case fsdn.events <- Event{Op: Destroy}:
+ case <-fsdn.done:
+ fsdn.log.Trace().Int("i", 1).Msg("done dir event processing")
+ return false
+ }
+ return true
+ }
+
+ if ev.Has(fsnotify.Create) {
+ err := fsdn.base.Add(fsdn.path)
+ if err != nil {
+ fsdn.log.IfErr(err).Str("name", fsdn.path).Msg("Unable to add directory")
+ select {
+ case fsdn.events <- Event{Op: Error, Err: err}:
+ case <-fsdn.done:
+ fsdn.log.Trace().Int("i", 2).Msg("done dir event processing")
+ return false
+ }
+ }
+ fsdn.log.Debug().Str("name", fsdn.path).Msg("Directory added")
+ return listDirElements(fsdn.log, fsdn.fetcher, fsdn.events, fsdn.done)
+ }
+
+ fsdn.log.Trace().Str("name", ev.Name).Str("op", ev.Op.String()).Msg("Directory processed")
+ return true
+}
+
+func (fsdn *fsdirNotifier) processFileEvent(ev *fsnotify.Event) bool {
+ if ev.Has(fsnotify.Create) || ev.Has(fsnotify.Write) {
+ if fi, err := os.Lstat(ev.Name); err != nil || !fi.Mode().IsRegular() {
+ regular := err == nil && fi.Mode().IsRegular()
+ fsdn.log.Trace().Str("name", ev.Name).Str("op", ev.Op.String()).Err(err).Bool("regular", regular).Msg("error with file")
+ return true
+ }
+ fsdn.log.Trace().Str("name", ev.Name).Str("op", ev.Op.String()).Msg("File updated")
+ return fsdn.sendEvent(Update, filepath.Base(ev.Name))
+ }
+
+ if ev.Has(fsnotify.Rename) {
+ fi, err := os.Lstat(ev.Name)
+ if err != nil {
+ fsdn.log.Trace().Str("name", ev.Name).Str("op", ev.Op.String()).Msg("File deleted")
+ return fsdn.sendEvent(Delete, filepath.Base(ev.Name))
+ }
+ if fi.Mode().IsRegular() {
+ fsdn.log.Trace().Str("name", ev.Name).Str("op", ev.Op.String()).Msg("File updated")
+ return fsdn.sendEvent(Update, filepath.Base(ev.Name))
+ }
+ fsdn.log.Trace().Str("name", ev.Name).Msg("File not regular")
+ return true
+ }
+
+ if ev.Has(fsnotify.Remove) {
+ fsdn.log.Trace().Str("name", ev.Name).Str("op", ev.Op.String()).Msg("File deleted")
+ return fsdn.sendEvent(Delete, filepath.Base(ev.Name))
+ }
+
+ fsdn.log.Trace().Str("name", ev.Name).Str("op", ev.Op.String()).Msg("File processed")
+ return true
+}
+
+func (fsdn *fsdirNotifier) sendEvent(op EventOp, filename string) bool {
+ select {
+ case fsdn.events <- Event{Op: op, Name: filename}:
+ case <-fsdn.done:
+ fsdn.log.Trace().Msg("done file event processing")
+ return false
+ }
+ return true
+}
+
+func (fsdn *fsdirNotifier) Close() {
+ close(fsdn.done)
+}
ADDED box/notify/helper.go
Index: box/notify/helper.go
==================================================================
--- /dev/null
+++ box/notify/helper.go
@@ -0,0 +1,100 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 notify
+
+import (
+ "archive/zip"
+ "os"
+
+ "zettelstore.de/z/logger"
+)
+
+// MakeMetaFilename builds the name of the file containing metadata.
+func MakeMetaFilename(basename string) string {
+ return basename //+ ".meta"
+}
+
+// EntryFetcher return a list of (file) names of an directory.
+type EntryFetcher interface {
+ Fetch() ([]string, error)
+}
+
+type dirPathFetcher struct {
+ dirPath string
+}
+
+func newDirPathFetcher(dirPath string) EntryFetcher { return &dirPathFetcher{dirPath} }
+
+func (dpf *dirPathFetcher) Fetch() ([]string, error) {
+ entries, err := os.ReadDir(dpf.dirPath)
+ if err != nil {
+ return nil, err
+ }
+ result := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ if info, err1 := entry.Info(); err1 != nil || !info.Mode().IsRegular() {
+ continue
+ }
+ result = append(result, entry.Name())
+ }
+ return result, nil
+}
+
+type zipPathFetcher struct {
+ zipPath string
+}
+
+func newZipPathFetcher(zipPath string) EntryFetcher { return &zipPathFetcher{zipPath} }
+
+func (zpf *zipPathFetcher) Fetch() ([]string, error) {
+ reader, err := zip.OpenReader(zpf.zipPath)
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+ result := make([]string, 0, len(reader.File))
+ for _, f := range reader.File {
+ result = append(result, f.Name)
+ }
+ return result, nil
+}
+
+// listDirElements write all files within the directory path as events.
+func listDirElements(log *logger.Logger, fetcher EntryFetcher, events chan<- Event, done <-chan struct{}) bool {
+ select {
+ case events <- Event{Op: Make}:
+ case <-done:
+ return false
+ }
+ entries, err := fetcher.Fetch()
+ if err != nil {
+ select {
+ case events <- Event{Op: Error, Err: err}:
+ case <-done:
+ return false
+ }
+ }
+ for _, name := range entries {
+ log.Trace().Str("name", name).Msg("File listed")
+ select {
+ case events <- Event{Op: List, Name: name}:
+ case <-done:
+ return false
+ }
+ }
+
+ select {
+ case events <- Event{Op: List}:
+ case <-done:
+ return false
+ }
+ return true
+}
ADDED box/notify/notify.go
Index: box/notify/notify.go
==================================================================
--- /dev/null
+++ box/notify/notify.go
@@ -0,0 +1,82 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 notify provides some notification services to be used by box services.
+package notify
+
+import "fmt"
+
+// Notifier send events about their container and content.
+type Notifier interface {
+ // Return the channel
+ Events() <-chan Event
+
+ // Signal a refresh of the container. This will result in some events.
+ Refresh()
+
+ // Close the notifier (and eventually the channel)
+ Close()
+}
+
+// EventOp describe a notification operation.
+type EventOp uint8
+
+// Valid constants for event operations.
+//
+// Error signals a detected error. Details are in Event.Err.
+//
+// Make signals that the container is detected. List events will follow.
+//
+// List signals a found file, if Event.Name is not empty. Otherwise it signals
+// the end of files within the container.
+//
+// Destroy signals that the container is not there any more. It might me Make later again.
+//
+// Update signals that file Event.Name was created/updated.
+// File name is relative to the container.
+//
+// Delete signals that file Event.Name was removed.
+// File name is relative to the container's name.
+const (
+ _ EventOp = iota
+ Error // Error while operating
+ Make // Make container
+ List // List container
+ Destroy // Destroy container
+ Update // Update element
+ Delete // Delete element
+)
+
+// String representation of operation code.
+func (c EventOp) String() string {
+ switch c {
+ case Error:
+ return "ERROR"
+ case Make:
+ return "MAKE"
+ case List:
+ return "LIST"
+ case Destroy:
+ return "DESTROY"
+ case Update:
+ return "UPDATE"
+ case Delete:
+ return "DELETE"
+ default:
+ return fmt.Sprintf("UNKNOWN(%d)", c)
+ }
+}
+
+// Event represents a single container / element event.
+type Event struct {
+ Op EventOp
+ Name string
+ Err error // Valid iff Op == Error
+}
ADDED box/notify/simpledir.go
Index: box/notify/simpledir.go
==================================================================
--- /dev/null
+++ box/notify/simpledir.go
@@ -0,0 +1,85 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 notify
+
+import (
+ "path/filepath"
+
+ "zettelstore.de/z/logger"
+)
+
+type simpleDirNotifier struct {
+ log *logger.Logger
+ events chan Event
+ done chan struct{}
+ refresh chan struct{}
+ fetcher EntryFetcher
+}
+
+// NewSimpleDirNotifier creates a directory based notifier that will not receive
+// any notifications from the operating system.
+func NewSimpleDirNotifier(log *logger.Logger, path string) (Notifier, error) {
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return nil, err
+ }
+ sdn := &simpleDirNotifier{
+ log: log,
+ events: make(chan Event),
+ done: make(chan struct{}),
+ refresh: make(chan struct{}),
+ fetcher: newDirPathFetcher(absPath),
+ }
+ go sdn.eventLoop()
+ return sdn, nil
+}
+
+// NewSimpleZipNotifier creates a zip-file based notifier that will not receive
+// any notifications from the operating system.
+func NewSimpleZipNotifier(log *logger.Logger, zipPath string) Notifier {
+ sdn := &simpleDirNotifier{
+ log: log,
+ events: make(chan Event),
+ done: make(chan struct{}),
+ refresh: make(chan struct{}),
+ fetcher: newZipPathFetcher(zipPath),
+ }
+ go sdn.eventLoop()
+ return sdn
+}
+
+func (sdn *simpleDirNotifier) Events() <-chan Event {
+ return sdn.events
+}
+
+func (sdn *simpleDirNotifier) Refresh() {
+ sdn.refresh <- struct{}{}
+}
+
+func (sdn *simpleDirNotifier) eventLoop() {
+ defer close(sdn.events)
+ defer close(sdn.refresh)
+ if !listDirElements(sdn.log, sdn.fetcher, sdn.events, sdn.done) {
+ return
+ }
+ for {
+ select {
+ case <-sdn.done:
+ return
+ case <-sdn.refresh:
+ listDirElements(sdn.log, sdn.fetcher, sdn.events, sdn.done)
+ }
+ }
+}
+
+func (sdn *simpleDirNotifier) Close() {
+ close(sdn.done)
+}
Index: cmd/cmd_file.go
==================================================================
--- cmd/cmd_file.go
+++ cmd/cmd_file.go
@@ -4,13 +4,10 @@
// 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.
-//
-// SPDX-License-Identifier: EUPL-1.2
-// SPDX-FileCopyrightText: 2020-present Detlef Stern
//-----------------------------------------------------------------------------
package cmd
import (
@@ -18,18 +15,17 @@
"flag"
"fmt"
"io"
"os"
- "t73f.de/r/zsc/api"
- "t73f.de/r/zsc/domain/id"
- "t73f.de/r/zsc/domain/meta"
- "t73f.de/r/zsx/input"
-
- "zettelstore.de/z/internal/encoder"
- "zettelstore.de/z/internal/parser"
- "zettelstore.de/z/internal/zettel"
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/domain"
+ "zettelstore.de/z/domain/id"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/encoder"
+ "zettelstore.de/z/input"
+ "zettelstore.de/z/parser"
)
// ---------- Subcommand: file -----------------------------------------------
func cmdFile(fs *flag.FlagSet) (int, error) {
@@ -38,25 +34,23 @@
if m == nil {
return 2, err
}
z := parser.ParseZettel(
context.Background(),
- zettel.Zettel{
+ domain.Zettel{
Meta: m,
- Content: zettel.NewContent(inp.Src[inp.Pos:]),
+ Content: domain.NewContent(inp.Src[inp.Pos:]),
},
- string(m.GetDefault(meta.KeySyntax, meta.DefaultSyntax)),
+ m.GetDefault(api.KeySyntax, meta.SyntaxZmk),
nil,
)
- encdr := encoder.Create(
- api.Encoder(enc),
- &encoder.CreateParameter{Lang: string(m.GetDefault(meta.KeyLang, meta.ValueLangEN))})
+ encdr := encoder.Create(api.Encoder(enc))
if encdr == nil {
fmt.Fprintf(os.Stderr, "Unknown format %q\n", enc)
return 2, nil
}
- _, err = encdr.WriteZettel(os.Stdout, z)
+ _, err = encdr.WriteZettel(os.Stdout, z, parser.ParseMetadata)
if err != nil {
return 2, err
}
fmt.Println()
Index: cmd/cmd_password.go
==================================================================
--- cmd/cmd_password.go
+++ cmd/cmd_password.go
@@ -4,13 +4,10 @@
// 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.
-//
-// SPDX-License-Identifier: EUPL-1.2
-// SPDX-FileCopyrightText: 2020-present Detlef Stern
//-----------------------------------------------------------------------------
package cmd
import (
@@ -18,14 +15,13 @@
"fmt"
"os"
"golang.org/x/term"
- "t73f.de/r/zsc/domain/id"
- "t73f.de/r/zsc/domain/meta"
-
- "zettelstore.de/z/internal/auth/cred"
+ "zettelstore.de/c/api"
+ "zettelstore.de/z/auth/cred"
+ "zettelstore.de/z/domain/id"
)
// ---------- Subcommand: password -------------------------------------------
func cmdPassword(fs *flag.FlagSet) (int, error) {
@@ -62,12 +58,12 @@
hashedPassword, err := cred.HashCredential(zid, ident, password)
if err != nil {
return 2, err
}
fmt.Printf("%v: %s\n%v: %s\n",
- meta.KeyCredential, hashedPassword,
- meta.KeyUserID, ident,
+ api.KeyCredential, hashedPassword,
+ api.KeyUserID, ident,
)
return 0, nil
}
func getPassword(prompt string) (string, error) {
Index: cmd/cmd_run.go
==================================================================
--- cmd/cmd_run.go
+++ cmd/cmd_run.go
@@ -4,32 +4,28 @@
// 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.
-//
-// SPDX-License-Identifier: EUPL-1.2
-// SPDX-FileCopyrightText: 2020-present Detlef Stern
//-----------------------------------------------------------------------------
package cmd
import (
"context"
"flag"
"net/http"
- "t73f.de/r/zsc/domain/meta"
-
- "zettelstore.de/z/internal/auth"
- "zettelstore.de/z/internal/box"
- "zettelstore.de/z/internal/config"
- "zettelstore.de/z/internal/kernel"
- "zettelstore.de/z/internal/usecase"
- "zettelstore.de/z/internal/web/adapter/api"
- "zettelstore.de/z/internal/web/adapter/webui"
- "zettelstore.de/z/internal/web/server"
+ "zettelstore.de/z/auth"
+ "zettelstore.de/z/box"
+ "zettelstore.de/z/config"
+ "zettelstore.de/z/domain/meta"
+ "zettelstore.de/z/kernel"
+ "zettelstore.de/z/usecase"
+ "zettelstore.de/z/web/adapter/api"
+ "zettelstore.de/z/web/adapter/webui"
+ "zettelstore.de/z/web/server"
)
// ---------- Subcommand: run ------------------------------------------------
func flgRun(fs *flag.FlagSet) {
@@ -58,28 +54,27 @@
webLog := kern.GetLogger(kernel.WebService)
var getUser getUserImpl
logAuth := kern.GetLogger(kernel.AuthService)
logUc := kern.GetLogger(kernel.CoreService).WithUser(&getUser)
- ucGetUser := usecase.NewGetUser(authManager, boxManager)
- ucAuthenticate := usecase.NewAuthenticate(logAuth, authManager, &ucGetUser)
+ ucAuthenticate := usecase.NewAuthenticate(logAuth, authManager, authManager, boxManager)
ucIsAuth := usecase.NewIsAuthenticated(logUc, &getUser, authManager)
ucCreateZettel := usecase.NewCreateZettel(logUc, rtConfig, protectedBoxManager)
- ucGetAllZettel := usecase.NewGetAllZettel(protectedBoxManager)
+ ucGetMeta := usecase.NewGetMeta(protectedBoxManager)
+ ucGetAllMeta := usecase.NewGetAllMeta(protectedBoxManager)
ucGetZettel := usecase.NewGetZettel(protectedBoxManager)
ucParseZettel := usecase.NewParseZettel(rtConfig, ucGetZettel)
- ucQuery := usecase.NewQuery(protectedBoxManager)
- ucEvaluate := usecase.NewEvaluate(rtConfig, &ucGetZettel, &ucQuery)
- ucQuery.SetEvaluate(&ucEvaluate)
- ucTagZettel := usecase.NewTagZettel(protectedBoxManager, &ucQuery)
- ucRoleZettel := usecase.NewRoleZettel(protectedBoxManager, &ucQuery)
+ ucListMeta := usecase.NewListMeta(protectedBoxManager)
+ ucEvaluate := usecase.NewEvaluate(rtConfig, ucGetZettel, ucGetMeta, ucListMeta)
ucListSyntax := usecase.NewListSyntax(protectedBoxManager)
ucListRoles := usecase.NewListRoles(protectedBoxManager)
+ ucZettelContext := usecase.NewZettelContext(protectedBoxManager, rtConfig)
ucDelete := usecase.NewDeleteZettel(logUc, protectedBoxManager)
ucUpdate := usecase.NewUpdateZettel(logUc, protectedBoxManager)
+ ucRename := usecase.NewRenameZettel(logUc, protectedBoxManager)
+ ucUnlinkedRefs := usecase.NewUnlinkedReferences(protectedBoxManager, rtConfig)
ucRefresh := usecase.NewRefresh(logUc, protectedBoxManager)
- ucReIndex := usecase.NewReIndex(logUc, protectedBoxManager)
ucVersion := usecase.NewVersion(kernel.Main.GetConfig(kernel.CoreService, kernel.CoreVersion).(string))
a := api.New(
webLog.Clone().Str("adapter", "api").Child(),
webSrv, authManager, authManager, rtConfig, authPolicy)
@@ -94,39 +89,48 @@
webSrv.Handle("/favicon.ico", wui.MakeFaviconHandler(assetDir))
}
// Web user interface
if !authManager.IsReadonly() {
- webSrv.AddListRoute('c', server.MethodGet, wui.MakeGetZettelFromListHandler(&ucQuery, &ucEvaluate, ucListRoles, ucListSyntax))
+ webSrv.AddZettelRoute('b', server.MethodGet, wui.MakeGetRenameZettelHandler(ucGetMeta))
+ webSrv.AddZettelRoute('b', server.MethodPost, wui.MakePostRenameZettelHandler(&ucRename))
+ webSrv.AddListRoute('c', server.MethodGet, wui.MakeGetZettelFromListHandler(ucListMeta, &ucEvaluate, ucListRoles, ucListSyntax))
webSrv.AddListRoute('c', server.MethodPost, wui.MakePostCreateZettelHandler(&ucCreateZettel))
webSrv.AddZettelRoute('c', server.MethodGet, wui.MakeGetCreateZettelHandler(
ucGetZettel, &ucCreateZettel, ucListRoles, ucListSyntax))
webSrv.AddZettelRoute('c', server.MethodPost, wui.MakePostCreateZettelHandler(&ucCreateZettel))
- webSrv.AddZettelRoute('d', server.MethodGet, wui.MakeGetDeleteZettelHandler(ucGetZettel, ucGetAllZettel))
+ webSrv.AddZettelRoute('d', server.MethodGet, wui.MakeGetDeleteZettelHandler(ucGetMeta, ucGetAllMeta))
webSrv.AddZettelRoute('d', server.MethodPost, wui.MakePostDeleteZettelHandler(&ucDelete))
webSrv.AddZettelRoute('e', server.MethodGet, wui.MakeEditGetZettelHandler(ucGetZettel, ucListRoles, ucListSyntax))
webSrv.AddZettelRoute('e', server.MethodPost, wui.MakeEditSetZettelHandler(&ucUpdate))
}
webSrv.AddListRoute('g', server.MethodGet, wui.MakeGetGoActionHandler(&ucRefresh))
- webSrv.AddListRoute('h', server.MethodGet, wui.MakeListHTMLMetaHandler(&ucQuery, &ucTagZettel, &ucRoleZettel, &ucReIndex))
- webSrv.AddZettelRoute('h', server.MethodGet, wui.MakeGetHTMLZettelHandler(&ucEvaluate, ucGetZettel))
+ webSrv.AddListRoute('h', server.MethodGet, wui.MakeListHTMLMetaHandler(ucListMeta, &ucEvaluate))
+ webSrv.AddZettelRoute('h', server.MethodGet, wui.MakeGetHTMLZettelHandler(&ucEvaluate, ucGetMeta))
webSrv.AddListRoute('i', server.MethodGet, wui.MakeGetLoginOutHandler())
webSrv.AddListRoute('i', server.MethodPost, wui.MakePostLoginHandler(&ucAuthenticate))
webSrv.AddZettelRoute('i', server.MethodGet, wui.MakeGetInfoHandler(
- ucParseZettel, &ucEvaluate, ucGetZettel, ucGetAllZettel, &ucQuery))
+ ucParseZettel, &ucEvaluate, ucGetMeta, ucGetAllMeta, ucUnlinkedRefs))
+ webSrv.AddZettelRoute('k', server.MethodGet, wui.MakeZettelContextHandler(
+ ucZettelContext, &ucEvaluate))
// API
webSrv.AddListRoute('a', server.MethodPost, a.MakePostLoginHandler(&ucAuthenticate))
webSrv.AddListRoute('a', server.MethodPut, a.MakeRenewAuthHandler())
+ webSrv.AddZettelRoute('o', server.MethodGet, a.MakeGetOrderHandler(
+ usecase.NewZettelOrder(protectedBoxManager, ucEvaluate)))
+ webSrv.AddZettelRoute('u', server.MethodGet, a.MakeListUnlinkedMetaHandler(ucGetMeta, ucUnlinkedRefs))
webSrv.AddListRoute('x', server.MethodGet, a.MakeGetDataHandler(ucVersion))
webSrv.AddListRoute('x', server.MethodPost, a.MakePostCommandHandler(&ucIsAuth, &ucRefresh))
- webSrv.AddListRoute('z', server.MethodGet, a.MakeQueryHandler(&ucQuery, &ucTagZettel, &ucRoleZettel, &ucReIndex))
- webSrv.AddZettelRoute('z', server.MethodGet, a.MakeGetZettelHandler(ucGetZettel, ucParseZettel, ucEvaluate))
+ webSrv.AddZettelRoute('x', server.MethodGet, a.MakeZettelContextHandler(ucZettelContext))
+ webSrv.AddListRoute('z', server.MethodGet, a.MakeQueryHandler(ucListMeta))
+ webSrv.AddZettelRoute('z', server.MethodGet, a.MakeGetZettelHandler(ucGetMeta, ucGetZettel, ucParseZettel, ucEvaluate))
if !authManager.IsReadonly() {
webSrv.AddListRoute('z', server.MethodPost, a.MakePostCreateZettelHandler(&ucCreateZettel))
webSrv.AddZettelRoute('z', server.MethodPut, a.MakeUpdateZettelHandler(&ucUpdate))
webSrv.AddZettelRoute('z', server.MethodDelete, a.MakeDeleteZettelHandler(&ucDelete))
+ webSrv.AddZettelRoute('z', server.MethodMove, a.MakeRenameZettelHandler(&ucRename))
}
if authManager.WithAuth() {
webSrv.SetUserRetriever(usecase.NewGetUserByZid(boxManager))
}
Index: cmd/command.go
==================================================================
--- cmd/command.go
+++ cmd/command.go
@@ -4,23 +4,19 @@
// 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.
-//
-// SPDX-License-Identifier: EUPL-1.2
-// SPDX-FileCopyrightText: 2020-present Detlef Stern
//-----------------------------------------------------------------------------
package cmd
import (
"flag"
- "maps"
- "slices"
- "zettelstore.de/z/internal/logger"
+ "zettelstore.de/c/maps"
+ "zettelstore.de/z/logger"
)
// Command stores information about commands / sub-commands.
type Command struct {
Name string // command name as it appears on the command line
@@ -65,6 +61,6 @@
cmd, ok := commands[name]
return cmd, ok
}
// List returns a sorted list of all registered command names.
-func List() []string { return slices.Sorted(maps.Keys(commands)) }
+func List() []string { return maps.Keys(commands) }
Index: cmd/main.go
==================================================================
--- cmd/main.go
+++ cmd/main.go
@@ -4,16 +4,12 @@
// 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.
-//
-// SPDX-License-Identifier: EUPL-1.2
-// SPDX-FileCopyrightText: 2020-present Detlef Stern
//-----------------------------------------------------------------------------
-// Package cmd provides the commands to call Zettelstore from the command line.
package cmd
import (
"crypto/sha256"
"flag"
@@ -24,24 +20,23 @@
"runtime/debug"
"strconv"
"strings"
"time"
- "t73f.de/r/zsc/api"
- "t73f.de/r/zsc/domain/id"
- "t73f.de/r/zsc/domain/meta"
- "t73f.de/r/zsx/input"
-
- "zettelstore.de/z/internal/auth"
- "zettelstore.de/z/internal/auth/impl"
- "zettelstore.de/z/internal/box"
- "zettelstore.de/z/internal/box/compbox"
- "zettelstore.de/z/internal/box/manager"
- "zettelstore.de/z/internal/config"
- "zettelstore.de/z/internal/kernel"
- "zettelstore.de/z/internal/logger"
- "zettelstore.de/z/internal/web/server"
+ "zettelstore.de/c/api"
+ "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/logger"
+ "zettelstore.de/z/web/server"
)
const strRunSimple = "run-simple"
func init() {
@@ -91,19 +86,19 @@
Name: "password",
Func: cmdPassword,
})
}
-func fetchStartupConfiguration(fs *flag.FlagSet) (string, *meta.Meta) {
+func fetchStartupConfiguration(fs *flag.FlagSet) (cfg *meta.Meta) {
if configFlag := fs.Lookup("c"); configFlag != nil {
if filename := configFlag.Value.String(); filename != "" {
content, err := readConfiguration(filename)
- return filename, createConfiguration(content, err)
+ return createConfiguration(content, err)
}
}
- filename, content, err := searchAndReadConfiguration()
- return filename, createConfiguration(content, err)
+ content, err := searchAndReadConfiguration()
+ return createConfiguration(content, err)
}
func createConfiguration(content []byte, err error) *meta.Meta {
if err != nil {
return meta.New(id.Invalid)
@@ -111,73 +106,72 @@
return meta.NewFromInput(id.Invalid, input.NewInput(content))
}
func readConfiguration(filename string) ([]byte, error) { return os.ReadFile(filename) }
-func searchAndReadConfiguration() (string, []byte, error) {
- for _, filename := range []string{"zettelstore.cfg", "zsconfig.txt", "zscfg.txt", "_zscfg", ".zscfg"} {
+func searchAndReadConfiguration() ([]byte, error) {
+ for _, filename := range []string{"zettelstore.cfg", "zsconfig.txt", "zscfg.txt", "_zscfg"} {
if content, err := readConfiguration(filename); err == nil {
- return filename, content, nil
+ return content, nil
}
}
- return "", nil, os.ErrNotExist
+ return readConfiguration(".zscfg")
}
-func getConfig(fs *flag.FlagSet) (string, *meta.Meta) {
- filename, cfg := fetchStartupConfiguration(fs)
+func getConfig(fs *flag.FlagSet) *meta.Meta {
+ cfg := fetchStartupConfiguration(fs)
fs.Visit(func(flg *flag.Flag) {
switch flg.Name {
case "p":
- cfg.Set(keyListenAddr, meta.Value(net.JoinHostPort("127.0.0.1", flg.Value.String())))
+ cfg.Set(keyListenAddr, net.JoinHostPort("127.0.0.1", flg.Value.String()))
case "a":
- cfg.Set(keyAdminPort, meta.Value(flg.Value.String()))
+ cfg.Set(keyAdminPort, flg.Value.String())
case "d":
val := flg.Value.String()
if strings.HasPrefix(val, "/") {
val = "dir://" + val
} else {
val = "dir:" + val
}
deleteConfiguredBoxes(cfg)
- cfg.Set(keyBoxOneURI, meta.Value(val))
+ cfg.Set(keyBoxOneURI, val)
case "l":
- cfg.Set(keyLogLevel, meta.Value(flg.Value.String()))
+ cfg.Set(keyLogLevel, flg.Value.String())
case "debug":
- cfg.Set(keyDebug, meta.Value(flg.Value.String()))
+ cfg.Set(keyDebug, flg.Value.String())
case "r":
- cfg.Set(keyReadOnly, meta.Value(flg.Value.String()))
+ cfg.Set(keyReadOnly, flg.Value.String())
case "v":
- cfg.Set(keyVerbose, meta.Value(flg.Value.String()))
+ cfg.Set(keyVerbose, flg.Value.String())
}
})
- return filename, cfg
+ return cfg
}
func deleteConfiguredBoxes(cfg *meta.Meta) {
- for key := range cfg.Rest() {
- if strings.HasPrefix(key, kernel.BoxURIs) {
+ for _, p := range cfg.PairsRest() {
+ if key := p.Key; strings.HasPrefix(key, kernel.BoxURIs) {
cfg.Delete(key)
}
}
}
const (
keyAdminPort = "admin-port"
keyAssetDir = "asset-dir"
keyBaseURL = "base-url"
- keyBoxOneURI = kernel.BoxURIs + "1"
keyDebug = "debug-mode"
keyDefaultDirBoxType = "default-dir-box-type"
keyInsecureCookie = "insecure-cookie"
keyInsecureHTML = "insecure-html"
keyListenAddr = "listen-addr"
keyLogLevel = "log-level"
keyMaxRequestSize = "max-request-size"
keyOwner = "owner"
keyPersistentCookie = "persistent-cookie"
+ keyBoxOneURI = kernel.BoxURIs + "1"
keyReadOnly = "read-only-mode"
- keyRuntimeProfiling = "runtime-profiling"
keyTokenLifetimeHTML = "token-lifetime-html"
keyTokenLifetimeAPI = "token-lifetime-api"
keyURLPrefix = "url-prefix"
keyVerbose = "verbose-mode"
)
@@ -186,11 +180,11 @@
debugMode := cfg.GetBool(keyDebug)
if debugMode && kernel.Main.GetKernelLogger().Level() > logger.DebugLevel {
kernel.Main.SetLogLevel(logger.DebugLevel.String())
}
if logLevel, found := cfg.Get(keyLogLevel); found {
- kernel.Main.SetLogLevel(string(logLevel))
+ kernel.Main.SetLogLevel(logLevel)
}
err := setConfigValue(nil, kernel.CoreService, kernel.CoreDebug, debugMode)
err = setConfigValue(err, kernel.CoreService, kernel.CoreVerbose, cfg.GetBool(keyVerbose))
if val, found := cfg.Get(keyAdminPort); found {
err = setConfigValue(err, kernel.CoreService, kernel.CorePort, val)
@@ -210,15 +204,13 @@
break
}
err = setConfigValue(err, kernel.BoxService, key, val)
}
- err = setConfigValue(
- err, kernel.ConfigService, kernel.ConfigInsecureHTML, cfg.GetDefault(keyInsecureHTML, kernel.ConfigSecureHTML))
+ err = setConfigValue(err, kernel.ConfigService, kernel.ConfigInsecureHTML, cfg.GetDefault(keyInsecureHTML, kernel.ConfigSecureHTML))
- err = setConfigValue(
- err, kernel.WebService, kernel.WebListenAddress, cfg.GetDefault(keyListenAddr, "127.0.0.1:23123"))
+ err = setConfigValue(err, kernel.WebService, kernel.WebListenAddress, cfg.GetDefault(keyListenAddr, "127.0.0.1:23123"))
if val, found := cfg.Get(keyBaseURL); found {
err = setConfigValue(err, kernel.WebService, kernel.WebBaseURL, val)
}
if val, found := cfg.Get(keyURLPrefix); found {
err = setConfigValue(err, kernel.WebService, kernel.WebURLPrefix, val)
@@ -230,11 +222,10 @@
}
err = setConfigValue(
err, kernel.WebService, kernel.WebTokenLifetimeAPI, cfg.GetDefault(keyTokenLifetimeAPI, ""))
err = setConfigValue(
err, kernel.WebService, kernel.WebTokenLifetimeHTML, cfg.GetDefault(keyTokenLifetimeHTML, ""))
- err = setConfigValue(err, kernel.WebService, kernel.WebProfiling, debugMode || cfg.GetBool(keyRuntimeProfiling))
if val, found := cfg.Get(keyAssetDir); found {
err = setConfigValue(err, kernel.WebService, kernel.WebAssetDir, val)
}
return err == nil
}
@@ -241,11 +232,11 @@
func setConfigValue(err error, subsys kernel.Service, key string, val any) error {
if err == nil {
err = kernel.Main.SetConfig(subsys, key, fmt.Sprint(val))
if err != nil {
- kernel.Main.GetKernelLogger().Error().Str("key", key).Str("value", fmt.Sprint(val)).Err(err).Msg("Unable to set configuration")
+ kernel.Main.GetKernelLogger().Fatal().Str("key", key).Str("value", fmt.Sprint(val)).Err(err).Msg("Unable to set configuration")
}
}
return err
}
@@ -258,11 +249,11 @@
fs := command.GetFlags()
if err := fs.Parse(args); err != nil {
fmt.Fprintf(os.Stderr, "%s: unable to parse flags: %v %v\n", name, args, err)
return 1
}
- filename, cfg := getConfig(fs)
+ cfg := getConfig(fs)
if !setServiceConfig(cfg) {
fs.Usage()
return 2
}
@@ -281,15 +272,15 @@
if len(secret) < 16 && cfg.GetDefault(keyOwner, "") != "" {
fmt.Fprintf(os.Stderr, "secret must have at least length 16 when authentication is enabled, but is %q\n", secret)
return 2
}
cfg.Delete("secret")
- secretHash := fmt.Sprintf("%x", sha256.Sum256([]byte(string(secret))))
+ secret = fmt.Sprintf("%x", sha256.Sum256([]byte(secret)))
kern.SetCreators(
func(readonly bool, owner id.Zid) (auth.Manager, error) {
- return impl.New(readonly, owner, secretHash), nil
+ return impl.New(readonly, owner, secret), nil
},
createManager,
func(srv server.Server, plMgr box.Manager, authMgr auth.Manager, rtConfig config.Config) error {
setupRouting(srv, plMgr, authMgr, rtConfig)
return nil
@@ -297,11 +288,11 @@
)
if command.Simple {
kern.SetConfig(kernel.ConfigService, kernel.ConfigSimpleMode, "true")
}
- kern.Start(command.Header, command.LineServer, filename)
+ kern.Start(command.Header, command.LineServer)
exitCode, err := command.Func(fs)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", name, err)
}
kern.Shutdown(true)
@@ -309,11 +300,11 @@
}
// runSimple is called, when the user just starts the software via a double click
// or via a simple call “./zettelstore“ on the command line.
func runSimple() int {
- if _, _, err := searchAndReadConfiguration(); err == nil {
+ if _, err := searchAndReadConfiguration(); err == nil {
return executeCommand(strRunSimple)
}
dir := "./zettel"
if err := os.MkdirAll(dir, 0750); err != nil {
fmt.Fprintf(os.Stderr, "Unable to create zettel directory %q (%s)\n", dir, err)
Index: cmd/register.go
==================================================================
--- cmd/register.go
+++ cmd/register.go
@@ -4,20 +4,31 @@
// 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.
-//
-// SPDX-License-Identifier: EUPL-1.2
-// SPDX-FileCopyrightText: 2020-present Detlef Stern
//-----------------------------------------------------------------------------
+// Package cmd provides command generic functions.
package cmd
-// Mention all needed boxes, encoders, and parsers to have them registered.
+// Mention all needed encoders, parsers and stores to have them registered.
import (
- _ "zettelstore.de/z/internal/box/compbox" // Allow to use computed box.
- _ "zettelstore.de/z/internal/box/constbox" // Allow to use global internal box.
- _ "zettelstore.de/z/internal/box/dirbox" // Allow to use directory box.
- _ "zettelstore.de/z/internal/box/filebox" // Allow to use file box.
- _ "zettelstore.de/z/internal/box/membox" // Allow to use in-memory box.
+ _ "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/mdenc" // Allow to use markdown encoder.
+ _ "zettelstore.de/z/encoder/sexprenc" // Allow to use sexpr encoder.
+ _ "zettelstore.de/z/encoder/shtmlenc" // Allow to use SHTML encoder.
+ _ "zettelstore.de/z/encoder/textenc" // Allow to use text encoder.
+ _ "zettelstore.de/z/encoder/zmkenc" // Allow to use zmk encoder.
+ _ "zettelstore.de/z/kernel/impl" // Allow kernel implementation to create itself
+ _ "zettelstore.de/z/parser/blob" // Allow to use BLOB parser.
+ _ "zettelstore.de/z/parser/draw" // Allow to use draw 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.
)
Index: cmd/zettelstore/main.go
==================================================================
--- cmd/zettelstore/main.go
+++ cmd/zettelstore/main.go
@@ -4,13 +4,10 @@
// 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.
-//
-// SPDX-License-Identifier: EUPL-1.2
-// SPDX-FileCopyrightText: 2020-present Detlef Stern
//-----------------------------------------------------------------------------
// Package main is the starting point for the zettelstore command.
package main
@@ -19,11 +16,11 @@
"zettelstore.de/z/cmd"
)
// Version variable. Will be filled by build process.
-var version string
+var version string = ""
func main() {
exitCode := cmd.Main("Zettelstore", version)
os.Exit(exitCode)
}
ADDED collect/collect.go
Index: collect/collect.go
==================================================================
--- /dev/null
+++ collect/collect.go
@@ -0,0 +1,43 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 collect provides functions to collect items from a syntax tree.
+package collect
+
+import "zettelstore.de/z/ast"
+
+// Summary stores the relevant parts of the syntax tree
+type Summary struct {
+ Links []*ast.Reference // list of all linked material
+ Embeds []*ast.Reference // list of all embedded material
+ 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) (s Summary) {
+ ast.Walk(&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.TranscludeNode:
+ s.Embeds = append(s.Embeds, n.Ref)
+ case *ast.LinkNode:
+ s.Links = append(s.Links, n.Ref)
+ case *ast.EmbedRefNode:
+ s.Embeds = append(s.Embeds, n.Ref)
+ case *ast.CiteNode:
+ s.Cites = append(s.Cites, n)
+ }
+ return s
+}
ADDED collect/collect_test.go
Index: collect/collect_test.go
==================================================================
--- /dev/null
+++ collect/collect_test.go
@@ -0,0 +1,61 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 collect_test provides some unit test for collectors.
+package collect_test
+
+import (
+ "testing"
+
+ "zettelstore.de/z/ast"
+ "zettelstore.de/z/collect"
+)
+
+func parseRef(s string) *ast.Reference {
+ r := ast.ParseReference(s)
+ if !r.IsValid() {
+ panic(s)
+ }
+ return r
+}
+
+func TestLinks(t *testing.T) {
+ t.Parallel()
+ zn := &ast.ZettelNode{}
+ summary := collect.References(zn)
+ if summary.Links != nil || summary.Embeds != nil {
+ t.Error("No links/images expected, but got:", summary.Links, "and", summary.Embeds)
+ }
+
+ intNode := &ast.LinkNode{Ref: parseRef("01234567890123")}
+ para := ast.CreateParaNode(intNode, &ast.LinkNode{Ref: parseRef("https://zettelstore.de/z")})
+ zn.Ast = ast.BlockSlice{para}
+ summary = collect.References(zn)
+ if summary.Links == nil || summary.Embeds != nil {
+ t.Error("Links expected, and no images, but got:", summary.Links, "and", summary.Embeds)
+ }
+
+ para.Inlines = append(para.Inlines, intNode)
+ summary = collect.References(zn)
+ if cnt := len(summary.Links); cnt != 3 {
+ t.Error("Link count does not work. Expected: 3, got", summary.Links)
+ }
+}
+
+func TestEmbed(t *testing.T) {
+ t.Parallel()
+ zn := &ast.ZettelNode{
+ Ast: ast.BlockSlice{ast.CreateParaNode(&ast.EmbedRefNode{Ref: parseRef("12345678901234")})},
+ }
+ summary := collect.References(zn)
+ if summary.Embeds == nil {
+ t.Error("Only image expected, but got: ", summary.Embeds)
+ }
+}
ADDED collect/order.go
Index: collect/order.go
==================================================================
--- /dev/null
+++ collect/order.go
@@ -0,0 +1,73 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2021-present 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 collect provides functions to collect items from a syntax tree.
+package collect
+
+import "zettelstore.de/z/ast"
+
+// Order of internal reference within the given zettel.
+func Order(zn *ast.ZettelNode) (result []*ast.Reference) {
+ for _, bn := range zn.Ast {
+ ln, ok := bn.(*ast.NestedListNode)
+ if !ok {
+ continue
+ }
+ switch ln.Kind {
+ case ast.NestedListOrdered, ast.NestedListUnordered:
+ for _, is := range ln.Items {
+ if ref := firstItemZettelReference(is); ref != nil {
+ result = append(result, ref)
+ }
+ }
+ }
+ }
+ return result
+}
+
+func firstItemZettelReference(is ast.ItemSlice) *ast.Reference {
+ for _, in := range is {
+ if pn, ok := in.(*ast.ParaNode); ok {
+ if ref := firstInlineZettelReference(pn.Inlines); ref != nil {
+ return ref
+ }
+ }
+ }
+ return nil
+}
+
+func firstInlineZettelReference(is ast.InlineSlice) (result *ast.Reference) {
+ for _, inl := range is {
+ switch in := inl.(type) {
+ case *ast.LinkNode:
+ if ref := in.Ref; ref.IsZettel() {
+ return ref
+ }
+ result = firstInlineZettelReference(in.Inlines)
+ case *ast.EmbedRefNode:
+ result = firstInlineZettelReference(in.Inlines)
+ case *ast.EmbedBLOBNode:
+ result = firstInlineZettelReference(in.Inlines)
+ case *ast.CiteNode:
+ result = firstInlineZettelReference(in.Inlines)
+ case *ast.FootnoteNode:
+ // Ignore references in footnotes
+ continue
+ case *ast.FormatNode:
+ result = firstInlineZettelReference(in.Inlines)
+ default:
+ continue
+ }
+ if result != nil {
+ return result
+ }
+ }
+ return nil
+}
ADDED collect/split.go
Index: collect/split.go
==================================================================
--- /dev/null
+++ collect/split.go
@@ -0,0 +1,50 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 collect provides functions to collect items from a syntax tree.
+package collect
+
+import (
+ "zettelstore.de/z/ast"
+ "zettelstore.de/z/strfun"
+)
+
+// DivideReferences divides the given list of rederences into zettel, local, and external References.
+func DivideReferences(all []*ast.Reference) (zettel, local, external []*ast.Reference) {
+ if len(all) == 0 {
+ return nil, nil, nil
+ }
+
+ mapZettel := make(strfun.Set)
+ mapLocal := make(strfun.Set)
+ mapExternal := make(strfun.Set)
+ for _, ref := range all {
+ if ref.State == ast.RefStateSelf {
+ continue
+ }
+ if ref.IsZettel() {
+ zettel = appendRefToList(zettel, mapZettel, ref)
+ } else if ref.IsExternal() {
+ external = appendRefToList(external, mapExternal, ref)
+ } else {
+ local = appendRefToList(local, mapLocal, ref)
+ }
+ }
+ return zettel, local, external
+}
+
+func appendRefToList(reflist []*ast.Reference, refSet strfun.Set, ref *ast.Reference) []*ast.Reference {
+ s := ref.String()
+ if !refSet.Has(s) {
+ reflist = append(reflist, ref)
+ refSet.Set(s)
+ }
+ return reflist
+}
ADDED config/config.go
Index: config/config.go
==================================================================
--- /dev/null
+++ config/config.go
@@ -0,0 +1,102 @@
+//-----------------------------------------------------------------------------
+// Copyright (c) 2020-present 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 config provides functions to retrieve runtime configuration data.
+package config
+
+import (
+ "context"
+
+ "zettelstore.de/z/domain/meta"
+)
+
+// Key values that are supported by Config.Get
+const (
+ KeyFooterZettel = "footer-zettel"
+ KeyHomeZettel = "home-zettel"
+ // api.KeyLang
+)
+
+// Config allows to retrieve all defined configuration values that can be changed during runtime.
+type Config interface {
+ AuthConfig
+
+ // Get returns the value of the given key. It searches first in the given metadata,
+ // then in the data of the current user, and at last in the system-wide data.
+ Get(ctx context.Context, m *meta.Meta, key string) string
+
+ // AddDefaultValues enriches the given meta data with its default values.
+ AddDefaultValues(context.Context, *meta.Meta) *meta.Meta
+
+ // GetSiteName returns the current value of the "site-name" key.
+ GetSiteName() string
+
+ // GetHTMLInsecurity returns the current
+ GetHTMLInsecurity() HTMLInsecurity
+
+ // GetMaxTransclusions returns the maximum number of indirect transclusions.
+ GetMaxTransclusions() int
+
+ // GetYAMLHeader returns the current value of the "yaml-header" key.
+ GetYAMLHeader() bool
+
+ // GetZettelFileSyntax returns the current value of the "zettel-file-syntax" key.
+ GetZettelFileSyntax() []string
+}
+
+// AuthConfig are relevant configuration values for authentication.
+type AuthConfig interface {
+ // GetSimpleMode returns true if system tuns in simple-mode.
+ GetSimpleMode() bool
+
+ // GetExpertMode returns the current value of the "expert-mode" key.
+ GetExpertMode() bool
+
+ // GetVisibility returns the visibility value of the metadata.
+ GetVisibility(m *meta.Meta) meta.Visibility
+}
+
+// HTMLInsecurity states what kind of insecure HTML is allowed.
+// The lowest value is the most secure one (disallowing any HTML)
+type HTMLInsecurity uint8
+
+// Constant values for HTMLInsecurity:
+const (
+ NoHTML HTMLInsecurity = iota
+ SyntaxHTML
+ MarkdownHTML
+ ZettelmarkupHTML
+)
+
+func (hi HTMLInsecurity) String() string {
+ switch hi {
+ case SyntaxHTML:
+ return "html"
+ case MarkdownHTML:
+ return "markdown"
+ case ZettelmarkupHTML:
+ return "zettelmarkup"
+ }
+ return "secure"
+}
+
+// AllowHTML returns true, if the given HTML insecurity level matches the given syntax value.
+func (hi HTMLInsecurity) AllowHTML(syntax string) bool {
+ switch hi {
+ case SyntaxHTML:
+ return syntax == meta.SyntaxHTML
+ case MarkdownHTML:
+ return syntax == meta.SyntaxHTML || syntax == meta.SyntaxMarkdown || syntax == meta.SyntaxMD
+ case ZettelmarkupHTML:
+ return syntax == meta.SyntaxZmk || syntax == meta.SyntaxHTML ||
+ syntax == meta.SyntaxMarkdown || syntax == meta.SyntaxMD
+ }
+ return false
+}
Index: docs/development/00010000000000.zettel
==================================================================
--- docs/development/00010000000000.zettel
+++ docs/development/00010000000000.zettel
@@ -1,11 +1,10 @@
id: 00010000000000
title: Developments Notes
role: zettel
syntax: zmk
created: 00010101000000
-modified: 20231218182020
+modified: 20221026184905
* [[Required Software|20210916193200]]
* [[Fuzzing tests|20221026184300]]
* [[Checklist for Release|20210916194900]]
-* [[Development tools|20231218181900]]
Index: docs/development/20210916193200.zettel
==================================================================
--- docs/development/20210916193200.zettel
+++ docs/development/20210916193200.zettel
@@ -1,29 +1,23 @@
id: 20210916193200
title: Required Software
role: zettel
syntax: zmk
created: 20210916193200
-modified: 20241213124936
+modified: 20230327165135
The following software must be installed:
* A current, supported [[release of Go|https://go.dev/doc/devel/release]],
+* [[shadow|https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/shadow]] via ``go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest``,
+* [[staticcheck|https://staticcheck.io/]] via ``go install honnef.co/go/tools/cmd/staticcheck@latest``,
+* [[unparam|https://mvdan.cc/unparam]][^[[GitHub|https://github.com/mvdan/unparam]]] via ``go install mvdan.cc/unparam@latest``,
+* [[govulncheck|https://golang.org/x/vuln/cmd/govulncheck]] via ``go install golang.org/x/vuln/cmd/govulncheck@latest``,
* [[Fossil|https://fossil-scm.org/]],
* [[Git|https://git-scm.org/]] (most dependencies are accessible via Git only).
Make sure that the software is in your path, e.g. via:
+
```sh
export PATH=$PATH:/usr/local/go/bin
export PATH=$PATH:$(go env GOPATH)/bin
```
-
-The internal build tool needs the following software tools.
-They can be installed / updated via the build tool itself: ``go run tools/devtools/devtools.go``.
-
-Otherwise you can install the software by hand:
-
-* [[shadow|https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/shadow]] via ``go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest``,
-* [[staticcheck|https://staticcheck.io/]] via ``go install honnef.co/go/tools/cmd/staticcheck@latest``,
-* [[unparam|https://mvdan.cc/unparam]][^[[GitHub|https://github.com/mvdan/unparam]]] via ``go install mvdan.cc/unparam@latest``,
-* [[revive|https://revive.run]] via ``go install github.com/mgechev/revive@vlatest``,
-* [[govulncheck|https://golang.org/x/vuln/cmd/govulncheck]] via ``go install golang.org/x/vuln/cmd/govulncheck@latest``,
Index: docs/development/20210916194900.zettel
==================================================================
--- docs/development/20210916194900.zettel
+++ docs/development/20210916194900.zettel
@@ -1,58 +1,57 @@
id: 20210916194900
title: Checklist for Release
role: zettel
syntax: zmk
-created: 20210916194900
-modified: 20241213125640
+modified: 20220309105459
-# Sync with the official repository:
+# Sync with the official repository
#* ``fossil sync -u``
-# Make sure that there is no workspace defined:
+# Make sure that there is no workspace defined.
#* ``ls ..`` must not have a file ''go.work'', in no parent folder.
-# Make sure that all dependencies are up-to-date:
+# Make sure that all dependencies are up-to-date.
#* ``cat go.mod``
# Clean up your Go workspace:
-#* ``go run tools/clean/clean.go`` (alternatively: ``make clean``)
+#* ``go run tools/build.go clean`` (alternatively: ``make clean``).
# All internal tests must succeed:
-#* ``go run tools/check/check.go -r`` (alternatively: ``make relcheck``)
+#* ``go run tools/build.go relcheck`` (alternatively: ``make relcheck``).
# The API tests must succeed on every development platform:
-#* ``go run tools/testapi/testapi.go`` (alternatively: ``make api``)
+#* ``go run tools/build.go testapi`` (alternatively: ``make api``).
# Run [[linkchecker|https://linkchecker.github.io/linkchecker/]] with the manual:
#* ``go run -race cmd/zettelstore/main.go run -d docs/manual``
#* ``linkchecker http://127.0.0.1:23123 2>&1 | tee lc.txt``
#* Check all ""Error: 404 Not Found""
-#* Check all ""Error: 403 Forbidden"": allowed for endpoint ''/z'' for those zettel that are accessible only in ''expert-mode''
+#* Check all ""Error: 403 Forbidden"": allowed for endpoint ''/p'' with encoding ''html'' for those zettel that are accessible only in ''expert-mode''.
#* Try to resolve other error messages and warnings
#* Warnings about empty content can be ignored
# On every development platform, the box with 10.000 zettel must run, with ''-race'' enabled:
-#* ``go run -race cmd/zettelstore/main.go run -d DIR``
+#* ``go run -race cmd/zettelstore/main.go run -d DIR``.
# Create a development release:
-#* ``go run tools/build.go release`` (alternatively: ``make release``)
+#* ``go run tools/build.go release`` (alternatively: ``make release``).
# On every platform (esp. macOS), the box with 10.000 zettel must run properly:
#* ``./zettelstore -d DIR``
-# Update files in directory ''www'':
-#* ''index.wiki''
-#* ''download.wiki''
-#* ''changes.wiki''
-#* ''plan.wiki''
+# Update files in directory ''www''
+#* index.wiki
+#* download.wiki
+#* changes.wiki
+#* plan.wiki
# Set file ''VERSION'' to the new release version.
- It **must** consists of three numbers: ''MAJOR.MINOR.PATCH'', even if ''PATCH'' is zero.
+ It _must_ consist of three digits: MAJOR.MINOR.PATCH, even if PATCH is zero
# Disable Fossil autosync mode:
#* ``fossil setting autosync off``
# Commit the new release version:
#* ``fossil commit --tag release --tag vVERSION -m "Version VERSION"``
#* **Important:** the tag must follow the given pattern, e.g. ''v0.0.15''.
- Otherwise client software will not be able to import ''zettelstore.de/z''.
+ Otherwise client will not be able to import ''zettelkasten.de/z''.
# Clean up your Go workspace:
-#* ``go run tools/clean/clean.go`` (alternatively: ``make clean``)
+#* ``go run tools/build.go clean`` (alternatively: ``make clean``).
# Create the release:
-#* ``go run tools/build/build.go release`` (alternatively: ``make release``)
+#* ``go run tools/build.go release`` (alternatively: ``make release``).
# Remove previous executables:
#* ``fossil uv remove --glob '*-PREVVERSION*'``
# Add executables for release:
-#* ``cd releases``
+#* ``cd release``
#* ``fossil uv add *.zip``
#* ``cd ..``
#* Synchronize with main repository:
#* ``fossil sync -u``
# Enable autosync:
DELETED docs/development/20231218181900.zettel
Index: docs/development/20231218181900.zettel
==================================================================
--- docs/development/20231218181900.zettel
+++ /dev/null
@@ -1,116 +0,0 @@
-id: 20231218181900
-title: Development tools
-role: zettel
-syntax: zmk
-created: 20231218181956
-modified: 20231218184500
-
-The source code contains some tools to assist the development of Zettelstore.
-These are located in the ''tools'' directory.
-
-Most tool support the generic option ``-v``, which log internal activities.
-
-Some of the tools can be called easier by using ``make``, that reads in a provided ''Makefile''.
-
-=== Check
-The ""check"" tool automates some testing activities.
-It is called via the command line:
-```
-# go run tools/check/check.go
-```
-There is an additional option ``-r`` to check in advance of a release.
-
-The following checks are executed:
-* Execution of unit tests, like ``go test ./...``
-* Analyze the source code for general problems, as in ``go vet ./...``
-* Tries to find shadowed variable, via ``shadow ./...``
-* Performs some additional checks on the source code, via ``staticcheck ./...``
-* Checks the usage of function parameters and usage of return values, via ``unparam ./...``.
- In case the option ''-r'' is set, the check includes exported functions and internal tests.
-* In case option ''-r'' is set, the source code is checked against the vulnerability database, via ``govulncheck ./...``
-
-Please note, that most of the tools above are not automatically installed in a standard Go distribution.
-Use the command ""devtools"" to install them.
-
-=== Devtools
-The following command installs all needed tools:
-```
-# go run tooles/devtools/devtools.go
-```
-It will also automatically update these tools.
-
-=== TestAPI
-The following command will perform some high-level tests:
-```sh
-# go run tools/testapi/testapi.go
-```
-Basically, a Zettelstore will be started and then API calls will be made to simulate some typical activities with the Zettelstore.
-
-If a Zettelstore is already running on port 23123, this Zettelstore will be used instead.
-Even if the API test should clean up later, some zettel might stay created if a test fails.
-This feature is used, if you want to have more control on the running Zettelstore.
-You should start it with the following command:
-```sh
-# go run -race cmd/zettelstore/main.go run -c testdata/testbox/19700101000000.zettel
-```
-This allows you to debug failing API tests.
-
-=== HTMLlint
-The following command will check the generated HTML code for validity:
-```sh
-# go run tools/htmllint/htmllint.go
-```
-In addition, you might specify the URL od a running Zettelstore.
-Otherwise ''http://localhost:23123'' is used.
-
-This command fetches first the list of all zettel.
-This list is used to check the generated HTML code (''ZID'' is the paceholder for the zettel identification):
-
-* Check all zettel HTML encodings, via the path ''/z/ZID?enc=html&part=zettel''
-* Check all zettel web views, via the path ''/h/ZID''
-* The info page of all zettel is checked, via path ''/i/ZID''
-* A subset of max. 100 zettel will be checked for the validity of their edit page, via ''/e/ZID''
-* 10 random zettel are checked for a valid create form, via ''/c/ZID''
-* A maximum of 200 random zettel are checked for a valid delete dialog, via ''/d/ZID''
-
-Depending on the selected Zettelstore, the command might take a long time.
-
-You can shorten the time, if you disable any zettel query in the footer.
-
-=== Build
-The ""build"" tool allows to build the software, either for tests or for a release.
-
-The following command will create a Zettelstore executable for the architecture of the current computer:
-```sh
-# go tools/build/build.go build
-```
-You will find the executable in the ''bin'' directory.
-
-A full release will be build in the directory ''releases'', containing ZIP files for the computer architectures ""Linux/amd64"", ""Linux/arm"", ""MacOS/arm64"", ""MacOS/amd64"", and ""Windows/amd64"".
-In addition, the manual is also build as a ZIP file:
-```sh
-# go run tools/build/build.go release
-```
-
-If you just want the ZIP file with the manual, please use:
-```sh
-# go run tools/build/build.go manual
-```
-
-In case you want to check the version of the Zettelstore to be build, use:
-```sh
-# go run tools/build/build.go version
-```
-
-=== Clean
-To remove the directories ''bin'' and ''releases'', as well as all cached Go libraries used by Zettelstore, execute:
-```sh
-# go run tools/clean/clean.go
-```
-
-Internally, the following commands are executed
-```sh
-# rm -rf bin releases
-# go clean ./...
-# go clean -cache -modcache -testcache
-```
Index: docs/manual/00000000000100.zettel
==================================================================
--- docs/manual/00000000000100.zettel
+++ docs/manual/00000000000100.zettel
@@ -1,10 +1,10 @@
id: 00000000000100
title: Zettelstore Runtime Configuration
role: configuration
syntax: none
-created: 20210126175322
+created: 00010101000000
default-copyright: (c) 2020-present by Detlef Stern
default-license: EUPL-1.2-or-later
default-visibility: public
footer-zettel: 00001000000100
home-zettel: 00001000000000
Index: docs/manual/00001000000000.zettel
==================================================================
--- docs/manual/00001000000000.zettel
+++ docs/manual/00001000000000.zettel
@@ -1,13 +1,11 @@
id: 00001000000000
title: Zettelstore Manual
role: manual
tags: #manual #zettelstore
syntax: zmk
-created: 20210126175322
-modified: 20241128141924
-show-back-links: false
+modified: 20220803183647
* [[Introduction|00001001000000]]
* [[Design goals|00001002000000]]
* [[Installation|00001003000000]]
* [[Configuration|00001004000000]]
@@ -20,8 +18,6 @@
* [[Web user interface|00001014000000]]
* [[Tips and Tricks|00001017000000]]
* [[Troubleshooting|00001018000000]]
* Frequently asked questions
-Version: {{00001000000001}}
-
Licensed under the EUPL-1.2-or-later.
DELETED docs/manual/00001000000001.zettel
Index: docs/manual/00001000000001.zettel
==================================================================
--- docs/manual/00001000000001.zettel
+++ /dev/null
@@ -1,8 +0,0 @@
-id: 00001000000001
-title: Manual Version
-role: configuration
-syntax: zmk
-created: 20231002142915
-modified: 20231002142948
-
-To be set by build tool.
DELETED docs/manual/00001000000002.zettel
Index: docs/manual/00001000000002.zettel
==================================================================
--- docs/manual/00001000000002.zettel
+++ /dev/null
@@ -1,7 +0,0 @@
-id: 00001000000002
-title: manual
-role: role
-syntax: zmk
-created: 20231128184200
-
-Zettel with the role ""manual"" contain the manual of the zettelstore.
Index: docs/manual/00001001000000.zettel
==================================================================
--- docs/manual/00001001000000.zettel
+++ docs/manual/00001001000000.zettel
@@ -1,17 +1,25 @@
id: 00001001000000
title: Introduction to the Zettelstore
role: manual
tags: #introduction #manual #zettelstore
syntax: zmk
-created: 20210126175322
-modified: 20250102181246
-
-[[Personal knowledge management|https://en.wikipedia.org/wiki/Personal_knowledge_management]] involves collecting, classifying, storing, searching, retrieving, assessing, evaluating, and sharing knowledge as a daily activity.
-It's done by most individuals, not necessarily as part of their main business.
-It's essential for knowledge workers, such as students, researchers, lecturers, software developers, scientists, engineers, architects, etc.
-Many hobbyists build up a significant amount of knowledge, even if they do not need to think for a living.
-Personal knowledge management can be seen as a prerequisite for many kinds of collaboration.
-
-Zettelstore is software that collects and relates your notes (""zettel"") to represent and enhance your knowledge, supporting the ""[[Zettelkasten method|https://en.wikipedia.org/wiki/Zettelkasten]]"".
-The method is based on creating many individual notes, each containing one idea or piece of information, which are related to each other.
-Since knowledge is typically built up gradually, one major focus is a long-term store of these notes, hence the name ""Zettelstore"".
+
+[[Personal knowledge
+management|https://en.wikipedia.org/wiki/Personal_knowledge_management]] is
+about collecting, classifying, storing, searching, retrieving, assessing,
+evaluating, and sharing knowledge as a daily activity. Personal knowledge
+management is done by most people, not necessarily as part of their main
+business. It is essential for knowledge workers, like students, researchers,
+lecturers, software developers, scientists, engineers, architects, to name
+a few. Many hobbyists build up a significant amount of knowledge, even if the
+do not need to think for a living. Personal knowledge management can be seen as
+a prerequisite for many kinds of collaboration.
+
+Zettelstore is a software that collects and relates your notes (""zettel"")
+to represent and enhance your knowledge. It helps with many tasks of personal
+knowledge management by explicitly supporting the ""[[Zettelkasten
+method|https://en.wikipedia.org/wiki/Zettelkasten]]"". The method is based on
+creating many individual notes, each with one idea or information, that are
+related to each other. Since knowledge is typically build up gradually, one
+major focus is a long-term store of these notes, hence the name
+""Zettelstore"".
Index: docs/manual/00001002000000.zettel
==================================================================
--- docs/manual/00001002000000.zettel
+++ docs/manual/00001002000000.zettel
@@ -2,21 +2,17 @@
title: Design goals for the Zettelstore
role: manual
tags: #design #goal #manual #zettelstore
syntax: zmk
created: 20210126175322
-modified: 20250102191434
+modified: 20221018105415
Zettelstore supports the following design goals:
; Longevity of stored notes / zettel
: Every zettel you create should be readable without the help of any tool, even without Zettelstore.
-: It should not hard to write other software that works with your zettel.
-: Normal zettel should be stored in a single file.
- If this is not possible: at most in two files: one for the metadata, one for the content.
- The only exceptions are [[predefined zettel|00001005090000]] stored in the Zettelstore executable.
-: There is no additional database.
+: It should be not hard to write other software that works with your zettel.
; Single user
: All zettel belong to you, only to you.
Zettelstore provides its services only to one person: you.
If the computer running Zettelstore 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.
@@ -26,18 +22,16 @@
; Ease of operation
: 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 on how to synchronize your zettel.
+: 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
: Zettelstore provides a default [[web-based user interface|00001014000000]].
- Anyone can provide alternative user interfaces, e.g. for special purposes.
+ Anybody can provide alternative user interfaces, e.g. for special purposes.
; Simple service
: The purpose of Zettelstore is to safely store your zettel and to provide some initial relations between them.
: External software can be written to deeply analyze your zettel and the structures they form.
; Security by default
: Without any customization, Zettelstore provides its services in a safe and secure manner and does not expose you (or other users) to security risks.
-: If you know what you are doing, Zettelstore allows you to relax some security-related preferences.
+: If you know what use are doing, Zettelstore allows you to relax some security-related preferences.
However, even in this case, the more secure way is chosen.
-: The Zettelstore software uses a minimal design and uses other software dependencies only is essential needed.
-: There will be no plugin mechanism, which allows external software to control the inner workings of the Zettelstore software.
Index: docs/manual/00001003000000.zettel
==================================================================
--- docs/manual/00001003000000.zettel
+++ docs/manual/00001003000000.zettel
@@ -1,29 +1,27 @@
id: 00001003000000
title: Installation of the Zettelstore software
role: manual
tags: #installation #manual #zettelstore
syntax: zmk
-created: 20210126175322
-modified: 20250102185359
+modified: 20220119145756
=== The curious user
You just want to check out the Zettelstore software
-* Grab the appropriate executable and copy it to any directory
-* Start the Zettelstore software, e.g. with a double click[^On Windows and macOS, the operating system tries to protect you from possible malicious software.
- If you encounter a problem, please refer to the [[Troubleshooting|00001018000000]] page.]
+* Grab the appropriate executable and copy it into any directory
+* Start the Zettelstore software, e.g. with a double click[^On Windows and macOS, the operating system tries to protect you from possible malicious software. If you encounter problem, please take a look on the [[Troubleshooting|00001018000000]] page.]
* 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.
- A mostly empty Zettelstore is presented.
+ 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|00001014000000]] and learn about the various ways to write zettel.
* If you restart your device, please make sure to start your Zettelstore again.
=== The intermediate user
-You have already tried the Zettelstore software and now you want to use it permanently.
+You already tried the Zettelstore software and now you want to use it permanently.
Zettelstore should start automatically when you log into your computer.
Please follow [[these instructions|00001003300000]].
=== The server administrator
Index: docs/manual/00001003300000.zettel
==================================================================
--- docs/manual/00001003300000.zettel
+++ docs/manual/00001003300000.zettel
@@ -1,14 +1,13 @@
id: 00001003300000
title: Zettelstore installation for the intermediate user
role: manual
tags: #installation #manual #zettelstore
syntax: zmk
-created: 20211125191727
-modified: 20250227220050
+modified: 20220114175754
-You have already tried the Zettelstore software and now you want to use it permanently.
+You already tried the Zettelstore software and now you want to use it permanently.
Zettelstore should start automatically when you log into your computer.
* Grab the appropriate executable and copy it into the appropriate directory
* If you want to place your zettel into another directory, or if you want more than one [[Zettelstore box|00001004011200]], or if you want to [[enable authentication|00001010040100]], or if you want to tweak your Zettelstore in some other way, create an appropriate [[startup configuration file|00001004010000]].
* If you created a startup configuration file, you need to test it:
@@ -17,17 +16,11 @@
In most cases, this is done by the command ``cd DIR``, where ''DIR'' denotes the directory, where you placed the executable.
** Start the Zettelstore:
*** On Windows execute the command ``zettelstore.exe run -c CONFIG_FILE``
*** On macOS execute the command ``./zettelstore run -c CONFIG_FILE``
*** On Linux execute the command ``./zettelstore run -c CONFIG_FILE``
-** In all cases ''CONFIG_FILE'' must be replaced with the file name where you wrote the startup configuration.
+** In all cases ''CONFIG_FILE'' must be substituted by file name where you wrote the startup configuration.
** If you encounter some error messages, update the startup configuration, and try again.
* Depending on your operating system, there are different ways to register Zettelstore to start automatically:
** [[Windows|00001003305000]]
** [[macOS|00001003310000]]
** [[Linux|00001003315000]]
-
-A word of caution: Never expose Zettelstore directly to the Internet.
-As a personal service, Zettelstore is not designed to handle all aspects of the open web.
-For instance, it lacks support for certificate handling, which is necessary for encrypted HTTP connections.
-To ensure security, [[install Zettelstore on a server|00001003600000]] and place it behind a proxy server designed for Internet exposure.
-For more details, see: [[External server to encrypt message transport|00001010090100]].
Index: docs/manual/00001003305000.zettel
==================================================================
--- docs/manual/00001003305000.zettel
+++ docs/manual/00001003305000.zettel
@@ -1,12 +1,11 @@
id: 00001003305000
title: Enable Zettelstore to start automatically on Windows
role: manual
tags: #installation #manual #zettelstore
syntax: zmk
-created: 20211125191727
-modified: 20241213103259
+modified: 20220218125541
Windows is a complicated beast. There are several ways to automatically start Zettelstore.
=== Startup folder
@@ -33,11 +32,11 @@
The Windows Task scheduler allows you to start Zettelstore as an background task.
This is both an advantage and a disadvantage.
-On the plus side, Zettelstore runs in the background, and it does not disturb you.
+On the plus side, Zettelstore runs in the background, and it does not disturbs you.
All you have to do is to open your web browser, enter the appropriate URL, and there you go.
On the negative side, you will not be notified when you enter the wrong data in the Task scheduler and Zettelstore fails to start.
This can be mitigated by first using the command line prompt to start Zettelstore with the appropriate options.
Once everything works, you can register Zettelstore to be automatically started by the task scheduler.
@@ -70,11 +69,11 @@
{{00001003305112}}
The next steps are the trickiest.
-If you did not create a startup configuration file, then create an action that starts a program.
+If you did not created a startup configuration file, then create an action that starts a program.
Enter the file path where you placed the Zettelstore executable.
The ""Browse ..."" button helps you with that.[^I store my Zettelstore executable in the sub-directory ''bin'' of my home directory.]
It is essential that you also enter a directory, which serves as the environment for your zettelstore.
The (sub-) directory ''zettel'', which will contain your zettel, will be placed in this directory.
Index: docs/manual/00001003310000.zettel
==================================================================
--- docs/manual/00001003310000.zettel
+++ docs/manual/00001003310000.zettel
@@ -1,11 +1,10 @@
id: 00001003310000
title: Enable Zettelstore to start automatically on macOS
role: manual
tags: #installation #manual #zettelstore
syntax: zmk
-created: 20220114181521
modified: 20220119124635
There are several ways to automatically start Zettelstore.
* [[Login Items|#login-items]]
Index: docs/manual/00001003315000.zettel
==================================================================
--- docs/manual/00001003315000.zettel
+++ docs/manual/00001003315000.zettel
@@ -1,12 +1,11 @@
id: 00001003315000
title: Enable Zettelstore to start automatically on Linux
role: manual
tags: #installation #manual #zettelstore
syntax: zmk
-created: 20220114181521
-modified: 20250102221716
+modified: 20220307104944
Since there is no such thing as the one Linux, there are too many different ways to automatically start Zettelstore.
* One way is to interpret your Linux desktop system as a server and use the [[recipe to install Zettelstore on a server|00001003600000]].
** See below for a lighter alternative.
@@ -17,11 +16,11 @@
* [[LXDE|https://www.lxde.org/]] uses [[LXSession Edit|https://wiki.lxde.org/en/LXSession_Edit]] to allow users to specify autostart applications.
If you use a different desktop environment, it often helps to to provide its name and the string ""autostart"" to google for it with the search engine of your choice.
Yet another way is to make use of the middleware that is provided.
-Many Linux distributions make use of [[systemd|https://systemd.io/]], which allows to start processes on behalf of a user.
+Many Linux distributions make use of [[systemd|https://systemd.io/]], which allows to start processes on behalf of an user.
On the command line, adapt the following script to your own needs and execute it:
```
# mkdir -p "$HOME/.config/systemd/user"
# cd "$HOME/.config/systemd/user"
# cat <<__EOF__ > zettelstore.service
Index: docs/manual/00001003600000.zettel
==================================================================
--- docs/manual/00001003600000.zettel
+++ docs/manual/00001003600000.zettel
@@ -1,12 +1,11 @@
id: 00001003600000
title: Installation of Zettelstore on a server
role: manual
tags: #installation #manual #zettelstore
syntax: zmk
-created: 20211125191727
-modified: 20250227220033
+modified: 20211125185833
You want to provide a shared Zettelstore that can be used from your various devices.
Installing Zettelstore as a Linux service is not that hard.
Grab the appropriate executable and copy it into the appropriate directory:
@@ -50,11 +49,5 @@
Use the commands ``systemctl``{=sh} and ``journalctl``{=sh} to manage the service, e.g.:
```sh
# sudo systemctl status zettelstore # verify that it is running
# sudo journalctl -u zettelstore # obtain the output of the running zettelstore
```
-
-A word of caution: Never expose Zettelstore directly to the Internet.
-As a personal service, Zettelstore is not designed to handle all aspects of the open web.
-For instance, it lacks support for certificate handling, which is necessary for encrypted HTTP connections.
-To ensure security, place Zettelstore behind a proxy server designed for Internet exposure.
-For more details, see: [[External server to encrypt message transport|00001010090100]].
Index: docs/manual/00001004000000.zettel
==================================================================
--- docs/manual/00001004000000.zettel
+++ docs/manual/00001004000000.zettel
@@ -1,14 +1,13 @@
id: 00001004000000
title: Configuration of Zettelstore
role: manual
tags: #configuration #manual #zettelstore
syntax: zmk
-created: 20210126175322
-modified: 20250102181034
+modified: 20210510153233
-There are several levels to change the behavior and/or the appearance of Zettelstore.
+There are some levels to change the behavior and/or the appearance of Zettelstore.
# The first level is the way to start Zettelstore services and to manage it via command line (and, in part, via a graphical user interface).
#* [[Command line parameters|00001004050000]]
# As an intermediate user, you usually want to have more control over how Zettelstore is started.
This may include the URI under which your Zettelstore is accessible, or the directories in which your Zettel are stored.
Index: docs/manual/00001004010000.zettel
==================================================================
--- docs/manual/00001004010000.zettel
+++ docs/manual/00001004010000.zettel
@@ -2,39 +2,40 @@
title: Zettelstore startup configuration
role: manual
tags: #configuration #manual #zettelstore
syntax: zmk
created: 20210126175322
-modified: 20250102180346
+modified: 20221128155143
-The configuration file, specified by the ''-c CONFIGFILE'' [[command line option|00001004051000]], allows you to specify some startup options.
-These cannot be stored in a [[configuration zettel|00001004020000]] because they are needed before Zettelstore can start or because of security reasons.
-For example, Zettelstore needs to know in advance on which network address it must listen or where zettel are stored.
+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 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.
+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.
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 it.
+ 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""
; [!asset-dir|''asset-dir'']
-: Allows to specify a directory whose files are allowed to be transferred directly with the help of the web server.
+: Allows to specify a directory whose files are allowed be transferred directly with the help of the web server.
The URL prefix for these files is ''/assets/''.
- You can use this if you want to transfer files that are too large for a zettel, such as presentation, PDF, music or video files.
+ You can use this if you want to transfer files that are too large for a note to users.
+ Examples would be presentation files, PDF files, music files or video files.
- Files within the given directory will not be managed by Zettelstore.[^They will be managed by Zettelstore just in the very special case that the directory is one of the configured [[boxes|#box-uri-x]].]
+ Files within the given directory will not be managed by Zettelstore.[^They will be managed by Zettelstore just in the case that the directory is one of the configured [[boxes|#box-uri-x]].]
- If you specify only the URL prefix in your web client, the contents of the directory are listed.
+ If you specify only the URL prefix, then the contents of the directory are listed to the user.
To avoid this, create an empty file in the directory named ""index.html"".
Default: """", no asset directory is set, the URL prefix ''/assets/'' is invalid.
; [!base-url|''base-url'']
: Sets the absolute base URL for the service.
@@ -42,31 +43,31 @@
Note: [[''url-prefix''|#url-prefix]] must be the suffix of ''base-url'', otherwise the web service will not start.
Default: ""http://127.0.0.1:23123/"".
; [!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 incremented, starting with one, until no key is found.
- This allows to configuring than one box.
+ 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.
; [!debug-mode|''debug-mode'']
-: If set to [[true|00001006030500]], allows to debug the Zettelstore software (mostly used by Zettelstore developers).
+: Allows to debug the Zettelstore software (mostly used by the developers) if set to [[true|00001006030500]]
Disables any timeout values of the internal web server and does not send some security-related data.
Sets [[''log-level''|#log-level]] to ""debug"".
- Enables [[''runtime-profiling''|#runtime-profiling]].
Do not enable it for a production server.
Default: ""false""
; [!default-dir-box-type|''default-dir-box-type'']
-: Specifies the default value for the (sub-)type of [[directory boxes|00001004011400#type]], in which Zettel are typically stored.
+: 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|00001006030500]] if authentication is enabled and Zettelstore is not accessible via HTTPS (but via HTTP).
- Otherwise web browsers are free to ignore the authentication cookie.
+: Must be set to [[true|00001006030500]], 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.
Default: ""false""
; [!insecure-html|''insecure-html'']
: Allows to use HTML, e.g. within supported markup languages, even if this might introduce security-related problems.
However, HTML containing the ``