DELETED .deepsource.toml Index: .deepsource.toml ================================================================== --- .deepsource.toml +++ /dev/null @@ -1,8 +0,0 @@ -version = 1 - -[[analyzers]] -name = "go" -enabled = true - - [analyzers.meta] - import_root = "github.com/zettelstore/client" DELETED .github/dependabot.yml Index: .github/dependabot.yml ================================================================== --- .github/dependabot.yml +++ /dev/null @@ -1,12 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - package-ecosystem: "gomod" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "daily" - rebase-strategy: "disabled" Index: LICENSE.txt ================================================================== --- LICENSE.txt +++ LICENSE.txt @@ -1,6 +1,6 @@ -Copyright (c) 2021-2022 Detlef Stern +Copyright (c) 2021-present Detlef Stern Licensed under the EUPL Zettelstore client is licensed under the European Union Public License, version 1.2 or later (EUPL v. 1.2). The license is available in the Index: api/api.go ================================================================== --- api/api.go +++ api/api.go @@ -1,39 +1,24 @@ //----------------------------------------------------------------------------- -// Copyright (c) 2021-2022 Detlef Stern +// Copyright (c) 2021-present Detlef Stern // // This file is part of zettelstore-client. // // Zettelstore client 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: 2021-present Detlef Stern //----------------------------------------------------------------------------- // Package api contains common definitions used for client and server. package api -// ZettelID contains the identifier of a zettel. It is a string with 14 digits. -type ZettelID string - -// InvalidZID is an invalif zettel identifier -const InvalidZID = "" - -// IsValid returns true, if the idenfifier contains 14 digits. -func (zid ZettelID) IsValid() bool { - if len(zid) != 14 { - return false - } - for i := 0; i < 14; i++ { - ch := zid[i] - if ch < '0' || '9' < ch { - return false - } - } - return true -} - -// ZettelMeta is a map containg the metadata of a zettel. +import "t73f.de/r/zsc/domain/id" + +// ZettelMeta is a map containg the normalized metadata of a zettel. type ZettelMeta map[string]string // ZettelRights is an integer that encode access rights for a zettel. type ZettelRights uint8 @@ -41,81 +26,38 @@ const ( ZettelCanNone ZettelRights = 1 << iota ZettelCanCreate // Current user is allowed to create a new zettel ZettelCanRead // Requesting user is allowed to read the zettel ZettelCanWrite // Requesting user is allowed to update the zettel - ZettelCanRename // Requesting user is allowed to provide the zettel with a new identifier - ZettelCanDelete // Requesting user is allowed to delete the zettel -) - -// AuthJSON contains the result of an authentication call. -type AuthJSON struct { - Token string `json:"token"` - Type string `json:"token_type"` - Expires int `json:"expires_in"` -} - -// ZidJSON contains the identifier data of a zettel. -type ZidJSON struct { - ID ZettelID `json:"id"` -} - -// MetaJSON contains the metadata of a zettel. -type MetaJSON struct { - Meta ZettelMeta `json:"meta"` - Rights ZettelRights `json:"rights"` -} - -// ZidMetaJSON contains the identifier and the metadata of a zettel. -type ZidMetaJSON struct { - ID ZettelID `json:"id"` - Meta ZettelMeta `json:"meta"` - Rights ZettelRights `json:"rights"` -} - -// ZidMetaRelatedList contains identifier/metadata of a zettel and the same for related zettel -type ZidMetaRelatedList struct { - ID ZettelID `json:"id"` - Meta ZettelMeta `json:"meta"` - Rights ZettelRights `json:"rights"` - List []ZidMetaJSON `json:"list"` -} - -// ZettelDataJSON contains all data for a zettel. -type ZettelDataJSON struct { - Meta ZettelMeta `json:"meta"` - Encoding string `json:"encoding"` - Content string `json:"content"` -} - -// ZettelJSON contains all data for a zettel, the identifier, the metadata, and the content. -type ZettelJSON struct { - ID ZettelID `json:"id"` - Meta ZettelMeta `json:"meta"` - Encoding string `json:"encoding"` - Content string `json:"content"` - Rights ZettelRights `json:"rights"` -} - -// ZettelListJSON contains data for a zettel list. -type ZettelListJSON struct { - Query string `json:"query"` - Human string `json:"human"` - List []ZidMetaJSON `json:"list"` -} - -// MapMeta maps metadata keys to list of metadata. -type MapMeta map[string][]ZettelID - -// MapListJSON specifies the map of metadata key to list of metadata that contains the key. -type MapListJSON struct { - Map MapMeta `json:"map"` -} - -// VersionJSON contains version information. -type VersionJSON struct { - Major int `json:"major"` - Minor int `json:"minor"` - Patch int `json:"patch"` - Info string `json:"info"` - Hash string `json:"hash"` -} + placeholdergo1 // Was assigned to rename right, which is now removed + ZettelCanDelete // Requesting user is allowed to delete the zettel + ZettelMaxRight // Sentinel value +) + +// MetaRights contains the metadata of a zettel, and its rights. +type MetaRights struct { + Meta ZettelMeta + Rights ZettelRights +} + +// ZidMetaRights contains the identifier, the metadata of a zettel, and its rights. +type ZidMetaRights struct { + ID id.Zid + Meta ZettelMeta + Rights ZettelRights +} + +// ZettelData contains all data for a zettel. +// +// - Meta is a map containing the metadata of the zettel. +// - Rights is an integer specifying the access rights. +// - Encoding is a string specifying the encoding of the zettel content. +// - Content is the zettel content itself. +type ZettelData struct { + Meta ZettelMeta + Rights ZettelRights + Encoding string + Content string // raw, uninterpreted zettel content +} + +// Aggregate maps metadata keys to list of zettel identifier. +type Aggregate map[string][]id.Zid Index: api/const.go ================================================================== --- api/const.go +++ api/const.go @@ -1,190 +1,66 @@ //----------------------------------------------------------------------------- -// Copyright (c) 2021-2022 Detlef Stern +// Copyright (c) 2021-present Detlef Stern // // This file is part of zettelstore-client. // // Zettelstore client 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: 2021-present Detlef Stern //----------------------------------------------------------------------------- package api import "fmt" -// Predefined Zettel Identifier -const ( - // System zettel - ZidVersion = ZettelID("00000000000001") - ZidHost = ZettelID("00000000000002") - ZidOperatingSystem = ZettelID("00000000000003") - ZidLicense = ZettelID("00000000000004") - ZidAuthors = ZettelID("00000000000005") - ZidDependencies = ZettelID("00000000000006") - ZidLog = ZettelID("00000000000007") - ZidBoxManager = ZettelID("00000000000020") - ZidMetadataKey = ZettelID("00000000000090") - ZidParser = ZettelID("00000000000092") - ZidStartupConfiguration = ZettelID("00000000000096") - ZidConfiguration = ZettelID("00000000000100") - - // WebUI HTML templates are in the range 10000..19999 - ZidBaseTemplate = ZettelID("00000000010100") - ZidLoginTemplate = ZettelID("00000000010200") - ZidListTemplate = ZettelID("00000000010300") - ZidZettelTemplate = ZettelID("00000000010401") - ZidInfoTemplate = ZettelID("00000000010402") - ZidFormTemplate = ZettelID("00000000010403") - ZidRenameTemplate = ZettelID("00000000010404") - ZidDeleteTemplate = ZettelID("00000000010405") - ZidContextTemplate = ZettelID("00000000010406") - ZidRolesTemplate = ZettelID("00000000010500") - ZidTagsTemplate = ZettelID("00000000010600") - ZidErrorTemplate = ZettelID("00000000010700") - - // CSS-related zettel are in the range 20000..29999 - ZidBaseCSS = ZettelID("00000000020001") - ZidUserCSS = ZettelID("00000000025001") - ZidRoleCSSMap = ZettelID("00000000029000") // Maps roles to CSS zettel, which should be in the range 29001..29999. - - // WebUI JS zettel are in the range 30000..39999 - - // WebUI image zettel are in the range 40000..49999 - ZidEmoji = ZettelID("00000000040001") - - // Range 90000...99999 is reserved for zettel templates - ZidTOCNewTemplate = ZettelID("00000000090000") - ZidTemplateNewZettel = ZettelID("00000000090001") - ZidTemplateNewUser = ZettelID("00000000090002") - - ZidDefaultHome = ZettelID("00010000000000") -) - -// LengthZid factors the constant length of a zettel identifier -const LengthZid = len(ZidDefaultHome) - -// Values of the metadata key/value type. -const ( - MetaCredential = "Credential" - MetaEmpty = "EString" - MetaID = "Identifier" - MetaIDSet = "IdentifierSet" - MetaNumber = "Number" - MetaString = "String" - MetaTagSet = "TagSet" - MetaTimestamp = "Timestamp" - MetaURL = "URL" - MetaWord = "Word" - MetaWordSet = "WordSet" - MetaZettelmarkup = "Zettelmarkup" -) - -// Predefined general Metadata keys -const ( - KeyID = "id" - KeyTitle = "title" - KeyRole = "role" - KeyTags = "tags" - KeySyntax = "syntax" - KeyAllTags = "all-" + KeyTags - KeyBack = "back" - KeyBackward = "backward" - KeyBoxNumber = "box-number" - KeyContentTags = "content-" + KeyTags - KeyCopyright = "copyright" - KeyCredential = "credential" - KeyDead = "dead" - KeyFolge = "folge" - KeyForward = "forward" - KeyLang = "lang" - KeyLicense = "license" - KeyModified = "modified" - KeyPrecursor = "precursor" - KeyPublished = "published" - KeyReadOnly = "read-only" - KeySummary = "summary" - KeyURL = "url" - KeyUselessFiles = "useless-files" - KeyUserID = "user-id" - KeyUserRole = "user-role" - KeyVisibility = "visibility" -) - -// Predefined Metadata values -const ( - ValueRoleConfiguration = "configuration" - ValueRoleZettel = "zettel" - ValueSyntaxGif = "gif" - ValueSyntaxHTML = "html" - ValueSyntaxNone = "none" - ValueSyntaxSVG = "svg" - ValueSyntaxText = "text" - ValueSyntaxZmk = "zmk" - ValueFalse = "false" - ValueTrue = "true" - ValueLangEN = "en" - ValueUserRoleCreator = "creator" - ValueUserRoleOwner = "owner" - ValueUserRoleReader = "reader" - ValueUserRoleWriter = "writer" - ValueVisibilityCreator = "creator" - ValueVisibilityExpert = "expert" - ValueVisibilityLogin = "login" - ValueVisibilityOwner = "owner" - ValueVisibilityPublic = "public" -) - // Additional HTTP constants. const ( - MethodMove = "MOVE" // HTTP method for renaming a zettel - HeaderAccept = "Accept" HeaderContentType = "Content-Type" HeaderDestination = "Destination" HeaderLocation = "Location" ) // Values for HTTP query parameter. const ( - QueryKeyCommand = "_cmd" - QueryKeyDepth = "_depth" - QueryKeyDir = "_dir" - QueryKeyEncoding = "_enc" - QueryKeyKey = "_key" - QueryKeyLimit = "_limit" - QueryKeyMin = "_min" - QueryKeyNegate = "_negate" - QueryKeyOffset = "_offset" - QueryKeyOrder = "_order" - QueryKeyPart = "_part" - QueryKeyPhrase = "_phrase" - QueryKeySearch = "_s" - QueryKeySort = "_sort" -) - -// Supported dir values. -const ( - DirBackward = "backward" - DirForward = "forward" + QueryKeyCommand = "cmd" + QueryKeyEncoding = "enc" + QueryKeyParseOnly = "parseonly" + QueryKeyPart = "part" + QueryKeyPhrase = "phrase" + QueryKeyQuery = "q" + QueryKeyRole = "role" + QueryKeySeed = "_seed" + QueryKeyTag = "tag" ) // Supported encoding values. const ( - EncodingHTML = "html" - EncodingSexpr = "sexpr" - EncodingText = "text" - EncodingZJSON = "zjson" - EncodingZMK = "zmk" + EncodingHTML = "html" // Plain HTML + EncodingMD = "md" // Markdown + EncodingSHTML = "shtml" // SxHTML + EncodingSz = "sz" // Structure of zettel, encoded a an S-expression + EncodingText = "text" // plain text content + EncodingZMK = "zmk" // Zettelmarkup + + EncodingPlain = "plain" // Plain zettel, no processing + EncodingData = "data" // Plain zettel, metadata as S-Expression ) var mapEncodingEnum = map[string]EncodingEnum{ EncodingHTML: EncoderHTML, - EncodingSexpr: EncoderSexpr, + EncodingMD: EncoderMD, + EncodingSHTML: EncoderSHTML, + EncodingSz: EncoderSz, EncodingText: EncoderText, - EncodingZJSON: EncoderZJSON, EncodingZMK: EncoderZmk, + + EncodingPlain: EncoderPlain, + EncodingData: EncoderData, } var mapEnumEncoding = map[EncodingEnum]string{} func init() { for k, v := range mapEncodingEnum { @@ -205,14 +81,18 @@ // Values for EncoderEnum const ( EncoderUnknown EncodingEnum = iota EncoderHTML - EncoderSexpr + EncoderMD + EncoderSHTML + EncoderSz EncoderText - EncoderZJSON EncoderZmk + + EncoderPlain + EncoderData ) // String representation of an encoder key. func (e EncodingEnum) String() string { if f, ok := mapEnumEncoding[e]; ok { @@ -229,10 +109,63 @@ ) // Command to be executed atthe Zettelstore type Command string -// Supported command values +// Supported command values. const ( CommandAuthenticated = Command("authenticated") CommandRefresh = Command("refresh") ) + +// Supported search operator representations. +const ( + BackwardDirective = "BACKWARD" // Backward-only context + ContextDirective = "CONTEXT" // Context directive + CostDirective = "COST" // Maximum cost of a context operation + ForwardDirective = "FORWARD" // Forward-only context + FullDirective = "FULL" // Include tags in context + IdentDirective = "IDENT" // Use only specified zettel + ItemsDirective = "ITEMS" // Select list elements in a zettel + MaxDirective = "MAX" // Maximum number of context results + MinDirective = "MIN" // Minimum number of context results + LimitDirective = "LIMIT" // Maximum number of zettel + OffsetDirective = "OFFSET" // Offset to start returned zettel list + OrDirective = "OR" // Combine several search expression with an "or" + OrderDirective = "ORDER" // Specify metadata keys for the order of returned list + PhraseDirective = "PHRASE" // Only unlinked zettel with given phrase + PickDirective = "PICK" // Pick some random zettel + RandomDirective = "RANDOM" // Order zettel list randomly + ReverseDirective = "REVERSE" // Reverse the order of a zettel list + UnlinkedDirective = "UNLINKED" // Search for zettel that contain a phase(s) but do not link + + ActionSeparator = "|" // Separates action list of previous elements of query expression + + KeysAction = "KEYS" // Provide metadata key used + MinAction = "MIN" // Return only those values with a minimum amount of zettel + MaxAction = "MAX" // Return only those values with a maximum amount of zettel + NumberedAction = "NUMBERED" // Return a numbered list + RedirectAction = "REDIRECT" // Return the first zettel in list + ReIndexAction = "REINDEX" // Ensure that zettel is/are indexed. + + ExistOperator = "?" // Does zettel have metadata with given key? + ExistNotOperator = "!?" // True id zettel does not have metadata with given key. + + SearchOperatorNot = "!" + SearchOperatorEqual = "=" // True if values are equal + SearchOperatorNotEqual = "!=" // False if values are equal + SearchOperatorHas = ":" // True if values are equal/included + SearchOperatorHasNot = "!:" // False if values are equal/included + SearchOperatorPrefix = "[" // True if value is prefix of the other + SearchOperatorNoPrefix = "![" // False if value is prefix of the other + SearchOperatorSuffix = "]" // True if value is suffix of other + SearchOperatorNoSuffix = "!]" // False if value is suffix of other + SearchOperatorMatch = "~" // True if value is included in other + SearchOperatorNoMatch = "!~" // False if value is included in other + SearchOperatorLess = "<" // True if value is smaller than other + SearchOperatorNotLess = "!<" // False if value is smaller than other + SearchOperatorGreater = ">" // True if value is greater than other + SearchOperatorNotGreater = "!>" // False if value is greater than other +) + +// QueryPrefix is the prefix that denotes a query expression within a reference. +const QueryPrefix = "query:" Index: api/urlbuilder.go ================================================================== --- api/urlbuilder.go +++ api/urlbuilder.go @@ -1,149 +1,90 @@ //----------------------------------------------------------------------------- -// Copyright (c) 2020-2022 Detlef Stern +// Copyright (c) 2020-present Detlef Stern // // This file is part of zettelstore-client. // // Zettelstore client 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 api import ( - "net/url" - "strings" + "t73f.de/r/webs/urlbuilder" + "t73f.de/r/zsc/domain/id" ) -type urlQuery struct{ key, val string } - // URLBuilder should be used to create zettelstore URLs. type URLBuilder struct { - prefix string - key byte - rawLocal string - path []string - query []urlQuery - fragment string + base urlbuilder.URLBuilder + prefix string } // NewURLBuilder creates a new URL builder with the given prefix and key. func NewURLBuilder(prefix string, key byte) *URLBuilder { - return &URLBuilder{prefix: prefix, key: key} -} - -// Clone an URLBuilder -func (ub *URLBuilder) Clone() *URLBuilder { - cpy := new(URLBuilder) - cpy.key = ub.key - if len(ub.path) > 0 { - cpy.path = make([]string, 0, len(ub.path)) - cpy.path = append(cpy.path, ub.path...) - } - if len(ub.query) > 0 { - cpy.query = make([]urlQuery, 0, len(ub.query)) - cpy.query = append(cpy.query, ub.query...) - } - cpy.fragment = ub.fragment - return cpy -} - -// SetRawLocal sets everything that follows the prefix / key. -func (ub *URLBuilder) SetRawLocal(rawLocal string) *URLBuilder { - for len(rawLocal) > 0 && rawLocal[0] == '/' { - rawLocal = rawLocal[1:] - } - ub.rawLocal = rawLocal - ub.path = nil - ub.query = nil - ub.fragment = "" - return ub -} - -// SetZid sets the zettel identifier. -func (ub *URLBuilder) SetZid(zid ZettelID) *URLBuilder { - if len(ub.path) > 0 { - panic("Cannot add Zid") - } - ub.rawLocal = "" - ub.path = append(ub.path, string(zid)) - return ub -} - -// AppendPath adds a new path element -func (ub *URLBuilder) AppendPath(p string) *URLBuilder { - ub.rawLocal = "" - for len(p) > 0 && p[0] == '/' { - p = p[1:] - } - if p != "" { - ub.path = append(ub.path, p) - } - return ub -} - -// AppendQuery adds a new query parameter -func (ub *URLBuilder) AppendQuery(key, value string) *URLBuilder { - ub.rawLocal = "" - ub.query = append(ub.query, urlQuery{key, value}) - return ub -} - -// AppendSearch adds a new search -func (ub *URLBuilder) AppendSearch(value string) *URLBuilder { - ub.rawLocal = "" - ub.query = append(ub.query, urlQuery{QueryKeySearch, value}) + for len(prefix) > 0 && prefix[len(prefix)-1] == '/' { + prefix = prefix[0 : len(prefix)-1] + } + result := URLBuilder{prefix: prefix} + if key != '/' { + result.base.AddPath(string([]byte{key})) + } + return &result +} + +// Clone an URLBuilder. +func (ub *URLBuilder) Clone() *URLBuilder { + cpy := new(URLBuilder) + ub.base.Copy(&cpy.base) + cpy.prefix = ub.prefix + return cpy +} + +// SetZid sets the zettel identifier. +func (ub *URLBuilder) SetZid(zid id.Zid) *URLBuilder { + ub.base.AddPath(zid.String()) + return ub +} + +// AppendPath adds a new path element. +func (ub *URLBuilder) AppendPath(p string) *URLBuilder { + ub.base.AddPath(p) + return ub +} + +// AppendKVQuery adds a new key/value query parameter. +func (ub *URLBuilder) AppendKVQuery(key, value string) *URLBuilder { + ub.base.AddQuery(key, value) + return ub +} + +// AppendQuery adds a new query. +// +// Basically the same as [URLBuilder.AppendKVQuery]([api.QueryKeyQuery], value) +func (ub *URLBuilder) AppendQuery(value string) *URLBuilder { + if value != "" { + ub.base.AddQuery(QueryKeyQuery, value) + } return ub } // ClearQuery removes all query parameters. func (ub *URLBuilder) ClearQuery() *URLBuilder { - ub.rawLocal = "" - ub.query = nil - ub.fragment = "" + ub.base.RemoveQueries() return ub } -// SetFragment stores the fragment +// SetFragment sets the fragment. func (ub *URLBuilder) SetFragment(s string) *URLBuilder { - ub.rawLocal = "" - ub.fragment = s + ub.base.SetFragment(s) return ub } // String produces a string value. func (ub *URLBuilder) String() string { - var sb strings.Builder - - sb.WriteString(ub.prefix) - if ub.key != '/' { - sb.WriteByte(ub.key) - } - if ub.rawLocal != "" { - sb.WriteString(ub.rawLocal) - return sb.String() - } - for i, p := range ub.path { - if i > 0 || ub.key != '/' { - sb.WriteByte('/') - } - sb.WriteString(url.PathEscape(p)) - } - if len(ub.fragment) > 0 { - sb.WriteByte('#') - sb.WriteString(ub.fragment) - } - for i, q := range ub.query { - if i == 0 { - sb.WriteByte('?') - } else { - sb.WriteByte('&') - } - sb.WriteString(q.key) - if val := q.val; val != "" { - sb.WriteByte('=') - sb.WriteString(url.QueryEscape(val)) - } - } - return sb.String() + return ub.prefix + ub.base.String() } DELETED attrs/attrs.go Index: attrs/attrs.go ================================================================== --- attrs/attrs.go +++ /dev/null @@ -1,125 +0,0 @@ -//----------------------------------------------------------------------------- -// Copyright (c) 2020-2022 Detlef Stern -// -// This file is part of zettelstore-client. -// -// Zettelstore client 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 attrs stores attributes of zettel parts. -package attrs - -import ( - "strings" - - "zettelstore.de/c/maps" -) - -// Attributes store additional information about some node types. -type Attributes map[string]string - -// IsEmpty returns true if there are no attributes. -func (a Attributes) IsEmpty() bool { return len(a) == 0 } - -// DefaultAttribute is the value of the key of the default attribute -const DefaultAttribute = "-" - -// HasDefault returns true, if the default attribute "-" has been set. -func (a Attributes) HasDefault() bool { - if a != nil { - _, ok := a[DefaultAttribute] - return ok - } - return false -} - -// RemoveDefault removes the default attribute -func (a Attributes) RemoveDefault() Attributes { - if a != nil { - a.Remove(DefaultAttribute) - } - return a -} - -// Keys returns the sorted list of keys. -func (a Attributes) Keys() []string { return maps.Keys(a) } - -// Get returns the attribute value of the given key and a succes value. -func (a Attributes) Get(key string) (string, bool) { - if a != nil { - value, ok := a[key] - return value, ok - } - return "", false -} - -// Clone returns a duplicate of the attribute. -func (a Attributes) Clone() Attributes { - if a == nil { - return nil - } - attrs := make(map[string]string, len(a)) - for k, v := range a { - attrs[k] = v - } - return attrs -} - -// Set changes the attribute that a given key has now a given value. -func (a Attributes) Set(key, value string) Attributes { - if a == nil { - return map[string]string{key: value} - } - a[key] = value - return a -} - -// Remove the key from the attributes. -func (a Attributes) Remove(key string) Attributes { - if a != nil { - delete(a, key) - } - return a -} - -// AddClass adds a value to the class attribute. -func (a Attributes) AddClass(class string) Attributes { - if a == nil { - return map[string]string{"class": class} - } - classes := a.GetClasses() - for _, cls := range classes { - if cls == class { - return a - } - } - classes = append(classes, class) - a["class"] = strings.Join(classes, " ") - return a -} - -// GetClasses returns the class values as a string slice -func (a Attributes) GetClasses() []string { - if a == nil { - return nil - } - classes, ok := a["class"] - if !ok { - return nil - } - return strings.Fields(classes) -} - -// HasClass returns true, if attributes contains the given class. -func (a Attributes) HasClass(s string) bool { - if a == nil { - return false - } - classes, found := a["class"] - if !found { - return false - } - return strings.Contains(" "+classes+" ", " "+s+" ") -} DELETED attrs/attrs_test.go Index: attrs/attrs_test.go ================================================================== --- attrs/attrs_test.go +++ /dev/null @@ -1,75 +0,0 @@ -//----------------------------------------------------------------------------- -// Copyright (c) 2020-2022 Detlef Stern -// -// This file is part of zettelstore-client. -// -// Zettelstore client 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 attrs_test - -import ( - "testing" - - "zettelstore.de/c/attrs" -) - -func TestHasDefault(t *testing.T) { - t.Parallel() - attr := attrs.Attributes{} - if attr.HasDefault() { - t.Error("Should not have default attr") - } - attr = attrs.Attributes(map[string]string{"-": "value"}) - if !attr.HasDefault() { - t.Error("Should have default attr") - } -} - -func TestAttrClone(t *testing.T) { - t.Parallel() - orig := attrs.Attributes{} - clone := orig.Clone() - if !clone.IsEmpty() { - t.Error("Attrs must be empty") - } - - orig = attrs.Attributes(map[string]string{"": "0", "-": "1", "a": "b"}) - clone = orig.Clone() - if clone[""] != "0" || clone["-"] != "1" || clone["a"] != "b" || len(clone) != len(orig) { - t.Error("Wrong cloned map") - } - clone["a"] = "c" - if orig["a"] != "b" { - t.Error("Aliased map") - } -} - -func TestHasClass(t *testing.T) { - t.Parallel() - testcases := []struct { - classes string - class string - exp bool - }{ - {"", "", true}, - {"x", "", false}, - {"x", "x", true}, - {"x", "y", false}, - {"abc def ghi", "abc", true}, - {"abc def ghi", "def", true}, - {"abc def ghi", "ghi", true}, - {"ab de gi", "b", false}, - {"ab de gi", "d", false}, - } - for _, tc := range testcases { - var a attrs.Attributes - a = a.Set("class", tc.classes) - got := a.HasClass(tc.class) - if tc.exp != got { - t.Errorf("%q.HasClass(%q)=%v, but got %v", tc.classes, tc.class, tc.exp, got) - } - } -} Index: client/client.go ================================================================== --- client/client.go +++ client/client.go @@ -1,35 +1,38 @@ //----------------------------------------------------------------------------- -// Copyright (c) 2021-2022 Detlef Stern +// Copyright (c) 2021-present Detlef Stern // // This file is part of zettelstore-client. // // Zettelstore client 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: 2021-present Detlef Stern //----------------------------------------------------------------------------- // Package client provides a client for accessing the Zettelstore via its API. package client import ( - "bufio" "bytes" "context" - "encoding/json" + "fmt" "io" "net" "net/http" "net/url" "strconv" "strings" "time" - "codeberg.org/t73fde/sxpf" - "zettelstore.de/c/api" - "zettelstore.de/c/sexpr" - "zettelstore.de/c/zjson" + "t73f.de/r/sx" + "t73f.de/r/sx/sxreader" + "t73f.de/r/zsc/api" + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/sexp" ) // Client contains all data to execute requests. type Client struct { base string @@ -42,22 +45,19 @@ } // Base returns the base part of the URLs that are used to communicate with a Zettelstore. func (c *Client) Base() string { return c.base } -// NewClient create a new client. +// NewClient creates a new client with a given base URL to a Zettelstore. func NewClient(u *url.URL) *Client { myURL := *u myURL.User = nil myURL.ForceQuery = false myURL.RawQuery = "" myURL.Fragment = "" myURL.RawFragment = "" base := myURL.String() - if !strings.HasSuffix(base, "/") { - base += "/" - } c := Client{ base: base, client: http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ @@ -68,18 +68,36 @@ }, }, } return &c } + +// AllowRedirect will modify the client to not follow redirect status code when +// using the Zettelstore. The original behaviour can be restored by setting +// allow to false. +func (c *Client) AllowRedirect(allow bool) { + if allow { + c.client.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + } else { + c.client.CheckRedirect = nil + } +} // Error encapsulates the possible client call errors. +// +// - StatusCode is the HTTP status code, e.g. 200 +// - Message is the HTTP message, e.g. "OK" +// - Body is the HTTP body returned by a request. type Error struct { StatusCode int Message string Body []byte } +// Error returns the error as a string. func (err *Error) Error() string { var body string if err.Body == nil { body = "nil" } else if bl := len(err.Body); bl == 0 { @@ -110,11 +128,17 @@ Message: resp.Status[4:], Body: body, } } -func (c *Client) newURLBuilder(key byte) *api.URLBuilder { +// NewURLBuilder creates a new URL builder for the client with the given key. +// +// key is one of the defined lower case letters to specify an endpoint. +// See [Endpoints used by the API] for details. +// +// [Endpoints used by the API]: https://zettelstore.de/manual/h/00001012920000 +func (c *Client) NewURLBuilder(key byte) *api.URLBuilder { return api.NewURLBuilder(c.base, key) } func (*Client) newRequest(ctx context.Context, method string, ub *api.URLBuilder, body io.Reader) (*http.Request, error) { return http.NewRequestWithContext(ctx, method, ub.String(), body) } @@ -124,34 +148,37 @@ req.Header.Add("Authorization", c.tokenType+" "+c.token) } resp, err := c.client.Do(req) if err != nil { if resp != nil && resp.Body != nil { - resp.Body.Close() + _ = resp.Body.Close() } return nil, err } return resp, err } func (c *Client) buildAndExecuteRequest( - ctx context.Context, method string, ub *api.URLBuilder, body io.Reader, h http.Header) (*http.Response, error) { + ctx context.Context, + method string, + ub *api.URLBuilder, + body io.Reader, +) (*http.Response, error) { req, err := c.newRequest(ctx, method, ub, body) if err != nil { return nil, err } err = c.updateToken(ctx) if err != nil { return nil, err } - for key, val := range h { - req.Header[key] = append(req.Header[key], val...) - } return c.executeRequest(req) } // SetAuth sets authentication data. +// +// username and password are the same values that are used to authenticate via the Web-UI. func (c *Client) SetAuth(username, password string) { c.username = username c.password = password c.token = "" c.tokenType = "" @@ -161,23 +188,30 @@ func (c *Client) executeAuthRequest(req *http.Request) error { resp, err := c.executeRequest(req) if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return statusToError(resp) } - dec := json.NewDecoder(resp.Body) - var tinfo api.AuthJSON - err = dec.Decode(&tinfo) + rd := sxreader.MakeReader(resp.Body) + obj, err := rd.Read() + if err != nil { + return err + } + vals, err := sexp.ParseList(obj, "ssi") if err != nil { return err } - c.token = tinfo.Token - c.tokenType = tinfo.Type - c.expires = time.Now().Add(time.Duration(tinfo.Expires*10/9) * time.Second) + token := vals[1].(sx.String).GetValue() + if len(token) < 4 { + return fmt.Errorf("no valid token found: %q", token) + } + c.token = token + c.tokenType = vals[0].(sx.String).GetValue() + c.expires = time.Now().Add(time.Duration(vals[2].(sx.Int64)*9/10) * time.Second) return nil } func (c *Client) updateToken(ctx context.Context) error { if c.username == "" { @@ -188,484 +222,158 @@ } return c.RefreshToken(ctx) } // Authenticate sets a new token by sending user name and password. +// +// [Client.SetAuth] should be called before. func (c *Client) Authenticate(ctx context.Context) error { authData := url.Values{"username": {c.username}, "password": {c.password}} - req, err := c.newRequest(ctx, http.MethodPost, c.newURLBuilder('a'), strings.NewReader(authData.Encode())) + req, err := c.newRequest(ctx, http.MethodPost, c.NewURLBuilder('a'), strings.NewReader(authData.Encode())) if err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") return c.executeAuthRequest(req) } // RefreshToken updates the access token +// +// [Client.SetAuth] should be called before. func (c *Client) RefreshToken(ctx context.Context) error { - req, err := c.newRequest(ctx, http.MethodPut, c.newURLBuilder('a'), nil) + req, err := c.newRequest(ctx, http.MethodPut, c.NewURLBuilder('a'), nil) if err != nil { return err } return c.executeAuthRequest(req) } // CreateZettel creates a new zettel and returns its URL. -func (c *Client) CreateZettel(ctx context.Context, data []byte) (api.ZettelID, error) { - ub := c.newURLBuilder('z') - resp, err := c.buildAndExecuteRequest(ctx, http.MethodPost, ub, bytes.NewBuffer(data), nil) - if err != nil { - return api.InvalidZID, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - return api.InvalidZID, statusToError(resp) - } - b, err := io.ReadAll(resp.Body) - if err != nil { - return api.InvalidZID, err - } - if zid := api.ZettelID(b); zid.IsValid() { - return zid, nil - } - return api.InvalidZID, err -} - -// CreateZettelJSON creates a new zettel and returns its URL. -func (c *Client) CreateZettelJSON(ctx context.Context, data *api.ZettelDataJSON) (api.ZettelID, error) { - var buf bytes.Buffer - if err := encodeZettelData(&buf, data); err != nil { - return api.InvalidZID, err - } - ub := c.newURLBuilder('j') - resp, err := c.buildAndExecuteRequest(ctx, http.MethodPost, ub, &buf, nil) - if err != nil { - return api.InvalidZID, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - return api.InvalidZID, statusToError(resp) - } - dec := json.NewDecoder(resp.Body) - var newZid api.ZidJSON - err = dec.Decode(&newZid) - if err != nil { - return api.InvalidZID, err - } - if zid := newZid.ID; zid.IsValid() { - return zid, nil - } - return api.InvalidZID, err -} - -func encodeZettelData(buf *bytes.Buffer, data *api.ZettelDataJSON) error { - enc := json.NewEncoder(buf) - enc.SetEscapeHTML(false) - return enc.Encode(&data) -} - -var bsLF = []byte{'\n'} - -// ListZettel returns a list of all Zettel. -func (c *Client) ListZettel(ctx context.Context, query url.Values) ([][]byte, error) { - ub := c.newQueryURLBuilder('z', query) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - lines := bytes.Split(data, bsLF) - if len(lines[len(lines)-1]) == 0 { - lines = lines[:len(lines)-1] - } - return lines, nil -} - -// ListZettelJSON returns a list of zettel. -func (c *Client) ListZettelJSON(ctx context.Context, query url.Values) (string, string, []api.ZidMetaJSON, error) { - ub := c.newQueryURLBuilder('j', query) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return "", "", nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", "", nil, statusToError(resp) - } - dec := json.NewDecoder(resp.Body) - var zl api.ZettelListJSON - err = dec.Decode(&zl) - if err != nil { - return "", "", nil, err - } - return zl.Query, zl.Human, zl.List, nil -} - -// GetZettel returns a zettel as a string. -func (c *Client) GetZettel(ctx context.Context, zid api.ZettelID, part string) ([]byte, error) { - ub := c.newURLBuilder('z').SetZid(zid) - if part != "" && part != api.PartContent { - ub.AppendQuery(api.QueryKeyPart, part) - } - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - return io.ReadAll(resp.Body) -} - -// GetZettelJSON returns a zettel as a JSON struct. -func (c *Client) GetZettelJSON(ctx context.Context, zid api.ZettelID) (*api.ZettelDataJSON, error) { - ub := c.newURLBuilder('j').SetZid(zid) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - dec := json.NewDecoder(resp.Body) - var out api.ZettelDataJSON - err = dec.Decode(&out) - if err != nil { - return nil, err - } - return &out, nil -} - -// GetParsedZettel return a parsed zettel in a defined encoding. -func (c *Client) GetParsedZettel(ctx context.Context, zid api.ZettelID, enc api.EncodingEnum) ([]byte, error) { - return c.getZettelString(ctx, 'p', zid, enc) -} - -// GetEvaluatedZettel return an evaluated zettel in a defined encoding. -func (c *Client) GetEvaluatedZettel(ctx context.Context, zid api.ZettelID, enc api.EncodingEnum) ([]byte, error) { - return c.getZettelString(ctx, 'v', zid, enc) -} - -func (c *Client) getZettelString(ctx context.Context, key byte, zid api.ZettelID, enc api.EncodingEnum) ([]byte, error) { - ub := c.newURLBuilder(key).SetZid(zid) - ub.AppendQuery(api.QueryKeyEncoding, enc.String()) - ub.AppendQuery(api.QueryKeyPart, api.PartContent) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - return io.ReadAll(resp.Body) -} - -// GetParsedZettelZJSON returns an parsed zettel as a JSON-decoded data structure. -func (c *Client) GetParsedSexpr(ctx context.Context, zid api.ZettelID, part string) (sxpf.Value, error) { - return c.getSexpr(ctx, 'p', zid, part) -} - -// GetEvaluatedZettelZJSON returns an evaluated zettel as a JSON-decoded data structure. -func (c *Client) GetEvaluatedSexpr(ctx context.Context, zid api.ZettelID, part string) (sxpf.Value, error) { - return c.getSexpr(ctx, 'v', zid, part) -} - -func (c *Client) getSexpr(ctx context.Context, key byte, zid api.ZettelID, part string) (sxpf.Value, error) { - ub := c.newURLBuilder(key).SetZid(zid) - ub.AppendQuery(api.QueryKeyEncoding, api.EncodingSexpr) - if part != "" { - ub.AppendQuery(api.QueryKeyPart, part) - } - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - - return sxpf.ParseValue(sexpr.Smk, bufio.NewReaderSize(resp.Body, 8)) -} - -// GetParsedZettelZJSON returns an parsed zettel as a JSON-decoded data structure. -func (c *Client) GetParsedZJSON(ctx context.Context, zid api.ZettelID, part string) (zjson.Value, error) { - return c.getZJSON(ctx, 'p', zid, part) -} - -// GetEvaluatedZettelZJSON returns an evaluated zettel as a JSON-decoded data structure. -func (c *Client) GetEvaluatedZJSON(ctx context.Context, zid api.ZettelID, part string) (zjson.Value, error) { - return c.getZJSON(ctx, 'v', zid, part) -} - -func (c *Client) getZJSON(ctx context.Context, key byte, zid api.ZettelID, part string) (zjson.Value, error) { - ub := c.newURLBuilder(key).SetZid(zid) - ub.AppendQuery(api.QueryKeyEncoding, api.EncodingZJSON) - if part != "" { - ub.AppendQuery(api.QueryKeyPart, part) - } - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - return zjson.Decode(resp.Body) -} - -// GetMeta returns the metadata of a zettel. -func (c *Client) GetMeta(ctx context.Context, zid api.ZettelID) (api.ZettelMeta, error) { - ub := c.newURLBuilder('m').SetZid(zid) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - dec := json.NewDecoder(resp.Body) - var out api.MetaJSON - err = dec.Decode(&out) - if err != nil { - return nil, err - } - return out.Meta, nil -} - -// GetZettelOrder returns metadata of the given zettel and, more important, -// metadata of zettel that are referenced in a list within the first zettel. -func (c *Client) GetZettelOrder(ctx context.Context, zid api.ZettelID) (*api.ZidMetaRelatedList, error) { - ub := c.newURLBuilder('o').SetZid(zid) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - dec := json.NewDecoder(resp.Body) - var out api.ZidMetaRelatedList - err = dec.Decode(&out) - if err != nil { - return nil, err - } - return &out, nil -} - -// ContextDirection specifies how the context should be calculated. -type ContextDirection uint8 - -// Allowed values for ContextDirection -const ( - _ ContextDirection = iota - DirBoth - DirBackward - DirForward -) - -// GetZettelContext returns metadata of the given zettel and, more important, -// metadata of zettel that for the context of the first zettel. -func (c *Client) GetZettelContext( - ctx context.Context, zid api.ZettelID, dir ContextDirection, depth, limit int) ( - *api.ZidMetaRelatedList, error, -) { - ub := c.newURLBuilder('x').SetZid(zid) - switch dir { - case DirBackward: - ub.AppendQuery(api.QueryKeyDir, api.DirBackward) - case DirForward: - ub.AppendQuery(api.QueryKeyDir, api.DirForward) - } - if depth > 0 { - ub.AppendQuery(api.QueryKeyDepth, strconv.Itoa(depth)) - } - if limit > 0 { - ub.AppendQuery(api.QueryKeyLimit, strconv.Itoa(limit)) - } - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - dec := json.NewDecoder(resp.Body) - var out api.ZidMetaRelatedList - err = dec.Decode(&out) - if err != nil { - return nil, err - } - return &out, nil -} - -// GetUnlinkedReferences returns connections to other zettel, embedded material, externals URLs. -func (c *Client) GetUnlinkedReferences( - ctx context.Context, zid api.ZettelID, query url.Values) (*api.ZidMetaRelatedList, error) { - ub := c.newQueryURLBuilder('u', query).SetZid(zid) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - dec := json.NewDecoder(resp.Body) - var out api.ZidMetaRelatedList - err = dec.Decode(&out) - if err != nil { - return nil, err - } - return &out, nil -} - -// UpdateZettel updates an existing zettel. -func (c *Client) UpdateZettel(ctx context.Context, zid api.ZettelID, data []byte) error { - ub := c.newURLBuilder('z').SetZid(zid) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodPut, ub, bytes.NewBuffer(data), nil) +// +// data contains the zettel metadata and content, as it is stored in a file in a zettel box, +// or as returned by [Client.GetZettel]. +// Metadata is separated from zettel content by an empty line. +func (c *Client) CreateZettel(ctx context.Context, data []byte) (id.Zid, error) { + ub := c.NewURLBuilder('z') + resp, err := c.buildAndExecuteRequest(ctx, http.MethodPost, ub, bytes.NewBuffer(data)) + if err != nil { + return id.Invalid, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusCreated { + return id.Invalid, statusToError(resp) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return id.Invalid, err + } + return id.Parse(string(b)) +} + +// CreateZettelData creates a new zettel and returns its URL. +// +// data contains the zettel date, encoded as explicit struct. +func (c *Client) CreateZettelData(ctx context.Context, data api.ZettelData) (id.Zid, error) { + var buf bytes.Buffer + if _, err := sx.Print(&buf, sexp.EncodeZettel(data)); err != nil { + return id.Invalid, err + } + ub := c.NewURLBuilder('z').AppendKVQuery(api.QueryKeyEncoding, api.EncodingData) + resp, err := c.buildAndExecuteRequest(ctx, http.MethodPost, ub, &buf) + if err != nil { + return id.Invalid, err + } + defer func() { _ = resp.Body.Close() }() + rdr := sxreader.MakeReader(resp.Body) + obj, err := rdr.Read() + if resp.StatusCode != http.StatusCreated { + return id.Invalid, statusToError(resp) + } + if err != nil { + return id.Invalid, err + } + return makeZettelID(obj) +} + +func makeZettelID(obj sx.Object) (id.Zid, error) { + val, isInt64 := obj.(sx.Int64) + if !isInt64 || val <= 0 { + return id.Invalid, fmt.Errorf("invalid zettel ID: %v", val) + } + sVal := strconv.FormatInt(int64(val), 10) + if len(sVal) < 14 { + sVal = "00000000000000"[0:14-len(sVal)] + sVal + } + zid, err := id.Parse(sVal) + if err != nil { + return id.Invalid, fmt.Errorf("invalid zettel ID %v: %w", val, err) + } + return zid, nil +} + +// UpdateZettel updates an existing zettel, specified by its zettel identifier. +// +// data contains the zettel metadata and content, as it is stored in a file in a zettel box, +// or as returned by [Client.GetZettel]. +// Metadata is separated from zettel content by an empty line. +func (c *Client) UpdateZettel(ctx context.Context, zid id.Zid, data []byte) error { + ub := c.NewURLBuilder('z').SetZid(zid) + resp, err := c.buildAndExecuteRequest(ctx, http.MethodPut, ub, bytes.NewBuffer(data)) if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { return statusToError(resp) } return nil } -// UpdateZettelJSON updates an existing zettel. -func (c *Client) UpdateZettelJSON(ctx context.Context, zid api.ZettelID, data *api.ZettelDataJSON) error { - var buf bytes.Buffer - if err := encodeZettelData(&buf, data); err != nil { - return err - } - ub := c.newURLBuilder('j').SetZid(zid) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodPut, ub, &buf, nil) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - return statusToError(resp) - } - return nil -} - -// RenameZettel renames a zettel. -func (c *Client) RenameZettel(ctx context.Context, oldZid, newZid api.ZettelID) error { - ub := c.newURLBuilder('z').SetZid(oldZid) - h := http.Header{ - api.HeaderDestination: {c.newURLBuilder('z').SetZid(newZid).String()}, - } - resp, err := c.buildAndExecuteRequest(ctx, api.MethodMove, ub, nil, h) - if err != nil { - return err - } - defer resp.Body.Close() +// UpdateZettelData updates an existing zettel, specified by its zettel identifier. +func (c *Client) UpdateZettelData(ctx context.Context, zid id.Zid, data api.ZettelData) error { + var buf bytes.Buffer + if _, err := sx.Print(&buf, sexp.EncodeZettel(data)); err != nil { + return err + } + ub := c.NewURLBuilder('z').SetZid(zid).AppendKVQuery(api.QueryKeyEncoding, api.EncodingData) + resp, err := c.buildAndExecuteRequest(ctx, http.MethodPut, ub, &buf) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { return statusToError(resp) } return nil } // DeleteZettel deletes a zettel with the given identifier. -func (c *Client) DeleteZettel(ctx context.Context, zid api.ZettelID) error { - ub := c.newURLBuilder('z').SetZid(zid) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodDelete, ub, nil, nil) +func (c *Client) DeleteZettel(ctx context.Context, zid id.Zid) error { + ub := c.NewURLBuilder('z').SetZid(zid) + resp, err := c.buildAndExecuteRequest(ctx, http.MethodDelete, ub, nil) if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { return statusToError(resp) } return nil } // ExecuteCommand will execute a given command at the Zettelstore. +// +// See [API commands] for a list of valid commands. +// +// [API commands]: https://zettelstore.de/manual/h/00001012080100 func (c *Client) ExecuteCommand(ctx context.Context, command api.Command) error { - ub := c.newURLBuilder('x').AppendQuery(api.QueryKeyCommand, string(command)) - resp, err := c.buildAndExecuteRequest(ctx, http.MethodPost, ub, nil, nil) + ub := c.NewURLBuilder('x').AppendKVQuery(api.QueryKeyCommand, string(command)) + resp, err := c.buildAndExecuteRequest(ctx, http.MethodPost, ub, nil) if err != nil { return err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent { return statusToError(resp) } return nil } - -func (c *Client) newQueryURLBuilder(key byte, query url.Values) *api.URLBuilder { - ub := c.newURLBuilder(key) - for key, values := range query { - if key == api.QueryKeyEncoding { - continue - } - for _, val := range values { - ub.AppendQuery(key, val) - } - } - return ub -} - -// ListMapMeta returns a map of all metadata values with the given key to the -// list of zettel IDs containing this value. -func (c *Client) ListMapMeta(ctx context.Context, key string) (api.MapMeta, error) { - err := c.updateToken(ctx) - if err != nil { - return nil, err - } - req, err := c.newRequest(ctx, http.MethodGet, c.newURLBuilder('m').AppendQuery(api.QueryKeyKey, key), nil) - if err != nil { - return nil, err - } - resp, err := c.executeRequest(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, statusToError(resp) - } - dec := json.NewDecoder(resp.Body) - var mlj api.MapListJSON - err = dec.Decode(&mlj) - if err != nil { - return nil, err - } - return mlj.Map, nil -} - -// GetVersionJSON returns version information.. -func (c *Client) GetVersionJSON(ctx context.Context) (api.VersionJSON, error) { - resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, c.newURLBuilder('x'), nil, nil) - if err != nil { - return api.VersionJSON{}, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return api.VersionJSON{}, statusToError(resp) - } - dec := json.NewDecoder(resp.Body) - var version api.VersionJSON - err = dec.Decode(&version) - if err != nil { - return api.VersionJSON{}, err - } - return version, nil -} Index: client/client_test.go ================================================================== --- client/client_test.go +++ client/client_test.go @@ -1,43 +1,44 @@ //----------------------------------------------------------------------------- -// Copyright (c) 2022 Detlef Stern +// Copyright (c) 2022-present Detlef Stern // // This file is part of zettelstore-client. // // Zettelstore client 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: 2022-present Detlef Stern //----------------------------------------------------------------------------- package client_test import ( "context" "flag" - "log" "net/http" "net/url" "testing" - "codeberg.org/t73fde/sxpf" - "zettelstore.de/c/api" - "zettelstore.de/c/client" - "zettelstore.de/c/zjson" + "t73f.de/r/zsc/api" + "t73f.de/r/zsc/client" + "t73f.de/r/zsc/domain/id" ) func TestZettelList(t *testing.T) { c := getClient() - _, err := c.ListZettel(context.Background(), nil) + _, err := c.QueryZettel(context.Background(), "") if err != nil { t.Error(err) return } } func TestGetProtectedZettel(t *testing.T) { c := getClient() - _, err := c.GetZettel(context.Background(), api.ZidStartupConfiguration, api.PartZettel) + _, err := c.GetZettel(context.Background(), id.ZidStartupConfiguration, api.PartZettel) if err != nil { if cErr, ok := err.(*client.Error); ok && cErr.StatusCode == http.StatusForbidden { return } else { t.Error(err) @@ -44,76 +45,21 @@ } return } } -func TestGetZJSONZettel(t *testing.T) { - c := getClient() - data, err := c.GetEvaluatedZJSON(context.Background(), api.ZidDefaultHome, api.PartContent) - if err != nil { - t.Error(err) - return - } - if data == nil { - t.Error("No data") - } - var v vis - zjson.WalkBlock(&v, data.(zjson.Array), -1) - // t.Error("Argh") -} - -type vis struct{} - -func (*vis) BlockArray(a zjson.Array, pos int) zjson.CloseFunc { - log.Println("SBLO", pos, a) - return nil -} -func (*vis) InlineArray(a zjson.Array, pos int) zjson.CloseFunc { - log.Println("SINL", pos, a) - return nil -} -func (*vis) ItemArray(a zjson.Array, pos int) zjson.CloseFunc { - log.Println("SITE", pos, a) - return nil -} -func (*vis) BlockObject(t string, obj zjson.Object, pos int) (bool, zjson.CloseFunc) { - log.Println("BOBJ", pos, t, obj) - return true, nil -} -func (*vis) InlineObject(t string, obj zjson.Object, pos int) (bool, zjson.CloseFunc) { - log.Println("IOBJ", pos, t, obj) - return true, nil -} -func (*vis) Unexpected(val zjson.Value, pos int, exp string) { log.Println("Expect", pos, exp, val) } - -func TestGetSexprZettel(t *testing.T) { - c := getClient() - value, err := c.GetEvaluatedSexpr(context.Background(), api.ZidDefaultHome, api.PartContent) - if err != nil { - t.Error(err) - return - } - if value == nil { - t.Error("No data") - } - var env testEnv - env.t = t - res, err := sxpf.Eval(&env, value) - if err != nil { - t.Error(res, err) - } -} - -type testEnv struct{ t *testing.T } - -func noneFn(sxpf.Environment, *sxpf.Pair, int) (sxpf.Value, error) { return sxpf.Nil(), nil } -func (*testEnv) LookupForm(*sxpf.Symbol) (sxpf.Form, error) { - return sxpf.NewBuiltin("none", false, 0, -1, noneFn), nil -} -func (*testEnv) EvalSymbol(sym *sxpf.Symbol) (sxpf.Value, error) { return sym, nil } -func (*testEnv) EvalOther(val sxpf.Value) (sxpf.Value, error) { return val, nil } -func (te *testEnv) EvalPair(p *sxpf.Pair) (sxpf.Value, error) { return sxpf.EvalCallOrSeq(te, p) } +func TestGetSzZettel(t *testing.T) { + c := getClient() + value, err := c.GetEvaluatedSz(context.Background(), id.ZidDefaultHome, api.PartContent) + if err != nil { + t.Error(err) + return + } + if value.IsNil() { + t.Error("No data") + } +} var baseURL string func init() { flag.StringVar(&baseURL, "base-url", "http://localhost:23123/", "Base URL") ADDED client/retrieve.go Index: client/retrieve.go ================================================================== --- /dev/null +++ client/retrieve.go @@ -0,0 +1,524 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2021-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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: 2021-present Detlef Stern +//----------------------------------------------------------------------------- + +package client + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "net/http" + + "t73f.de/r/sx" + "t73f.de/r/sx/sxreader" + "t73f.de/r/zsc/api" + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/sexp" + "t73f.de/r/zsx" +) + +var bsLF = []byte{'\n'} + +// QueryZettel returns a list of all Zettel based on the given query. +// +// query is a search expression, as described in [Query the list of all zettel]. +// +// The functions returns a slice of bytes slices, where each byte slice contains the +// zettel identifier within its first 14 bytes. The next byte is a space character, +// followed by the title of the zettel. +// +// [Query the list of all zettel]: https://zettelstore.de/manual/h/00001012051400 +func (c *Client) QueryZettel(ctx context.Context, query string) ([][]byte, error) { + ub := c.NewURLBuilder('z').AppendQuery(query) + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNoContent: + return nil, nil + default: + return nil, statusToError(resp) + } + if err != nil { + return nil, err + } + lines := bytes.Split(data, bsLF) + if len(lines[len(lines)-1]) == 0 { + lines = lines[:len(lines)-1] + } + return lines, nil +} + +// QueryZettelData returns a list of zettel metadata. +// +// query is a search expression, as described in [Query the list of all zettel]. +// +// The functions returns the normalized query and its human-readable representation as +// its first two result values. +// +// [Query the list of all zettel]: https://zettelstore.de/manual/h/00001012051400 +func (c *Client) QueryZettelData(ctx context.Context, query string) (string, string, []api.ZidMetaRights, error) { + ub := c.NewURLBuilder('z').AppendKVQuery(api.QueryKeyEncoding, api.EncodingData).AppendQuery(query) + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) + if err != nil { + return "", "", nil, err + } + defer func() { _ = resp.Body.Close() }() + rdr := sxreader.MakeReader(resp.Body).SetListLimit(0) // No limit b/c number of zettel may be more than 100000. We must trust the server + obj, err := rdr.Read() + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNoContent: + return "", "", nil, nil + default: + return "", "", nil, statusToError(resp) + } + if err != nil { + return "", "", nil, err + } + vals, err := sexp.ParseList(obj, "yppr") + if err != nil { + return "", "", nil, err + } + qVals, err := sexp.ParseList(vals[1], "ys") + if err != nil { + return "", "", nil, err + } + hVals, err := sexp.ParseList(vals[2], "ys") + if err != nil { + return "", "", nil, err + } + metaList, err := parseMetaList(vals[3].(*sx.Pair)) + return zsx.GoValue(qVals[1]), zsx.GoValue(hVals[1]), metaList, err +} + +func parseMetaList(metaPair *sx.Pair) ([]api.ZidMetaRights, error) { + var result []api.ZidMetaRights + for node := metaPair; !sx.IsNil(node); { + elem, isPair := sx.GetPair(node) + if !isPair { + return nil, fmt.Errorf("meta-list not a proper list: %v", metaPair.String()) + } + node = elem.Tail() + vals, err := sexp.ParseList(elem.Car(), "yppp") + if err != nil { + return nil, err + } + + if errSym := sexp.CheckSymbol(vals[0], "zettel"); errSym != nil { + return nil, errSym + } + + idVals, err := sexp.ParseList(vals[1], "yi") + if err != nil { + return nil, err + } + if errSym := sexp.CheckSymbol(idVals[0], "id"); errSym != nil { + return nil, errSym + } + zid, err := makeZettelID(idVals[1]) + if err != nil { + return nil, err + } + + meta, err := sexp.ParseMeta(vals[2].(*sx.Pair)) + if err != nil { + return nil, err + } + + rights, err := sexp.ParseRights(vals[3]) + if err != nil { + return nil, err + } + + result = append(result, api.ZidMetaRights{ + ID: zid, + Meta: meta, + Rights: rights, + }) + } + return result, nil +} + +// QueryAggregate returns a aggregate as a result of a query. +// It is most often used in a query with an action, where the action is either +// a metadata key of type Word or of type TagSet. +// +// query is a search expression, as described in [Query the list of all zettel]. +// It must contain an aggregate action. +// +// [Query the list of all zettel]: https://zettelstore.de/manual/h/00001012051400 +func (c *Client) QueryAggregate(ctx context.Context, query string) (api.Aggregate, error) { + lines, err := c.QueryZettel(ctx, query) + if err != nil { + return nil, err + } + if len(lines) == 0 { + return nil, nil + } + agg := make(api.Aggregate, len(lines)) + for _, line := range lines { + if fields := bytes.Fields(line); len(fields) > 1 { + key := string(fields[0]) + for _, field := range fields[1:] { + if zid, zidErr := id.Parse(string(field)); zidErr == nil { + agg[key] = append(agg[key], zid) + } + } + } + } + return agg, nil +} + +// TagZettel returns the identifier of the tag zettel for a given tag. +// +// This method only works if c.AllowRedirect(true) was called. +func (c *Client) TagZettel(ctx context.Context, tag string) (id.Zid, error) { + return c.fetchTagOrRoleZettel(ctx, api.QueryKeyTag, tag) +} + +// RoleZettel returns the identifier of the tag zettel for a given role. +// +// This method only works if c.AllowRedirect(true) was called. +func (c *Client) RoleZettel(ctx context.Context, role string) (id.Zid, error) { + return c.fetchTagOrRoleZettel(ctx, api.QueryKeyRole, role) +} + +func (c *Client) fetchTagOrRoleZettel(ctx context.Context, key, val string) (id.Zid, error) { + if c.client.CheckRedirect == nil { + panic("client does not allow to track redirect") + } + ub := c.NewURLBuilder('z').AppendKVQuery(key, val) + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) + if err != nil { + return id.Invalid, err + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + if err != nil { + return id.Invalid, err + } + + switch resp.StatusCode { + case http.StatusNotFound: + return id.Invalid, nil + case http.StatusFound: + return id.Parse(string(data)) + default: + return id.Invalid, statusToError(resp) + } +} + +// GetZettel returns a zettel as a byte slice. +// +// part must be one of "meta", "content", or "zettel". +// +// The format of the byte slice is described in [Layout of a zettel]. +// +// [Layout of a zettel]: https://zettelstore.de/manual/h/00001006000000 +func (c *Client) GetZettel(ctx context.Context, zid id.Zid, part string) ([]byte, error) { + ub := c.NewURLBuilder('z').SetZid(zid) + if part != "" && part != api.PartContent { + ub.AppendKVQuery(api.QueryKeyPart, part) + } + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNoContent: + return nil, nil + default: + return nil, statusToError(resp) + } + return data, err +} + +// GetZettelData returns a zettel as a struct of its parts. +func (c *Client) GetZettelData(ctx context.Context, zid id.Zid) (api.ZettelData, error) { + ub := c.NewURLBuilder('z').SetZid(zid) + ub.AppendKVQuery(api.QueryKeyEncoding, api.EncodingData) + ub.AppendKVQuery(api.QueryKeyPart, api.PartZettel) + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) + if err == nil { + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return api.ZettelData{}, statusToError(resp) + } + rdr := sxreader.MakeReader(resp.Body) + obj, err2 := rdr.Read() + if err2 == nil { + return sexp.ParseZettel(obj) + } + } + return api.ZettelData{}, err +} + +// GetParsedZettel return a parsed zettel in a specified text-based encoding. +// +// A parsed zettel is just read from its box and is not processed any further. +// +// Valid encoding values are given as constants. They are described in more +// detail in [Encodings available via the API]. +// +// [Encodings available via the API]: https://zettelstore.de/manual/h/00001012920500 +func (c *Client) GetParsedZettel(ctx context.Context, zid id.Zid, enc api.EncodingEnum) ([]byte, error) { + return c.getZettelString(ctx, zid, enc, true) +} + +// GetEvaluatedZettel return an evaluated zettel in a specified text-based encoding. +// +// An evaluated zettel was parsed, and any transclusions etc. are resolved. +// This is the zettel representation you typically see on the Web UI. +// +// Valid encoding values are given as constants. They are described in more +// detail in [Encodings available via the API]. +// +// [Encodings available via the API]: https://zettelstore.de/manual/h/00001012920500 +func (c *Client) GetEvaluatedZettel(ctx context.Context, zid id.Zid, enc api.EncodingEnum) ([]byte, error) { + return c.getZettelString(ctx, zid, enc, false) +} + +func (c *Client) getZettelString(ctx context.Context, zid id.Zid, enc api.EncodingEnum, parseOnly bool) ([]byte, error) { + ub := c.NewURLBuilder('z').SetZid(zid) + ub.AppendKVQuery(api.QueryKeyEncoding, enc.String()) + ub.AppendKVQuery(api.QueryKeyPart, api.PartContent) + if parseOnly { + ub.AppendKVQuery(api.QueryKeyParseOnly, "") + } + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNoContent: + default: + return nil, statusToError(resp) + } + return io.ReadAll(resp.Body) +} + +// GetParsedSz returns a part of an parsed zettel as a Sexpr-decoded data structure. +// +// A parsed zettel is just read from its box and is not processed any further. +// +// part must be one of "meta", "content", or "zettel". +// +// Basically, this function returns the sz encoding of a part of a zettel. +func (c *Client) GetParsedSz(ctx context.Context, zid id.Zid, part string) (sx.Object, error) { + return c.getSz(ctx, zid, part, true) +} + +// GetEvaluatedSz returns an evaluated zettel as a Sexpr-decoded data structure. +// +// An evaluated zettel was parsed, and any transclusions etc. are resolved. +// This is the zettel representation you typically see on the Web UI. +// +// part must be one of "meta", "content", or "zettel". +// +// Basically, this function returns the sz encoding of a part of a zettel. +func (c *Client) GetEvaluatedSz(ctx context.Context, zid id.Zid, part string) (sx.Object, error) { + return c.getSz(ctx, zid, part, false) +} + +func (c *Client) getSz(ctx context.Context, zid id.Zid, part string, parseOnly bool) (sx.Object, error) { + ub := c.NewURLBuilder('z').SetZid(zid) + ub.AppendKVQuery(api.QueryKeyEncoding, api.EncodingSz) + if part != "" { + ub.AppendKVQuery(api.QueryKeyPart, part) + } + if parseOnly { + ub.AppendKVQuery(api.QueryKeyParseOnly, "") + } + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, statusToError(resp) + } + return sxreader.MakeReader(bufio.NewReaderSize(resp.Body, 8)).Read() +} + +// GetMetaData returns the metadata of a zettel. +func (c *Client) GetMetaData(ctx context.Context, zid id.Zid) (api.MetaRights, error) { + ub := c.NewURLBuilder('z').SetZid(zid) + ub.AppendKVQuery(api.QueryKeyEncoding, api.EncodingData) + ub.AppendKVQuery(api.QueryKeyPart, api.PartMeta) + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) + if err != nil { + return api.MetaRights{}, err + } + defer func() { _ = resp.Body.Close() }() + rdr := sxreader.MakeReader(resp.Body) + obj, err := rdr.Read() + if resp.StatusCode != http.StatusOK { + return api.MetaRights{}, statusToError(resp) + } + if err != nil { + return api.MetaRights{}, err + } + vals, err := sexp.ParseList(obj, "ypp") + if err != nil { + return api.MetaRights{}, err + } + if errSym := sexp.CheckSymbol(vals[0], "list"); errSym != nil { + return api.MetaRights{}, err + } + + meta, err := sexp.ParseMeta(vals[1].(*sx.Pair)) + if err != nil { + return api.MetaRights{}, err + } + + rights, err := sexp.ParseRights(vals[2]) + if err != nil { + return api.MetaRights{}, err + } + + return api.MetaRights{ + Meta: meta, + Rights: rights, + }, nil +} + +// GetReferences returns all references / URIs of a given zettel. +// +// part must be one of "meta", "content", or "zettel". +func (c *Client) GetReferences(ctx context.Context, zid id.Zid, part string) (urls []string, err error) { + ub := c.NewURLBuilder('r').SetZid(zid) + if part != "" { + ub.AppendKVQuery(api.QueryKeyPart, part) + } + ub.AppendKVQuery(api.QueryKeyEncoding, api.EncodingData) // data encoding is more robust. + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + rdr := sxreader.MakeReader(resp.Body) + obj, err := rdr.Read() + if resp.StatusCode != http.StatusOK { + return nil, statusToError(resp) + } + if err != nil { + return nil, err + } + seq, isSeq := sx.GetSequence(obj) + if !isSeq { + return nil, fmt.Errorf("not a sequence: %T/%v", obj, obj) + } + for val := range seq.Values() { + if s, isString := sx.GetString(val); isString { + urls = append(urls, s.GetValue()) + } + } + return urls, nil +} + +// GetVersionInfo returns version information of the Zettelstore that is used. +func (c *Client) GetVersionInfo(ctx context.Context) (VersionInfo, error) { + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, c.NewURLBuilder('x'), nil) + if err != nil { + return VersionInfo{}, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return VersionInfo{}, statusToError(resp) + } + rdr := sxreader.MakeReader(resp.Body) + obj, err := rdr.Read() + if err == nil { + if vals, errVals := sexp.ParseList(obj, "iiiss"); errVals == nil { + return VersionInfo{ + Major: int(vals[0].(sx.Int64)), + Minor: int(vals[1].(sx.Int64)), + Patch: int(vals[2].(sx.Int64)), + Info: vals[3].(sx.String).GetValue(), + Hash: vals[4].(sx.String).GetValue(), + }, nil + } + } + return VersionInfo{}, err +} + +// VersionInfo contains version information of the associated Zettelstore. +// +// - Major is an integer containing the major software version of Zettelstore. +// If its value is greater than zero, different major versions are not compatible. +// - Minor is an integer specifying the minor software version for the given major version. +// If the major version is greater than zero, minor versions are backward compatible. +// - Patch is an integer that specifies a change within a minor version. +// A version that have equal major and minor versions and differ in patch version are +// always compatible, even if the major version equals zero. +// - Info contains some optional text, i.e. it may be the empty string. Typically, Info +// specifies a developer version by containing the string "dev". +// - Hash contains the value of the source code version stored in the Zettelstore repository. +// You can use it to reproduce bugs that occured, when source code was changed since +// its introduction. +type VersionInfo struct { + Major int + Minor int + Patch int + Info string + Hash string +} + +// GetApplicationZid returns the zettel identifier used to configure a client +// application with the given name. +func (c *Client) GetApplicationZid(ctx context.Context, appname string) (id.Zid, error) { + mr, err := c.GetMetaData(ctx, id.ZidAppDirectory) + if err != nil { + return id.Invalid, err + } + key := appname + "-zid" + val, found := mr.Meta[key] + if !found { + return id.Invalid, fmt.Errorf("no application registered: %v", appname) + } + zid, err := id.Parse(val) + if err == nil { + return zid, nil + } + return id.Invalid, fmt.Errorf("invalid identifier for application %v: %v", appname, val) +} + +// Get executes a GET request to the given URL and returns the read data. +func (c *Client) Get(ctx context.Context, ub *api.URLBuilder) ([]byte, error) { + resp, err := c.buildAndExecuteRequest(ctx, http.MethodGet, ub, nil) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNoContent: + return nil, nil + default: + return nil, statusToError(resp) + } + data, err := io.ReadAll(resp.Body) + return data, err +} ADDED docs/fuzz.txt Index: docs/fuzz.txt ================================================================== --- /dev/null +++ docs/fuzz.txt @@ -0,0 +1,4 @@ +The source code contains some simple fuzzing tests. You should call them +regulary to make sure the the software will cope with unusual input. + +go test -fuzz=FuzzParseBlocks t73f.de/r/zsc/sz/zmk ADDED domain/id/id.go Index: domain/id/id.go ================================================================== --- /dev/null +++ domain/id/id.go @@ -0,0 +1,218 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 id provides zettel specific types, constants, and functions about +// zettel identifier. +package id + +import ( + "strconv" + "time" +) + +// Zid is the internal identifier of a zettel. Typically, it is a time stamp +// of the form "YYYYMMDDHHmmSS" converted to an unsigned integer. +type Zid uint64 + +// LengthZid factors the constant length of a zettel identifier +const LengthZid = 14 + +// Some important ZettelIDs. +const ( + Invalid = Zid(0) // Invalid is a Zid that will never be valid + + maxZid = 99999999999999 +) + +// Predefined zettel identifier. +// +// See [List of predefined zettel]. +// +// [List of predefined zettel]: https://zettelstore.de/manual/h/00001005090000 +const ( + // System zettel + ZidVersion = Zid(1) + ZidHost = Zid(2) + ZidOperatingSystem = Zid(3) + ZidLicense = Zid(4) + ZidAuthors = Zid(5) + ZidDependencies = Zid(6) + ZidModules = Zid(7) + ZidLog = Zid(9) + ZidMemory = Zid(10) + ZidSx = Zid(11) + ZidHTTP = Zid(12) + ZidAPI = Zid(13) + ZidWebUI = Zid(14) + ZidConsole = Zid(15) + ZidBoxManager = Zid(20) + ZidZettel = Zid(21) + ZidIndex = Zid(22) + ZidQuery = Zid(23) + ZidMetadataKey = Zid(90) + ZidParser = Zid(92) + ZidStartupConfiguration = Zid(96) + ZidConfiguration = Zid(100) + ZidDirectory = Zid(101) + + // WebUI HTML templates are in the range 10000..19999 + ZidBaseTemplate = Zid(10100) + ZidLoginTemplate = Zid(10200) + ZidListTemplate = Zid(10300) + ZidZettelTemplate = Zid(10401) + ZidInfoTemplate = Zid(10402) + ZidFormTemplate = Zid(10403) + ZidDeleteTemplate = Zid(10405) + ZidErrorTemplate = Zid(10700) + + // WebUI sxn code zettel are in the range 19000..19999 + ZidSxnStart = Zid(19000) + ZidSxnBase = Zid(19990) + + // CSS-related zettel are in the range 20000..29999 + ZidBaseCSS = Zid(20001) + ZidUserCSS = Zid(25001) + + // WebUI JS zettel are in the range 30000..39999 + + // WebUI image zettel are in the range 40000..49999 + ZidEmoji = Zid(40001) + + // Other sxn code zettel are in the range 50000..59999 + + // Predefined Zettelmarkup zettel are in the range 60000..69999 + ZidRoleZettelZettel = Zid(60010) + ZidRoleConfigurationZettel = Zid(60020) + ZidRoleRoleZettel = Zid(60030) + ZidRoleTagZettel = Zid(60040) + + // Range 80000...89999 is reserved for web ui menus + ZidTOCListsMenu = Zid(80001) // "Lists" menu + + // Range 90000...99999 is reserved for zettel templates + ZidTOCNewTemplate = Zid(90000) + ZidTemplateNewZettel = Zid(90001) + ZidTemplateNewRole = Zid(90004) + ZidTemplateNewTag = Zid(90003) + ZidTemplateNewUser = Zid(90002) + + // Range 00000999999900...00000999999999 are predefined zettel to be searched by content. + ZidAppDirectory = Zid(999999999) + + // Default Home Zettel + ZidDefaultHome = Zid(10000000000) +) + +// ParseUint interprets a string as a possible zettel identifier +// and returns its integer value. +func ParseUint(s string) (uint64, error) { + res, err := strconv.ParseUint(s, 10, 47) + if err != nil { + return 0, err + } + if res == 0 || res > maxZid { + return res, strconv.ErrRange + } + return res, nil +} + +// Parse interprets a string as a zettel identification and +// returns its value. +func Parse(s string) (Zid, error) { + if len(s) != LengthZid { + return Invalid, strconv.ErrSyntax + } + res, err := ParseUint(s) + if err != nil { + return Invalid, err + } + return Zid(res), nil +} + +// MustParse tries to interpret a string as a zettel identifier and returns +// its value or panics otherwise. +func MustParse(s string) Zid { + zid, err := Parse(string(s)) + if err == nil { + return zid + } + panic(err) +} + +// String converts the zettel identification to a string of 14 digits. +// Only defined for valid ids. +func (zid Zid) String() string { + var result [LengthZid]byte + zid.toByteArray(&result) + return string(result[:]) +} + +// Bytes converts the zettel identification to a byte slice of 14 digits. +// Only defined for valid ids. +func (zid Zid) Bytes() []byte { + var result [LengthZid]byte + zid.toByteArray(&result) + return result[:] +} + +// toByteArray converts the Zid into a fixed byte array, usable for printing. +// +// Based on idea by Daniel Lemire: "Converting integers to fix-digit representations quickly" +// https://lemire.me/blog/2021/11/18/converting-integers-to-fix-digit-representations-quickly/ +func (zid Zid) toByteArray(result *[LengthZid]byte) { + date := uint64(zid) / 1000000 + fullyear := date / 10000 + century, year := fullyear/100, fullyear%100 + monthday := date % 10000 + month, day := monthday/100, monthday%100 + time := uint64(zid) % 1000000 + hmtime, second := time/100, time%100 + hour, minute := hmtime/100, hmtime%100 + + result[0] = byte(century/10) + '0' + result[1] = byte(century%10) + '0' + result[2] = byte(year/10) + '0' + result[3] = byte(year%10) + '0' + result[4] = byte(month/10) + '0' + result[5] = byte(month%10) + '0' + result[6] = byte(day/10) + '0' + result[7] = byte(day%10) + '0' + result[8] = byte(hour/10) + '0' + result[9] = byte(hour%10) + '0' + result[10] = byte(minute/10) + '0' + result[11] = byte(minute%10) + '0' + result[12] = byte(second/10) + '0' + result[13] = byte(second%10) + '0' +} + +// IsValid determines if zettel id is a valid one, e.g. consists of max. 14 digits. +func (zid Zid) IsValid() bool { return 0 < zid && zid <= maxZid } + +// TimestampLayout to transform a date into a Zid and into other internal dates. +const TimestampLayout = "20060102150405" + +// New returns a new zettel id based on the current time. +func New(withSeconds bool) Zid { + now := time.Now().Local() + var s string + if withSeconds { + s = now.Format(TimestampLayout) + } else { + s = now.Format("20060102150400") + } + res, err := Parse(s) + if err != nil { + panic(err) + } + return res +} ADDED domain/id/id_test.go Index: domain/id/id_test.go ================================================================== --- /dev/null +++ domain/id/id_test.go @@ -0,0 +1,92 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 id_test provides unit tests for testing zettel id specific functions. +package id_test + +import ( + "testing" + + "t73f.de/r/zsc/domain/id" +) + +func TestIsValid(t *testing.T) { + t.Parallel() + validIDs := []string{ + "00000000000001", + "00000000000020", + "00000000000300", + "00000000004000", + "00000000050000", + "00000000600000", + "00000007000000", + "00000080000000", + "00000900000000", + "00001000000000", + "00020000000000", + "00300000000000", + "04000000000000", + "50000000000000", + "99999999999999", + "00001007030200", + "20200310195100", + "12345678901234", + } + + for i, sid := range validIDs { + zid, err := id.Parse(sid) + if err != nil { + t.Errorf("i=%d: sid=%q is not valid, but should be. err=%v", i, sid, err) + } + s := zid.String() + if s != sid { + t.Errorf( + "i=%d: zid=%v does not format to %q, but to %q", i, zid, sid, s) + } + } + + invalidIDs := []string{ + "", "0", "a", + "00000000000000", + "0000000000000a", + "000000000000000", + "20200310T195100", + "+1234567890123", + } + + for i, sid := range invalidIDs { + if zid, err := id.Parse(sid); err == nil { + t.Errorf("i=%d: sid=%q is valid (zid=%s), but should not be", i, sid, zid) + } + } +} + +var sResult string // to disable compiler optimization in loop below + +func BenchmarkString(b *testing.B) { + var s string + for b.Loop() { + s = id.Zid(12345678901200).String() + } + sResult = s +} + +var bResult []byte // to disable compiler optimization in loop below + +func BenchmarkBytes(b *testing.B) { + var bs []byte + for b.Loop() { + bs = id.Zid(12345678901200).Bytes() + } + bResult = bs +} ADDED domain/id/idset/idset.go Index: domain/id/idset/idset.go ================================================================== --- /dev/null +++ domain/id/idset/idset.go @@ -0,0 +1,325 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2021-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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: 2021-present Detlef Stern +//----------------------------------------------------------------------------- + +// Package idset implements sets of zettel identifier. +package idset + +import ( + "slices" + "strings" + + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/domain/meta" +) + +// Set is a set of zettel identifier +type Set struct { + seq []id.Zid +} + +// String returns a string representation of the set. +func (s *Set) String() string { + return "{" + s.MetaString() + "}" +} + +// MetaString returns a string representation of the set to be stored as metadata. +func (s *Set) MetaString() string { + if s == nil || len(s.seq) == 0 { + return "" + } + var sb strings.Builder + for i, zid := range s.seq { + if i > 0 { + sb.WriteByte(' ') + } + sb.Write(zid.Bytes()) + } + return sb.String() +} + +// MetaValue returns a metadata value representation of the set. +func (s *Set) MetaValue() meta.Value { return meta.Value(s.MetaString()) } + +// New returns a new set of identifier with the given initial values. +func New(zids ...id.Zid) *Set { + switch l := len(zids); l { + case 0: + return &Set{seq: nil} + case 1: + return &Set{seq: []id.Zid{zids[0]}} + default: + result := Set{seq: make([]id.Zid, 0, l)} + result.AddSlice(zids) + return &result + } +} + +// NewCap returns a new set of identifier with the given capacity and initial values. +func NewCap(c int, zids ...id.Zid) *Set { + result := Set{seq: make([]id.Zid, 0, max(c, len(zids)))} + result.AddSlice(zids) + return &result +} + +// IsEmpty returns true, if the set conains no element. +func (s *Set) IsEmpty() bool { + return s == nil || len(s.seq) == 0 +} + +// Length returns the number of elements in this set. +func (s *Set) Length() int { + if s == nil { + return 0 + } + return len(s.seq) +} + +// Clone returns a copy of the given set. +func (s *Set) Clone() *Set { + if s == nil || len(s.seq) == 0 { + return nil + } + return &Set{seq: slices.Clone(s.seq)} +} + +// Add adds a Add to the set. +func (s *Set) Add(zid id.Zid) *Set { + if s == nil { + return New(zid) + } + s.add(zid) + return s +} + +// Contains return true if the set is non-nil and the set contains the given Zettel identifier. +func (s *Set) Contains(zid id.Zid) bool { return s != nil && s.contains(zid) } + +// ContainsOrNil return true if the set is nil or if the set contains the given Zettel identifier. +func (s *Set) ContainsOrNil(zid id.Zid) bool { return s == nil || s.contains(zid) } + +// AddSlice adds all identifier of the given slice to the set. +func (s *Set) AddSlice(sl []id.Zid) *Set { + if s == nil { + return New(sl...) + } + s.seq = slices.Grow(s.seq, len(sl)) + for _, zid := range sl { + s.add(zid) + } + return s +} + +// SafeSorted returns the set as a new sorted slice of zettel identifier. +func (s *Set) SafeSorted() []id.Zid { + if s == nil { + return nil + } + return slices.Clone(s.seq) +} + +// IntersectOrSet removes all zettel identifier that are not in the other set. +// Both sets can be modified by this method. One of them is the set returned. +// It contains the intersection of both, if s is not nil. +// +// If s == nil, then the other set is always returned. +func (s *Set) IntersectOrSet(other *Set) *Set { + if s == nil || other == nil { + return other.Clone() + } + topos, spos, opos := 0, 0, 0 + for spos < len(s.seq) && opos < len(other.seq) { + sz, oz := s.seq[spos], other.seq[opos] + if sz < oz { + spos++ + continue + } + if sz > oz { + opos++ + continue + } + s.seq[topos] = sz + topos++ + spos++ + opos++ + } + s.seq = s.seq[:topos] + return s +} + +// IUnion adds the elements of set other to s. +func (s *Set) IUnion(other *Set) *Set { + if other == nil || len(other.seq) == 0 { + return s + } + // TODO: if other is large enough (and s is not too small) -> optimize by swapping and/or loop through both + return s.AddSlice(other.seq) +} + +// ISubstract removes all zettel identifier from 's' that are in the set 'other'. +func (s *Set) ISubstract(other *Set) { + if s == nil || len(s.seq) == 0 || other == nil || len(other.seq) == 0 { + return + } + topos, spos, opos := 0, 0, 0 + for spos < len(s.seq) && opos < len(other.seq) { + sz, oz := s.seq[spos], other.seq[opos] + if sz < oz { + s.seq[topos] = sz + topos++ + spos++ + continue + } + if sz == oz { + spos++ + } + opos++ + } + for spos < len(s.seq) { + s.seq[topos] = s.seq[spos] + topos++ + spos++ + } + s.seq = s.seq[:topos] +} + +// Diff returns the difference sets between the two sets: the first difference +// set is the set of elements that are in other, but not in s; the second +// difference set is the set of element that are in s but not in other. +// +// in other words: the first result is the set of elements from other that must +// be added to s; the second result is the set of elements that must be removed +// from s, so that s would have the same elemest as other. +func (s *Set) Diff(other *Set) (newS, remS *Set) { + if s == nil || len(s.seq) == 0 { + return other.Clone(), nil + } + if other == nil || len(other.seq) == 0 { + return nil, s.Clone() + } + seqS, seqO := s.seq, other.seq + var newRefs, remRefs []id.Zid + npos, opos := 0, 0 + for npos < len(seqO) && opos < len(seqS) { + rn, ro := seqO[npos], seqS[opos] + if rn == ro { + npos++ + opos++ + continue + } + if rn < ro { + newRefs = append(newRefs, rn) + npos++ + continue + } + remRefs = append(remRefs, ro) + opos++ + } + if npos < len(seqO) { + newRefs = append(newRefs, seqO[npos:]...) + } + if opos < len(seqS) { + remRefs = append(remRefs, seqS[opos:]...) + } + return newFromSlice(newRefs), newFromSlice(remRefs) +} + +// Remove the identifier from the set. +func (s *Set) Remove(zid id.Zid) *Set { + if s == nil || len(s.seq) == 0 { + return nil + } + if pos, found := s.find(zid); found { + copy(s.seq[pos:], s.seq[pos+1:]) + s.seq = s.seq[:len(s.seq)-1] + } + if len(s.seq) == 0 { + return nil + } + return s +} + +// Equal returns true if the other set is equal to the given set. +func (s *Set) Equal(other *Set) bool { + if s == nil { + return other == nil + } + if other == nil { + return false + } + return slices.Equal(s.seq, other.seq) +} + +// ForEach calls the given function for each element of the set. +// +// Every element is bigger than the previous one. +func (s *Set) ForEach(fn func(zid id.Zid)) { + if s != nil { + for _, zid := range s.seq { + fn(zid) + } + } +} + +// Pop return one arbitrary element of the set. +func (s *Set) Pop() (id.Zid, bool) { + if s != nil { + if l := len(s.seq); l > 0 { + zid := s.seq[l-1] + s.seq = s.seq[:l-1] + return zid, true + } + } + return id.Invalid, false +} + +// Optimize the amount of memory to store the set. +func (s *Set) Optimize() { + if s != nil { + s.seq = slices.Clip(s.seq) + } +} + +// ----- unchecked base operations + +func newFromSlice(seq []id.Zid) *Set { + if l := len(seq); l == 0 { + return nil + } + return &Set{seq: seq} +} + +func (s *Set) add(zid id.Zid) { + if pos, found := s.find(zid); !found { + s.seq = slices.Insert(s.seq, pos, zid) + } +} + +func (s *Set) contains(zid id.Zid) bool { + _, found := s.find(zid) + return found +} + +func (s *Set) find(zid id.Zid) (int, bool) { + hi := len(s.seq) + for lo := 0; lo < hi; { + m := lo + (hi-lo)/2 + if z := s.seq[m]; z == zid { + return m, true + } else if z < zid { + lo = m + 1 + } else { + hi = m + } + } + return hi, false +} ADDED domain/id/idset/idset_test.go Index: domain/id/idset/idset_test.go ================================================================== --- /dev/null +++ domain/id/idset/idset_test.go @@ -0,0 +1,241 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2021-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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: 2021-present Detlef Stern +//----------------------------------------------------------------------------- + +package idset_test + +import ( + "slices" + "testing" + + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/domain/id/idset" +) + +func TestSetContainsOrNil(t *testing.T) { + t.Parallel() + testcases := []struct { + s *idset.Set + zid id.Zid + exp bool + }{ + {nil, id.Invalid, true}, + {nil, 14, true}, + {idset.New(), id.Invalid, false}, + {idset.New(), 1, false}, + {idset.New(), id.Invalid, false}, + {idset.New(1), 1, true}, + } + for i, tc := range testcases { + got := tc.s.ContainsOrNil(tc.zid) + if got != tc.exp { + t.Errorf("%d: %v.ContainsOrNil(%v) == %v, but got %v", i, tc.s, tc.zid, tc.exp, got) + } + } +} + +func TestSetAdd(t *testing.T) { + t.Parallel() + testcases := []struct { + s1, s2 *idset.Set + exp []id.Zid + }{ + {nil, nil, nil}, + {idset.New(), nil, nil}, + {idset.New(), idset.New(), nil}, + {nil, idset.New(1), []id.Zid{1}}, + {idset.New(1), nil, []id.Zid{1}}, + {idset.New(1), idset.New(), []id.Zid{1}}, + {idset.New(1), idset.New(2), []id.Zid{1, 2}}, + {idset.New(1), idset.New(1), []id.Zid{1}}, + } + for i, tc := range testcases { + sl1 := tc.s1.SafeSorted() + sl2 := tc.s2.SafeSorted() + got := tc.s1.IUnion(tc.s2).SafeSorted() + if !slices.Equal(got, tc.exp) { + t.Errorf("%d: %v.Add(%v) should be %v, but got %v", i, sl1, sl2, tc.exp, got) + } + } +} + +func TestSetSafeSorted(t *testing.T) { + t.Parallel() + testcases := []struct { + set *idset.Set + exp []id.Zid + }{ + {nil, nil}, + {idset.New(), nil}, + {idset.New(9, 4, 6, 1, 7), []id.Zid{1, 4, 6, 7, 9}}, + } + for i, tc := range testcases { + got := tc.set.SafeSorted() + if !slices.Equal(got, tc.exp) { + t.Errorf("%d: %v.SafeSorted() should be %v, but got %v", i, tc.set, tc.exp, got) + } + } +} + +func TestSetIntersectOrSet(t *testing.T) { + t.Parallel() + testcases := []struct { + s1, s2 *idset.Set + exp []id.Zid + }{ + {nil, nil, nil}, + {idset.New(), nil, nil}, + {nil, idset.New(), nil}, + {idset.New(), idset.New(), nil}, + {idset.New(1), nil, nil}, + {nil, idset.New(1), []id.Zid{1}}, + {idset.New(1), idset.New(), nil}, + {idset.New(), idset.New(1), nil}, + {idset.New(1), idset.New(2), nil}, + {idset.New(2), idset.New(1), nil}, + {idset.New(1), idset.New(1), []id.Zid{1}}, + } + for i, tc := range testcases { + sl1 := tc.s1.SafeSorted() + sl2 := tc.s2.SafeSorted() + got := tc.s1.IntersectOrSet(tc.s2).SafeSorted() + if !slices.Equal(got, tc.exp) { + t.Errorf("%d: %v.IntersectOrSet(%v) should be %v, but got %v", i, sl1, sl2, tc.exp, got) + } + } +} + +func TestSetIUnion(t *testing.T) { + t.Parallel() + testcases := []struct { + s1, s2 *idset.Set + exp *idset.Set + }{ + {nil, nil, nil}, + {idset.New(), nil, nil}, + {nil, idset.New(), nil}, + {idset.New(), idset.New(), nil}, + {idset.New(1), nil, idset.New(1)}, + {nil, idset.New(1), idset.New(1)}, + {idset.New(1), idset.New(), idset.New(1)}, + {idset.New(), idset.New(1), idset.New(1)}, + {idset.New(1), idset.New(2), idset.New(1, 2)}, + {idset.New(2), idset.New(1), idset.New(2, 1)}, + {idset.New(1), idset.New(1), idset.New(1)}, + {idset.New(1, 2, 3), idset.New(2, 3, 4), idset.New(1, 2, 3, 4)}, + } + for i, tc := range testcases { + s1 := tc.s1.Clone() + sl1 := s1.SafeSorted() + sl2 := tc.s2.SafeSorted() + got := s1.IUnion(tc.s2) + if !got.Equal(tc.exp) { + t.Errorf("%d: %v.IUnion(%v) should be %v, but got %v", i, sl1, sl2, tc.exp, got) + } + } +} + +func TestSetISubtract(t *testing.T) { + t.Parallel() + testcases := []struct { + s1, s2 *idset.Set + exp []id.Zid + }{ + {nil, nil, nil}, + {idset.New(), nil, nil}, + {nil, idset.New(), nil}, + {idset.New(), idset.New(), nil}, + {idset.New(1), nil, []id.Zid{1}}, + {nil, idset.New(1), nil}, + {idset.New(1), idset.New(), []id.Zid{1}}, + {idset.New(), idset.New(1), nil}, + {idset.New(1), idset.New(2), []id.Zid{1}}, + {idset.New(2), idset.New(1), []id.Zid{2}}, + {idset.New(1), idset.New(1), nil}, + {idset.New(1, 2, 3), idset.New(1), []id.Zid{2, 3}}, + {idset.New(1, 2, 3), idset.New(2), []id.Zid{1, 3}}, + {idset.New(1, 2, 3), idset.New(3), []id.Zid{1, 2}}, + {idset.New(1, 2, 3), idset.New(1, 2), []id.Zid{3}}, + {idset.New(1, 2, 3), idset.New(1, 3), []id.Zid{2}}, + {idset.New(1, 2, 3), idset.New(2, 3), []id.Zid{1}}, + } + for i, tc := range testcases { + s1 := tc.s1.Clone() + sl1 := s1.SafeSorted() + sl2 := tc.s2.SafeSorted() + s1.ISubstract(tc.s2) + got := s1.SafeSorted() + if !slices.Equal(got, tc.exp) { + t.Errorf("%d: %v.ISubstract(%v) should be %v, but got %v", i, sl1, sl2, tc.exp, got) + } + } +} + +func TestSetDiff(t *testing.T) { + t.Parallel() + testcases := []struct { + in1, in2 *idset.Set + exp1, exp2 *idset.Set + }{ + {nil, nil, nil, nil}, + {idset.New(1), nil, nil, idset.New(1)}, + {nil, idset.New(1), idset.New(1), nil}, + {idset.New(1), idset.New(1), nil, nil}, + {idset.New(1, 2), idset.New(1), nil, idset.New(2)}, + {idset.New(1), idset.New(1, 2), idset.New(2), nil}, + {idset.New(1, 2), idset.New(1, 3), idset.New(3), idset.New(2)}, + {idset.New(1, 2, 3), idset.New(2, 3, 4), idset.New(4), idset.New(1)}, + {idset.New(2, 3, 4), idset.New(1, 2, 3), idset.New(1), idset.New(4)}, + } + for i, tc := range testcases { + gotN, gotO := tc.in1.Diff(tc.in2) + if !tc.exp1.Equal(gotN) { + t.Errorf("%d: expected %v, but got: %v", i, tc.exp1, gotN) + } + if !tc.exp2.Equal(gotO) { + t.Errorf("%d: expected %v, but got: %v", i, tc.exp2, gotO) + } + } +} + +func TestSetRemove(t *testing.T) { + t.Parallel() + testcases := []struct { + s1, s2 *idset.Set + exp []id.Zid + }{ + {nil, nil, nil}, + {idset.New(), nil, nil}, + {idset.New(), idset.New(), nil}, + {idset.New(1), nil, []id.Zid{1}}, + {idset.New(1), idset.New(), []id.Zid{1}}, + {idset.New(1), idset.New(2), []id.Zid{1}}, + {idset.New(1), idset.New(1), []id.Zid{}}, + } + for i, tc := range testcases { + sl1 := tc.s1.SafeSorted() + sl2 := tc.s2.SafeSorted() + newS1 := idset.New(sl1...) + newS1.ISubstract(tc.s2) + got := newS1.SafeSorted() + if !slices.Equal(got, tc.exp) { + t.Errorf("%d: %v.Remove(%v) should be %v, but got %v", i, sl1, sl2, tc.exp, got) + } + } +} + +func BenchmarkSet(b *testing.B) { + s := idset.NewCap(b.N) + for i := range b.N { + s.Add(id.Zid(i)) + } +} ADDED domain/meta/collection.go Index: domain/meta/collection.go ================================================================== --- /dev/null +++ domain/meta/collection.go @@ -0,0 +1,114 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2022-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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: 2022-present Detlef Stern +//----------------------------------------------------------------------------- + +package meta + +import ( + "slices" + "strings" +) + +// Arrangement stores metadata within its categories. +// Typecally a category might be a tag name, a role name, a syntax value. +type Arrangement map[string][]*Meta + +// CreateArrangement by inspecting a given key and use the found +// value as a category. +func CreateArrangement(metaList []*Meta, key string) Arrangement { + if len(metaList) == 0 { + return nil + } + descr := Type(key) + if descr == nil { + return nil + } + if descr.IsSet { + return createSetArrangement(metaList, key) + } + return createSimplearrangement(metaList, key) +} + +func createSetArrangement(metaList []*Meta, key string) Arrangement { + a := make(Arrangement) + for _, m := range metaList { + for val := range m.GetFields(key) { + a[val] = append(a[val], m) + } + } + return a +} + +func createSimplearrangement(metaList []*Meta, key string) Arrangement { + a := make(Arrangement) + for _, m := range metaList { + if val, ok := m.Get(key); ok && val != "" { + a[string(val)] = append(a[string(val)], m) + } + } + return a +} + +// Counted returns the list of categories, together with the number of +// metadata for each category. +func (a Arrangement) Counted() CountedCategories { + if len(a) == 0 { + return nil + } + result := make(CountedCategories, 0, len(a)) + for cat, metas := range a { + result = append(result, CountedCategory{Name: cat, Count: len(metas)}) + } + return result +} + +// CountedCategory contains of a name and the number how much this name occured +// somewhere. +type CountedCategory struct { + Name string + Count int +} + +// CountedCategories is the list of CountedCategories. +// Every name must occur only once. +type CountedCategories []CountedCategory + +// SortByName sorts the list by the name attribute. +// Since each name must occur only once, two CountedCategories cannot have +// the same name. +func (ccs CountedCategories) SortByName() { + slices.SortFunc(ccs, func(i, j CountedCategory) int { return strings.Compare(i.Name, j.Name) }) +} + +// SortByCount sorts the list by the count attribute, descending. +// If two counts are equal, elements are sorted by name. +func (ccs CountedCategories) SortByCount() { + slices.SortFunc(ccs, func(i, j CountedCategory) int { + iCount, jCount := i.Count, j.Count + if iCount > jCount { + return -1 + } + if iCount == jCount { + return strings.Compare(i.Name, j.Name) + } + return 1 + }) +} + +// Categories returns just the category names. +func (ccs CountedCategories) Categories() []string { + result := make([]string, len(ccs)) + for i, cc := range ccs { + result[i] = cc.Name + } + return result +} ADDED domain/meta/meta.go Index: domain/meta/meta.go ================================================================== --- /dev/null +++ domain/meta/meta.go @@ -0,0 +1,458 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 meta provides the zettel specific type 'meta'. +package meta + +import ( + "iter" + "maps" + "regexp" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "t73f.de/r/zero/set" + "t73f.de/r/zsc/domain/id" +) + +type keyUsage int + +const ( + _ keyUsage = iota + usageUser // Key will be manipulated by the user + usageComputed // Key is computed by zettelstore + usageProperty // Key is computed and not stored by zettelstore +) + +// DescriptionKey formally describes each supported metadata key. +type DescriptionKey struct { + Name string + Type *DescriptionType + usage keyUsage + Inverse string +} + +// IsComputed returns true, if metadata is computed and not set by the user. +func (kd *DescriptionKey) IsComputed() bool { return kd.usage >= usageComputed } + +// IsProperty returns true, if metadata is a computed property. +func (kd *DescriptionKey) IsProperty() bool { return kd.usage >= usageProperty } + +var registeredKeys = make(map[string]*DescriptionKey) + +func registerKey(name string, t *DescriptionType, usage keyUsage, inverse string) { + if _, ok := registeredKeys[name]; ok { + panic("Key '" + name + "' already defined") + } + if inverse != "" { + if t != TypeID && t != TypeIDSet { + panic("Inversable key '" + name + "' is not identifier type, but " + t.String()) + } + inv, ok := registeredKeys[inverse] + if !ok { + panic("Inverse Key '" + inverse + "' not found") + } + if !inv.IsComputed() { + panic("Inverse Key '" + inverse + "' is not computed.") + } + if inv.Type != TypeIDSet { + panic("Inverse Key '" + inverse + "' is not an identifier set, but " + inv.Type.String()) + } + } + registeredKeys[name] = &DescriptionKey{name, t, usage, inverse} +} + +// IsComputed returns true, if key denotes a computed metadata key. +func IsComputed(name string) bool { + if kd, ok := registeredKeys[name]; ok { + return kd.IsComputed() + } + return false +} + +// IsProperty returns true, if key denotes a property metadata value. +func IsProperty(name string) bool { + if kd, ok := registeredKeys[name]; ok { + return kd.IsProperty() + } + return false +} + +// Inverse returns the name of the inverse key. +func Inverse(name string) string { + if kd, ok := registeredKeys[name]; ok { + return kd.Inverse + } + return "" +} + +// GetDescription returns the key description object of the given key name. +func GetDescription(name string) DescriptionKey { + if d, ok := registeredKeys[name]; ok { + return *d + } + return DescriptionKey{Type: Type(name)} +} + +// GetSortedKeyDescriptions delivers all metadata key descriptions as a slice, sorted by name. +func GetSortedKeyDescriptions() []*DescriptionKey { + keys := slices.Sorted(maps.Keys(registeredKeys)) + result := make([]*DescriptionKey, 0, len(keys)) + for _, n := range keys { + result = append(result, registeredKeys[n]) + } + return result +} + +// Key is the type of metadata keys. +type Key = string + +// Predefined / supported metadata keys. +// +// See [Supported Metadata Keys]. +// +// [Supported Metadata Keys]: https://zettelstore.de/manual/h/00001006020000 +const ( + KeyID = "id" + KeyTitle = "title" + KeyRole = "role" + KeyTags = "tags" + KeySyntax = "syntax" + KeyAuthor = "author" + KeyBack = "back" + KeyBackward = "backward" + KeyBoxNumber = "box-number" + KeyCopyright = "copyright" + KeyCreated = "created" + KeyCredential = "credential" + KeyDead = "dead" + KeyExpire = "expire" + KeyFolge = "folge" + KeyFolgeRole = "folge-role" + KeyForward = "forward" + KeyLang = "lang" + KeyLicense = "license" + KeyModified = "modified" + KeyPrecursor = "precursor" + KeyPredecessor = "predecessor" + KeyPrequel = "prequel" + KeyPublished = "published" + KeyQuery = "query" + KeyReadOnly = "read-only" + KeySequel = "sequel" + KeySubordinates = "subordinates" + KeySuccessors = "successors" + KeySummary = "summary" + KeySuperior = "superior" + KeyURL = "url" + KeyUselessFiles = "useless-files" + KeyUserID = "user-id" + KeyUserRole = "user-role" + KeyVisibility = "visibility" +) + +// Supported keys. +func init() { + registerKey(KeyID, TypeID, usageComputed, "") + registerKey(KeyTitle, TypeEmpty, usageUser, "") + registerKey(KeyRole, TypeWord, usageUser, "") + registerKey(KeyTags, TypeTagSet, usageUser, "") + registerKey(KeySyntax, TypeWord, usageUser, "") + + // Properties that are inverse keys + registerKey(KeyFolge, TypeIDSet, usageProperty, "") + registerKey(KeySequel, TypeIDSet, usageProperty, "") + registerKey(KeySuccessors, TypeIDSet, usageProperty, "") + registerKey(KeySubordinates, TypeIDSet, usageProperty, "") + + // Non-inverse keys + registerKey(KeyAuthor, TypeString, usageUser, "") + registerKey(KeyBack, TypeIDSet, usageProperty, "") + registerKey(KeyBackward, TypeIDSet, usageProperty, "") + registerKey(KeyBoxNumber, TypeNumber, usageProperty, "") + registerKey(KeyCopyright, TypeString, usageUser, "") + registerKey(KeyCreated, TypeTimestamp, usageComputed, "") + registerKey(KeyCredential, TypeCredential, usageUser, "") + registerKey(KeyDead, TypeIDSet, usageProperty, "") + registerKey(KeyExpire, TypeTimestamp, usageUser, "") + registerKey(KeyFolgeRole, TypeWord, usageUser, "") + registerKey(KeyForward, TypeIDSet, usageProperty, "") + registerKey(KeyLang, TypeWord, usageUser, "") + registerKey(KeyLicense, TypeEmpty, usageUser, "") + registerKey(KeyModified, TypeTimestamp, usageComputed, "") + registerKey(KeyPrecursor, TypeIDSet, usageUser, KeyFolge) + registerKey(KeyPredecessor, TypeID, usageUser, KeySuccessors) + registerKey(KeyPrequel, TypeIDSet, usageUser, KeySequel) + registerKey(KeyPublished, TypeTimestamp, usageProperty, "") + registerKey(KeyQuery, TypeEmpty, usageUser, "") + registerKey(KeyReadOnly, TypeWord, usageUser, "") + registerKey(KeySummary, TypeString, usageUser, "") + registerKey(KeySuperior, TypeIDSet, usageUser, KeySubordinates) + registerKey(KeyURL, TypeURL, usageUser, "") + registerKey(KeyUselessFiles, TypeString, usageProperty, "") + registerKey(KeyUserID, TypeWord, usageUser, "") + registerKey(KeyUserRole, TypeWord, usageUser, "") + registerKey(KeyVisibility, TypeWord, usageUser, "") +} + +// NewPrefix is the prefix for metadata keys in template zettel for creating new zettel. +const NewPrefix = "new-" + +// Meta contains all meta-data of a zettel. +type Meta struct { + Zid id.Zid + pairs map[Key]Value + YamlSep bool +} + +// New creates a new chunk for storing metadata. +func New(zid id.Zid) *Meta { + return &Meta{Zid: zid, pairs: make(map[Key]Value, 5)} +} + +// NewWithData creates metadata object with given data. +func NewWithData(zid id.Zid, data map[string]string) *Meta { + pairs := make(map[Key]Value, len(data)) + for k, v := range data { + pairs[k] = Value(v) + } + return &Meta{Zid: zid, pairs: pairs} +} + +// ByteSize returns the number of bytes stored for the metadata. +func (m *Meta) ByteSize() int { + if m == nil { + return 0 + } + result := 6 // storage needed for Zid + for k, v := range m.pairs { + result += len(k) + len(v) + 1 // 1 because separator + } + return result +} + +// Clone returns a new copy of the metadata. +func (m *Meta) Clone() *Meta { + return &Meta{ + Zid: m.Zid, + pairs: maps.Clone(m.pairs), + YamlSep: m.YamlSep, + } +} + +// Map returns a copy of the meta data as a string map. +func (m *Meta) Map() map[string]string { + pairs := make(map[string]string, len(m.pairs)) + for k, v := range m.pairs { + pairs[k] = string(v) + } + return pairs +} + +var reKey = regexp.MustCompile("^[0-9a-z][-0-9a-z]{0,254}$") + +// KeyIsValid returns true, if the string is a valid metadata key. +func KeyIsValid(s string) bool { return reKey.MatchString(s) } + +var firstKeys = []string{KeyTitle, KeyRole, KeyTags, KeySyntax} + +// Set stores the given string value under the given key. +func (m *Meta) Set(key string, value Value) { + if key != KeyID { + m.pairs[key] = value.TrimSpace() + } +} + +// SetNonEmpty stores the given value under the given key, if the value is non-empty. +// An empty value will delete the previous association. +func (m *Meta) SetNonEmpty(key string, value Value) { + if value == "" { + delete(m.pairs, key) // TODO: key != KeyID + } else { + m.Set(key, value.TrimSpace()) + } +} + +// Get retrieves the string value of a given key. The bool value signals, +// whether there was a value stored or not. +func (m *Meta) Get(key string) (Value, bool) { + if m == nil { + return "", false + } + if key == KeyID { + return Value(m.Zid.String()), true + } + value, ok := m.pairs[key] + return value, ok +} + +// GetDefault retrieves the string value of the given key. If no value was +// stored, the given default value is returned. +func (m *Meta) GetDefault(key string, def Value) Value { + if value, found := m.Get(key); found { + return value + } + return def +} + +// GetTitle returns the title of the metadata. It is the only key that has a +// defined default value: the string representation of the zettel identifier. +func (m *Meta) GetTitle() string { + if title, found := m.Get(KeyTitle); found { + return string(title) + } + return m.Zid.String() +} + +// All returns an iterator over all key/value pairs, except the zettel identifier +// and computed values. +func (m *Meta) All() iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + m.firstKeys()(yield) + m.restKeys(notComputedKey)(yield) + } +} + +// Computed returns an iterator over all key/value pairs, except the zettel identifier. +func (m *Meta) Computed() iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + m.firstKeys()(yield) + m.restKeys(anyKey)(yield) + } +} + +// Rest returns an iterator over all key/value pairs, except the zettel identifier, +// the main keys, and computed values. +func (m *Meta) Rest() iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + m.restKeys(notComputedKey)(yield) + } +} + +// ComputedRest returns an iterator over all key/value pairs, except the zettel identifier, +// and the main keys. +func (m *Meta) ComputedRest() iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + m.restKeys(anyKey)(yield) + } +} + +func (m *Meta) firstKeys() iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + for _, key := range firstKeys { + if val, ok := m.pairs[key]; ok { + if !yield(key, val) { + return + } + } + } + } +} + +func (m *Meta) restKeys(addKeyPred func(Key) bool) iter.Seq2[Key, Value] { + return func(yield func(Key, Value) bool) { + keys := slices.Sorted(maps.Keys(m.pairs)) + for _, key := range keys { + if !slices.Contains(firstKeys, key) && addKeyPred(key) { + if !yield(key, m.pairs[key]) { + return + } + } + } + } +} + +func notComputedKey(key string) bool { return !IsComputed(key) } +func anyKey(string) bool { return true } + +// Delete removes a key from the data. +func (m *Meta) Delete(key string) { + if key != KeyID { + delete(m.pairs, key) + } +} + +// Equal compares to metas for equality. +func (m *Meta) Equal(o *Meta, allowComputed bool) bool { + if m == nil && o == nil { + return true + } + if m == nil || o == nil || m.Zid != o.Zid { + return false + } + tested := set.New[string]() + for k, v := range m.pairs { + tested.Add(k) + if !equalValue(k, v, o, allowComputed) { + return false + } + } + for k, v := range o.pairs { + if !tested.Contains(k) && !equalValue(k, v, m, allowComputed) { + return false + } + } + return true +} + +func equalValue(key string, val Value, other *Meta, allowComputed bool) bool { + if allowComputed || !IsComputed(key) { + if valO, found := other.pairs[key]; !found || val != valO { + return false + } + } + return true +} + +// Sanitize all metadata keys and values, so that they can be written safely into a file. +func (m *Meta) Sanitize() { + if m == nil { + return + } + for key, val := range m.pairs { + newKey := RemoveNonGraphic(key) + if key == newKey { + m.pairs[key] = Value(RemoveNonGraphic(string(val))) + } else { + delete(m.pairs, key) + m.pairs[newKey] = Value(RemoveNonGraphic(string(val))) + } + } +} + +// RemoveNonGraphic changes the given string not to include non-graphical characters. +// It is needed to sanitize meta data. +func RemoveNonGraphic(s string) string { + if s == "" { + return "" + } + pos := 0 + var sb strings.Builder + for pos < len(s) { + nextPos := strings.IndexFunc(s[pos:], func(r rune) bool { return !unicode.IsGraphic(r) }) + if nextPos < 0 { + break + } + sb.WriteString(s[pos:nextPos]) + sb.WriteByte(' ') + _, size := utf8.DecodeRuneInString(s[nextPos:]) + pos = nextPos + size + } + if pos == 0 { + return strings.TrimSpace(s) + } + sb.WriteString(s[pos:]) + return strings.TrimSpace(sb.String()) +} ADDED domain/meta/meta_test.go Index: domain/meta/meta_test.go ================================================================== --- /dev/null +++ domain/meta/meta_test.go @@ -0,0 +1,266 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 meta + +import ( + "iter" + "slices" + "strings" + "testing" + + "t73f.de/r/zsc/domain/id" +) + +const testID = id.Zid(98765432101234) + +func TestKeyIsValid(t *testing.T) { + t.Parallel() + validKeys := []string{"0", "a", "0-", "title", "title-----", strings.Repeat("r", 255)} + for _, key := range validKeys { + if !KeyIsValid(key) { + t.Errorf("Key %q wrongly identified as invalid key", key) + } + } + invalidKeys := []string{"", "-", "-a", "Title", "a_b", strings.Repeat("e", 256)} + for _, key := range invalidKeys { + if KeyIsValid(key) { + t.Errorf("Key %q wrongly identified as valid key", key) + } + } +} + +func TestTitleHeader(t *testing.T) { + t.Parallel() + m := New(testID) + if got, ok := m.Get(KeyTitle); ok && got != "" { + t.Errorf("Title is not empty, but %q", got) + } + addToMeta(m, KeyTitle, " ") + if got, ok := m.Get(KeyTitle); ok && got != "" { + t.Errorf("Title is not empty, but %q", got) + } + const st = "A simple text" + addToMeta(m, KeyTitle, " "+st+" ") + if got, ok := m.Get(KeyTitle); !ok || got != st { + t.Errorf("Title is not %q, but %q", st, got) + } + addToMeta(m, KeyTitle, " "+st+"\t") + const exp = st + " " + st + if got, ok := m.Get(KeyTitle); !ok || got != exp { + t.Errorf("Title is not %q, but %q", exp, got) + } + + m = New(testID) + const at = "A Title" + addToMeta(m, KeyTitle, at) + addToMeta(m, KeyTitle, " ") + if got, ok := m.Get(KeyTitle); !ok || got != at { + t.Errorf("Title is not %q, but %q", at, got) + } +} + +func checkTags(t *testing.T, exp []string, m *Meta) { + t.Helper() + got := slices.Collect(m.GetFields(KeyTags)) + for i, tag := range exp { + if i < len(got) { + if tag != got[i] { + t.Errorf("Pos=%d, expected %q, got %q", i, exp[i], got[i]) + } + } else { + t.Errorf("Expected %q, but is missing", exp[i]) + } + } + if len(exp) < len(got) { + t.Errorf("Extra tags: %q", got[len(exp):]) + } +} + +func TestTagsHeader(t *testing.T) { + t.Parallel() + m := New(testID) + checkTags(t, []string{}, m) + + addToMeta(m, KeyTags, "") + checkTags(t, []string{}, m) + + addToMeta(m, KeyTags, " #t1 #t2 #t3 #t4 ") + checkTags(t, []string{"#t1", "#t2", "#t3", "#t4"}, m) + + addToMeta(m, KeyTags, "#t5") + checkTags(t, []string{"#t1", "#t2", "#t3", "#t4", "#t5"}, m) + + addToMeta(m, KeyTags, "t6") + checkTags(t, []string{"#t1", "#t2", "#t3", "#t4", "#t5"}, m) +} + +func TestSyntax(t *testing.T) { + t.Parallel() + m := New(testID) + if got, ok := m.Get(KeySyntax); ok || got != "" { + t.Errorf("Syntax is not %q, but %q", "", got) + } + addToMeta(m, KeySyntax, " ") + if got, _ := m.Get(KeySyntax); got != "" { + t.Errorf("Syntax is not %q, but %q", "", got) + } + addToMeta(m, KeySyntax, "MarkDown") + const exp = "markdown" + if got, ok := m.Get(KeySyntax); !ok || got != exp { + t.Errorf("Syntax is not %q, but %q", exp, got) + } + addToMeta(m, KeySyntax, " ") + if got, _ := m.Get(KeySyntax); got != "" { + t.Errorf("Syntax is not %q, but %q", "", got) + } +} + +func checkHeader(t *testing.T, exp map[string]string, gotI iter.Seq2[Key, Value]) { + t.Helper() + got := make(map[string]string) + gotI(func(key Key, val Value) bool { + got[key] = string(val) + if _, ok := exp[key]; !ok { + t.Errorf("Key %q is not expected, but has value %q", key, val) + } + return true + }) + for k, v := range exp { + if gv, ok := got[k]; !ok || v != gv { + if ok { + t.Errorf("Key %q is not %q, but %q", k, v, got[k]) + } else { + t.Errorf("Key %q missing, should have value %q", k, v) + } + } + } +} + +func TestDefaultHeader(t *testing.T) { + t.Parallel() + m := New(testID) + addToMeta(m, "h1", "d1") + addToMeta(m, "H2", "D2") + addToMeta(m, "H1", "D1.1") + exp := map[string]string{"h1": "d1 D1.1", "h2": "D2"} + checkHeader(t, exp, m.All()) + addToMeta(m, "", "d0") + checkHeader(t, exp, m.All()) + addToMeta(m, "h3", "") + exp["h3"] = "" + checkHeader(t, exp, m.All()) + addToMeta(m, "h3", " ") + checkHeader(t, exp, m.All()) + addToMeta(m, "h4", " ") + exp["h4"] = "" + checkHeader(t, exp, m.All()) +} + +func TestDelete(t *testing.T) { + t.Parallel() + m := New(testID) + m.Set("key", "val") + if got, ok := m.Get("key"); !ok || got != "val" { + t.Errorf("Value != %q, got: %v/%q", "val", ok, got) + } + m.Set("key", "") + if got, ok := m.Get("key"); !ok || got != "" { + t.Errorf("Value != %q, got: %v/%q", "", ok, got) + } + m.Delete("key") + if got, ok := m.Get("key"); ok || got != "" { + t.Errorf("Value != %q, got: %v/%q", "", ok, got) + } +} + +func TestEqual(t *testing.T) { + t.Parallel() + testcases := []struct { + pairs1, pairs2 []string + allowComputed bool + exp bool + }{ + {nil, nil, true, true}, + {nil, nil, false, true}, + {[]string{"a", "a"}, nil, false, false}, + {[]string{"a", "a"}, nil, true, false}, + {[]string{KeyFolge, "0"}, nil, true, false}, + {[]string{KeyFolge, "0"}, nil, false, true}, + {[]string{KeyFolge, "0"}, []string{KeyFolge, "0"}, true, true}, + {[]string{KeyFolge, "0"}, []string{KeyFolge, "0"}, false, true}, + } + for i, tc := range testcases { + m1 := pairs2meta(tc.pairs1) + m2 := pairs2meta(tc.pairs2) + got := m1.Equal(m2, tc.allowComputed) + if tc.exp != got { + t.Errorf("%d: %v =?= %v: expected=%v, but got=%v", i, tc.pairs1, tc.pairs2, tc.exp, got) + } + got = m2.Equal(m1, tc.allowComputed) + if tc.exp != got { + t.Errorf("%d: %v =!= %v: expected=%v, but got=%v", i, tc.pairs1, tc.pairs2, tc.exp, got) + } + } + + // Pathologic cases + var m1, m2 *Meta + if !m1.Equal(m2, true) { + t.Error("Nil metas should be treated equal") + } + m1 = New(testID) + if m1.Equal(m2, true) { + t.Error("Empty meta should not be equal to nil") + } + if m2.Equal(m1, true) { + t.Error("Nil meta should should not be equal to empty") + } + m2 = New(testID + 1) + if m1.Equal(m2, true) { + t.Error("Different ID should differentiate") + } + if m2.Equal(m1, true) { + t.Error("Different ID should differentiate") + } +} + +func pairs2meta(pairs []string) *Meta { + m := New(testID) + for i := 0; i < len(pairs); i += 2 { + m.Set(pairs[i], Value(pairs[i+1])) + } + return m +} + +func TestRemoveNonGraphic(t *testing.T) { + testCases := []struct { + inp string + exp string + }{ + {"", ""}, + {" ", ""}, + {"a", "a"}, + {"a ", "a"}, + {"a b", "a b"}, + {"\n", ""}, + {"a\n", "a"}, + {"a\nb", "a b"}, + {"a\tb", "a b"}, + } + for i, tc := range testCases { + got := RemoveNonGraphic(tc.inp) + if tc.exp != got { + t.Errorf("%q/%d: expected %q, but got %q", tc.inp, i, tc.exp, got) + } + } +} ADDED domain/meta/parse.go Index: domain/meta/parse.go ================================================================== --- /dev/null +++ domain/meta/parse.go @@ -0,0 +1,169 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 meta + +import ( + "iter" + "slices" + "strings" + + "t73f.de/r/zero/set" + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsx/input" +) + +// NewFromInput parses the meta data of a zettel. +func NewFromInput(zid id.Zid, inp *input.Input) *Meta { + if inp.Ch == '-' && inp.PeekN(0) == '-' && inp.PeekN(1) == '-' { + skipToEOL(inp) + inp.EatEOL() + } + meta := New(zid) + for { + inp.SkipSpace() + switch inp.Ch { + case '\r': + if inp.Peek() == '\n' { + inp.Next() + } + fallthrough + case '\n': + inp.Next() + return meta + case input.EOS: + return meta + case '%': + skipToEOL(inp) + inp.EatEOL() + continue + } + parseHeader(meta, inp) + if inp.Ch == '-' && inp.PeekN(0) == '-' && inp.PeekN(1) == '-' { + skipToEOL(inp) + inp.EatEOL() + meta.YamlSep = true + return meta + } + } +} + +func parseHeader(m *Meta, inp *input.Input) { + pos := inp.Pos + for isHeader(inp.Ch) { + inp.Next() + } + key := inp.Src[pos:inp.Pos] + inp.SkipSpace() + if inp.Ch == ':' { + inp.Next() + } + var val []byte + for { + inp.SkipSpace() + pos = inp.Pos + skipToEOL(inp) + val = append(val, inp.Src[pos:inp.Pos]...) + inp.EatEOL() + if !inp.IsSpace() { + break + } + val = append(val, ' ') + } + addToMeta(m, string(key), Value(val)) +} + +func skipToEOL(inp *input.Input) { + for { + switch inp.Ch { + case '\n', '\r', input.EOS: + return + } + inp.Next() + } +} + +// Return true iff rune is valid for header key. +func isHeader(ch rune) bool { + return ('a' <= ch && ch <= 'z') || + ('0' <= ch && ch <= '9') || + ch == '-' || + ('A' <= ch && ch <= 'Z') +} + +type predValidElem func(string) bool + +func addToSet(set *set.Set[string], it iter.Seq[string], useElem predValidElem) { + for e := range it { + if len(e) > 0 && useElem(e) { + set.Add(e) + } + } +} + +func addSet(m *Meta, key string, val Value, useElem predValidElem) { + newElems := val.Fields() + oldElems := m.GetFields(key) + + s := set.New[string]() + addToSet(s, newElems, useElem) + if s.Length() == 0 { + // Nothing to add. Maybe because of rejected elements. + return + } + addToSet(s, oldElems, useElem) + m.SetList(key, slices.Sorted(s.Values())) +} + +func addData(m *Meta, k string, v Value) { + if o, ok := m.Get(k); !ok || o == "" { + m.Set(k, v) + } else if v != "" { + m.Set(k, o+" "+v) + } +} + +func addToMeta(m *Meta, key string, val Value) { + v := val.TrimSpace() + key = strings.ToLower(key) + if !KeyIsValid(key) { + return + } + switch key { + case "", KeyID: + // Empty key and 'id' key will be ignored + return + } + + switch Type(key) { + case TypeTagSet: + addSet(m, key, v.ToLower(), func(s string) bool { return s[0] == '#' && len(s) > 1 }) + case TypeWord: + m.Set(key, v.ToLower()) + case TypeID: + if _, err := id.Parse(string(v)); err == nil { + m.Set(key, v) + } + case TypeIDSet: + addSet(m, key, v, func(s string) bool { + _, err := id.Parse(s) + return err == nil + }) + case TypeTimestamp: + if _, ok := v.AsTime(); ok { + m.Set(key, v) + } + default: + addData(m, key, v) + } +} ADDED domain/meta/parse_test.go Index: domain/meta/parse_test.go ================================================================== --- /dev/null +++ domain/meta/parse_test.go @@ -0,0 +1,188 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 meta_test + +import ( + "iter" + "slices" + "strings" + "testing" + + "t73f.de/r/zsc/domain/meta" + "t73f.de/r/zsx/input" +) + +func parseMetaStr(src string) *meta.Meta { + return meta.NewFromInput(testID, input.NewInput([]byte(src))) +} + +func TestEmpty(t *testing.T) { + t.Parallel() + m := parseMetaStr("") + if got, ok := m.Get(meta.KeySyntax); ok || got != "" { + t.Errorf("Syntax is not %q, but %q", "", got) + } + if got := slices.Collect(m.GetDefault(meta.KeyTags, "").Fields()); len(got) > 0 { + t.Errorf("Tags are not nil, but %v", got) + } +} + +func TestTitle(t *testing.T) { + t.Parallel() + td := []struct { + s string + e meta.Value + }{ + {meta.KeyTitle + ": a title", "a title"}, + {meta.KeyTitle + ": a\n\t title", "a title"}, + {meta.KeyTitle + ": a\n\t title\r\n x", "a title x"}, + {meta.KeyTitle + " AbC", "AbC"}, + {meta.KeyTitle + " AbC\n ded", "AbC ded"}, + {meta.KeyTitle + ": o\ntitle: p", "o p"}, + {meta.KeyTitle + ": O\n\ntitle: P", "O"}, + {meta.KeyTitle + ": b\r\ntitle: c", "b c"}, + {meta.KeyTitle + ": B\r\n\r\ntitle: C", "B"}, + {meta.KeyTitle + ": r\rtitle: q", "r q"}, + {meta.KeyTitle + ": R\r\rtitle: Q", "R"}, + } + for i, tc := range td { + m := parseMetaStr(tc.s) + if got, ok := m.Get(meta.KeyTitle); !ok || got != tc.e { + t.Log(m) + t.Errorf("TC=%d: expected %q, got %q", i, tc.e, got) + } + } +} + +func TestTags(t *testing.T) { + t.Parallel() + testcases := []struct { + src string + exp string + }{ + {"", ""}, + {meta.KeyTags + ":", ""}, + {meta.KeyTags + ": c", ""}, + {meta.KeyTags + ": #", ""}, + {meta.KeyTags + ": #c", "c"}, + {meta.KeyTags + ": #c #", "c"}, + {meta.KeyTags + ": #c #b", "b c"}, + {meta.KeyTags + ": #c # #", "c"}, + {meta.KeyTags + ": #c # #b", "b c"}, + } + for i, tc := range testcases { + m := parseMetaStr(tc.src) + tagsString, found := m.Get(meta.KeyTags) + if !found { + if tc.exp != "" { + t.Errorf("%d / %q: no %s found", i, tc.src, meta.KeyTags) + } + continue + } + tags := tagsString.AsTags() + if tc.exp == "" && len(tags) > 0 { + t.Errorf("%d / %q: expected no %s, but got %v", i, tc.src, meta.KeyTags, tags) + continue + } + got := strings.Join(tags, " ") + if tc.exp != got { + t.Errorf("%d / %q: expected %q, got: %q", i, tc.src, tc.exp, got) + } + } +} + +func TestNewFromInput(t *testing.T) { + t.Parallel() + testcases := []struct { + input string + exp []pair + }{ + {"", []pair{}}, + {" a:b", []pair{{"a", "b"}}}, + {"%a:b", []pair{}}, + {"a:b\r\n\r\nc:d", []pair{{"a", "b"}}}, + {"a:b\r\n%c:d", []pair{{"a", "b"}}}, + {"% a:b\r\n c:d", []pair{{"c", "d"}}}, + {"---\r\na:b\r\n", []pair{{"a", "b"}}}, + {"---\r\na:b\r\n--\r\nc:d", []pair{{"a", "b"}, {"c", "d"}}}, + {"---\r\na:b\r\n---\r\nc:d", []pair{{"a", "b"}}}, + {"---\r\na:b\r\n----\r\nc:d", []pair{{"a", "b"}}}, + {"new-title:\nnew-url:", []pair{{"new-title", ""}, {"new-url", ""}}}, + } + for i, tc := range testcases { + meta := parseMetaStr(tc.input) + if got := iter2pairs(meta.All()); !equalPairs(tc.exp, got) { + t.Errorf("TC=%d: expected=%v, got=%v", i, tc.exp, got) + } + } + + // Test, whether input position is correct. + inp := input.NewInput([]byte("---\na:b\n---\nX")) + m := meta.NewFromInput(testID, inp) + exp := []pair{{"a", "b"}} + if got := iter2pairs(m.All()); !equalPairs(exp, got) { + t.Errorf("Expected=%v, got=%v", exp, got) + } + expCh := 'X' + if gotCh := inp.Ch; gotCh != expCh { + t.Errorf("Expected=%v, got=%v", expCh, gotCh) + } +} + +type pair struct { + key meta.Key + val meta.Value +} + +func iter2pairs(it iter.Seq2[meta.Key, meta.Value]) (result []pair) { + it(func(key meta.Key, val meta.Value) bool { + result = append(result, pair{key, val}) + return true + }) + return result +} + +func equalPairs(one, two []pair) bool { + if len(one) != len(two) { + return false + } + for i := range len(one) { + if one[i].key != two[i].key || one[i].val != two[i].val { + return false + } + } + return true +} + +func TestPrecursorIDSet(t *testing.T) { + t.Parallel() + var testdata = []struct { + inp string + exp meta.Value + }{ + {"", ""}, + {"123", ""}, + {"12345678901234", "12345678901234"}, + {"123 12345678901234", "12345678901234"}, + {"12345678901234 123", "12345678901234"}, + {"01234567890123 123 12345678901234", "01234567890123 12345678901234"}, + {"12345678901234 01234567890123", "01234567890123 12345678901234"}, + } + for i, tc := range testdata { + m := parseMetaStr(meta.KeyPrecursor + ": " + tc.inp) + if got, ok := m.Get(meta.KeyPrecursor); (!ok && tc.exp != "") || tc.exp != got { + t.Errorf("TC=%d: expected %q, but got %q when parsing %q", i, tc.exp, got, tc.inp) + } + } +} ADDED domain/meta/type.go Index: domain/meta/type.go ================================================================== --- /dev/null +++ domain/meta/type.go @@ -0,0 +1,181 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 meta + +import ( + "iter" + "strconv" + "strings" + "sync" + "time" + + zeroiter "t73f.de/r/zero/iter" + "t73f.de/r/zsc/domain/id" +) + +// DescriptionType is a description of a specific key type. +type DescriptionType struct { + Name string + IsSet bool +} + +// String returns the string representation of the given type +func (t DescriptionType) String() string { return t.Name } + +var registeredTypes = make(map[string]*DescriptionType) + +func registerType(name string, isSet bool) *DescriptionType { + if _, ok := registeredTypes[name]; ok { + panic("Type '" + name + "' already registered") + } + t := &DescriptionType{name, isSet} + registeredTypes[name] = t + return t +} + +// Values of the metadata key/value types. +// +// See [Supported Key Types]. +// +// [Supported Key Types]: https://zettelstore.de/manual/h/00001006030000 +const ( + MetaCredential = "Credential" + MetaEmpty = "EString" + MetaID = "Identifier" + MetaIDSet = "IdentifierSet" + MetaNumber = "Number" + MetaString = "String" + MetaTagSet = "TagSet" + MetaTimestamp = "Timestamp" + MetaURL = "URL" + MetaWord = "Word" +) + +// Supported key types. +var ( + TypeCredential = registerType(MetaCredential, false) + TypeEmpty = registerType(MetaEmpty, false) + TypeID = registerType(MetaID, false) + TypeIDSet = registerType(MetaIDSet, true) + TypeNumber = registerType(MetaNumber, false) + TypeString = registerType(MetaString, false) + TypeTagSet = registerType(MetaTagSet, true) + TypeTimestamp = registerType(MetaTimestamp, false) + TypeURL = registerType(MetaURL, false) + TypeWord = registerType(MetaWord, false) +) + +// Type returns a type hint for the given key. If no type hint is specified, +// TypeUnknown is returned. +func (*Meta) Type(key string) *DescriptionType { + return Type(key) +} + +// Some constants for key suffixes that determine a type. +const ( + SuffixKeyRole = "-role" + SuffixKeyURL = "-url" +) + +var ( + cachedTypedKeys = make(map[string]*DescriptionType) + mxTypedKey sync.RWMutex + suffixTypes = map[string]*DescriptionType{ + "-date": TypeTimestamp, + "-number": TypeNumber, + SuffixKeyRole: TypeWord, + "-time": TypeTimestamp, + SuffixKeyURL: TypeURL, + "-zettel": TypeID, + "-zid": TypeID, + "-zids": TypeIDSet, + } +) + +// Type returns a type hint for the given key. If no type hint is specified, +// TypeEmpty is returned. +func Type(key string) *DescriptionType { + if k, ok := registeredKeys[key]; ok { + return k.Type + } + mxTypedKey.RLock() + k, found := cachedTypedKeys[key] + mxTypedKey.RUnlock() + if found { + return k + } + + for suffix, t := range suffixTypes { + if strings.HasSuffix(key, suffix) { + mxTypedKey.Lock() + defer mxTypedKey.Unlock() + // Double check to avoid races + if _, found = cachedTypedKeys[key]; !found { + cachedTypedKeys[key] = t + } + return t + } + } + return TypeEmpty +} + +// SetList stores the given string list value under the given key. +func (m *Meta) SetList(key string, values []string) { + if key != KeyID { + for i, val := range values { + values[i] = string(Value(val).TrimSpace()) + } + m.pairs[key] = Value(strings.Join(values, " ")) + } +} + +// SetWord stores the given word under the given key. +func (m *Meta) SetWord(key, word string) { + for val := range Value(word).Elems() { + m.Set(key, val) + return + } +} + +// SetNow stores the current timestamp under the given key. +func (m *Meta) SetNow(key string) { + m.Set(key, Value(time.Now().Local().Format(id.TimestampLayout))) +} + +// GetBool returns the boolean value of the given key. +func (m *Meta) GetBool(key string) bool { + if val, ok := m.Get(key); ok { + return val.AsBool() + } + return false +} + +// GetFields returns the metadata value as a sequence of string. The bool value +// signals, whether there was a value stored or not. +func (m *Meta) GetFields(key Key) iter.Seq[string] { + if val, ok := m.Get(key); ok { + return val.Fields() + } + return zeroiter.EmptySeq[string]() +} + +// GetNumber retrieves the numeric value of a given key. +func (m *Meta) GetNumber(key string, def int64) int64 { + if value, ok := m.Get(key); ok { + if num, err := strconv.ParseInt(string(value), 10, 64); err == nil { + return num + } + } + return def +} ADDED domain/meta/type_test.go Index: domain/meta/type_test.go ================================================================== --- /dev/null +++ domain/meta/type_test.go @@ -0,0 +1,79 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 meta_test + +import ( + "strconv" + "testing" + "time" + + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/domain/meta" +) + +func TestNow(t *testing.T) { + t.Parallel() + m := meta.New(id.Invalid) + m.SetNow("key") + val, ok := m.Get("key") + if !ok { + t.Error("Unable to get value of key") + } + if len(val) != 14 { + t.Errorf("Value is not 14 digits long: %q", val) + } + if _, err := strconv.ParseInt(string(val), 10, 64); err != nil { + t.Errorf("Unable to parse %q as an int64: %v", val, err) + } + if _, ok = val.AsTime(); !ok { + t.Errorf("Unable to get time from value %q", val) + } +} + +func TestTimeValue(t *testing.T) { + t.Parallel() + testCases := []struct { + value meta.Value + valid bool + exp time.Time + }{ + {"", false, time.Time{}}, + {"1", false, time.Time{}}, + {"00000000000000", false, time.Time{}}, + {"98765432109876", false, time.Time{}}, + {"20201221111905", true, time.Date(2020, time.December, 21, 11, 19, 5, 0, time.UTC)}, + {"2023", true, time.Date(2023, time.January, 1, 0, 0, 0, 0, time.UTC)}, + {"20231", false, time.Time{}}, + {"202310", true, time.Date(2023, time.October, 1, 0, 0, 0, 0, time.UTC)}, + {"2023103", false, time.Time{}}, + {"20231030", true, time.Date(2023, time.October, 30, 0, 0, 0, 0, time.UTC)}, + {"202310301", false, time.Time{}}, + {"2023103016", true, time.Date(2023, time.October, 30, 16, 0, 0, 0, time.UTC)}, + {"20231030165", false, time.Time{}}, + {"202310301654", true, time.Date(2023, time.October, 30, 16, 54, 0, 0, time.UTC)}, + {"2023103016541", false, time.Time{}}, + {"20231030165417", true, time.Date(2023, time.October, 30, 16, 54, 17, 0, time.UTC)}, + {"2023103916541700", false, time.Time{}}, + } + for i, tc := range testCases { + got, ok := tc.value.AsTime() + if ok != tc.valid { + t.Errorf("%d: parsing of %q should be %v, but got %v", i, tc.value, tc.valid, ok) + continue + } + if got != tc.exp { + t.Errorf("%d: parsing of %q should return %v, but got %v", i, tc.value, tc.exp, got) + } + } +} ADDED domain/meta/values.go Index: domain/meta/values.go ================================================================== --- /dev/null +++ domain/meta/values.go @@ -0,0 +1,225 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 meta + +import ( + "fmt" + "iter" + "slices" + "strings" + "time" + + zeroiter "t73f.de/r/zero/iter" + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsx/input" +) + +// Value ist a single metadata value. +type Value string + +// AsBool returns the value interpreted as a bool. +func (val Value) AsBool() bool { + if len(val) > 0 { + switch val[0] { + case '0', 'f', 'F', 'n', 'N': + return false + } + } + return true +} + +// AsTime returns the time value of the given value. +func (val Value) AsTime() (time.Time, bool) { + if t, err := time.Parse(id.TimestampLayout, ExpandTimestamp(val)); err == nil { + return t, true + } + return time.Time{}, false +} + +// ExpandTimestamp makes a short-form timestamp larger. +func ExpandTimestamp(val Value) string { + switch l := len(val); l { + case 4: // YYYY + return string(val) + "0101000000" + case 6: // YYYYMM + return string(val) + "01000000" + case 8, 10, 12: // YYYYMMDD, YYYYMMDDhh, YYYYMMDDhhmm + return string(val) + "000000"[:14-l] + case 14: // YYYYMMDDhhmmss + return string(val) + default: + if l > 14 { + return string(val[:14]) + } + return string(val) + } +} + +// Fields iterates over the value as a list/set of strings. +func (val Value) Fields() iter.Seq[string] { + return strings.FieldsSeq(string(val)) +} + +// Elems iterates over the value as a list/set of values. +func (val Value) Elems() iter.Seq[Value] { + return zeroiter.MapSeq(val.Fields(), func(s string) Value { return Value(s) }) +} + +// AsSlice transforms a value into a slice of strings. +func (val Value) AsSlice() []string { + return strings.Fields(string(val)) +} + +// ToLower maps the value to lowercase runes. +func (val Value) ToLower() Value { return Value(strings.ToLower(string(val))) } + +// TrimSpace removes all leading and remaining space from value +func (val Value) TrimSpace() Value { + return Value(strings.TrimFunc(string(val), input.IsSpace)) +} + +// AsTags returns the value as a sequence of normalized tags. +func (val Value) AsTags() []string { + return slices.Collect(zeroiter.MapSeq( + val.Fields(), + func(e string) string { return string(Value(e).ToLower().CleanTag()) })) +} + +// CleanTag removes the number character ('#') from a tag value. +func (val Value) CleanTag() Value { + if len(val) > 1 && val[0] == '#' { + return val[1:] + } + return val +} + +// NormalizeTag adds a missing prefix "#" to the tag +func (val Value) NormalizeTag() Value { + if len(val) > 0 && val[0] == '#' { + return val + } + return "#" + val +} + +// Predefined metadata values. +const ( + ValueFalse = "false" + ValueTrue = "true" + ValueLangEN = "en" // Default for "lang" + ValueRoleConfiguration = "configuration" // A role for internal zettel + ValueRoleTag = "tag" // A role for tag zettel + ValueRoleRole = "role" // A role for role zettel + ValueRoleZettel = "zettel" // A role for zettel + ValueSyntaxCSS = "css" // Syntax: CSS + ValueSyntaxDraw = "draw" // Syntax: Drawing + ValueSyntaxGif = "gif" // Syntax: GIF image + ValueSyntaxHTML = "html" // Syntax: HTML + ValueSyntaxJPEG = "jpeg" // Syntax: JPEG image + ValueSyntaxJPG = "jpg" // Syntax: PEG image + ValueSyntaxMarkdown = "markdown" // Syntax: Markdown / CommonMark + ValueSyntaxMD = "md" // Syntax: Markdown / CommonMark + ValueSyntaxNone = "none" // Syntax: no syntax / content, just metadata + ValueSyntaxPlain = "plain" // Syntax: plain text + ValueSyntaxPNG = "png" // Syntax: PNG image + ValueSyntaxSVG = "svg" // Syntax: SVG + ValueSyntaxSxn = "sxn" // Syntax: S-Expression + ValueSyntaxText = "text" // Syntax: plain text + ValueSyntaxTxt = "txt" // Syntax: plain text + ValueSyntaxWebp = "webp" // Syntax: WEBP image + ValueSyntaxZmk = "zmk" // Syntax: Zettelmarkup + ValueUserRoleCreator = "creator" + ValueUserRoleOwner = "owner" + ValueUserRoleReader = "reader" + ValueUserRoleWriter = "writer" + ValueVisibilityCreator = "creator" + ValueVisibilityExpert = "expert" + ValueVisibilityLogin = "login" + ValueVisibilityOwner = "owner" + ValueVisibilityPublic = "public" +) + +// DefaultSyntax is the default value for metadata 'syntax'. +const DefaultSyntax = ValueSyntaxPlain + +// Visibility enumerates the variations of the 'visibility' meta key. +type Visibility int + +// Supported values for visibility. +const ( + _ Visibility = iota + VisibilityUnknown + VisibilityPublic + VisibilityCreator + VisibilityLogin + VisibilityOwner + VisibilityExpert +) + +var visMap = map[Value]Visibility{ + ValueVisibilityPublic: VisibilityPublic, + ValueVisibilityCreator: VisibilityCreator, + ValueVisibilityLogin: VisibilityLogin, + ValueVisibilityOwner: VisibilityOwner, + ValueVisibilityExpert: VisibilityExpert, +} +var revVisMap = map[Visibility]Value{} + +func init() { + for k, v := range visMap { + revVisMap[v] = k + } +} + +// AsVisibility returns the visibility value of the given value string +func (val Value) AsVisibility() Visibility { + if vis, ok := visMap[val]; ok { + return vis + } + return VisibilityUnknown +} + +func (v Visibility) String() string { + if s, ok := revVisMap[v]; ok { + return string(s) + } + return fmt.Sprintf("Unknown (%d)", v) +} + +// UserRole enumerates the supported values of meta key 'user-role'. +type UserRole int + +// Supported values for user roles. +const ( + _ UserRole = iota + UserRoleUnknown + UserRoleCreator + UserRoleReader + UserRoleWriter + UserRoleOwner +) + +var urMap = map[Value]UserRole{ + ValueUserRoleCreator: UserRoleCreator, + ValueUserRoleReader: UserRoleReader, + ValueUserRoleWriter: UserRoleWriter, + ValueUserRoleOwner: UserRoleOwner, +} + +// AsUserRole role returns the user role of the given string. +func (val Value) AsUserRole() UserRole { + if ur, ok := urMap[val]; ok { + return ur + } + return UserRoleUnknown +} ADDED domain/meta/write.go Index: domain/meta/write.go ================================================================== --- /dev/null +++ domain/meta/write.go @@ -0,0 +1,59 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 meta + +import "io" + +// Write writes metadata to a writer, excluding computed and propery values. +func (m *Meta) Write(w io.Writer) (int, error) { + return m.doWrite(w, IsComputed) +} + +// WriteComputed writes metadata to a writer, including computed values, +// but excluding property values. +func (m *Meta) WriteComputed(w io.Writer) (int, error) { + return m.doWrite(w, IsProperty) +} + +func (m *Meta) doWrite(w io.Writer, ignoreKeyPred func(string) bool) (length int, err error) { + for key, val := range m.Computed() { + if ignoreKeyPred(key) { + continue + } + if err != nil { + break + } + var l int + l, err = io.WriteString(w, key) + length += l + if err == nil { + l, err = w.Write(colonSpace) + length += l + } + if err == nil { + l, err = io.WriteString(w, string(val)) + length += l + } + if err == nil { + l, err = w.Write(newline) + length += l + } + } + return length, err +} + +var ( + colonSpace = []byte{':', ' '} + newline = []byte{'\n'} +) ADDED domain/meta/write_test.go Index: domain/meta/write_test.go ================================================================== --- /dev/null +++ domain/meta/write_test.go @@ -0,0 +1,59 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore Client 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 meta_test + +import ( + "strings" + "testing" + + "t73f.de/r/zsc/domain/id" + "t73f.de/r/zsc/domain/meta" +) + +const testID = id.Zid(98765432101234) + +func newMeta(title string, tags []string, syntax string) *meta.Meta { + m := meta.New(testID) + if title != "" { + m.Set(meta.KeyTitle, meta.Value(title)) + } + if tags != nil { + m.Set(meta.KeyTags, meta.Value(strings.Join(tags, " "))) + } + if syntax != "" { + m.Set(meta.KeySyntax, meta.Value(syntax)) + } + return m +} +func assertWriteMeta(t *testing.T, m *meta.Meta, expected string) { + t.Helper() + var sb strings.Builder + _, _ = m.Write(&sb) + if got := sb.String(); got != expected { + t.Errorf("\nExp: %q\ngot: %q", expected, got) + } +} + +func TestWriteMeta(t *testing.T) { + t.Parallel() + assertWriteMeta(t, newMeta("", nil, ""), "") + + m := newMeta("TITLE", []string{"#t1", "#t2"}, "syntax") + assertWriteMeta(t, m, "title: TITLE\ntags: #t1 #t2\nsyntax: syntax\n") + + m = newMeta("TITLE", nil, "") + m.Set("user", "zettel") + m.Set("auth", "basic") + assertWriteMeta(t, m, "title: TITLE\nauth: basic\nuser: zettel\n") +} Index: go.mod ================================================================== --- go.mod +++ go.mod @@ -1,5 +1,11 @@ -module zettelstore.de/c +module t73f.de/r/zsc -go 1.19 +go 1.24 -require codeberg.org/t73fde/sxpf v0.0.0-20220719090054-749a39d0a7a0 +require ( + t73f.de/r/sx v0.0.0-20250415161954-42ed9c4d6abc + t73f.de/r/sxwebs v0.0.0-20250415162443-110f49c5a1ae + t73f.de/r/webs v0.0.0-20250311182734-f263a38b32d5 + t73f.de/r/zero v0.0.0-20250226205915-c4194684acb7 + t73f.de/r/zsx v0.0.0-20250415162540-fc13b286b6ce +) Index: go.sum ================================================================== --- go.sum +++ go.sum @@ -1,2 +1,10 @@ -codeberg.org/t73fde/sxpf v0.0.0-20220719090054-749a39d0a7a0 h1:viya/OgeF16+i8caBPJmcLQhGpZodPh+/nxtJzSSO1s= -codeberg.org/t73fde/sxpf v0.0.0-20220719090054-749a39d0a7a0/go.mod h1:4fAHEF3VH+ofbZkF6NzqiItTNy2X11tVCnZX99jXouA= +t73f.de/r/sx v0.0.0-20250415161954-42ed9c4d6abc h1:tlsP+47Rf8i9Zv1TqRnwfbQx3nN/F/92RkT6iCA6SVA= +t73f.de/r/sx v0.0.0-20250415161954-42ed9c4d6abc/go.mod h1:hzg05uSCMk3D/DWaL0pdlowfL2aWQeGIfD1S04vV+Xg= +t73f.de/r/sxwebs v0.0.0-20250415162443-110f49c5a1ae h1:K6nxN/bb0BCSiDffwNPGTF2uf5WcTdxcQXzByXNuJ7M= +t73f.de/r/sxwebs v0.0.0-20250415162443-110f49c5a1ae/go.mod h1:0LQ9T1svSg9ADY/6vQLKNUu6LqpPi8FGr7fd2qDT5H8= +t73f.de/r/webs v0.0.0-20250311182734-f263a38b32d5 h1:nnKfs/2i9n3S5VjbSj98odcwZKGcL96qPSIUATT/2P8= +t73f.de/r/webs v0.0.0-20250311182734-f263a38b32d5/go.mod h1:zk92hSKB4iWyT290+163seNzu350TA9XLATC9kOldqo= +t73f.de/r/zero v0.0.0-20250226205915-c4194684acb7 h1:OuzHSfniY8UzLmo5zp1w23Kd9h7x9CSXP2jQ+kppeqU= +t73f.de/r/zero v0.0.0-20250226205915-c4194684acb7/go.mod h1:T1vFcHoymUQcr7+vENBkS1yryZRZ3YB8uRtnMy8yRBA= +t73f.de/r/zsx v0.0.0-20250415162540-fc13b286b6ce h1:R9rtg4ecx4YYixsMmsh+wdcqLdY9GxoC5HZ9mMS33to= +t73f.de/r/zsx v0.0.0-20250415162540-fc13b286b6ce/go.mod h1:tXOlmsQBoY4mY7Plu0LCCMZNSJZJbng98fFarZXAWvM= DELETED html/html.go Index: html/html.go ================================================================== --- html/html.go +++ /dev/null @@ -1,106 +0,0 @@ -//----------------------------------------------------------------------------- -// Copyright (c) 2022 Detlef Stern -// -// This file is part of zettelstore-client. -// -// Zettelstore client 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 html provides types, constants and function to work with HTML. -package html - -import ( - "io" - "strings" -) - -const ( - htmlQuot = """ // longer than """, but often requested in standards - htmlAmp = "&" - htmlLt = "<" - htmlGt = ">" - htmlNull = "\uFFFD" - htmlLitSpace = "\u00a0" - htmlVisSpace = "\u2423" -) - -var ( - htmlEscapes = []string{`&`, htmlAmp, - `<`, htmlLt, - `>`, htmlGt, - `"`, htmlQuot, - "\000", htmlNull, - } - htmlEscaper = strings.NewReplacer(htmlEscapes...) - - htmlVisEscapes = append(append([]string{}, htmlEscapes...), - " ", htmlVisSpace, - htmlLitSpace, htmlVisSpace, - ) - htmlVisEscaper = strings.NewReplacer(htmlVisEscapes...) -) - -// Escape writes to w the escaped HTML equivalent of the given string. -func Escape(w io.Writer, s string) (int, error) { return htmlEscaper.WriteString(w, s) } - -// EscapeVisible writes to w the escaped HTML equivalent of the given string. -// Each space is written as U-2423. -func EscapeVisible(w io.Writer, s string) (int, error) { return htmlVisEscaper.WriteString(w, s) } - -var ( - escQuot = []byte(htmlQuot) // longer than """, but often requested in standards - escAmp = []byte(htmlAmp) - escNull = []byte(htmlNull) -) - -// AttributeEscape writes to w the escaped HTML equivalent of the given string to be used -// in attributes. -func AttributeEscape(w io.Writer, s string) (int, error) { - length := 0 - last := 0 - var html []byte - lenS := len(s) - for i := 0; i < lenS; i++ { - switch s[i] { - case '\000': - html = escNull - case '"': - html = escQuot - case '&': - html = escAmp - default: - continue - } - l, err := io.WriteString(w, s[last:i]) - length += l - if err != nil { - return length, err - } - l, err = w.Write(html) - length += l - if err != nil { - return length, err - } - last = i + 1 - } - l, err := io.WriteString(w, s[last:]) - return length + l, err -} - -var unsafeSnippets = []string{ - "") -} - -func (env *EncEnvironment) WriteEndTag(tag string) { - env.WriteStrings("") -} - -func (env *EncEnvironment) WriteImage(args *sxpf.Pair) { - ref := env.GetPair(args.GetTail()) - env.WriteImageWithSource(args, env.GetString(ref.GetTail())) -} - -func (env *EncEnvironment) WriteImageWithSource(args *sxpf.Pair, src string) { - a := env.GetAttributes(args) - a = a.Set("src", src) - if title := args.GetTail().GetTail().GetTail(); !title.IsNil() { - a = a.Set("title", text.EvaluateInlineString(title)) - } - env.WriteStartTag("img", a) -} - -func (env *EncEnvironment) LookupForm(sym *sxpf.Symbol) (sxpf.Form, error) { - return env.Builtins.LookupForm(sym) -} - -func (env *EncEnvironment) EvalOther(val sxpf.Value) (sxpf.Value, error) { - if strVal, ok := val.(*sxpf.String); ok { - env.WriteEscaped(strVal.GetValue()) - return nil, nil - } - return val, nil -} - -func (env *EncEnvironment) EvalSymbol(val *sxpf.Symbol) (sxpf.Value, error) { - env.WriteEscaped(val.GetValue()) - return nil, nil -} - -func (env *EncEnvironment) EvalPair(p *sxpf.Pair) (sxpf.Value, error) { - return sxpf.EvalCallOrSeq(env, p) -} - -func EvaluateInline(baseEnv *EncEnvironment, value sxpf.Value, withFootnotes, noLinks bool) string { - var buf bytes.Buffer - env := EncEnvironment{w: &buf, noLinks: noLinks} - if baseEnv != nil { - env.Builtins = baseEnv.Builtins - env.writeFootnotes = withFootnotes && baseEnv.writeFootnotes - env.footnotes = baseEnv.footnotes - } else { - env.Builtins = buildBuiltins() - } - _, err := sxpf.Eval(&env, value) - if err != nil { - return err.Error() - } - if baseEnv != nil { - baseEnv.footnotes = env.footnotes - } - return buf.String() -} - -func (env *EncEnvironment) WriteEndnotes() { - if len(env.footnotes) == 0 { - return - } - env.WriteString("
    ") - for i := 0; i < len(env.footnotes); i++ { - fni := env.footnotes[i] - n := strconv.Itoa(i + 1) - un := env.unique + n - a := fni.attrs.Clone().AddClass("zs-endnote").Set("value", n) - if _, found := a.Get("id"); !found { - a = a.Set("id", "fn:"+un) - } - if _, found := a.Get("role"); !found { - a = a.Set("role", "doc-endnote") - } - env.WriteStartTag("li", a) - sxpf.EvalSequence(env, fni.note) // may add more footnotes - env.WriteStrings( - ` ↩︎") - } - env.footnotes = nil - env.WriteString("
") -} - -type encodingFunc func(env *EncEnvironment, args *sxpf.Pair) - -var defaultEncodingFunctions = []struct { - sym *sxpf.Symbol - minArgs int - fn encodingFunc -}{ - {sexpr.SymPara, 0, func(env *EncEnvironment, args *sxpf.Pair) { - env.WriteString("

") - sxpf.EvalSequence(env, args) - env.WriteString("

") - }}, - {sexpr.SymHeading, 5, func(env *EncEnvironment, args *sxpf.Pair) { - nLevel := env.GetInteger(args) - if nLevel <= 0 { - return - } - level := strconv.FormatInt(nLevel+int64(env.headingOffset), 10) - - argAttr := args.GetTail() - a := env.GetAttributes(argAttr) - argFragment := argAttr.GetTail().GetTail() - if fragment := env.GetString(argFragment); fragment != "" { - a = a.Set("id", fragment) - } - - env.WriteStrings("") - sxpf.EvalSequence(env, argFragment.GetTail()) - env.WriteStrings("") - }}, - {sexpr.SymThematic, 0, func(env *EncEnvironment, args *sxpf.Pair) { - env.WriteString("") - }}, - {sexpr.SymListUnordered, 0, makeListFn("ul")}, - {sexpr.SymListOrdered, 0, makeListFn("ol")}, - {sexpr.SymListQuote, 0, func(env *EncEnvironment, args *sxpf.Pair) { - env.WriteString("
") - if !args.IsNil() && args.GetFirst().IsNil() { - sxpf.Eval(env, env.GetPair(args)) - } else { - for elem := args; !elem.IsNil(); elem = elem.GetTail() { - env.WriteString("

") - sxpf.Eval(env, env.GetPair(elem)) - env.WriteString("

") - } - } - env.WriteString("
") - }}, - {sexpr.SymDescription, 0, func(env *EncEnvironment, args *sxpf.Pair) { - env.WriteString("
") - for elem := args; !elem.IsNil(); elem = elem.GetTail() { - env.WriteString("
") - sxpf.Eval(env, elem.GetFirst()) - env.WriteString("
") - elem = elem.GetTail() - if elem.IsNil() { - break - } - ddlist, err := elem.GetPair() - if err != nil { - continue - } - for dditem := ddlist; !dditem.IsNil(); dditem = dditem.GetTail() { - env.WriteString("
") - sxpf.Eval(env, dditem.GetFirst()) - env.WriteString("
") - } - } - env.WriteString("
") - }}, - {sexpr.SymTable, 1, func(env *EncEnvironment, args *sxpf.Pair) { - env.WriteString("") - if header := env.GetPair(args); !header.IsNil() { - env.WriteString("") - env.writeTableRow(header) - env.WriteString("") - } - if argBody := args.GetTail(); !argBody.IsNil() { - env.WriteString("") - for row := argBody; !row.IsNil(); row = row.GetTail() { - env.writeTableRow(env.GetPair(row)) - } - env.WriteString("") - } - env.WriteString("
") - }}, - {sexpr.SymCell, 0, makeCellFn("")}, - {sexpr.SymCellCenter, 0, makeCellFn("center")}, - {sexpr.SymCellLeft, 0, makeCellFn("left")}, - {sexpr.SymCellRight, 0, makeCellFn("right")}, - {sexpr.SymRegionBlock, 2, func(env *EncEnvironment, args *sxpf.Pair) { - a := env.GetAttributes(args) - if val, found := a.Get(""); found { - a = a.Remove("").AddClass(val) - } - env.writeRegion(args, a, "div") - }}, - {sexpr.SymRegionQuote, 2, func(env *EncEnvironment, args *sxpf.Pair) { - env.writeRegion(args, nil, "blockquote") - }}, - {sexpr.SymRegionVerse, 2, func(env *EncEnvironment, args *sxpf.Pair) { - env.writeRegion(args, nil, "div") - }}, - {sexpr.SymVerbatimComment, 1, func(env *EncEnvironment, args *sxpf.Pair) { - if env.GetAttributes(args).HasDefault() { - if s := env.GetString(args.GetTail()); s != "" { - env.WriteString("") - } - } - }}, - {sexpr.SymVerbatimEval, 2, func(env *EncEnvironment, args *sxpf.Pair) { - a := env.GetAttributes(args).AddClass("zs-eval") - env.writeVerbatim(args, a) - }}, - {sexpr.SymVerbatimHTML, 2, execHTML}, - {sexpr.SymVerbatimMath, 2, func(env *EncEnvironment, args *sxpf.Pair) { - a := env.GetAttributes(args).AddClass("zs-math") - env.writeVerbatim(args, a) - }}, - {sexpr.SymVerbatimProg, 2, func(env *EncEnvironment, args *sxpf.Pair) { - a := setProgLang(env.GetAttributes(args)) - oldVisible := env.visibleSpace - if a.HasDefault() { - a = a.RemoveDefault() - env.visibleSpace = true - } - env.writeVerbatim(args, a) - env.visibleSpace = oldVisible - }}, - {sexpr.SymVerbatimZettel, 0, DoNothingFn}, - {sexpr.SymBLOB, 3, func(env *EncEnvironment, args *sxpf.Pair) { - argSyntax := args.GetTail() - env.writeBLOB(env.GetString(args), env.GetString(argSyntax), env.GetString(argSyntax.GetTail())) - }}, - {sexpr.SymTransclude, 2, func(env *EncEnvironment, args *sxpf.Pair) { - ref := env.GetPair(args) - refKind := env.GetSymbol(ref) - if refKind == nil { - return - } - if refValue := env.GetString(ref.GetTail()); refValue != "" { - if sexpr.SymRefStateExternal.Equal(refKind) { - a := attrs.Attributes{}.Set("src", refValue).AddClass("external") - env.WriteString("

") - return - } - env.WriteStrings("") - return - } - if env.err == nil { - _, env.err = fmt.Fprintf(env.w, "%v\n", args) - } - log.Println("TRAN", args) - }}, - {sexpr.SymText, 0, func(env *EncEnvironment, args *sxpf.Pair) { - if !sxpf.IsNil(args) { - env.WriteEscaped(env.GetString(args)) - } - }}, - {sexpr.SymSpace, 0, func(env *EncEnvironment, args *sxpf.Pair) { - if sxpf.IsNil(args) { - env.WriteString(" ") - return - } - env.WriteEscaped(env.GetString(args)) - }}, - {sexpr.SymSoft, 0, func(env *EncEnvironment, _ *sxpf.Pair) { env.WriteString(" ") }}, - {sexpr.SymHard, 0, func(env *EncEnvironment, _ *sxpf.Pair) { env.WriteString("
") }}, - {sexpr.SymTag, 0, func(env *EncEnvironment, args *sxpf.Pair) { - if !sxpf.IsNil(args) { - env.WriteEscaped(env.GetString(args)) - } - }}, - {sexpr.SymLinkInvalid, 2, func(env *EncEnvironment, args *sxpf.Pair) { WriteAsSpan(env, args) }}, - {sexpr.SymLinkZettel, 2, func(env *EncEnvironment, args *sxpf.Pair) { WriteHRefLink(env, args) }}, - {sexpr.SymLinkSelf, 2, func(env *EncEnvironment, args *sxpf.Pair) { WriteHRefLink(env, args) }}, - {sexpr.SymLinkFound, 2, func(env *EncEnvironment, args *sxpf.Pair) { WriteHRefLink(env, args) }}, - {sexpr.SymLinkBroken, 2, func(env *EncEnvironment, args *sxpf.Pair) { - if a, refValue, ok := PrepareLink(env, args); ok { - WriteLink(env, args, a.AddClass("broken"), refValue, "") - } - }}, - {sexpr.SymLinkHosted, 2, func(env *EncEnvironment, args *sxpf.Pair) { WriteHRefLink(env, args) }}, - {sexpr.SymLinkBased, 2, func(env *EncEnvironment, args *sxpf.Pair) { WriteHRefLink(env, args) }}, - {sexpr.SymLinkSearch, 2, func(env *EncEnvironment, args *sxpf.Pair) { - if a, refValue, ok := PrepareLink(env, args); ok { - query := "?" + api.QueryKeySearch + "=" + url.QueryEscape(refValue) - WriteLink(env, args, a.Set("href", query), refValue, "") - } - }}, - {sexpr.SymLinkExternal, 2, func(env *EncEnvironment, args *sxpf.Pair) { - if a, refValue, ok := PrepareLink(env, args); ok { - WriteLink(env, args, a.Set("href", refValue).AddClass("external"), refValue, "") - } - }}, - {sexpr.SymEmbed, 3, func(env *EncEnvironment, args *sxpf.Pair) { - argRef := args.GetTail() - if syntax := env.GetString(argRef.GetTail()); syntax == api.ValueSyntaxSVG { - ref := env.GetPair(argRef) - env.WriteStrings( - `
") - } else { - env.WriteImage(args) - } - }}, - {sexpr.SymEmbedBLOB, 3, func(env *EncEnvironment, args *sxpf.Pair) { - argSyntax := args.GetTail() - a, syntax, data := env.GetAttributes(args), env.GetString(argSyntax), env.GetString(argSyntax.GetTail()) - title, _ := a.Get("title") - env.writeBLOB(title, syntax, data) - }}, - {sexpr.SymCite, 2, func(env *EncEnvironment, args *sxpf.Pair) { - env.WriteStartTag("span", env.GetAttributes(args)) - argKey := args.GetTail() - if key := env.GetString(argKey); key != "" { - env.WriteEscaped(key) - if text := argKey.GetTail(); !text.IsNil() { - env.WriteString(", ") - sxpf.EvalSequence(env, text) - } - } - env.WriteString("") - }}, - {sexpr.SymMark, 3, func(env *EncEnvironment, args *sxpf.Pair) { - if env.noLinks { - sxpf.Eval(env, sxpf.NewPair(sexpr.SymFormatSpan, args)) - return - } - argFragment := args.GetTail().GetTail() - if fragment := env.GetString(argFragment); fragment != "" { - env.WriteString(``) - sxpf.EvalSequence(env, argFragment.GetTail()) - env.WriteString("") - } else { - sxpf.EvalSequence(env, argFragment.GetTail()) - } - }}, - {sexpr.SymFootnote, 1, func(env *EncEnvironment, args *sxpf.Pair) { - if env.writeFootnotes { - a := env.GetAttributes(args) - env.footnotes = append(env.footnotes, sfootnodeInfo{args.GetTail(), a}) - n := strconv.Itoa(len(env.footnotes)) - un := env.unique + n - env.WriteStrings( - ``, n, ``) - } - }}, - {sexpr.SymFormatDelete, 1, makeFormatFn("del")}, - {sexpr.SymFormatEmph, 1, makeFormatFn("em")}, - {sexpr.SymFormatInsert, 1, makeFormatFn("ins")}, - {sexpr.SymFormatQuote, 1, writeQuote}, - {sexpr.SymFormatSpan, 1, makeFormatFn("span")}, - {sexpr.SymFormatStrong, 1, makeFormatFn("strong")}, - {sexpr.SymFormatSub, 1, makeFormatFn("sub")}, - {sexpr.SymFormatSuper, 1, makeFormatFn("sup")}, - {sexpr.SymLiteralComment, 1, func(env *EncEnvironment, args *sxpf.Pair) { - if env.GetAttributes(args).HasDefault() { - if s := env.GetString(args.GetTail()); s != "" { - env.WriteString("") - } - } - }}, - {sexpr.SymLiteralHTML, 2, execHTML}, - {sexpr.SymLiteralInput, 2, func(env *EncEnvironment, args *sxpf.Pair) { - env.writeLiteral(args, nil, "kbd") - }}, - {sexpr.SymLiteralMath, 2, func(env *EncEnvironment, args *sxpf.Pair) { - a := env.GetAttributes(args).AddClass("zs-math") - env.writeLiteral(args, a, "code") - }}, - {sexpr.SymLiteralOutput, 2, func(env *EncEnvironment, args *sxpf.Pair) { - env.writeLiteral(args, nil, "samp") - }}, - {sexpr.SymLiteralProg, 2, func(env *EncEnvironment, args *sxpf.Pair) { - a := setProgLang(env.GetAttributes(args)) - env.writeLiteral(args, a, "code") - }}, - {sexpr.SymLiteralZettel, 0, DoNothingFn}, -} - -// DoNothingFn is a function that does nothing. -func DoNothingFn(*EncEnvironment, *sxpf.Pair) { /* Should really do nothing */ } - -func makeListFn(tag string) encodingFunc { - return func(env *EncEnvironment, args *sxpf.Pair) { - env.WriteStartTag(tag, nil) - for elem := args; !elem.IsNil(); elem = elem.GetTail() { - env.WriteStartTag("li", nil) - sxpf.Eval(env, elem.GetFirst()) - env.WriteEndTag("li") - } - env.WriteEndTag(tag) - } -} - -func (env *EncEnvironment) writeTableRow(cells *sxpf.Pair) { - if !cells.IsNil() { - env.WriteString("") - for cell := cells; !cell.IsNil(); cell = cell.GetTail() { - sxpf.Eval(env, cell.GetFirst()) - } - env.WriteString("") - } -} -func makeCellFn(align string) encodingFunc { - return func(env *EncEnvironment, args *sxpf.Pair) { - if align == "" { - env.WriteString("") - } else { - env.WriteStrings(``) - } - sxpf.EvalSequence(env, args) - env.WriteString("") - } -} - -func (env *EncEnvironment) writeRegion(args *sxpf.Pair, a attrs.Attributes, tag string) { - if a == nil { - a = env.GetAttributes(args) - } - env.WriteStartTag(tag, a) - sxpf.Eval(env, env.GetPair(args.GetTail())) - if cite := env.GetPair(args.GetTail().GetTail()); !cite.IsNil() { - env.WriteString("") - sxpf.EvalSequence(env, cite) - env.WriteString("") - } - env.WriteEndTag(tag) -} - -func (env *EncEnvironment) writeVerbatim(args *sxpf.Pair, a attrs.Attributes) { - env.WriteString("
")
-	env.WriteStartTag("code", a)
-	env.WriteEscapedOrVisible(env.GetString(args.GetTail()))
-	env.WriteString("
") -} - -func execHTML(env *EncEnvironment, args *sxpf.Pair) { - if s := env.GetString(args.GetTail()); s != "" && IsSafe(s) { - env.WriteString(s) - } -} - -func (env *EncEnvironment) writeBLOB(title, syntax, data string) { - if data == "" { - return - } - switch syntax { - case "": - case api.ValueSyntaxSVG: - // TODO: add title as description - env.WriteStrings("

", data, "

") - default: - env.WriteStrings(`

`) - } else { - env.WriteString(`">

`) - } - } -} - -func PrepareLink(env *EncEnvironment, args *sxpf.Pair) (attrs.Attributes, string, bool) { - if env.noLinks { - WriteAsSpan(env, args) - return nil, "", false - } - return env.GetAttributes(args), env.GetString(args.GetTail()), true -} - -func WriteAsSpan(env *EncEnvironment, args *sxpf.Pair) { - if args.Length() > 2 { - sxpf.Eval(env, sxpf.NewPair(sexpr.SymFormatSpan, sxpf.NewPair(args.GetFirst(), args.GetTail().GetTail()))) - } -} - -func WriteLink(env *EncEnvironment, args *sxpf.Pair, a attrs.Attributes, refValue, suffix string) { - env.WriteString("") - - if args.Length() > 2 { - sxpf.EvalSequence(env, args.GetTail().GetTail()) - } else { - env.WriteString(refValue) - } - env.WriteStrings("", suffix) -} - -func WriteHRefLink(env *EncEnvironment, args *sxpf.Pair) { - if a, refValue, ok := PrepareLink(env, args); ok { - WriteLink(env, args, a.Set("href", refValue), refValue, "") - } -} - -func makeFormatFn(tag string) encodingFunc { - return func(env *EncEnvironment, args *sxpf.Pair) { - a := env.GetAttributes(args) - if val, found := a.Get(""); found { - a = a.Remove("").AddClass(val) - } - env.WriteStartTag(tag, a) - sxpf.EvalSequence(env, args.GetTail()) - env.WriteEndTag(tag) - } -} - -func writeQuote(env *EncEnvironment, args *sxpf.Pair) { - const langAttr = "lang" - a := env.GetAttributes(args) - lang, hasLang := a.Get(langAttr) - if hasLang { - a = a.Remove(langAttr) - env.WriteStartTag("span", attrs.Attributes{}.Set(langAttr, lang)) - } - env.WriteStartTag("q", a) - sxpf.EvalSequence(env, args.GetTail()) - env.WriteEndTag("q") - if hasLang { - env.WriteEndTag("span") - } -} - -func (env *EncEnvironment) writeLiteral(args *sxpf.Pair, a attrs.Attributes, tag string) { - if a == nil { - a = env.GetAttributes(args) - } - oldVisible := env.visibleSpace - if a.HasDefault() { - env.visibleSpace = true - a = a.RemoveDefault() - } - env.WriteStartTag(tag, a) - env.WriteEscapedOrVisible(env.GetString(args.GetTail())) - env.visibleSpace = oldVisible - env.WriteEndTag(tag) -} - -func setProgLang(a attrs.Attributes) attrs.Attributes { - if val, found := a.Get(""); found { - a = a.AddClass("language-" + val).Remove("") - } - return a -} DELETED maps/maps.go Index: maps/maps.go ================================================================== --- maps/maps.go +++ /dev/null @@ -1,25 +0,0 @@ -//----------------------------------------------------------------------------- -// Copyright (c) 2022 Detlef Stern -// -// This file is part of zettelstore-client. -// -// Zettelstore client 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 maps - -import "sort" - -func Keys[T any](m map[string]T) []string { - if len(m) == 0 { - return nil - } - result := make([]string, 0, len(m)) - for k := range m { - result = append(result, k) - } - sort.Strings(result) - return result -} DELETED maps/maps_test.go Index: maps/maps_test.go ================================================================== --- maps/maps_test.go +++ /dev/null @@ -1,46 +0,0 @@ -//----------------------------------------------------------------------------- -// Copyright (c) 2022 Detlef Stern -// -// This file is part of zettelstore-client. -// -// Zettelstore client 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 maps_test - -import ( - "testing" - - "zettelstore.de/c/maps" -) - -func isSorted(seq []string) bool { - for i := 1; i < len(seq); i++ { - if seq[i] < seq[i-1] { - return false - } - } - return true -} - -func TestKeys(t *testing.T) { - testcases := []struct{ keys []string }{ - {nil}, {[]string{""}}, - {[]string{"z", "y", "a"}}, - } - for i, tc := range testcases { - m := make(map[string]struct{}) - for _, k := range tc.keys { - m[k] = struct{}{} - } - got := maps.Keys(m) - if len(got) != len(tc.keys) { - t.Errorf("%d: wrong number of keys: exp %d, got %d", i, len(tc.keys), len(got)) - } - if !isSorted(got) { - t.Errorf("%d: keys not sorted: %v", i, got) - } - } -} ADDED sexp/sexp.go Index: sexp/sexp.go ================================================================== --- /dev/null +++ sexp/sexp.go @@ -0,0 +1,229 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2023-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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: 2023-present Detlef Stern +//----------------------------------------------------------------------------- + +// Package sexp contains helper function to work with s-expression in an alien +// environment. +package sexp + +import ( + "errors" + "fmt" + "sort" + + "t73f.de/r/sx" + "t73f.de/r/zsc/api" +) + +// EncodeZettel transforms zettel data into a sx object. +func EncodeZettel(zettel api.ZettelData) sx.Object { + return sx.MakeList( + sx.MakeSymbol("zettel"), + meta2sz(zettel.Meta), + sx.MakeList(sx.MakeSymbol("rights"), sx.Int64(int64(zettel.Rights))), + sx.MakeList(sx.MakeSymbol("encoding"), sx.MakeString(zettel.Encoding)), + sx.MakeList(sx.MakeSymbol("content"), sx.MakeString(zettel.Content)), + ) +} + +// ParseZettel parses an object to contain all needed data for a zettel. +func ParseZettel(obj sx.Object) (api.ZettelData, error) { + vals, err := ParseList(obj, "ypppp") + if err != nil { + return api.ZettelData{}, err + } + if errSym := CheckSymbol(vals[0], "zettel"); errSym != nil { + return api.ZettelData{}, errSym + } + + meta, err := ParseMeta(vals[1].(*sx.Pair)) + if err != nil { + return api.ZettelData{}, err + } + + rights, err := ParseRights(vals[2]) + if err != nil { + return api.ZettelData{}, err + } + + encVals, err := ParseList(vals[3], "ys") + if err != nil { + return api.ZettelData{}, err + } + if errSym := CheckSymbol(encVals[0], "encoding"); errSym != nil { + return api.ZettelData{}, errSym + } + + contentVals, err := ParseList(vals[4], "ys") + if err != nil { + return api.ZettelData{}, err + } + if errSym := CheckSymbol(contentVals[0], "content"); errSym != nil { + return api.ZettelData{}, errSym + } + + return api.ZettelData{ + Meta: meta, + Rights: rights, + Encoding: encVals[1].(sx.String).GetValue(), + Content: contentVals[1].(sx.String).GetValue(), + }, nil +} + +// EncodeMetaRights translates metadata/rights into a sx object. +func EncodeMetaRights(mr api.MetaRights) *sx.Pair { + return sx.MakeList( + sx.SymbolList, + meta2sz(mr.Meta), + sx.MakeList(sx.MakeSymbol("rights"), sx.Int64(int64(mr.Rights))), + ) +} + +func meta2sz(m api.ZettelMeta) sx.Object { + var result sx.ListBuilder + result.Add(sx.MakeSymbol("meta")) + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + val := sx.MakeList(sx.MakeSymbol(k), sx.MakeString(m[k])) + result.Add(val) + } + return result.List() +} + +// ParseMeta translates the given list to metadata. +func ParseMeta(pair *sx.Pair) (api.ZettelMeta, error) { + if err := CheckSymbol(pair.Car(), "meta"); err != nil { + return nil, err + } + res := api.ZettelMeta{} + for obj := range pair.Tail().Values() { + mVals, err := ParseList(obj, "ys") + if err != nil { + return nil, err + } + res[(mVals[0].(*sx.Symbol)).GetValue()] = mVals[1].(sx.String).GetValue() + } + return res, nil +} + +// ParseRights returns the rights values of the given object. +func ParseRights(obj sx.Object) (api.ZettelRights, error) { + rVals, err := ParseList(obj, "yi") + if err != nil { + return api.ZettelMaxRight, err + } + if errSym := CheckSymbol(rVals[0], "rights"); errSym != nil { + return api.ZettelMaxRight, errSym + } + i64 := int64(rVals[1].(sx.Int64)) + if i64 < 0 && i64 >= int64(api.ZettelMaxRight) { + return api.ZettelMaxRight, fmt.Errorf("invalid zettel right value: %v", i64) + } + return api.ZettelRights(i64), nil +} + +// ParseList parses the given object as a proper list, based on a type specification. +// +// 'b' expects a boolean, 'i' an int64, 'o' any object, 'p' a pair, 's' a string, +// and 'y' expects a symbol. A 'r' as the last type spracification matches all +// remaining values, including a non existent object. +func ParseList(obj sx.Object, spec string) (sx.Vector, error) { + pair, isPair := sx.GetPair(obj) + if !isPair { + return nil, fmt.Errorf("not a list: %T/%v", obj, obj) + } + if pair == nil { + if spec == "r" { + return sx.Vector{sx.Nil()}, nil + } + if spec == "" { + return nil, nil + } + return nil, ErrElementsMissing + } + + specLen := len(spec) + result := make(sx.Vector, 0, specLen) + node, i := pair, 0 +loop: + for ; node != nil; i++ { + if i >= specLen { + return nil, ErrNoSpec + } + var val sx.Object + var ok bool + car := node.Car() + switch spec[i] { + case 'b': + val, ok = sx.MakeBoolean(!sx.IsNil(car)), true + case 'i': + val, ok = car.(sx.Int64) + case 'o': + val, ok = car, true + case 'p': + val, ok = sx.GetPair(car) + case 'r': + if i < specLen-1 { + return nil, fmt.Errorf("spec 'r' must be the last: %v", spec) + } + result = append(result, node) + i++ + break loop + case 's': + val, ok = sx.GetString(car) + case 'y': + val, ok = sx.GetSymbol(car) + default: + return nil, fmt.Errorf("unknown spec '%c'", spec[i]) + } + if !ok { + return nil, fmt.Errorf("does not match spec '%v': %v", spec[i], car) + } + result = append(result, val) + next, isNextPair := sx.GetPair(node.Cdr()) + if !isNextPair { + return nil, sx.ErrImproper{Pair: pair} + } + node = next + } + if i < specLen { + if lastSpec := specLen - 1; i < lastSpec || spec[lastSpec] != 'r' { + return nil, ErrElementsMissing + } + result = append(result, sx.Nil()) + } + return result, nil +} + +// ErrElementsMissing is returned, +// if ParseList is called with a list smaller than the number of type specifications. +var ErrElementsMissing = errors.New("spec contains more data") + +// ErrNoSpec is returned, +// if ParseList if called with a list greater than the number of type specifications. +var ErrNoSpec = errors.New("no spec for elements") + +// CheckSymbol ensures that the given object is a symbol with the given name. +func CheckSymbol(obj sx.Object, name string) error { + sym, isSymbol := sx.GetSymbol(obj) + if !isSymbol { + return fmt.Errorf("object %v/%T is not a symbol", obj, obj) + } + if got := sym.GetValue(); got != name { + return fmt.Errorf("symbol %q expected, but got: %q", name, got) + } + return nil +} ADDED sexp/sexp_test.go Index: sexp/sexp_test.go ================================================================== --- /dev/null +++ sexp/sexp_test.go @@ -0,0 +1,89 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2023-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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: 2023-present Detlef Stern +//----------------------------------------------------------------------------- + +package sexp_test + +import ( + "testing" + + "t73f.de/r/sx" + "t73f.de/r/zsc/sexp" +) + +func TestParseObject(t *testing.T) { + if elems, err := sexp.ParseList(sx.MakeString("a"), "s"); err == nil { + t.Error("expected an error, but got: ", elems) + } + if elems, err := sexp.ParseList(sx.Nil(), ""); err != nil { + t.Error(err) + } else if len(elems) != 0 { + t.Error("Must be empty, but got:", elems) + } + if elems, err := sexp.ParseList(sx.Nil(), "b"); err == nil { + t.Error("expected error, but got: ", elems) + } + + if elems, err := sexp.ParseList(sx.MakeList(sx.MakeString("a")), "ss"); err == nil { + t.Error("expected error, but got: ", elems) + } + if elems, err := sexp.ParseList(sx.MakeList(sx.MakeString("a")), ""); err == nil { + t.Error("expected error, but got: ", elems) + } + if _, err := sexp.ParseList(sx.MakeList(sx.MakeString("a")), "b"); err != nil { + t.Error("expected [1], but got error: ", err) + } + if elems, err := sexp.ParseList(sx.Cons(sx.Nil(), sx.MakeString("a")), "ps"); err == nil { + t.Error("expected error, but got: ", elems) + } + + if elems, err := sexp.ParseList(sx.MakeList(sx.MakeString("a")), "s"); err != nil { + t.Error(err) + } else if len(elems) != 1 { + t.Error("length == 1, but got: ", elems) + } else { + _ = elems[0].(sx.String) + } + + if elems, err := sexp.ParseList(sx.Nil(), "r"); err != nil { + t.Error(err) + } else if len(elems) != 1 { + t.Error("length == 1, but got: ", elems) + } else if !sx.IsNil(elems[0]) { + t.Error("must be nil, but got:", elems[0]) + } + if elems, err := sexp.ParseList(sx.MakeList(sx.MakeString("a")), "r"); err != nil { + t.Error(err) + } else if len(elems) != 1 { + t.Error("length == 1, but got: ", elems) + } else if !elems[0].IsEqual(sx.MakeList(sx.MakeString("a"))) { + t.Error("must be (\"a\"), but got:", elems[0]) + } + if elems, err := sexp.ParseList(sx.MakeList(sx.MakeString("a")), "sr"); err != nil { + t.Error(err) + } else if len(elems) != 2 { + t.Error("length == 2, but got: ", elems) + } else if !elems[0].IsEqual(sx.MakeString("a")) { + t.Error("0-th must be \"a\", but got:", elems[0]) + } else if !sx.IsNil(elems[1]) { + t.Error("must be nil, but got:", elems[1]) + } + if elems, err := sexp.ParseList(sx.MakeList(sx.MakeString("a"), sx.MakeString("b"), sx.MakeString("c")), "sr"); err != nil { + t.Error(err) + } else if len(elems) != 2 { + t.Error("length == 2, but got: ", elems) + } else if !elems[0].IsEqual(sx.MakeString("a")) { + t.Error("0-th must be \"a\", but got:", elems[0]) + } else if !elems[1].IsEqual(sx.MakeList(sx.MakeString("b"), sx.MakeString("c"))) { + t.Error("must be nil, but got:", elems[1]) + } +} DELETED sexpr/attrs.go Index: sexpr/attrs.go ================================================================== --- sexpr/attrs.go +++ /dev/null @@ -1,36 +0,0 @@ -//----------------------------------------------------------------------------- -// Copyright (c) 2022 Detlef Stern -// -// This file is part of zettelstore-client. -// -// Zettelstore client 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 sexpr - -import ( - "codeberg.org/t73fde/sxpf" - "zettelstore.de/c/attrs" -) - -// GetAttributes traverses a s-expression list and returns an attribute structure. -func GetAttributes(seq *sxpf.Pair) (result attrs.Attributes) { - for elem := seq; !elem.IsNil(); elem = elem.GetTail() { - attr, err := elem.GetPair() - if err != nil { - continue - } - key, err := attr.GetString() - if err != nil { - continue - } - val, err := attr.GetTail().GetString() - if err != nil { - continue - } - result = result.Set(key, val) - } - return result -} DELETED sexpr/const.go Index: sexpr/const.go ================================================================== --- sexpr/const.go +++ /dev/null @@ -1,107 +0,0 @@ -//----------------------------------------------------------------------------- -// Copyright (c) 2022 Detlef Stern -// -// This file is part of zettelstore-client. -// -// Zettelstore client 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 sexpr - -import "codeberg.org/t73fde/sxpf" - -// Various constants for Zettel data. Some of them are technically variables. - -// Symbols for Zettel node types. -var ( - Smk = sxpf.NewTrivialSymbolMaker() - SymBLOB = Smk.MakeSymbol("BLOB") - SymCell = Smk.MakeSymbol("CELL") - SymCellCenter = Smk.MakeSymbol("CELL-CENTER") - SymCellLeft = Smk.MakeSymbol("CELL-LEFT") - SymCellRight = Smk.MakeSymbol("CELL-RIGHT") - SymCite = Smk.MakeSymbol("CITE") - SymDescription = Smk.MakeSymbol("DESCRIPTION") - SymEmbed = Smk.MakeSymbol("EMBED") - SymEmbedBLOB = Smk.MakeSymbol("EMBED-BLOB") - SymFootnote = Smk.MakeSymbol("FOOTNOTE") - SymFormatEmph = Smk.MakeSymbol("FORMAT-EMPH") - SymFormatDelete = Smk.MakeSymbol("FORMAT-DELETE") - SymFormatInsert = Smk.MakeSymbol("FORMAT-INSERT") - SymFormatQuote = Smk.MakeSymbol("FORMAT-QUOTE") - SymFormatSpan = Smk.MakeSymbol("FORMAT-SPAN") - SymFormatSub = Smk.MakeSymbol("FORMAT-SUB") - SymFormatSuper = Smk.MakeSymbol("FORMAT-SUPER") - SymFormatStrong = Smk.MakeSymbol("FORMAT-STRONG") - SymHard = Smk.MakeSymbol("HARD") - SymHeading = Smk.MakeSymbol("HEADING") - SymLinkInvalid = Smk.MakeSymbol("LINK-INVALID") - SymLinkZettel = Smk.MakeSymbol("LINK-ZETTEL") - SymLinkSelf = Smk.MakeSymbol("LINK-SELF") - SymLinkFound = Smk.MakeSymbol("LINK-FOUND") - SymLinkBroken = Smk.MakeSymbol("LINK-BROKEN") - SymLinkHosted = Smk.MakeSymbol("LINK-HOSTED") - SymLinkBased = Smk.MakeSymbol("LINK-BASED") - SymLinkSearch = Smk.MakeSymbol("LINK-SEARCH") - SymLinkExternal = Smk.MakeSymbol("LINK-EXTERNAL") - SymListOrdered = Smk.MakeSymbol("ORDERED") - SymListUnordered = Smk.MakeSymbol("UNORDERED") - SymListQuote = Smk.MakeSymbol("QUOTATION") - SymLiteralProg = Smk.MakeSymbol("LITERAL-CODE") - SymLiteralComment = Smk.MakeSymbol("LITERAL-COMMENT") - SymLiteralHTML = Smk.MakeSymbol("LITERAL-HTML") - SymLiteralInput = Smk.MakeSymbol("LITERAL-INPUT") - SymLiteralMath = Smk.MakeSymbol("LITERAL-MATH") - SymLiteralOutput = Smk.MakeSymbol("LITERAL-OUTPUT") - SymLiteralZettel = Smk.MakeSymbol("LITERAL-ZETTEL") - SymMark = Smk.MakeSymbol("MARK") - SymPara = Smk.MakeSymbol("PARA") - SymRegionBlock = Smk.MakeSymbol("REGION-BLOCK") - SymRegionQuote = Smk.MakeSymbol("REGION-QUOTE") - SymRegionVerse = Smk.MakeSymbol("REGION-VERSE") - SymSoft = Smk.MakeSymbol("SOFT") - SymSpace = Smk.MakeSymbol("SPACE") - SymTable = Smk.MakeSymbol("TABLE") - SymTag = Smk.MakeSymbol("TAG") - SymText = Smk.MakeSymbol("TEXT") - SymThematic = Smk.MakeSymbol("THEMATIC") - SymTransclude = Smk.MakeSymbol("TRANSCLUDE") - SymUnknown = Smk.MakeSymbol("UNKNOWN-NODE") - SymVerbatimComment = Smk.MakeSymbol("VERBATIM-COMMENT") - SymVerbatimEval = Smk.MakeSymbol("VERBATIM-EVAL") - SymVerbatimHTML = Smk.MakeSymbol("VERBATIM-HTML") - SymVerbatimMath = Smk.MakeSymbol("VERBATIM-MATH") - SymVerbatimProg = Smk.MakeSymbol("VERBATIM-CODE") - SymVerbatimZettel = Smk.MakeSymbol("VERBATIM-ZETTEL") -) - -// Constant symbols for reference states. -var ( - SymRefStateInvalid = Smk.MakeSymbol("INVALID") - SymRefStateZettel = Smk.MakeSymbol("ZETTEL") - SymRefStateSelf = Smk.MakeSymbol("SELF") - SymRefStateFound = Smk.MakeSymbol("FOUND") - SymRefStateBroken = Smk.MakeSymbol("BROKEN") - SymRefStateHosted = Smk.MakeSymbol("HOSTED") - SymRefStateBased = Smk.MakeSymbol("BASED") - SymRefStateSearch = Smk.MakeSymbol("SEARCH") - SymRefStateExternal = Smk.MakeSymbol("EXTERNAL") -) - -// Symbols for metadata types -var ( - SymTypeCredential = Smk.MakeSymbol("CREDENTIAL") - SymTypeEmpty = Smk.MakeSymbol("EMPTY-STRING") - SymTypeID = Smk.MakeSymbol("ZID") - SymTypeIDSet = Smk.MakeSymbol("ZID-SET") - SymTypeNumber = Smk.MakeSymbol("NUMBER") - SymTypeString = Smk.MakeSymbol("STRING") - SymTypeTagSet = Smk.MakeSymbol("TAG-SET") - SymTypeTimestamp = Smk.MakeSymbol("TIMESTAMP") - SymTypeURL = Smk.MakeSymbol("URL") - SymTypeWord = Smk.MakeSymbol("WORD") - SymTypeWordSet = Smk.MakeSymbol("WORD-SET") - SymTypeZettelmarkup = Smk.MakeSymbol("ZETTELMARKUP") -) DELETED sexpr/sexpr.go Index: sexpr/sexpr.go ================================================================== --- sexpr/sexpr.go +++ /dev/null @@ -1,109 +0,0 @@ -//----------------------------------------------------------------------------- -// Copyright (c) 2022 Detlef Stern -// -// This file is part of zettelstore-client. -// -// Zettelstore client 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 sexpr - -import "codeberg.org/t73fde/sxpf" - -func MakeString(val sxpf.Value) string { - if strVal, ok := val.(*sxpf.String); ok { - return strVal.GetValue() - } - return "" -} - -// GetMetaContent returns the metadata and the content of a sexpr encoded zettel. -func GetMetaContent(zettel sxpf.Value) (Meta, *sxpf.Pair) { - if pair, ok := zettel.(*sxpf.Pair); ok { - m := pair.GetFirst() - if s := pair.GetSecond(); s != nil { - if p, ok := s.(*sxpf.Pair); ok { - if content, err := p.GetPair(); err == nil { - return MakeMeta(m), content - } - } - } - return MakeMeta(m), nil - } - return nil, nil -} - -type Meta map[string]MetaValue -type MetaValue struct { - Type string - Key string - Value sxpf.Value -} - -func MakeMeta(val sxpf.Value) Meta { - if result := doMakeMeta(val); len(result) > 0 { - return result - } - return nil -} -func doMakeMeta(val sxpf.Value) Meta { - result := make(map[string]MetaValue) - for { - if val == nil { - return result - } - pair, ok := val.(*sxpf.Pair) - if !ok { - return result - } - if mv, ok := makeMetaValue(pair); ok { - result[mv.Key] = mv - } - val = pair.GetSecond() - } -} -func makeMetaValue(pair *sxpf.Pair) (MetaValue, bool) { - var result MetaValue - typePair, ok := pair.GetFirst().(*sxpf.Pair) - if !ok { - return result, false - } - typeVal, ok := typePair.GetFirst().(*sxpf.Symbol) - if !ok { - return result, false - } - keyPair, ok := typePair.GetSecond().(*sxpf.Pair) - if !ok { - return result, false - } - keyStr, ok := keyPair.GetFirst().(*sxpf.String) - if !ok { - return result, false - } - valPair, ok := keyPair.GetSecond().(*sxpf.Pair) - if !ok { - return result, false - } - result.Type = typeVal.GetValue() - result.Key = keyStr.GetValue() - result.Value = valPair.GetFirst() - return result, true -} - -func (m Meta) GetString(key string) string { - if v, found := m[key]; found { - return MakeString(v.Value) - } - return "" -} - -func (m Meta) GetPair(key string) *sxpf.Pair { - if mv, found := m[key]; found { - if seq, ok := mv.Value.(*sxpf.Pair); ok && !seq.IsEmpty() { - return seq - } - } - return nil -} ADDED shtml/const.go Index: shtml/const.go ================================================================== --- /dev/null +++ shtml/const.go @@ -0,0 +1,83 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2024-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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: 2024-present Detlef Stern +//----------------------------------------------------------------------------- + +package shtml + +import "t73f.de/r/sx" + +// Symbols for HTML header tags +var ( + SymBody = sx.MakeSymbol("body") + SymHead = sx.MakeSymbol("head") + SymHTML = sx.MakeSymbol("html") + SymMeta = sx.MakeSymbol("meta") + SymScript = sx.MakeSymbol("script") + SymTitle = sx.MakeSymbol("title") +) + +// Symbols for HTML body tags +var ( + SymA = sx.MakeSymbol("a") + SymASIDE = sx.MakeSymbol("aside") + symBLOCKQUOTE = sx.MakeSymbol("blockquote") + symBR = sx.MakeSymbol("br") + symCITE = sx.MakeSymbol("cite") + symCODE = sx.MakeSymbol("code") + symDD = sx.MakeSymbol("dd") + symDEL = sx.MakeSymbol("del") + SymDIV = sx.MakeSymbol("div") + symDL = sx.MakeSymbol("dl") + symDT = sx.MakeSymbol("dt") + symEM = sx.MakeSymbol("em") + SymEMBED = sx.MakeSymbol("embed") + SymFIGURE = sx.MakeSymbol("figure") + SymH1 = sx.MakeSymbol("h1") + SymH2 = sx.MakeSymbol("h2") + SymHR = sx.MakeSymbol("hr") + SymIMG = sx.MakeSymbol("img") + symINS = sx.MakeSymbol("ins") + symKBD = sx.MakeSymbol("kbd") + SymLI = sx.MakeSymbol("li") + symMARK = sx.MakeSymbol("mark") + SymOL = sx.MakeSymbol("ol") + SymP = sx.MakeSymbol("p") + symPRE = sx.MakeSymbol("pre") + symSAMP = sx.MakeSymbol("samp") + SymSPAN = sx.MakeSymbol("span") + SymSTRONG = sx.MakeSymbol("strong") + symSUB = sx.MakeSymbol("sub") + symSUP = sx.MakeSymbol("sup") + symTABLE = sx.MakeSymbol("table") + symTBODY = sx.MakeSymbol("tbody") + symTHEAD = sx.MakeSymbol("thead") + symTD = sx.MakeSymbol("td") + symTH = sx.MakeSymbol("th") + symTR = sx.MakeSymbol("tr") + SymUL = sx.MakeSymbol("ul") +) + +// Symbols for HTML attribute keys +var ( + SymAttrClass = sx.MakeSymbol("class") + SymAttrHref = sx.MakeSymbol("href") + SymAttrID = sx.MakeSymbol("id") + SymAttrLang = sx.MakeSymbol("lang") + SymAttrOpen = sx.MakeSymbol("open") + SymAttrRel = sx.MakeSymbol("rel") + SymAttrRole = sx.MakeSymbol("role") + SymAttrSrc = sx.MakeSymbol("src") + SymAttrTarget = sx.MakeSymbol("target") + SymAttrTitle = sx.MakeSymbol("title") + SymAttrType = sx.MakeSymbol("type") + SymAttrValue = sx.MakeSymbol("value") +) ADDED shtml/lang.go Index: shtml/lang.go ================================================================== --- /dev/null +++ shtml/lang.go @@ -0,0 +1,106 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2023-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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: 2023-present Detlef Stern +//----------------------------------------------------------------------------- + +package shtml + +import ( + "strings" + + "t73f.de/r/zsc/domain/meta" +) + +// LangStack is a stack to store the nesting of "lang" attribute values. +// It is used to generate typographically correct quotes. +type LangStack []string + +// NewLangStack creates a new language stack. +func NewLangStack(lang string) LangStack { + ls := make([]string, 1, 16) + ls[0] = lang + return ls +} + +// Reset restores the language stack to its initial value. +func (ls *LangStack) Reset() { + *ls = (*ls)[0:1] +} + +// Push adds a new language value. +func (ls *LangStack) Push(lang string) { + *ls = append(*ls, lang) +} + +// Pop removes the topmost language value. +func (ls *LangStack) Pop() { + *ls = (*ls)[0 : len(*ls)-1] +} + +// Top returns the topmost language value. +func (ls *LangStack) Top() string { + return (*ls)[len(*ls)-1] +} + +// Dup duplicates the topmost language value. +func (ls *LangStack) Dup() { + *ls = append(*ls, (*ls)[len(*ls)-1]) +} + +// QuoteInfo contains language specific data about quotes. +type QuoteInfo struct { + primLeft, primRight string + secLeft, secRight string + nbsp bool +} + +// GetPrimary returns the primary left and right quote entity. +func (qi *QuoteInfo) GetPrimary() (string, string) { + return qi.primLeft, qi.primRight +} + +// GetSecondary returns the secondary left and right quote entity. +func (qi *QuoteInfo) GetSecondary() (string, string) { + return qi.secLeft, qi.secRight +} + +// GetQuotes returns quotes based on a nesting level. +func (qi *QuoteInfo) GetQuotes(level uint) (string, string) { + if level%2 == 0 { + return qi.GetPrimary() + } + return qi.GetSecondary() +} + +// GetNBSp returns true, if there must be a non-breaking space between the +// quote entities and the quoted text. +func (qi *QuoteInfo) GetNBSp() bool { return qi.nbsp } + +var langQuotes = map[string]*QuoteInfo{ + "": {""", """, """, """, false}, + meta.ValueLangEN: {"“", "”", "‘", "’", false}, + "de": {"„", "“", "‚", "‘", false}, + "fr": {"«", "»", "‹", "›", true}, +} + +// GetQuoteInfo returns language specific data about quotes. +func GetQuoteInfo(lang string) *QuoteInfo { + langFields := strings.FieldsFunc(lang, func(r rune) bool { return r == '-' || r == '_' }) + for len(langFields) > 0 { + langSup := strings.Join(langFields, "-") + quotes, ok := langQuotes[langSup] + if ok { + return quotes + } + langFields = langFields[0 : len(langFields)-1] + } + return langQuotes[""] +} ADDED shtml/shtml.go Index: shtml/shtml.go ================================================================== --- /dev/null +++ shtml/shtml.go @@ -0,0 +1,959 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2023-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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: 2023-present Detlef Stern +//----------------------------------------------------------------------------- + +// Package shtml transforms a s-expr encoded zettel AST into a s-expr representation of HTML. +package shtml + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + "t73f.de/r/sx" + "t73f.de/r/sxwebs/sxhtml" + "t73f.de/r/zsc/api" + "t73f.de/r/zsc/domain/meta" + "t73f.de/r/zsc/sz" + "t73f.de/r/zsx" +) + +// Evaluator will transform a s-expression that encodes the zettel AST into an s-expression +// that represents HTML. +type Evaluator struct { + headingOffset int64 + unique string + noLinks bool // true iff output must not include links + + fns map[string]EvalFn + minArgs map[string]int +} + +// NewEvaluator creates a new Evaluator object. +func NewEvaluator(headingOffset int) *Evaluator { + ev := &Evaluator{ + headingOffset: int64(headingOffset), + + fns: make(map[string]EvalFn, 128), + minArgs: make(map[string]int, 128), + } + ev.bindMetadata() + ev.bindBlocks() + ev.bindInlines() + return ev +} + +// SetUnique sets a prefix to make several HTML ids unique. +func (ev *Evaluator) SetUnique(s string) { ev.unique = s } + +// IsValidName returns true, if name is a valid symbol name. +func isValidName(s string) bool { return s != "" } + +// EvaluateAttributes transforms the given attributes into a HTML s-expression. +func EvaluateAttributes(a zsx.Attributes) *sx.Pair { + if len(a) == 0 { + return nil + } + plist := sx.Nil() + keys := a.Keys() + for i := len(keys) - 1; i >= 0; i-- { + key := keys[i] + if key != zsx.DefaultAttribute && isValidName(key) { + plist = plist.Cons(sx.Cons(sx.MakeSymbol(key), sx.MakeString(a[key]))) + } + } + if plist == nil { + return nil + } + return plist.Cons(sxhtml.SymAttr) +} + +// Evaluate a metadata s-expression into a list of HTML s-expressions. +func (ev *Evaluator) Evaluate(lst *sx.Pair, env *Environment) (*sx.Pair, error) { + result := ev.Eval(lst, env) + if err := env.err; err != nil { + return nil, err + } + pair, isPair := sx.GetPair(result) + if !isPair { + return nil, fmt.Errorf("evaluation does not result in a pair, but %T/%v", result, result) + } + + for i := 0; i < len(env.endnotes); i++ { + // May extend tr.endnotes -> do not use for i := range len(...)!!! + + if env.endnotes[i].noteHx != nil { + continue + } + + noteHx, _ := ev.EvaluateList(env.endnotes[i].noteAST, env) + env.endnotes[i].noteHx = noteHx + } + + return pair, nil +} + +// EvaluateList will evaluate all list elements separately and returns them as a sx.Pair list +func (ev *Evaluator) EvaluateList(lst sx.Vector, env *Environment) (*sx.Pair, error) { + var result sx.ListBuilder + for _, elem := range lst { + p := ev.Eval(elem, env) + result.Add(p) + } + if err := env.err; err != nil { + return nil, err + } + return result.List(), nil +} + +// Endnotes returns a SHTML object with all collected endnotes. +func Endnotes(env *Environment) *sx.Pair { + if env.err != nil || len(env.endnotes) == 0 { + return nil + } + + var result sx.ListBuilder + result.AddN( + SymOL, + sx.Nil().Cons(sx.Cons(SymAttrClass, sx.MakeString("zs-endnotes"))).Cons(sxhtml.SymAttr), + ) + for i, fni := range env.endnotes { + noteNum := strconv.Itoa(i + 1) + attrs := fni.attrs.Cons(sx.Cons(SymAttrClass, sx.MakeString("zs-endnote"))). + Cons(sx.Cons(SymAttrValue, sx.MakeString(noteNum))). + Cons(sx.Cons(SymAttrID, sx.MakeString("fn:"+fni.noteID))). + Cons(sx.Cons(SymAttrRole, sx.MakeString("doc-endnote"))). + Cons(sxhtml.SymAttr) + + backref := sx.Nil().Cons(sx.MakeString("\u21a9\ufe0e")). + Cons(sx.Nil(). + Cons(sx.Cons(SymAttrClass, sx.MakeString("zs-endnote-backref"))). + Cons(sx.Cons(SymAttrHref, sx.MakeString("#fnref:"+fni.noteID))). + Cons(sx.Cons(SymAttrRole, sx.MakeString("doc-backlink"))). + Cons(sxhtml.SymAttr)). + Cons(SymA) + + var li sx.ListBuilder + li.AddN(SymLI, attrs) + li.ExtendBang(fni.noteHx) + li.AddN(sx.MakeString(" "), backref) + result.Add(li.List()) + } + return result.List() +} + +// Environment where sz objects are evaluated to shtml objects +type Environment struct { + err error + langStack LangStack + endnotes []endnoteInfo + quoteNesting uint +} +type endnoteInfo struct { + noteID string // link id + noteAST sx.Vector // Endnote as list of AST inline elements + attrs *sx.Pair // attrs a-list + noteHx *sx.Pair // Endnote as SxHTML +} + +// MakeEnvironment builds a new evaluation environment. +func MakeEnvironment(lang string) Environment { + return Environment{ + err: nil, + langStack: NewLangStack(lang), + endnotes: nil, + quoteNesting: 0, + } +} + +// GetError returns the last error found. +func (env *Environment) GetError() error { return env.err } + +// Reset the environment. +func (env *Environment) Reset() { + env.langStack.Reset() + env.endnotes = nil + env.quoteNesting = 0 +} + +// pushAttribute adds the current attributes to the environment. +func (env *Environment) pushAttributes(a zsx.Attributes) { + if value, ok := a.Get("lang"); ok { + env.langStack.Push(value) + } else { + env.langStack.Dup() + } +} + +// popAttributes removes the current attributes from the envrionment. +func (env *Environment) popAttributes() { env.langStack.Pop() } + +// getLanguage returns the current language. +func (env *Environment) getLanguage() string { return env.langStack.Top() } + +func (env *Environment) getQuotes() (string, string, bool) { + qi := GetQuoteInfo(env.getLanguage()) + leftQ, rightQ := qi.GetQuotes(env.quoteNesting) + return leftQ, rightQ, qi.GetNBSp() +} + +// EvalFn is a function to be called for evaluation. +type EvalFn func(sx.Vector, *Environment) sx.Object + +func (ev *Evaluator) bind(sym *sx.Symbol, minArgs int, fn EvalFn) { + symVal := sym.GetValue() + ev.fns[symVal] = fn + if minArgs > 0 { + ev.minArgs[symVal] = minArgs + } +} + +// ResolveBinding returns the function bound to the given name. +func (ev *Evaluator) ResolveBinding(sym *sx.Symbol) EvalFn { + if fn, found := ev.fns[sym.GetValue()]; found { + return fn + } + return nil +} + +// Rebind overwrites a binding, but leaves the minimum number of arguments intact. +func (ev *Evaluator) Rebind(sym *sx.Symbol, fn EvalFn) { + symVal := sym.GetValue() + if _, found := ev.fns[symVal]; !found { + panic(sym) + } + ev.fns[symVal] = fn +} + +func (ev *Evaluator) bindMetadata() { + ev.bind(sz.SymMeta, 0, ev.evalList) + evalMetaString := func(args sx.Vector, env *Environment) sx.Object { + a := make(zsx.Attributes, 2). + Set("name", getSymbol(args[0], env).GetValue()). + Set("content", getString(args[1], env).GetValue()) + return ev.EvaluateMeta(a) + } + ev.bind(sz.SymTypeCredential, 2, evalMetaString) + ev.bind(sz.SymTypeEmpty, 2, evalMetaString) + ev.bind(sz.SymTypeID, 2, evalMetaString) + ev.bind(sz.SymTypeNumber, 2, evalMetaString) + ev.bind(sz.SymTypeString, 2, evalMetaString) + ev.bind(sz.SymTypeTimestamp, 2, evalMetaString) + ev.bind(sz.SymTypeURL, 2, evalMetaString) + ev.bind(sz.SymTypeWord, 2, evalMetaString) + + evalMetaSet := func(args sx.Vector, env *Environment) sx.Object { + var sb strings.Builder + for obj := range getList(args[1], env).Values() { + sb.WriteByte(' ') + sb.WriteString(getString(obj, env).GetValue()) + } + s := sb.String() + if len(s) > 0 { + s = s[1:] + } + a := make(zsx.Attributes, 2). + Set("name", getSymbol(args[0], env).GetValue()). + Set("content", s) + return ev.EvaluateMeta(a) + } + ev.bind(sz.SymTypeIDSet, 2, evalMetaSet) + ev.bind(sz.SymTypeTagSet, 2, evalMetaSet) +} + +// EvaluateMeta returns HTML meta object for an attribute. +func (ev *Evaluator) EvaluateMeta(a zsx.Attributes) *sx.Pair { + return sx.Nil().Cons(EvaluateAttributes(a)).Cons(SymMeta) +} + +func (ev *Evaluator) bindBlocks() { + ev.bind(zsx.SymBlock, 0, ev.evalList) + ev.bind(zsx.SymPara, 0, func(args sx.Vector, env *Environment) sx.Object { + return ev.evalSlice(args, env).Cons(SymP) + }) + ev.bind(zsx.SymHeading, 5, func(args sx.Vector, env *Environment) sx.Object { + nLevel := getInt64(args[0], env) + if nLevel <= 0 { + env.err = fmt.Errorf("%v is a negative heading level", nLevel) + return sx.Nil() + } + level := strconv.FormatInt(nLevel+ev.headingOffset, 10) + headingSymbol := sx.MakeSymbol("h" + level) + + a := GetAttributes(args[1], env) + env.pushAttributes(a) + defer env.popAttributes() + if fragment := getString(args[3], env).GetValue(); fragment != "" { + a = a.Set("id", ev.unique+fragment) + } + + if result, _ := ev.EvaluateList(args[4:], env); result != nil { + if len(a) > 0 { + result = result.Cons(EvaluateAttributes(a)) + } + return result.Cons(headingSymbol) + } + return sx.MakeList(headingSymbol, sx.MakeString("")) + }) + ev.bind(zsx.SymThematic, 0, func(args sx.Vector, env *Environment) sx.Object { + result := sx.Nil() + if len(args) > 0 { + if attrList := getList(args[0], env); attrList != nil { + result = result.Cons(EvaluateAttributes(zsx.GetAttributes(attrList))) + } + } + return result.Cons(SymHR) + }) + + ev.bind(zsx.SymListOrdered, 1, ev.makeListFn(SymOL)) + ev.bind(zsx.SymListUnordered, 1, ev.makeListFn(SymUL)) + ev.bind(zsx.SymListQuote, 1, func(args sx.Vector, env *Environment) sx.Object { + if len(args) == 1 { + return sx.Nil() + } + var result sx.ListBuilder + result.Add(symBLOCKQUOTE) + if attrs := EvaluateAttributes(GetAttributes(args[0], env)); attrs != nil { + result.Add(attrs) + } + for _, elem := range args[1:] { + if quote, isPair := sx.GetPair(ev.Eval(elem, env)); isPair { + result.Add(quote.Cons(sxhtml.SymListSplice)) + } + } + return result.List() + }) + + ev.bind(zsx.SymDescription, 1, func(args sx.Vector, env *Environment) sx.Object { + if len(args) == 1 { + return sx.Nil() + } + var result sx.ListBuilder + result.Add(symDL) + if attrs := EvaluateAttributes(GetAttributes(args[0], env)); attrs != nil { + result.Add(attrs) + } + for pos := 1; pos < len(args); pos++ { + term := ev.evalDescriptionTerm(getList(args[pos], env), env) + result.Add(term.Cons(symDT)) + pos++ + if pos >= len(args) { + break + } + ddBlock := getList(ev.Eval(args[pos], env), env) + if ddBlock == nil { + continue + } + for ddlst := range ddBlock.Values() { + dditem := getList(ddlst, env) + result.Add(dditem.Cons(symDD)) + } + } + return result.List() + }) + + ev.bind(zsx.SymTable, 1, func(args sx.Vector, env *Environment) sx.Object { + thead := sx.Nil() + if header := getList(args[0], env); !sx.IsNil(header) { + thead = sx.Nil().Cons(ev.evalTableRow(symTH, header, env)).Cons(symTHEAD) + } + + var tbody sx.ListBuilder + if len(args) > 1 { + tbody.Add(symTBODY) + for _, row := range args[1:] { + tbody.Add(ev.evalTableRow(symTD, getList(row, env), env)) + } + } + + table := sx.Nil() + if !tbody.IsEmpty() { + table = table.Cons(tbody.List()) + } + if thead != nil { + table = table.Cons(thead) + } + if table == nil { + return sx.Nil() + } + return table.Cons(symTABLE) + }) + ev.bind(zsx.SymCell, 1, func(args sx.Vector, env *Environment) sx.Object { + tdata := ev.evalSlice(args[1:], env) + pattrs := getList(args[0], env) + if alignPairs := pattrs.Assoc(zsx.SymAttrAlign); alignPairs != nil { + if salign, isString := sx.GetString(alignPairs.Cdr()); isString { + a := zsx.GetAttributes(pattrs.RemoveAssoc(zsx.SymAttrAlign)) + // Since in Sz there are attributes of align:center|left|right, we can reuse the values. + a = a.AddClass(salign.GetValue()) + tdata = tdata.Cons(EvaluateAttributes(a)) + } + } + return tdata + }) + + ev.bind(zsx.SymRegionBlock, 2, ev.makeRegionFn(SymDIV, true)) + ev.bind(zsx.SymRegionQuote, 2, ev.makeRegionFn(symBLOCKQUOTE, false)) + ev.bind(zsx.SymRegionVerse, 2, ev.makeRegionFn(SymDIV, false)) + + ev.bind(zsx.SymVerbatimComment, 1, func(args sx.Vector, env *Environment) sx.Object { + if GetAttributes(args[0], env).HasDefault() { + if len(args) > 1 { + if s := getString(args[1], env); s.GetValue() != "" { + return sx.Nil().Cons(s).Cons(sxhtml.SymBlockComment) + } + } + } + return nil + }) + ev.bind(zsx.SymVerbatimEval, 2, func(args sx.Vector, env *Environment) sx.Object { + return evalVerbatim(GetAttributes(args[0], env).AddClass("zs-eval"), getString(args[1], env)) + }) + ev.bind(zsx.SymVerbatimHTML, 2, ev.evalHTML) + ev.bind(zsx.SymVerbatimMath, 2, func(args sx.Vector, env *Environment) sx.Object { + return evalVerbatim(GetAttributes(args[0], env).AddClass("zs-math"), getString(args[1], env)) + }) + ev.bind(zsx.SymVerbatimCode, 2, func(args sx.Vector, env *Environment) sx.Object { + a := GetAttributes(args[0], env) + content := getString(args[1], env) + if a.HasDefault() { + content = sx.MakeString(visibleReplacer.Replace(content.GetValue())) + } + return evalVerbatim(a, content) + }) + ev.bind(zsx.SymVerbatimZettel, 0, nilFn) + ev.bind(zsx.SymBLOB, 4, func(args sx.Vector, env *Environment) sx.Object { + a := GetAttributes(args[0], env) + return evalBLOB(a, getList(args[1], env), getString(args[2], env), getString(args[3], env)) + }) + ev.bind(zsx.SymTransclude, 2, func(args sx.Vector, env *Environment) sx.Object { + if refSym, refValue := GetReference(args[1], env); refSym != nil { + if refSym.IsEqualSymbol(zsx.SymRefStateExternal) { + a := GetAttributes(args[0], env).Set("src", refValue).AddClass("external") + // TODO: if len(args) > 2, add "alt" attr based on args[2:], as in SymEmbed + return sx.Nil().Cons(sx.Nil().Cons(EvaluateAttributes(a)).Cons(SymIMG)).Cons(SymP) + } + return sx.MakeList( + sxhtml.SymInlineComment, + sx.MakeString("transclude"), + refSym, + sx.MakeString("->"), + sx.MakeString(refValue), + ) + } + return ev.evalSlice(args, env) + }) +} + +func (ev *Evaluator) makeListFn(sym *sx.Symbol) EvalFn { + return func(args sx.Vector, env *Environment) sx.Object { + var result sx.ListBuilder + result.Add(sym) + if attrs := EvaluateAttributes(GetAttributes(args[0], env)); attrs != nil { + result.Add(attrs) + } + if len(args) > 1 { + for _, elem := range args[1:] { + item := sx.Nil().Cons(SymLI) + if res, isPair := sx.GetPair(ev.Eval(elem, env)); isPair { + item.ExtendBang(res) + } + result.Add(item) + } + } + return result.List() + } +} + +func (ev *Evaluator) evalDescriptionTerm(term *sx.Pair, env *Environment) *sx.Pair { + var result sx.ListBuilder + for obj := range term.Values() { + elem := ev.Eval(obj, env) + result.Add(elem) + } + return result.List() +} + +func (ev *Evaluator) evalTableRow(sym *sx.Symbol, pairs *sx.Pair, env *Environment) *sx.Pair { + if pairs == nil { + return nil + } + var row sx.ListBuilder + row.Add(symTR) + for obj := range pairs.Values() { + row.Add(sx.Cons(sym, ev.Eval(obj, env))) + } + return row.List() +} + +func (ev *Evaluator) makeRegionFn(sym *sx.Symbol, genericToClass bool) EvalFn { + return func(args sx.Vector, env *Environment) sx.Object { + a := GetAttributes(args[0], env) + env.pushAttributes(a) + defer env.popAttributes() + if genericToClass { + if val, found := a.Get(""); found { + a = a.Remove("").AddClass(val) + } + } + var result sx.ListBuilder + result.Add(sym) + if len(a) > 0 { + result.Add(EvaluateAttributes(a)) + } + if region, isPair := sx.GetPair(args[1]); isPair { + if evalRegion := ev.EvalPairList(region, env); evalRegion != nil { + result.ExtendBang(evalRegion) + } + } + if len(args) > 2 { + if cite, _ := ev.EvaluateList(args[2:], env); cite != nil { + result.Add(cite.Cons(symCITE)) + } + } + return result.List() + } +} + +func evalVerbatim(a zsx.Attributes, s sx.String) sx.Object { + a = setProgLang(a) + code := sx.Nil().Cons(s) + if al := EvaluateAttributes(a); al != nil { + code = code.Cons(al) + } + code = code.Cons(symCODE) + return sx.Nil().Cons(code).Cons(symPRE) +} + +func (ev *Evaluator) bindInlines() { + ev.bind(zsx.SymInline, 0, ev.evalList) + ev.bind(zsx.SymText, 1, func(args sx.Vector, env *Environment) sx.Object { return getString(args[0], env) }) + ev.bind(zsx.SymSoft, 0, func(sx.Vector, *Environment) sx.Object { return sx.MakeString(" ") }) + ev.bind(zsx.SymHard, 0, func(sx.Vector, *Environment) sx.Object { return sx.Nil().Cons(symBR) }) + + ev.bind(zsx.SymLink, 2, func(args sx.Vector, env *Environment) sx.Object { + a := GetAttributes(args[0], env) + env.pushAttributes(a) + defer env.popAttributes() + refSym, refValue := GetReference(args[1], env) + switch refSym { + case sz.SymRefStateZettel, zsx.SymRefStateSelf, sz.SymRefStateFound, zsx.SymRefStateHosted, sz.SymRefStateBased: + return ev.evalLink(a.Set("href", refValue), refValue, args[2:], env) + + case zsx.SymRefStateExternal: + return ev.evalLink(a.Set("href", refValue).Add("rel", "external"), refValue, args[2:], env) + + case sz.SymRefStateQuery: + query := "?" + api.QueryKeyQuery + "=" + url.QueryEscape(refValue) + return ev.evalLink(a.Set("href", query), refValue, args[2:], env) + + case sz.SymRefStateBroken: + return ev.evalLink(a.AddClass("broken"), refValue, args[2:], env) + } + + // sz.SymRefStateInvalid or unknown + var inline *sx.Pair + if len(args) > 2 { + inline = ev.evalSlice(args[2:], env) + } + if inline == nil { + inline = sx.Nil().Cons(sx.MakeString(refValue)) + } + return inline.Cons(SymSPAN) + }) + + ev.bind(zsx.SymEmbed, 3, func(args sx.Vector, env *Environment) sx.Object { + _, refValue := GetReference(args[1], env) + a := GetAttributes(args[0], env).Set("src", refValue) + if len(args) > 3 { + var sb strings.Builder + flattenText(&sb, sx.MakeList(args[3:]...)) + if d := sb.String(); d != "" { + a = a.Set("alt", d) + } + } + return sx.MakeList(SymIMG, EvaluateAttributes(a)) + }) + ev.bind(zsx.SymEmbedBLOB, 3, func(args sx.Vector, env *Environment) sx.Object { + a, syntax, data := GetAttributes(args[0], env), getString(args[1], env), getString(args[2], env) + summary, hasSummary := a.Get(meta.KeySummary) + if !hasSummary { + summary = "" + } + return evalBLOB( + a, + sx.MakeList(sxhtml.SymListSplice, sx.MakeString(summary)), + syntax, + data, + ) + }) + + ev.bind(zsx.SymCite, 2, func(args sx.Vector, env *Environment) sx.Object { + a := GetAttributes(args[0], env) + env.pushAttributes(a) + defer env.popAttributes() + result := sx.Nil() + if key := getString(args[1], env); key.GetValue() != "" { + if len(args) > 2 { + result = ev.evalSlice(args[2:], env).Cons(sx.MakeString(", ")) + } + result = result.Cons(key) + } + if len(a) > 0 { + result = result.Cons(EvaluateAttributes(a)) + } + if result == nil { + return nil + } + return result.Cons(SymSPAN) + }) + ev.bind(zsx.SymMark, 3, func(args sx.Vector, env *Environment) sx.Object { + result := ev.evalSlice(args[3:], env) + if !ev.noLinks { + if fragment := getString(args[2], env).GetValue(); fragment != "" { + a := zsx.Attributes{"id": fragment + ev.unique} + return result.Cons(EvaluateAttributes(a)).Cons(SymA) + } + } + return result.Cons(SymSPAN) + }) + ev.bind(zsx.SymEndnote, 1, func(args sx.Vector, env *Environment) sx.Object { + a := GetAttributes(args[0], env) + env.pushAttributes(a) + defer env.popAttributes() + attrPlist := sx.Nil() + if len(a) > 0 { + if attrs := EvaluateAttributes(a); attrs != nil { + attrPlist = attrs.Tail() + } + } + + noteNum := strconv.Itoa(len(env.endnotes) + 1) + noteID := ev.unique + noteNum + env.endnotes = append(env.endnotes, endnoteInfo{ + noteID: noteID, noteAST: args[1:], noteHx: nil, attrs: attrPlist}) + hrefAttr := sx.Nil().Cons(sx.Cons(SymAttrRole, sx.MakeString("doc-noteref"))). + Cons(sx.Cons(SymAttrHref, sx.MakeString("#fn:"+noteID))). + Cons(sx.Cons(SymAttrClass, sx.MakeString("zs-noteref"))). + Cons(sxhtml.SymAttr) + href := sx.Nil().Cons(sx.MakeString(noteNum)).Cons(hrefAttr).Cons(SymA) + supAttr := sx.Nil().Cons(sx.Cons(SymAttrID, sx.MakeString("fnref:"+noteID))).Cons(sxhtml.SymAttr) + return sx.Nil().Cons(href).Cons(supAttr).Cons(symSUP) + }) + + ev.bind(zsx.SymFormatDelete, 1, ev.makeFormatFn(symDEL)) + ev.bind(zsx.SymFormatEmph, 1, ev.makeFormatFn(symEM)) + ev.bind(zsx.SymFormatInsert, 1, ev.makeFormatFn(symINS)) + ev.bind(zsx.SymFormatMark, 1, ev.makeFormatFn(symMARK)) + ev.bind(zsx.SymFormatQuote, 1, ev.evalQuote) + ev.bind(zsx.SymFormatSpan, 1, ev.makeFormatFn(SymSPAN)) + ev.bind(zsx.SymFormatStrong, 1, ev.makeFormatFn(SymSTRONG)) + ev.bind(zsx.SymFormatSub, 1, ev.makeFormatFn(symSUB)) + ev.bind(zsx.SymFormatSuper, 1, ev.makeFormatFn(symSUP)) + + ev.bind(zsx.SymLiteralComment, 1, func(args sx.Vector, env *Environment) sx.Object { + if GetAttributes(args[0], env).HasDefault() { + if len(args) > 1 { + if s := getString(ev.Eval(args[1], env), env); s.GetValue() != "" { + return sx.Nil().Cons(s).Cons(sxhtml.SymInlineComment) + } + } + } + return sx.Nil() + }) + ev.bind(zsx.SymLiteralInput, 2, func(args sx.Vector, env *Environment) sx.Object { + return evalLiteral(args, nil, symKBD, env) + }) + ev.bind(zsx.SymLiteralMath, 2, func(args sx.Vector, env *Environment) sx.Object { + a := GetAttributes(args[0], env).AddClass("zs-math") + return evalLiteral(args, a, symCODE, env) + }) + ev.bind(zsx.SymLiteralOutput, 2, func(args sx.Vector, env *Environment) sx.Object { + return evalLiteral(args, nil, symSAMP, env) + }) + ev.bind(zsx.SymLiteralCode, 2, func(args sx.Vector, env *Environment) sx.Object { + return evalLiteral(args, nil, symCODE, env) + }) +} + +func (ev *Evaluator) makeFormatFn(sym *sx.Symbol) EvalFn { + return func(args sx.Vector, env *Environment) sx.Object { + a := GetAttributes(args[0], env) + env.pushAttributes(a) + defer env.popAttributes() + if val, hasClass := a.Get(""); hasClass { + a = a.Remove("").AddClass(val) + } + res := ev.evalSlice(args[1:], env) + if len(a) > 0 { + res = res.Cons(EvaluateAttributes(a)) + } + return res.Cons(sym) + } +} + +func (ev *Evaluator) evalQuote(args sx.Vector, env *Environment) sx.Object { + a := GetAttributes(args[0], env) + env.pushAttributes(a) + defer env.popAttributes() + + if val, hasClass := a.Get(""); hasClass { + a = a.Remove("").AddClass(val) + } + leftQ, rightQ, withNbsp := env.getQuotes() + + env.quoteNesting++ + res := ev.evalSlice(args[1:], env) + env.quoteNesting-- + + lastPair := res.LastPair() + if lastPair.IsNil() { + res = sx.Cons(sx.MakeList(sxhtml.SymNoEscape, sx.MakeString(leftQ), sx.MakeString(rightQ)), sx.Nil()) + } else { + if withNbsp { + lastPair.AppendBang(sx.MakeList(sxhtml.SymNoEscape, sx.MakeString(" "), sx.MakeString(rightQ))) + res = res.Cons(sx.MakeList(sxhtml.SymNoEscape, sx.MakeString(leftQ), sx.MakeString(" "))) + } else { + lastPair.AppendBang(sx.MakeList(sxhtml.SymNoEscape, sx.MakeString(rightQ))) + res = res.Cons(sx.MakeList(sxhtml.SymNoEscape, sx.MakeString(leftQ))) + } + } + if len(a) > 0 { + res = res.Cons(EvaluateAttributes(a)) + return res.Cons(SymSPAN) + } + return res.Cons(sxhtml.SymListSplice) +} + +var visibleReplacer = strings.NewReplacer(" ", "\u2423") + +func evalLiteral(args sx.Vector, a zsx.Attributes, sym *sx.Symbol, env *Environment) sx.Object { + if a == nil { + a = GetAttributes(args[0], env) + } + a = setProgLang(a) + literal := getString(args[1], env).GetValue() + if a.HasDefault() { + a = a.RemoveDefault() + literal = visibleReplacer.Replace(literal) + } + res := sx.Nil().Cons(sx.MakeString(literal)) + if len(a) > 0 { + res = res.Cons(EvaluateAttributes(a)) + } + return res.Cons(sym) +} +func setProgLang(a zsx.Attributes) zsx.Attributes { + if val, found := a.Get(""); found { + a = a.AddClass("language-" + val).Remove("") + } + return a +} + +func (ev *Evaluator) evalHTML(args sx.Vector, env *Environment) sx.Object { + if s := getString(ev.Eval(args[1], env), env); s.GetValue() != "" && IsSafe(s.GetValue()) { + return sx.Nil().Cons(s).Cons(sxhtml.SymNoEscape) + } + return nil +} + +func evalBLOB(a zsx.Attributes, description *sx.Pair, syntax, data sx.String) sx.Object { + if data.GetValue() == "" { + return sx.Nil() + } + switch syntax.GetValue() { + case "": + return sx.Nil() + case meta.ValueSyntaxSVG: + return sx.Nil().Cons(sx.Nil().Cons(data).Cons(sxhtml.SymNoEscape)).Cons(SymP) + default: + a = a.Add("src", "data:image/"+syntax.GetValue()+";base64,"+data.GetValue()) + var sb strings.Builder + flattenText(&sb, description) + if d := sb.String(); d != "" { + a = a.Add("alt", d) + } + return sx.Nil().Cons(sx.Nil().Cons(EvaluateAttributes(a)).Cons(SymIMG)).Cons(SymP) + } +} + +func flattenText(sb *strings.Builder, lst *sx.Pair) { + for elem := range lst.Values() { + switch obj := elem.(type) { + case sx.String: + sb.WriteString(obj.GetValue()) + case *sx.Pair: + flattenText(sb, obj) + } + } +} + +func (ev *Evaluator) evalList(args sx.Vector, env *Environment) sx.Object { + return ev.evalSlice(args, env) +} +func nilFn(sx.Vector, *Environment) sx.Object { return sx.Nil() } + +// Eval evaluates an object in an environment. +func (ev *Evaluator) Eval(obj sx.Object, env *Environment) sx.Object { + if env.err != nil { + return sx.Nil() + } + if sx.IsNil(obj) { + return obj + } + lst, isLst := sx.GetPair(obj) + if !isLst { + return obj + } + sym, found := sx.GetSymbol(lst.Car()) + if !found { + env.err = fmt.Errorf("symbol expected, but got %T/%v", lst.Car(), lst.Car()) + return sx.Nil() + } + symVal := sym.GetValue() + fn, found := ev.fns[symVal] + if !found { + env.err = fmt.Errorf("symbol %q not bound", sym) + return sx.Nil() + } + var args sx.Vector + for cdr := lst.Cdr(); !sx.IsNil(cdr); { + pair, isPair := sx.GetPair(cdr) + if !isPair { + break + } + args = append(args, pair.Car()) + cdr = pair.Cdr() + } + if minArgs, hasMinArgs := ev.minArgs[symVal]; hasMinArgs { + if minArgs > len(args) { + env.err = fmt.Errorf("%v needs at least %d arguments, but got only %d", sym, minArgs, len(args)) + return sx.Nil() + } + } + result := fn(args, env) + if env.err != nil { + return sx.Nil() + } + return result +} + +func (ev *Evaluator) evalSlice(args sx.Vector, env *Environment) *sx.Pair { + var result sx.ListBuilder + for _, arg := range args { + elem := ev.Eval(arg, env) + result.Add(elem) + } + if env.err == nil { + return result.List() + } + return nil +} + +// EvalPairList evaluates a list of lists. +func (ev *Evaluator) EvalPairList(pair *sx.Pair, env *Environment) *sx.Pair { + var result sx.ListBuilder + for obj := range pair.Values() { + elem := ev.Eval(obj, env) + result.Add(elem) + } + if env.err == nil { + return result.List() + } + return nil +} + +func (ev *Evaluator) evalLink(a zsx.Attributes, refValue string, inline sx.Vector, env *Environment) sx.Object { + result := ev.evalSlice(inline, env) + if len(inline) == 0 { + result = sx.Nil().Cons(sx.MakeString(refValue)) + } + if ev.noLinks { + return result.Cons(SymSPAN) + } + return result.Cons(EvaluateAttributes(a)).Cons(SymA) +} + +func getSymbol(obj sx.Object, env *Environment) *sx.Symbol { + if env.err == nil { + if sym, ok := sx.GetSymbol(obj); ok { + return sym + } + env.err = fmt.Errorf("%v/%T is not a symbol", obj, obj) + } + return sx.MakeSymbol("???") +} +func getString(val sx.Object, env *Environment) sx.String { + if env.err == nil { + if s, ok := sx.GetString(val); ok { + return s + } + env.err = fmt.Errorf("%v/%T is not a string", val, val) + } + return sx.MakeString("") +} +func getList(val sx.Object, env *Environment) *sx.Pair { + if env.err == nil { + if res, isPair := sx.GetPair(val); isPair { + return res + } + env.err = fmt.Errorf("%v/%T is not a list", val, val) + } + return nil +} +func getInt64(val sx.Object, env *Environment) int64 { + if env.err != nil { + return -1017 + } + if num, ok := sx.GetNumber(val); ok { + return int64(num.(sx.Int64)) + } + env.err = fmt.Errorf("%v/%T is not a number", val, val) + return -1017 +} + +// GetAttributes evaluates the given arg in the given environment and returns +// the contained attributes. +func GetAttributes(arg sx.Object, env *Environment) zsx.Attributes { + return zsx.GetAttributes(getList(arg, env)) +} + +// GetReference returns the reference symbol and the reference value of a reference pair. +func GetReference(val sx.Object, env *Environment) (*sx.Symbol, string) { + if env.err == nil { + if p := getList(val, env); env.err == nil { + sym, val := sz.GetReference(p) + if sym != nil { + return sym, val + } + env.err = fmt.Errorf("%v/%T is not a reference", val, val) + } + } + return nil, "" +} + +var unsafeSnippets = []string{ + " id.LengthZid && s[id.LengthZid] == '#' { + zidPart := s[:id.LengthZid] + if _, err := id.Parse(zidPart); err == nil { + if u, err2 := url.Parse(s); err2 != nil || u.String() != s { + return MakeReference(zsx.SymRefStateInvalid, s) + } + return MakeReference(SymRefStateZettel, s) + } + if zidPart == "00000000000000" { + return MakeReference(zsx.SymRefStateInvalid, s) + } + } + if strings.HasPrefix(s, api.QueryPrefix) { + return MakeReference(SymRefStateQuery, s[len(api.QueryPrefix):]) + } + if strings.HasPrefix(s, "//") { + if u, err := url.Parse(s[1:]); err == nil { + if u.Scheme == "" && u.Opaque == "" && u.Host == "" && u.User == nil { + if u.String() == s[1:] { + return MakeReference(SymRefStateBased, s[1:]) + } + return MakeReference(zsx.SymRefStateInvalid, s) + } + } + } + + if s == "" { + return MakeReference(zsx.SymRefStateInvalid, s) + } + u, err := url.Parse(s) + if err != nil || u.String() != s { + return MakeReference(zsx.SymRefStateInvalid, s) + } + sym := zsx.SymRefStateExternal + if u.Scheme == "" && u.Opaque == "" && u.Host == "" && u.User == nil { + if s[0] == '#' { + sym = zsx.SymRefStateSelf + } else { + sym = zsx.SymRefStateHosted + } + } + return MakeReference(sym, s) +} ADDED sz/ref_test.go Index: sz/ref_test.go ================================================================== --- /dev/null +++ sz/ref_test.go @@ -0,0 +1,76 @@ +// ----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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 sz_test + +import ( + "testing" + + "t73f.de/r/zsc/sz" +) + +func TestParseReference(t *testing.T) { + t.Parallel() + testcases := []struct { + s string + exp string + }{ + {"", `(INVALID "")`}, + {"abc", `(HOSTED "abc")`}, + {"abc def", `(INVALID "abc def")`}, + {"/hosted", `(HOSTED "/hosted")`}, + {"/hosted ref", `(INVALID "/hosted ref")`}, + {"./", `(HOSTED "./")`}, + {"./12345678901234", `(HOSTED "./12345678901234")`}, + {"../", `(HOSTED "../")`}, + {"../12345678901234", `(HOSTED "../12345678901234")`}, + {"abc#frag", `(HOSTED "abc#frag")`}, + {"abc#frag space", `(INVALID "abc#frag space")`}, + {"abc#", `(INVALID "abc#")`}, + {"abc# ", `(INVALID "abc# ")`}, + {"/hosted#frag", `(HOSTED "/hosted#frag")`}, + {"./#frag", `(HOSTED "./#frag")`}, + {"./12345678901234#frag", `(HOSTED "./12345678901234#frag")`}, + {"../#frag", `(HOSTED "../#frag")`}, + {"../12345678901234#frag", `(HOSTED "../12345678901234#frag")`}, + {"#frag", `(SELF "#frag")`}, + {"#", `(INVALID "#")`}, + {"# ", `(INVALID "# ")`}, + {"https://t73f.de", `(EXTERNAL "https://t73f.de")`}, + {"https://t73f.de/12345678901234", `(EXTERNAL "https://t73f.de/12345678901234")`}, + {"http://t73f.de/1234567890", `(EXTERNAL "http://t73f.de/1234567890")`}, + {"mailto:ds@zettelstore.de", `(EXTERNAL "mailto:ds@zettelstore.de")`}, + {",://", `(INVALID ",://")`}, + + // ZS specific + {"00000000000000", `(INVALID "00000000000000")`}, + {"00000000000000#frag", `(INVALID "00000000000000#frag")`}, + {"12345678901234", `(ZETTEL "12345678901234")`}, + {"12345678901234#frag", `(ZETTEL "12345678901234#frag")`}, + {"12345678901234#", `(INVALID "12345678901234#")`}, + {"12345678901234# space", `(INVALID "12345678901234# space")`}, + {"12345678901234#frag ", `(INVALID "12345678901234#frag ")`}, + {"12345678901234#frag space", `(INVALID "12345678901234#frag space")`}, + {"query:role:zettel LIMIT 13", `(QUERY "role:zettel LIMIT 13")`}, + {"//based", `(BASED "/based")`}, + {"//based#frag", `(BASED "/based#frag")`}, + {"//based#", `(INVALID "//based#")`}, + } + for _, tc := range testcases { + t.Run(tc.s, func(t *testing.T) { + if got := sz.ScanReference(tc.s); got.String() != tc.exp { + t.Errorf("%q should be %q, but got %q", tc.s, tc.exp, got) + } + }) + } +} ADDED sz/sz.go Index: sz/sz.go ================================================================== --- /dev/null +++ sz/sz.go @@ -0,0 +1,105 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2022-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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: 2022-present Detlef Stern +//----------------------------------------------------------------------------- + +// Package sz contains zettel data handling as sx expressions. +package sz + +import ( + "t73f.de/r/sx" + "t73f.de/r/zsx" +) + +// GetMetaContent returns the metadata and the content of a sz encoded zettel. +func GetMetaContent(zettel sx.Object) (Meta, *sx.Pair) { + if pair, isPair := sx.GetPair(zettel); isPair { + m := pair.Car() + if s := pair.Tail(); s != nil { + if content, isContentPair := sx.GetPair(s.Car()); isContentPair { + return MakeMeta(m), content + } + } + return MakeMeta(m), nil + } + return nil, nil +} + +// Meta map metadata keys to MetaValue. +type Meta map[string]MetaValue + +// MetaValue is an extended metadata value: +// +// - Type: the type assiciated with the metata key +// - Key: the metadata key itself +// - Value: the metadata value as an (sx-) object. +type MetaValue struct { + Type string + Key string + Value sx.Object +} + +// MakeMeta build a Meta based on a list of metadata objects. +func MakeMeta(obj sx.Object) Meta { + if result := doMakeMeta(obj); len(result) > 0 { + return result + } + return nil +} +func doMakeMeta(obj sx.Object) Meta { + lst, isList := sx.GetPair(obj) + if !isList || !lst.Car().IsEqual(SymMeta) { + return nil + } + result := make(map[string]MetaValue) + for node := range lst.Tail().Pairs() { + if mv, found := makeMetaValue(node.Head()); found { + result[mv.Key] = mv + } + } + return result +} +func makeMetaValue(mnode *sx.Pair) (MetaValue, bool) { + var result MetaValue + typeSym, isSymbol := sx.GetSymbol(mnode.Car()) + if !isSymbol { + return result, false + } + next := mnode.Tail() + keySym, isSymbol := sx.GetSymbol(next.Car()) + if !isSymbol { + return result, false + } + next = next.Tail() + result.Type = typeSym.GetValue() + result.Key = keySym.GetValue() + result.Value = next.Car() + return result, true +} + +// GetString return the metadata string value associated with the given key. +func (m Meta) GetString(key string) string { + if v, found := m[key]; found { + return zsx.GoValue(v.Value) + } + return "" +} + +// GetPair return the metadata value associated with the given key, +// as a list of objects. +func (m Meta) GetPair(key string) *sx.Pair { + if mv, found := m[key]; found { + if pair, isPair := sx.GetPair(mv.Value); isPair { + return pair + } + } + return nil +} ADDED sz/zmk/block.go Index: sz/zmk/block.go ================================================================== --- /dev/null +++ sz/zmk/block.go @@ -0,0 +1,747 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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 zmk + +import ( + "fmt" + + "t73f.de/r/sx" + "t73f.de/r/zsx" + "t73f.de/r/zsx/input" +) + +// parseBlock parses one block. +func (cp *Parser) parseBlock(blocksBuilder *sx.ListBuilder, lastPara *sx.Pair) *sx.Pair { + bn, cont := cp.parseBlock0(lastPara) + if bn != nil { + blocksBuilder.Add(bn) + } + if cont { + return lastPara + } + if bn.Car().IsEqual(zsx.SymPara) { + return bn + } + return nil +} + +func (cp *Parser) parseBlock0(lastPara *sx.Pair) (res *sx.Pair, cont bool) { + inp := cp.inp + pos := inp.Pos + if cp.nestingLevel <= maxNestingLevel { + cp.nestingLevel++ + defer func() { cp.nestingLevel-- }() + + var bn *sx.Pair + success := false + + switch inp.Ch { + case input.EOS: + return nil, false + case '\n', '\r': + inp.EatEOL() + cp.cleanupListsAfterEOL() + return nil, false + case ':': + bn, success = cp.parseColon() + case '@', '`', runeModGrave, '%', '~', '$': + cp.clearStacked() + bn, success = parseVerbatim(inp) + case '"', '<': + cp.clearStacked() + bn, success = cp.parseRegion() + case '=': + cp.clearStacked() + bn, success = cp.parseHeading() + case '-': + cp.clearStacked() + bn, success = parseHRule(inp) + case '*', '#', '>': + cp.lastRow = nil + cp.descrl = nil + bn, success = cp.parseNestedList() + case ';': + cp.lists = nil + cp.lastRow = nil + bn, success = cp.parseDefTerm() + case ' ': + cp.lastRow = nil + bn, success = nil, cp.parseIndent() + case '|': + cp.lists = nil + cp.descrl = nil + bn, success = cp.parseRow(), true + case '{': + cp.clearStacked() + bn, success = cp.parseTransclusion() + } + + if success { + return bn, false + } + } + inp.SetPos(pos) + cp.clearStacked() + ins := cp.parsePara() + if startsWithSpaceSoftBreak(ins) { + ins = ins.Tail().Tail() + } else if lastPara != nil { + lastPair := lastPara.LastPair() + lastPair.ExtendBang(ins) + return nil, true + } + return zsx.MakeParaList(ins), false +} + +func startsWithSpaceSoftBreak(ins *sx.Pair) bool { + if ins == nil { + return false + } + pair0, isPair0 := sx.GetPair(ins.Car()) + if pair0 == nil || !isPair0 { + return false + } + next := ins.Tail() + if next == nil { + return false + } + pair1, isPair1 := sx.GetPair(next.Car()) + if pair1 == nil || !isPair1 { + return false + } + + if pair0.Car().IsEqual(zsx.SymText) && isBreakSym(pair1.Car()) { + if args := pair0.Tail(); args != nil { + if val, isString := sx.GetString(args.Car()); isString { + for _, ch := range val.GetValue() { + if !input.IsSpace(ch) { + return false + } + } + return true + } + } + } + return false +} + +var symSeparator = sx.MakeSymbol("sEpArAtOr") + +func (cp *Parser) cleanupListsAfterEOL() { + for _, l := range cp.lists { + l.LastPair().Head().LastPair().AppendBang(sx.Cons(symSeparator, nil)) + } + if descrl := cp.descrl; descrl != nil { + if lastPair, pos := lastPairPos(descrl); pos > 2 && pos%2 != 0 { + lastPair.Head().LastPair().AppendBang(sx.Cons(symSeparator, nil)) + } + } +} + +// parseColon determines which element should be parsed. +func (cp *Parser) parseColon() (*sx.Pair, bool) { + inp := cp.inp + if inp.PeekN(1) == ':' { + cp.clearStacked() + return cp.parseRegion() + } + return cp.parseDefDescr() +} + +// parsePara parses paragraphed inline material as a sx List. +func (cp *Parser) parsePara() *sx.Pair { + var lb sx.ListBuilder + for { + in := cp.parseInline() + if in == nil { + return lb.List() + } + lb.Add(in) + if isBreakSym(in.Car()) { + ch := cp.inp.Ch + switch ch { + // Must contain all cases from above switch in parseBlock. + case input.EOS, '\n', '\r', '@', '`', runeModGrave, '%', '~', '$', '"', '<', '=', '-', '*', '#', '>', ';', ':', ' ', '|', '{': + return lb.List() + } + } + } +} + +// countDelim read from input until a non-delimiter is found and returns number of delimiter chars. +func countDelim(inp *input.Input, delim rune) int { + cnt := 0 + for inp.Ch == delim { + cnt++ + inp.Next() + } + return cnt +} + +// parseVerbatim parses a verbatim block. +func parseVerbatim(inp *input.Input) (*sx.Pair, bool) { + fch := inp.Ch + cnt := countDelim(inp, fch) + if cnt < 3 { + return nil, false + } + attrs := parseBlockAttributes(inp) + inp.SkipToEOL() + if inp.Ch == input.EOS { + return nil, false + } + var sym *sx.Symbol + switch fch { + case '@': + sym = zsx.SymVerbatimZettel + case '`', runeModGrave: + sym = zsx.SymVerbatimCode + case '%': + sym = zsx.SymVerbatimComment + case '~': + sym = zsx.SymVerbatimEval + case '$': + sym = zsx.SymVerbatimMath + default: + panic(fmt.Sprintf("%q is not a verbatim char", fch)) + } + content := make([]byte, 0, 512) + for { + inp.EatEOL() + posL := inp.Pos + switch inp.Ch { + case fch: + if countDelim(inp, fch) >= cnt { + inp.SkipToEOL() + return zsx.MakeVerbatim(sym, attrs, string(content)), true + } + inp.SetPos(posL) + case input.EOS: + return nil, false + } + inp.SkipToEOL() + if len(content) > 0 { + content = append(content, '\n') + } + content = append(content, inp.Src[posL:inp.Pos]...) + } +} + +// parseRegion parses a block region. +func (cp *Parser) parseRegion() (*sx.Pair, bool) { + inp := cp.inp + fch := inp.Ch + cnt := countDelim(inp, fch) + if cnt < 3 { + return nil, false + } + + var sym *sx.Symbol + switch fch { + case ':': + sym = zsx.SymRegionBlock + case '<': + sym = zsx.SymRegionQuote + case '"': + sym = zsx.SymRegionVerse + default: + panic(fmt.Sprintf("%q is not a region char", fch)) + } + attrs := parseBlockAttributes(inp) + inp.SkipToEOL() + if inp.Ch == input.EOS { + return nil, false + } + var blocksBuilder sx.ListBuilder + var lastPara *sx.Pair + inp.EatEOL() + for { + posL := inp.Pos + switch inp.Ch { + case fch: + if countDelim(inp, fch) >= cnt { + ins := cp.parseRegionLastLine() + return zsx.MakeRegion(sym, attrs, blocksBuilder.List(), ins), true + } + inp.SetPos(posL) + case input.EOS: + return nil, false + } + + lastPara = cp.parseBlock(&blocksBuilder, lastPara) + } +} + +// parseRegionLastLine parses the last line of a region and returns its inline text. +func (cp *Parser) parseRegionLastLine() *sx.Pair { + inp := cp.inp + cp.clearStacked() // remove any lists defined in the region + inp.SkipSpace() + var region sx.ListBuilder + for { + switch inp.Ch { + case input.EOS, '\n', '\r': + return region.List() + } + in := cp.parseInline() + if in == nil { + return region.List() + } + region.Add(in) + } +} + +// parseHeading parses a head line. +func (cp *Parser) parseHeading() (*sx.Pair, bool) { + inp := cp.inp + delims := countDelim(inp, inp.Ch) + if delims < 3 { + return nil, false + } + if inp.Ch != ' ' { + return nil, false + } + inp.Next() + inp.SkipSpace() + if delims > 7 { + delims = 7 + } + level := delims - 2 + var attrs *sx.Pair + var text sx.ListBuilder + for { + if input.IsEOLEOS(inp.Ch) { + return zsx.MakeHeading(level, attrs, text.List(), "", ""), true + } + in := cp.parseInline() + if in == nil { + return zsx.MakeHeading(level, attrs, text.List(), "", ""), true + } + text.Add(in) + if inp.Ch == '{' && inp.Peek() != '{' { + attrs = parseBlockAttributes(inp) + inp.SkipToEOL() + return zsx.MakeHeading(level, attrs, text.List(), "", ""), true + } + } +} + +// parseHRule parses a horizontal rule. +func parseHRule(inp *input.Input) (*sx.Pair, bool) { + if countDelim(inp, inp.Ch) < 3 { + return nil, false + } + + attrs := parseBlockAttributes(inp) + inp.SkipToEOL() + return zsx.MakeThematic(attrs), true +} + +// parseNestedList parses a list. +func (cp *Parser) parseNestedList() (*sx.Pair, bool) { + inp := cp.inp + kinds := parseNestedListKinds(inp) + if len(kinds) == 0 { + return nil, false + } + inp.SkipSpace() + if !kinds[len(kinds)-1].IsEqual(zsx.SymListQuote) && input.IsEOLEOS(inp.Ch) { + return nil, false + } + + if len(kinds) < len(cp.lists) { + cp.lists = cp.lists[:len(kinds)] + } + ln, newLnCount := cp.buildNestedList(kinds) + pv := cp.parseLinePara() + bn := zsx.MakeBlock() + if pv != nil { + bn.AppendBang(zsx.MakeParaList(pv)) + } + lastItemPair := ln.LastPair() + lastItemPair.AppendBang(bn) + return cp.cleanupParsedNestedList(newLnCount) +} + +func parseNestedListKinds(inp *input.Input) []*sx.Symbol { + result := make([]*sx.Symbol, 0, 8) + for { + var sym *sx.Symbol + switch inp.Ch { + case '*': + sym = zsx.SymListUnordered + case '#': + sym = zsx.SymListOrdered + case '>': + sym = zsx.SymListQuote + default: + panic(fmt.Sprintf("%q is not a region char", inp.Ch)) + } + result = append(result, sym) + switch inp.Next() { + case '*', '#', '>': + case ' ', input.EOS, '\n', '\r': + return result + default: + return nil + } + } +} + +func (cp *Parser) buildNestedList(kinds []*sx.Symbol) (ln *sx.Pair, newLnCount int) { + for i, kind := range kinds { + if i < len(cp.lists) { + if !cp.lists[i].Car().IsEqual(kind) { + ln = sx.Cons(kind, sx.Cons(sx.Nil(), sx.Nil())) + newLnCount++ + cp.lists[i] = ln + cp.lists = cp.lists[:i+1] + } else { + ln = cp.lists[i] + } + } else { + ln = sx.Cons(kind, sx.Cons(sx.Nil(), sx.Nil())) + newLnCount++ + cp.lists = append(cp.lists, ln) + } + } + return ln, newLnCount +} + +func (cp *Parser) cleanupParsedNestedList(newLnCount int) (*sx.Pair, bool) { + childPos := len(cp.lists) - 1 + parentPos := childPos - 1 + for range newLnCount { + if parentPos < 0 { + return cp.lists[0], true + } + parentLn := cp.lists[parentPos] + childLn := cp.lists[childPos] + if firstParent := parentLn.Tail().Tail(); firstParent != nil { + // Add list to last item of the parent list + lastParent := firstParent.LastPair() + lastParent.Head().LastPair().AppendBang(childLn) + } else { + // Set list to first child of parent. + parentLn.LastPair().AppendBang(zsx.MakeBlock(cp.lists[childPos])) + } + childPos-- + parentPos-- + } + return nil, true +} + +// parseDefTerm parses a term of a definition list. +func (cp *Parser) parseDefTerm() (res *sx.Pair, success bool) { + inp := cp.inp + if inp.Next() != ' ' { + return nil, false + } + inp.Next() + inp.SkipSpace() + descrl := cp.descrl + if descrl == nil { + descrl = sx.Cons(zsx.SymDescription, sx.Cons(sx.Nil(), sx.Nil())) + cp.descrl = descrl + res = descrl + } + lastPair, pos := lastPairPos(descrl) + for first := true; ; first = false { + in := cp.parseInline() + if in == nil { + if pos%2 != 0 { + // lastPair is either the empty description list or the last block of definitions + return nil, false + } + // lastPair is the definition term + return res, true + } + if pos%2 != 0 { + // lastPair is either the empty description list or the last block of definitions + lastPair = lastPair.AppendBang(sx.Cons(in, nil)) + pos++ + } else if first { + // Previous term had no description + lastPair = lastPair. + AppendBang(zsx.MakeBlock()). + AppendBang(sx.Cons(in, nil)) + pos += 2 + } else { + // lastPair is the term part and we need to append the inline list just read + lastPair.Head().LastPair().AppendBang(in) + } + if isBreakSym(in.Car()) { + return res, true + } + } +} + +// parseDefDescr parses a description of a definition list. +func (cp *Parser) parseDefDescr() (res *sx.Pair, success bool) { + inp := cp.inp + if inp.Next() != ' ' { + return nil, false + } + inp.Next() + inp.SkipSpace() + descrl := cp.descrl + lastPair, lpPos := lastPairPos(descrl) + if descrl == nil || lpPos < 0 { + // No term given + return nil, false + } + + pn := cp.parseLinePara() + if pn == nil { + return nil, false + } + + newDef := zsx.MakeBlock(zsx.MakeParaList(pn)) + if lpPos%2 == 0 { + // Just a term, but no definitions + lastPair.AppendBang(zsx.MakeBlock(newDef)) + } else { + // lastPara points a the last definition + lastPair.Head().LastPair().AppendBang(newDef) + } + return nil, true +} + +func lastPairPos(p *sx.Pair) (*sx.Pair, int) { + cnt := 0 + for node := p; node != nil; { + next := node.Tail() + if next == nil { + return node, cnt + } + node = next + cnt++ + } + return nil, -1 +} + +// parseIndent parses initial spaces to continue a list. +func (cp *Parser) parseIndent() bool { + inp := cp.inp + cnt := 0 + for { + if inp.Next() != ' ' { + break + } + cnt++ + } + if cp.lists != nil { + return cp.parseIndentForList(cnt) + } + if cp.descrl != nil { + return cp.parseIndentForDescription(cnt) + } + return false +} + +func (cp *Parser) parseIndentForList(cnt int) bool { + if len(cp.lists) < cnt { + cnt = len(cp.lists) + } + cp.lists = cp.lists[:cnt] + if cnt == 0 { + return false + } + pv := cp.parseLinePara() + if pv == nil { + return false + } + ln := cp.lists[cnt-1] + lbn := ln.LastPair().Head() + lpn := lbn.LastPair().Head() + if lpn.Car().IsEqual(zsx.SymPara) { + lpn.LastPair().SetCdr(pv) + } else { + lbn.LastPair().AppendBang(zsx.MakeParaList(pv)) + } + return true +} + +func (cp *Parser) parseIndentForDescription(cnt int) bool { + descrl := cp.descrl + lastPair, pos := lastPairPos(descrl) + if cnt < 1 || pos < 2 { + return false + } + if pos%2 == 0 { + // Continuation of a definition term + for { + in := cp.parseInline() + if in == nil { + return true + } + lastPair.Head().LastPair().AppendBang(in) + if isBreakSym(in.Car()) { + return true + } + } + } + + // Continuation of a definition description. + // Either it is a continuation of a definition paragraph, or it is a new paragraph. + pn := cp.parseLinePara() + if pn == nil { + return false + } + + bn := lastPair.Head() + + // Check for new paragraph + for curr := bn.Tail(); curr != nil; { + obj := curr.Head() + if obj == nil { + break + } + next := curr.Tail() + if next == nil { + break + } + if symSeparator.IsEqual(next.Head().Car()) { + // It is a new paragraph! + obj.LastPair().AppendBang(zsx.MakeParaList(pn)) + return true + } + curr = next + } + + // Continuation of existing paragraph + para := bn.LastPair().Head().LastPair().Head() + if para.Car().IsEqual(zsx.SymPara) { + para.LastPair().SetCdr(pn) + } else { + bn.LastPair().AppendBang(zsx.MakeParaList(pn)) + } + return true +} + +// parseLinePara parses one paragraph of inline material. +func (cp *Parser) parseLinePara() *sx.Pair { + var lb sx.ListBuilder + for { + in := cp.parseInline() + if in == nil { + return lb.List() + } + lb.Add(in) + if isBreakSym(in.Car()) { + return lb.List() + } + } +} + +// parseRow parse one table row. +func (cp *Parser) parseRow() *sx.Pair { + inp := cp.inp + if inp.Peek() == '%' { + inp.SkipToEOL() + return nil + } + + var row sx.ListBuilder + for { + inp.Next() + cell := cp.parseCell() + if cell != nil { + row.Add(cell) + } + switch inp.Ch { + case '\n', '\r': + inp.EatEOL() + fallthrough + case input.EOS: + // add to table + if cp.lastRow == nil { + if row.IsEmpty() { + return nil + } + cp.lastRow = sx.Cons(row.List(), nil) + return cp.lastRow.Cons(nil).Cons(zsx.SymTable) + } + cp.lastRow = cp.lastRow.AppendBang(row.List()) + return nil + } + // inp.Ch must be '|' + } +} + +// parseCell parses one single cell of a table row. +func (cp *Parser) parseCell() *sx.Pair { + inp := cp.inp + var cell sx.ListBuilder + for { + if input.IsEOLEOS(inp.Ch) { + if cell.IsEmpty() { + return nil + } + return zsx.MakeCell(nil, cell.List()) + } + if inp.Ch == '|' { + return zsx.MakeCell(nil, cell.List()) + } + + in := cp.parseInline() + cell.Add(in) + } +} + +// parseTransclusion parses '{' '{' '{' ZID '}' '}' '}' +func (cp *Parser) parseTransclusion() (*sx.Pair, bool) { + inp := cp.inp + if countDelim(inp, '{') != 3 { + return nil, false + } + posA, posE := inp.Pos, 0 + +loop: + + for { + switch inp.Ch { + case input.EOS: + return nil, false + case '\n', '\r', ' ', '\t': + if !cp.isSpaceReference(inp.Src[posA:]) { + return nil, false + } + case '\\': + switch inp.Next() { + case input.EOS, '\n', '\r': + return nil, false + } + case '}': + posE = inp.Pos + if posA >= posE { + return nil, false + } + if inp.Next() != '}' { + continue + } + if inp.Next() != '}' { + continue + } + break loop + } + inp.Next() + } + inp.Next() // consume last '}' + attrs := parseBlockAttributes(inp) + inp.SkipToEOL() + refText := string(inp.Src[posA:posE]) + ref := cp.scanReference(refText) + return zsx.MakeTransclusion(attrs, ref, sx.Nil()), true +} ADDED sz/zmk/inline.go Index: sz/zmk/inline.go ================================================================== --- /dev/null +++ sz/zmk/inline.go @@ -0,0 +1,471 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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 zmk + +import ( + "fmt" + "slices" + "strings" + + "t73f.de/r/sx" + "t73f.de/r/zsx" + "t73f.de/r/zsx/input" +) + +func (cp *Parser) parseInline() *sx.Pair { + inp := cp.inp + pos := inp.Pos + if cp.nestingLevel <= maxNestingLevel { + cp.nestingLevel++ + defer func() { cp.nestingLevel-- }() + + var in *sx.Pair + success := false + switch inp.Ch { + case input.EOS: + return nil + case '\n', '\r': + return parseSoftBreak(inp) + case '[': + switch inp.Next() { + case '[': + in, success = cp.parseLink('[', ']') + case '@': + in, success = cp.parseCite() + case '^': + in, success = cp.parseEndnote() + case '!': + in, success = cp.parseMark() + } + case '{': + if inp.Next() == '{' { + in, success = cp.parseEmbed('{', '}') + } + case '%': + in, success = parseComment(inp) + case '_', '*', '>', '~', '^', ',', '"', '#', ':': + in, success = cp.parseFormat() + case '\'', '`', '=', runeModGrave: + in, success = parseLiteral(inp) + case '$': + in, success = parseLiteralMath(inp) + case '\\': + return parseBackslash(inp) + case '-': + in, success = parseNdash(inp) + case '&': + in, success = parseEntity(inp) + } + if success { + return in + } + } + inp.SetPos(pos) + return parseText(inp) +} + +func parseText(inp *input.Input) *sx.Pair { return zsx.MakeText(parseString(inp)) } + +func parseString(inp *input.Input) string { + pos := inp.Pos + if inp.Ch == '\\' { + inp.Next() + return parseBackslashRest(inp) + } + for { + switch inp.Next() { + // The following case must contain all runes that occur in parseInline! + // Plus the closing brackets ] and } and ) and the middle | + case input.EOS, '\n', '\r', '[', ']', '{', '}', '(', ')', '|', '%', '_', '*', '>', '~', '^', ',', '"', '#', ':', '\'', '`', runeModGrave, '$', '=', '\\', '-', '&': + return string(inp.Src[pos:inp.Pos]) + } + } +} + +func parseBackslash(inp *input.Input) *sx.Pair { + switch inp.Next() { + case '\n', '\r': + inp.EatEOL() + return zsx.MakeHard() + default: + return zsx.MakeText(parseBackslashRest(inp)) + } +} + +func parseBackslashRest(inp *input.Input) string { + if input.IsEOLEOS(inp.Ch) { + return "\\" + } + if inp.Ch == ' ' { + inp.Next() + return "\u00a0" + } + pos := inp.Pos + inp.Next() + return string(inp.Src[pos:inp.Pos]) +} + +func parseSoftBreak(inp *input.Input) *sx.Pair { + inp.EatEOL() + return zsx.MakeSoft() +} + +func (cp *Parser) parseLink(openCh, closeCh rune) (*sx.Pair, bool) { + if refString, text, ok := cp.parseReference(openCh, closeCh); ok { + attrs := parseInlineAttributes(cp.inp) + if len(refString) > 0 { + ref := cp.scanReference(refString) + return zsx.MakeLink(attrs, ref, text), true + } + } + return nil, false +} +func (cp *Parser) parseEmbed(openCh, closeCh rune) (*sx.Pair, bool) { + if refString, text, ok := cp.parseReference(openCh, closeCh); ok { + attrs := parseInlineAttributes(cp.inp) + if len(refString) > 0 { + return zsx.MakeEmbed(attrs, cp.scanReference(refString), "", text), true + } + } + return nil, false +} + +func (cp *Parser) parseReference(openCh, closeCh rune) (string, *sx.Pair, bool) { + inp := cp.inp + inp.Next() + inp.SkipSpace() + if inp.Ch == openCh { + // Additional opening chars result in a fail + return "", nil, false + } + var lb sx.ListBuilder + pos := inp.Pos + if !cp.isSpaceReference(inp.Src[pos:]) { + hasSpace, ok := readReferenceToSep(inp, closeCh) + if !ok { + return "", nil, false + } + if inp.Ch == '|' { // First part must be inline text + if pos == inp.Pos { // [[| or {{| + return "", nil, false + } + cp.inp = input.NewInput(inp.Src[pos:inp.Pos]) + for { + in := cp.parseInline() + if in == nil { + break + } + lb.Add(in) + } + cp.inp = inp + inp.Next() + } else { + if hasSpace { + return "", nil, false + } + inp.SetPos(pos) + } + } + + inp.SkipSpace() + pos = inp.Pos + if !cp.readReferenceToClose(closeCh) { + return "", nil, false + } + ref := strings.TrimSpace(string(inp.Src[pos:inp.Pos])) + if inp.Next() != closeCh { + return "", nil, false + } + inp.Next() + return ref, lb.List(), true +} + +func readReferenceToSep(inp *input.Input, closeCh rune) (bool, bool) { + hasSpace := false + for { + switch inp.Ch { + case input.EOS: + return false, false + case '\n', '\r', ' ': + hasSpace = true + case '|': + return hasSpace, true + case '\\': + switch inp.Next() { + case input.EOS: + return false, false + case '\n', '\r': + hasSpace = true + } + case '%': + if inp.Next() == '%' { + inp.SkipToEOL() + } + continue + case closeCh: + if inp.Next() == closeCh { + return hasSpace, true + } + continue + } + inp.Next() + } +} + +func (cp *Parser) readReferenceToClose(closeCh rune) bool { + inp := cp.inp + pos := inp.Pos + for { + switch inp.Ch { + case input.EOS: + return false + case '\t', '\r', '\n', ' ': + if !cp.isSpaceReference(inp.Src[pos:]) { + return false + } + case '\\': + switch inp.Next() { + case input.EOS, '\n', '\r': + return false + } + case closeCh: + return true + } + inp.Next() + } +} + +func (cp *Parser) parseCite() (*sx.Pair, bool) { + inp := cp.inp + switch inp.Next() { + case ' ', ',', '|', ']', '\n', '\r': + return nil, false + } + pos := inp.Pos +loop: + for { + switch inp.Ch { + case input.EOS: + return nil, false + case ' ', ',', '|', ']', '\n', '\r': + break loop + } + inp.Next() + } + posL := inp.Pos + switch inp.Ch { + case ' ', ',', '|': + inp.Next() + } + ins, ok := cp.parseLinkLikeRest() + if !ok { + return nil, false + } + attrs := parseInlineAttributes(inp) + return zsx.MakeCite(attrs, string(inp.Src[pos:posL]), ins), true +} + +func (cp *Parser) parseEndnote() (*sx.Pair, bool) { + cp.inp.Next() + ins, ok := cp.parseLinkLikeRest() + if !ok { + return nil, false + } + attrs := parseInlineAttributes(cp.inp) + return zsx.MakeEndnote(attrs, ins), true +} + +func (cp *Parser) parseMark() (*sx.Pair, bool) { + inp := cp.inp + inp.Next() + pos := inp.Pos + for inp.Ch != '|' && inp.Ch != ']' { + if !isNameRune(inp.Ch) { + return nil, false + } + inp.Next() + } + mark := string(inp.Src[pos:inp.Pos]) + var ins *sx.Pair + if inp.Ch == '|' { + inp.Next() + var ok bool + ins, ok = cp.parseLinkLikeRest() + if !ok { + return nil, false + } + } else { + inp.Next() + } + return zsx.MakeMark(mark, "", "", ins), true + // Problematisch ist, dass hier noch nicht mn.Fragment und mn.Slug gesetzt werden. + // Evtl. muss es ein PreMark-Symbol geben +} + +func (cp *Parser) parseLinkLikeRest() (*sx.Pair, bool) { + var ins sx.ListBuilder + inp := cp.inp + inp.SkipSpace() + for inp.Ch != ']' { + in := cp.parseInline() + if in == nil { + return nil, false + } + ins.Add(in) + if input.IsEOLEOS(inp.Ch) && isBreakSym(in.Car()) { + return nil, false + } + } + inp.Next() + return ins.List(), true +} + +func parseComment(inp *input.Input) (*sx.Pair, bool) { + if inp.Next() != '%' { + return nil, false + } + for inp.Ch == '%' { + inp.Next() + } + attrs := parseInlineAttributes(inp) + inp.SkipSpace() + pos := inp.Pos + for { + if input.IsEOLEOS(inp.Ch) { + return zsx.MakeLiteral(zsx.SymLiteralComment, attrs, string(inp.Src[pos:inp.Pos])), true + } + inp.Next() + } +} + +var mapRuneFormat = map[rune]*sx.Symbol{ + '_': zsx.SymFormatEmph, + '*': zsx.SymFormatStrong, + '>': zsx.SymFormatInsert, + '~': zsx.SymFormatDelete, + '^': zsx.SymFormatSuper, + ',': zsx.SymFormatSub, + '"': zsx.SymFormatQuote, + '#': zsx.SymFormatMark, + ':': zsx.SymFormatSpan, +} + +func (cp *Parser) parseFormat() (*sx.Pair, bool) { + inp := cp.inp + fch := inp.Ch + symFormat, ok := mapRuneFormat[fch] + if !ok { + panic(fmt.Sprintf("%q is not a formatting char", fch)) + } + // read 2nd formatting character + if inp.Next() != fch { + return nil, false + } + inp.Next() + var inlines sx.ListBuilder + for { + if inp.Ch == input.EOS { + return nil, false + } + if inp.Ch == fch { + if inp.Next() == fch { + inp.Next() + attrs := parseInlineAttributes(inp) + return zsx.MakeFormat(symFormat, attrs, inlines.List()), true + } + inlines.Add(zsx.MakeText(string(fch))) + } else if in := cp.parseInline(); in != nil { + if input.IsEOLEOS(inp.Ch) && isBreakSym(in.Car()) { + return nil, false + } + inlines.Add(in) + } + } +} + +var mapRuneLiteral = map[rune]*sx.Symbol{ + '`': zsx.SymLiteralCode, + runeModGrave: zsx.SymLiteralCode, + '\'': zsx.SymLiteralInput, + '=': zsx.SymLiteralOutput, + // No '$': sz.SymLiteralMath, because pairing literal math is a little different +} + +func parseLiteral(inp *input.Input) (*sx.Pair, bool) { + fch := inp.Ch + symLiteral, ok := mapRuneLiteral[fch] + if !ok { + panic(fmt.Sprintf("%q is not a formatting char", fch)) + } + // read 2nd formatting character + if inp.Next() != fch { + return nil, false + } + inp.Next() + var sb strings.Builder + for { + if inp.Ch == input.EOS { + return nil, false + } + if inp.Ch == fch { + if inp.Peek() == fch { + inp.Next() + inp.Next() + return zsx.MakeLiteral(symLiteral, parseInlineAttributes(inp), sb.String()), true + } + sb.WriteRune(fch) + inp.Next() + } else { + s := parseString(inp) + sb.WriteString(s) + } + } +} + +func parseLiteralMath(inp *input.Input) (res *sx.Pair, success bool) { + // read 2nd formatting character + if inp.Next() != '$' { + return nil, false + } + inp.Next() + pos := inp.Pos + for { + if inp.Ch == input.EOS { + return nil, false + } + if inp.Ch == '$' && inp.Peek() == '$' { + content := slices.Clone(inp.Src[pos:inp.Pos]) + inp.Next() + inp.Next() + return zsx.MakeLiteral(zsx.SymLiteralMath, parseInlineAttributes(inp), string(content)), true + } + inp.Next() + } +} + +func parseNdash(inp *input.Input) (*sx.Pair, bool) { + if inp.Peek() != inp.Ch { + return nil, false + } + inp.Next() + inp.Next() + return zsx.MakeText("\u2013"), true +} + +func parseEntity(inp *input.Input) (*sx.Pair, bool) { + if text, ok := zsx.ScanEntity(inp); ok { + return zsx.MakeText(text), true + } + return nil, false +} ADDED sz/zmk/post-processor.go Index: sz/zmk/post-processor.go ================================================================== --- /dev/null +++ sz/zmk/post-processor.go @@ -0,0 +1,588 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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 zmk + +import ( + "strings" + + "t73f.de/r/sx" + "t73f.de/r/zsx" +) + +var symInVerse = sx.MakeSymbol("in-verse") +var symNoBlock = sx.MakeSymbol("no-block") + +type postProcessor struct{} + +func (pp *postProcessor) VisitBefore(lst *sx.Pair, env *sx.Pair) (sx.Object, bool) { + if lst == nil { + return nil, true + } + sym, isSym := sx.GetSymbol(lst.Car()) + if !isSym { + panic(lst) + } + if fn, found := symMap[sym]; found { + return fn(pp, lst, env), true + } + return nil, false +} + +func (pp *postProcessor) VisitAfter(lst *sx.Pair, _ *sx.Pair) sx.Object { return lst } + +func (pp *postProcessor) visitPairList(lst *sx.Pair, env *sx.Pair) *sx.Pair { + var pList sx.ListBuilder + for node := range lst.Pairs() { + if elem, isPair := sx.GetPair(zsx.Walk(pp, node.Head(), env)); isPair && elem != nil { + pList.Add(elem) + } + } + return pList.List() +} + +var symMap map[*sx.Symbol]func(*postProcessor, *sx.Pair, *sx.Pair) *sx.Pair + +func init() { + symMap = map[*sx.Symbol]func(*postProcessor, *sx.Pair, *sx.Pair) *sx.Pair{ + zsx.SymBlock: postProcessBlockList, + zsx.SymPara: postProcessInlineList, + zsx.SymRegionBlock: postProcessRegion, + zsx.SymRegionQuote: postProcessRegion, + zsx.SymRegionVerse: postProcessRegionVerse, + zsx.SymVerbatimComment: postProcessVerbatim, + zsx.SymVerbatimEval: postProcessVerbatim, + zsx.SymVerbatimMath: postProcessVerbatim, + zsx.SymVerbatimCode: postProcessVerbatim, + zsx.SymVerbatimZettel: postProcessVerbatim, + zsx.SymHeading: postProcessHeading, + zsx.SymListOrdered: postProcessItemList, + zsx.SymListUnordered: postProcessItemList, + zsx.SymListQuote: postProcessQuoteList, + zsx.SymDescription: postProcessDescription, + zsx.SymTable: postProcessTable, + + zsx.SymInline: postProcessInlineList, + zsx.SymText: postProcessText, + zsx.SymSoft: postProcessSoft, + zsx.SymEndnote: postProcessEndnote, + zsx.SymMark: postProcessMark, + zsx.SymLink: postProcessInlines4, + zsx.SymEmbed: postProcessEmbed, + zsx.SymCite: postProcessInlines4, + zsx.SymFormatDelete: postProcessFormat, + zsx.SymFormatEmph: postProcessFormat, + zsx.SymFormatInsert: postProcessFormat, + zsx.SymFormatMark: postProcessFormat, + zsx.SymFormatQuote: postProcessFormat, + zsx.SymFormatStrong: postProcessFormat, + zsx.SymFormatSpan: postProcessFormat, + zsx.SymFormatSub: postProcessFormat, + zsx.SymFormatSuper: postProcessFormat, + + symSeparator: ignoreProcess, + } +} + +func ignoreProcess(*postProcessor, *sx.Pair, *sx.Pair) *sx.Pair { return nil } + +func postProcessBlockList(pp *postProcessor, lst *sx.Pair, env *sx.Pair) *sx.Pair { + result := pp.visitPairList(lst.Tail(), env) + if result == nil { + if noBlockPair := env.Assoc(symNoBlock); noBlockPair == nil || sx.IsTrue(noBlockPair.Cdr()) { + return nil + } + } + return result.Cons(lst.Car()) +} + +func postProcessInlineList(pp *postProcessor, lst *sx.Pair, env *sx.Pair) *sx.Pair { + sym := lst.Car() + if rest := pp.visitInlines(lst.Tail(), env); rest != nil { + return rest.Cons(sym) + } + return nil +} + +func postProcessRegion(pp *postProcessor, rn *sx.Pair, env *sx.Pair) *sx.Pair { + return doPostProcessRegion(pp, rn, env, env) +} + +func postProcessRegionVerse(pp *postProcessor, rn *sx.Pair, env *sx.Pair) *sx.Pair { + return doPostProcessRegion(pp, rn, env.Cons(sx.Cons(symInVerse, nil)), env) +} + +func doPostProcessRegion(pp *postProcessor, rn *sx.Pair, envBlock, envInline *sx.Pair) *sx.Pair { + sym := rn.Car().(*sx.Symbol) + next := rn.Tail() + attrs := next.Car().(*sx.Pair) + next = next.Tail() + blocks := pp.visitPairList(next.Head(), envBlock) + text := pp.visitInlines(next.Tail(), envInline) + if blocks == nil && text == nil { + return nil + } + return zsx.MakeRegion(sym, attrs, blocks, text) +} + +func postProcessVerbatim(_ *postProcessor, verb *sx.Pair, _ *sx.Pair) *sx.Pair { + if content, isString := sx.GetString(verb.Tail().Tail().Car()); isString && content.GetValue() != "" { + return verb + } + return nil +} + +func postProcessHeading(pp *postProcessor, hn *sx.Pair, env *sx.Pair) *sx.Pair { + next := hn.Tail() + level := next.Car().(sx.Int64) + next = next.Tail() + attrs := next.Car().(*sx.Pair) + next = next.Tail() + slug := next.Car().(sx.String) + next = next.Tail() + fragment := next.Car().(sx.String) + if text := pp.visitInlines(next.Tail(), env); text != nil { + return zsx.MakeHeading(int(level), attrs, text, slug.GetValue(), fragment.GetValue()) + } + return nil +} + +func postProcessItemList(pp *postProcessor, ln *sx.Pair, env *sx.Pair) *sx.Pair { + attrs := ln.Tail().Head() + elems := pp.visitListElems(ln.Tail(), env) + if elems == nil { + return nil + } + return zsx.MakeList(ln.Car().(*sx.Symbol), attrs, elems) +} + +func postProcessQuoteList(pp *postProcessor, ln *sx.Pair, env *sx.Pair) *sx.Pair { + attrs := ln.Tail().Head() + elems := pp.visitListElems(ln.Tail(), env.Cons(sx.Cons(symNoBlock, nil))) + + // Collect multiple paragraph items into one item. + + var newElems sx.ListBuilder + var newPara sx.ListBuilder + + addtoParagraph := func() { + if !newPara.IsEmpty() { + newElems.Add(sx.MakeList(zsx.SymBlock, newPara.List().Cons(zsx.SymPara))) + newPara.Reset() + } + } + for node := range elems.Pairs() { + item := node.Head() + if !item.Car().IsEqual(zsx.SymBlock) { + continue + } + itemTail := item.Tail() + if itemTail == nil || itemTail.Tail() != nil { + addtoParagraph() + newElems.Add(item) + continue + } + if pn := itemTail.Head(); pn.Car().IsEqual(zsx.SymPara) { + if !newPara.IsEmpty() { + newPara.Add(sx.Cons(zsx.SymSoft, nil)) + } + newPara.ExtendBang(pn.Tail()) + continue + } + addtoParagraph() + newElems.Add(item) + } + addtoParagraph() + return zsx.MakeList(ln.Car().(*sx.Symbol), attrs, newElems.List()) +} + +func (pp *postProcessor) visitListElems(ln *sx.Pair, env *sx.Pair) *sx.Pair { + var pList sx.ListBuilder + for node := range ln.Tail().Pairs() { + if elem := zsx.Walk(pp, node.Head(), env); elem != nil { + pList.Add(elem) + } + } + return pList.List() +} + +func postProcessDescription(pp *postProcessor, dl *sx.Pair, env *sx.Pair) *sx.Pair { + attrs := dl.Tail().Head() + var dList sx.ListBuilder + isTerm := false + for node := range dl.Tail().Tail().Pairs() { + isTerm = !isTerm + if isTerm { + dList.Add(pp.visitInlines(node.Head(), env)) + } else { + dList.Add(zsx.Walk(pp, node.Head(), env)) + } + } + return dList.List().Cons(attrs).Cons(dl.Car()) +} + +func postProcessTable(pp *postProcessor, tbl *sx.Pair, env *sx.Pair) *sx.Pair { + sym := tbl.Car() + next := tbl.Tail() + header := next.Head() + if header != nil { + // Already post-processed + return tbl + } + rows, width := pp.visitRows(next.Tail(), env) + if rows == nil { + // Header and row are nil -> no table + return nil + } + header, rows, align := splitTableHeader(rows, width) + alignRow(header, align) + for node := range rows.Pairs() { + alignRow(node.Head(), align) + } + return rows.Cons(header).Cons(sym) +} + +func (pp *postProcessor) visitRows(rows *sx.Pair, env *sx.Pair) (*sx.Pair, int) { + maxWidth := 0 + var pRows sx.ListBuilder + for node := range rows.Pairs() { + row := node.Head() + row, width := pp.visitCells(row, env) + if maxWidth < width { + maxWidth = width + } + pRows.Add(row) + } + return pRows.List(), maxWidth +} + +func (pp *postProcessor) visitCells(cells *sx.Pair, env *sx.Pair) (*sx.Pair, int) { + width := 0 + var pCells sx.ListBuilder + for node := range cells.Pairs() { + cell := node.Head() + rest := cell.Tail() + attrs := rest.Head() + ins := pp.visitInlines(rest.Tail(), env) + pCells.Add(zsx.MakeCell(attrs, ins)) + width++ + } + return pCells.List(), width +} + +func splitTableHeader(rows *sx.Pair, width int) (header, realRows *sx.Pair, align []byte) { + align = make([]byte, width) + + foundHeader := false + cellCount := 0 + + // assert: rows != nil (checked in postProcessTable) + for node := range rows.Head().Pairs() { + cell := node.Head() + cellCount++ + rest := cell.Tail() // attrs := rest.Head() + cellInlines := rest.Tail() + if cellInlines == nil { + continue + } + + // elem is first cell inline element + elem := cellInlines.Head() + if elem.Car().IsEqual(zsx.SymText) { + if s, isString := sx.GetString(elem.Tail().Car()); isString && s.GetValue() != "" { + str := s.GetValue() + if str[0] == '=' { + foundHeader = true + elem.SetCdr(sx.Cons(sx.MakeString(str[1:]), nil)) + } + } + } + + // move to the last cell inline element + for { + next := cellInlines.Tail() + if next == nil { + break + } + cellInlines = next + } + + elem = cellInlines.Head() + if elem.Car().IsEqual(zsx.SymText) { + if s, isString := sx.GetString(elem.Tail().Car()); isString && s.GetValue() != "" { + str := s.GetValue() + lastByte := str[len(str)-1] + if cellAlign, isValid := getCellAlignment(lastByte); isValid { + elem.SetCdr(sx.Cons(sx.MakeString(str[0:len(str)-1]), nil)) + rest.SetCar(makeCellAttrs(cellAlign)) + } + align[cellCount-1] = lastByte + } + } + } + + if !foundHeader { + return nil, rows, align + } + + return rows.Head(), rows.Tail(), align +} + +func alignRow(row *sx.Pair, defaultAlign []byte) { + if row == nil { + return + } + var lastCellNode *sx.Pair + cellColumnNo := 0 + for node := range row.Pairs() { + lastCellNode = node + cell := node.Head() + cellColumnNo++ + rest := cell.Tail() // attrs := rest.Head() + if cellAlign, isValid := getCellAlignment(defaultAlign[cellColumnNo-1]); isValid { + rest.SetCar(makeCellAttrs(cellAlign)) + } + cellInlines := rest.Tail() + if cellInlines == nil { + continue + } + + // elem is first cell inline element + elem := cellInlines.Head() + if elem.Car().IsEqual(zsx.SymText) { + if s, isString := sx.GetString(elem.Tail().Car()); isString && s.GetValue() != "" { + str := s.GetValue() + cellAlign, isValid := getCellAlignment(str[0]) + if isValid { + elem.SetCdr(sx.Cons(sx.MakeString(str[1:]), nil)) + rest.SetCar(makeCellAttrs(cellAlign)) + } + } + } + } + + for cellColumnNo < len(defaultAlign) { + var attrs *sx.Pair + if cellAlign, isValid := getCellAlignment(defaultAlign[cellColumnNo]); isValid { + attrs = makeCellAttrs(cellAlign) + } + lastCellNode = lastCellNode.AppendBang(zsx.MakeCell(attrs, nil)) + cellColumnNo++ + } +} + +func makeCellAttrs(align sx.String) *sx.Pair { + return sx.Cons(sx.Cons(zsx.SymAttrAlign, align), sx.Nil()) +} + +func getCellAlignment(ch byte) (sx.String, bool) { + switch ch { + case ':': + return zsx.AttrAlignCenter, true + case '<': + return zsx.AttrAlignLeft, true + case '>': + return zsx.AttrAlignRight, true + default: + return sx.MakeString(""), false + } +} + +func (pp *postProcessor) visitInlines(lst *sx.Pair, env *sx.Pair) *sx.Pair { + length := lst.Length() + if length <= 0 { + return nil + } + inVerse := env.Assoc(symInVerse) != nil + vector := make([]*sx.Pair, 0, length) + // 1st phase: process all childs, ignore ' ' / '\t' at start, and merge some elements + for node := range lst.Pairs() { + elem, isPair := sx.GetPair(zsx.Walk(pp, node.Head(), env)) + if !isPair || elem == nil { + continue + } + elemSym := elem.Car() + elemTail := elem.Tail() + + if inVerse && elemSym.IsEqual(zsx.SymText) { + if s, isString := sx.GetString(elemTail.Car()); isString { + verseText := s.GetValue() + verseText = strings.ReplaceAll(verseText, " ", "\u00a0") + elemTail.SetCar(sx.MakeString(verseText)) + } + } + + if len(vector) == 0 { + // If the 1st element is a TEXT, remove all ' ', '\t' at the beginning, if outside a verse block. + if !elemSym.IsEqual(zsx.SymText) { + vector = append(vector, elem) + continue + } + + elemText := elemTail.Car().(sx.String).GetValue() + if elemText != "" && (elemText[0] == ' ' || elemText[0] == '\t') { + for elemText != "" { + if ch := elemText[0]; ch != ' ' && ch != '\t' { + break + } + elemText = elemText[1:] + } + elemTail.SetCar(sx.MakeString(elemText)) + } + if elemText != "" { + vector = append(vector, elem) + } + continue + } + last := vector[len(vector)-1] + lastSym := last.Car() + + if lastSym.IsEqual(zsx.SymText) && elemSym.IsEqual(zsx.SymText) { + // Merge two TEXT elements into one + lastText := last.Tail().Car().(sx.String).GetValue() + elemText := elem.Tail().Car().(sx.String).GetValue() + last.SetCdr(sx.Cons(sx.MakeString(lastText+elemText), sx.Nil())) + continue + } + + if lastSym.IsEqual(zsx.SymText) && elemSym.IsEqual(zsx.SymSoft) { + // Merge (TEXT "... ") (SOFT) to (TEXT "...") (HARD) + lastTail := last.Tail() + if lastText := lastTail.Car().(sx.String).GetValue(); strings.HasSuffix(lastText, " ") { + newText := removeTrailingSpaces(lastText) + if newText == "" { + vector[len(vector)-1] = sx.Cons(zsx.SymHard, sx.Nil()) + continue + } + lastTail.SetCar(sx.MakeString(newText)) + elemSym = zsx.SymHard + elem.SetCar(elemSym) + } + } + + vector = append(vector, elem) + } + if len(vector) == 0 { + return nil + } + + // 2nd phase: remove (SOFT), (HARD) at the end, remove trailing spaces in (TEXT "...") + lastPos := len(vector) - 1 + for lastPos >= 0 { + elem := vector[lastPos] + elemSym := elem.Car() + if elemSym.IsEqual(zsx.SymText) { + elemTail := elem.Tail() + elemText := elemTail.Car().(sx.String).GetValue() + newText := removeTrailingSpaces(elemText) + if newText != "" { + elemTail.SetCar(sx.MakeString(newText)) + break + } + lastPos-- + } else if isBreakSym(elemSym) { + lastPos-- + } else { + break + } + } + if lastPos < 0 { + return nil + } + + result := sx.Cons(vector[0], nil) + curr := result + for i := 1; i <= lastPos; i++ { + curr = curr.AppendBang(vector[i]) + } + return result +} + +func removeTrailingSpaces(s string) string { + for len(s) > 0 { + if ch := s[len(s)-1]; ch != ' ' && ch != '\t' { + return s + } + s = s[0 : len(s)-1] + } + return "" +} + +func postProcessText(_ *postProcessor, txt *sx.Pair, _ *sx.Pair) *sx.Pair { + if tail := txt.Tail(); tail != nil { + if content, isString := sx.GetString(tail.Car()); isString && content.GetValue() != "" { + return txt + } + } + return nil +} + +func postProcessSoft(_ *postProcessor, sn *sx.Pair, env *sx.Pair) *sx.Pair { + if env.Assoc(symInVerse) == nil { + return sn + } + return sx.Cons(zsx.SymHard, nil) +} + +func postProcessEndnote(pp *postProcessor, en *sx.Pair, env *sx.Pair) *sx.Pair { + next := en.Tail() + attrs := next.Car().(*sx.Pair) + if text := pp.visitInlines(next.Tail(), env); text != nil { + return zsx.MakeEndnote(attrs, text) + } + return zsx.MakeEndnote(attrs, sx.Nil()) +} + +func postProcessMark(pp *postProcessor, en *sx.Pair, env *sx.Pair) *sx.Pair { + next := en.Tail() + mark := next.Car().(sx.String) + next = next.Tail() + slug := next.Car().(sx.String) + next = next.Tail() + fragment := next.Car().(sx.String) + text := pp.visitInlines(next.Tail(), env) + return zsx.MakeMark(mark.GetValue(), slug.GetValue(), fragment.GetValue(), text) +} + +func postProcessInlines4(pp *postProcessor, ln *sx.Pair, env *sx.Pair) *sx.Pair { + sym := ln.Car() + next := ln.Tail() + attrs := next.Car() + next = next.Tail() + val3 := next.Car() + text := pp.visitInlines(next.Tail(), env) + return text.Cons(val3).Cons(attrs).Cons(sym) +} + +func postProcessEmbed(pp *postProcessor, ln *sx.Pair, env *sx.Pair) *sx.Pair { + next := ln.Tail() + attrs := next.Car().(*sx.Pair) + next = next.Tail() + ref := next.Car() + next = next.Tail() + syntax := next.Car().(sx.String) + text := pp.visitInlines(next.Tail(), env) + return zsx.MakeEmbed(attrs, ref, syntax.GetValue(), text) +} + +func postProcessFormat(pp *postProcessor, fn *sx.Pair, env *sx.Pair) *sx.Pair { + symFormat := fn.Car().(*sx.Symbol) + next := fn.Tail() // Attrs + attrs := next.Car().(*sx.Pair) + next = next.Tail() // Possible inlines + if next == nil { + return fn + } + inlines := pp.visitInlines(next, env) + return zsx.MakeFormat(symFormat, attrs, inlines) +} ADDED sz/zmk/zmk.go Index: sz/zmk/zmk.go ================================================================== --- /dev/null +++ sz/zmk/zmk.go @@ -0,0 +1,263 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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 zmk provides a parser for zettelmarkup. +package zmk + +import ( + "maps" + "slices" + "strings" + "unicode" + + "t73f.de/r/sx" + "t73f.de/r/zsc/api" + "t73f.de/r/zsc/sz" + "t73f.de/r/zsx" + "t73f.de/r/zsx/input" +) + +// Parser allows to parse its plain text input into Zettelmarkup. +type Parser struct { + inp *input.Input // Input stream + lists []*sx.Pair // Stack of lists + lastRow *sx.Pair // Last row of table, or nil if not in table. + descrl *sx.Pair // Current description list + nestingLevel int // Count nesting of block and inline elements + + scanReference func(string) *sx.Pair // Builds a reference node from a given string reference + isSpaceReference func([]byte) bool // Returns true, if src starts with a reference that allows white space +} + +// Initialize the parser with the input stream and a reference scanner. +func (cp *Parser) Initialize(inp *input.Input) { + var zeroParser Parser + *cp = zeroParser + cp.inp = inp + cp.scanReference = sz.ScanReference + cp.isSpaceReference = withQueryPrefix +} + +// Parse tries to parse the input as a block element. +func (cp *Parser) Parse() *sx.Pair { + + var lastPara *sx.Pair + var blkBuild sx.ListBuilder + for cp.inp.Ch != input.EOS { + lastPara = cp.parseBlock(&blkBuild, lastPara) + } + if cp.nestingLevel != 0 { + panic("Nesting level was not decremented") + } + + var pp postProcessor + if bs := pp.visitPairList(blkBuild.List(), nil); bs != nil { + return bs.Cons(zsx.SymBlock) + } + return nil +} + +func withQueryPrefix(src []byte) bool { + return len(src) > len(api.QueryPrefix) && string(src[:len(api.QueryPrefix)]) == api.QueryPrefix +} + +// runeModGrave is Unicode code point U+02CB (715) called "MODIFIER LETTER +// GRAVE ACCENT". On the iPad it is much more easier to type in this code point +// than U+0060 (96) "Grave accent" (aka backtick). Therefore, U+02CB will be +// considered equivalent to U+0060. +const runeModGrave = 'ˋ' // This is NOT '`'! + +const maxNestingLevel = 50 + +// clearStacked removes all multi-line nodes from parser. +func (cp *Parser) clearStacked() { + cp.lists = nil + cp.lastRow = nil + cp.descrl = nil +} + +type attrMap map[string]string + +func (attrs attrMap) updateAttrs(key, val string) { + if prevVal := attrs[key]; len(prevVal) > 0 { + attrs[key] = prevVal + " " + val + } else { + attrs[key] = val + } +} + +func (attrs attrMap) asPairAssoc() *sx.Pair { + var lb sx.ListBuilder + for _, key := range slices.Sorted(maps.Keys(attrs)) { + lb.Add(sx.Cons(sx.MakeString(key), sx.MakeString(attrs[key]))) + } + return lb.List() +} + +func parseNormalAttribute(inp *input.Input, attrs attrMap) bool { + posK := inp.Pos + for isNameRune(inp.Ch) { + inp.Next() + } + if posK == inp.Pos { + return false + } + key := string(inp.Src[posK:inp.Pos]) + if inp.Ch != '=' { + attrs[key] = "" + return true + } + return parseAttributeValue(inp, key, attrs) +} + +func parseAttributeValue(inp *input.Input, key string, attrs attrMap) bool { + if inp.Next() == '"' { + return parseQuotedAttributeValue(inp, key, attrs) + } + posV := inp.Pos + for { + switch inp.Ch { + case input.EOS: + return false + case '\n', '\r', ' ', ',', '}': + attrs.updateAttrs(key, string(inp.Src[posV:inp.Pos])) + return true + } + inp.Next() + } +} + +func parseQuotedAttributeValue(inp *input.Input, key string, attrs attrMap) bool { + inp.Next() + var sb strings.Builder + for { + switch inp.Ch { + case input.EOS: + return false + case '"': + attrs.updateAttrs(key, sb.String()) + inp.Next() + return true + case '\\': + switch inp.Next() { + case input.EOS, '\n', '\r': + return false + } + fallthrough + default: + sb.WriteRune(inp.Ch) + inp.Next() + } + } + +} + +func parseBlockAttributes(inp *input.Input) *sx.Pair { + pos := inp.Pos + for isNameRune(inp.Ch) { + inp.Next() + } + if pos < inp.Pos { + return attrMap{"": string(inp.Src[pos:inp.Pos])}.asPairAssoc() + } + + // No immediate name: skip spaces + inp.SkipSpace() + return parseInlineAttributes(inp) +} + +func parseInlineAttributes(inp *input.Input) *sx.Pair { + pos := inp.Pos + if attrs, success := doParseAttributes(inp); success { + return attrs + } + inp.SetPos(pos) + return nil +} + +// doParseAttributes reads attributes. +func doParseAttributes(inp *input.Input) (*sx.Pair, bool) { + if inp.Ch != '{' { + return nil, false + } + inp.Next() + a := attrMap{} + if !parseAttributeValues(inp, a) { + return nil, false + } + inp.Next() + return a.asPairAssoc(), true +} + +func parseAttributeValues(inp *input.Input, a attrMap) bool { + for { + skipSpaceLine(inp) + switch inp.Ch { + case input.EOS: + return false + case '}': + return true + case '.': + inp.Next() + posC := inp.Pos + for isNameRune(inp.Ch) { + inp.Next() + } + if posC == inp.Pos { + return false + } + a.updateAttrs("class", string(inp.Src[posC:inp.Pos])) + case '=': + delete(a, "") + if !parseAttributeValue(inp, "", a) { + return false + } + default: + if !parseNormalAttribute(inp, a) { + return false + } + } + + switch inp.Ch { + case '}': + return true + case '\n', '\r': + case ' ', ',': + inp.Next() + default: + return false + } + } +} + +func skipSpaceLine(inp *input.Input) { + for { + switch inp.Ch { + case ' ': + inp.Next() + case '\n', '\r': + inp.EatEOL() + default: + return + } + } +} + +func isNameRune(ch rune) bool { + return unicode.IsLetter(ch) || unicode.IsDigit(ch) || ch == '-' || ch == '_' +} + +// isBreakSym return true if the object is either a soft or a hard break symbol. +func isBreakSym(obj sx.Object) bool { + return zsx.SymSoft.IsEqual(obj) || zsx.SymHard.IsEqual(obj) +} ADDED sz/zmk/zmk_fuzz_test.go Index: sz/zmk/zmk_fuzz_test.go ================================================================== --- /dev/null +++ sz/zmk/zmk_fuzz_test.go @@ -0,0 +1,31 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2022-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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: 2022-present Detlef Stern +//----------------------------------------------------------------------------- + +package zmk_test + +import ( + "testing" + + "t73f.de/r/zsc/sz/zmk" + "t73f.de/r/zsx/input" +) + +func FuzzParseBlocks(f *testing.F) { + var parser zmk.Parser + f.Fuzz(func(t *testing.T, src []byte) { + t.Parallel() + inp := input.NewInput(src) + parser.Initialize(inp) + parser.Parse() + }) +} ADDED sz/zmk/zmk_test.go Index: sz/zmk/zmk_test.go ================================================================== --- /dev/null +++ sz/zmk/zmk_test.go @@ -0,0 +1,861 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2020-present Detlef Stern +// +// This file is part of zettelstore-client. +// +// Zettelstore client 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 zmk_test provides some tests for the zettelmarkup parser. +package zmk_test + +import ( + "fmt" + "strings" + "testing" + + "t73f.de/r/sx" + "t73f.de/r/zsc/sz/zmk" + "t73f.de/r/zsx" + "t73f.de/r/zsx/input" +) + +type TestCase struct{ source, want string } +type TestCases []TestCase +type symbolMap map[string]*sx.Symbol + +func replace(s string, sm symbolMap, tcs TestCases) TestCases { + var sym string + if len(sm) > 0 { + sym = sm[s].GetValue() + } + var testCases TestCases + for _, tc := range tcs { + source := strings.ReplaceAll(tc.source, "$", s) + want := tc.want + if sym != "" { + want = strings.ReplaceAll(want, "$%", sym) + } + want = strings.ReplaceAll(want, "$", s) + testCases = append(testCases, TestCase{source, want}) + } + return testCases +} + +func checkTcs(t *testing.T, tcs TestCases) { + t.Helper() + + var parser zmk.Parser + for tcn, tc := range tcs { + t.Run(fmt.Sprintf("TC=%02d,src=%q", tcn, tc.source), func(st *testing.T) { + st.Helper() + inp := input.NewInput([]byte(tc.source)) + parser.Initialize(inp) + ast := parser.Parse() + zsx.Walk(astWalker{}, ast, nil) + got := ast.String() + if tc.want != got { + st.Errorf("\nwant=%q\n got=%q", tc.want, got) + } + }) + } +} + +type astWalker struct{} + +func (astWalker) VisitBefore(node *sx.Pair, env *sx.Pair) (sx.Object, bool) { return sx.Nil(), false } +func (astWalker) VisitAfter(node *sx.Pair, env *sx.Pair) sx.Object { return node } + +func TestEdges(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"\"\"\"\n; \n0{{0}}{0}\n\"\"\"", "(BLOCK (REGION-VERSE () ((DESCRIPTION () ()) (PARA (TEXT \"0\") (EMBED ((\"0\" . \"\")) (HOSTED \"0\") \"\")))))"}, + }) +} + +func TestEOL(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"", "()"}, + {"\n", "()"}, + {"\r", "()"}, + {"\r\n", "()"}, + {"\n\n", "()"}, + }) +} + +func TestText(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"abcd", "(BLOCK (PARA (TEXT \"abcd\")))"}, + {"ab cd", "(BLOCK (PARA (TEXT \"ab cd\")))"}, + {"abcd ", "(BLOCK (PARA (TEXT \"abcd\")))"}, + {" abcd", "(BLOCK (PARA (TEXT \"abcd\")))"}, + {"\\", "(BLOCK (PARA (TEXT \"\\\\\")))"}, + {"\\\n", "()"}, + {"\\\ndef", "(BLOCK (PARA (HARD) (TEXT \"def\")))"}, + {"\\\r", "()"}, + {"\\\rdef", "(BLOCK (PARA (HARD) (TEXT \"def\")))"}, + {"\\\r\n", "()"}, + {"\\\r\ndef", "(BLOCK (PARA (HARD) (TEXT \"def\")))"}, + {"\\a", "(BLOCK (PARA (TEXT \"a\")))"}, + {"\\aa", "(BLOCK (PARA (TEXT \"aa\")))"}, + {"a\\a", "(BLOCK (PARA (TEXT \"aa\")))"}, + {"\\+", "(BLOCK (PARA (TEXT \"+\")))"}, + {"\\ ", "(BLOCK (PARA (TEXT \"\u00a0\")))"}, + {"http://a, http://b", "(BLOCK (PARA (TEXT \"http://a, http://b\")))"}, + }) +} + +func TestSpace(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {" ", "()"}, + {"\t", "()"}, + {" ", "()"}, + }) +} + +func TestSoftBreak(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"x\ny", "(BLOCK (PARA (TEXT \"x\") (SOFT) (TEXT \"y\")))"}, + {"z\n", "(BLOCK (PARA (TEXT \"z\")))"}, + {" \n ", "()"}, + {" \n", "()"}, + }) +} + +func TestHardBreak(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"x \ny", "(BLOCK (PARA (TEXT \"x\") (HARD) (TEXT \"y\")))"}, + {"z \n", "(BLOCK (PARA (TEXT \"z\")))"}, + {" \n ", "()"}, + {" \n", "()"}, + }) +} + +func TestLink(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"[", "(BLOCK (PARA (TEXT \"[\")))"}, + {"[[", "(BLOCK (PARA (TEXT \"[[\")))"}, + {"[[|", "(BLOCK (PARA (TEXT \"[[|\")))"}, + {"[[]", "(BLOCK (PARA (TEXT \"[[]\")))"}, + {"[[|]", "(BLOCK (PARA (TEXT \"[[|]\")))"}, + {"[[]]", "(BLOCK (PARA (TEXT \"[[]]\")))"}, + {"[[|]]", "(BLOCK (PARA (TEXT \"[[|]]\")))"}, + {"[[ ]]", "(BLOCK (PARA (TEXT \"[[ ]]\")))"}, + {"[[\n]]", "(BLOCK (PARA (TEXT \"[[\") (SOFT) (TEXT \"]]\")))"}, + {"[[ a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\"))))"}, + {"[[a ]]", "(BLOCK (PARA (TEXT \"[[a ]]\")))"}, + {"[[a\n]]", "(BLOCK (PARA (TEXT \"[[a\") (SOFT) (TEXT \"]]\")))"}, + {"[[a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\"))))"}, + {"[[12345678901234]]", "(BLOCK (PARA (LINK () (ZETTEL \"12345678901234\"))))"}, + {"[[a]", "(BLOCK (PARA (TEXT \"[[a]\")))"}, + {"[[|a]]", "(BLOCK (PARA (TEXT \"[[|a]]\")))"}, + {"[[b|]]", "(BLOCK (PARA (TEXT \"[[b|]]\")))"}, + {"[[b|a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\") (TEXT \"b\"))))"}, + {"[[b| a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\") (TEXT \"b\"))))"}, + {"[[b%c|a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\") (TEXT \"b%c\"))))"}, + {"[[b%%c|a]]", "(BLOCK (PARA (TEXT \"[[b\") (LITERAL-COMMENT () \"c|a]]\")))"}, + {"[[b|a]", "(BLOCK (PARA (TEXT \"[[b|a]\")))"}, + {"[[b\nc|a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\") (TEXT \"b\") (SOFT) (TEXT \"c\"))))"}, + {"[[b c|a#n]]", "(BLOCK (PARA (LINK () (HOSTED \"a#n\") (TEXT \"b c\"))))"}, + {"[[a]]go", "(BLOCK (PARA (LINK () (HOSTED \"a\")) (TEXT \"go\")))"}, + {"[[b|a]]{go}", "(BLOCK (PARA (LINK ((\"go\" . \"\")) (HOSTED \"a\") (TEXT \"b\"))))"}, + {"[[[[a]]|b]]", "(BLOCK (PARA (TEXT \"[[\") (LINK () (HOSTED \"a\")) (TEXT \"|b]]\")))"}, + {"[[a[b]c|d]]", "(BLOCK (PARA (LINK () (HOSTED \"d\") (TEXT \"a[b]c\"))))"}, + {"[[[b]c|d]]", "(BLOCK (PARA (TEXT \"[\") (LINK () (HOSTED \"d\") (TEXT \"b]c\"))))"}, + {"[[a[]c|d]]", "(BLOCK (PARA (LINK () (HOSTED \"d\") (TEXT \"a[]c\"))))"}, + {"[[a[b]|d]]", "(BLOCK (PARA (LINK () (HOSTED \"d\") (TEXT \"a[b]\"))))"}, + {"[[\\|]]", "(BLOCK (PARA (LINK () (INVALID \"\\\\|\"))))"}, + {"[[\\||a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\") (TEXT \"|\"))))"}, + {"[[b\\||a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\") (TEXT \"b|\"))))"}, + {"[[b\\|c|a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\") (TEXT \"b|c\"))))"}, + {"[[\\]]]", "(BLOCK (PARA (LINK () (INVALID \"\\\\]\"))))"}, + {"[[\\]|a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\") (TEXT \"]\"))))"}, + {"[[b\\]|a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\") (TEXT \"b]\"))))"}, + {"[[\\]\\||a]]", "(BLOCK (PARA (LINK () (HOSTED \"a\") (TEXT \"]|\"))))"}, + {"[[http://a]]", "(BLOCK (PARA (LINK () (EXTERNAL \"http://a\"))))"}, + {"[[http://a|http://a]]", "(BLOCK (PARA (LINK () (EXTERNAL \"http://a\") (TEXT \"http://a\"))))"}, + {"[[[[a]]]]", "(BLOCK (PARA (TEXT \"[[\") (LINK () (HOSTED \"a\")) (TEXT \"]]\")))"}, + {"[[query:title]]", "(BLOCK (PARA (LINK () (QUERY \"title\"))))"}, + {"[[query:title syntax]]", "(BLOCK (PARA (LINK () (QUERY \"title syntax\"))))"}, + {"[[query:title | action]]", "(BLOCK (PARA (LINK () (QUERY \"title | action\"))))"}, + {"[[Text|query:title]]", "(BLOCK (PARA (LINK () (QUERY \"title\") (TEXT \"Text\"))))"}, + {"[[Text|query:title syntax]]", "(BLOCK (PARA (LINK () (QUERY \"title syntax\") (TEXT \"Text\"))))"}, + {"[[Text|query:title | action]]", "(BLOCK (PARA (LINK () (QUERY \"title | action\") (TEXT \"Text\"))))"}, + }) +} + +func TestEmbed(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"{", "(BLOCK (PARA (TEXT \"{\")))"}, + {"{{", "(BLOCK (PARA (TEXT \"{{\")))"}, + {"{{|", "(BLOCK (PARA (TEXT \"{{|\")))"}, + {"{{}", "(BLOCK (PARA (TEXT \"{{}\")))"}, + {"{{|}", "(BLOCK (PARA (TEXT \"{{|}\")))"}, + {"{{}}", "(BLOCK (PARA (TEXT \"{{}}\")))"}, + {"{{|}}", "(BLOCK (PARA (TEXT \"{{|}}\")))"}, + {"{{ }}", "(BLOCK (PARA (TEXT \"{{ }}\")))"}, + {"{{\n}}", "(BLOCK (PARA (TEXT \"{{\") (SOFT) (TEXT \"}}\")))"}, + {"{{a }}", "(BLOCK (PARA (TEXT \"{{a }}\")))"}, + {"{{a\n}}", "(BLOCK (PARA (TEXT \"{{a\") (SOFT) (TEXT \"}}\")))"}, + {"{{a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\")))"}, + {"{{12345678901234}}", "(BLOCK (PARA (EMBED () (ZETTEL \"12345678901234\") \"\")))"}, + {"{{ a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\")))"}, + {"{{a}", "(BLOCK (PARA (TEXT \"{{a}\")))"}, + {"{{|a}}", "(BLOCK (PARA (TEXT \"{{|a}}\")))"}, + {"{{b|}}", "(BLOCK (PARA (TEXT \"{{b|}}\")))"}, + {"{{b|a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\" (TEXT \"b\"))))"}, + {"{{b| a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\" (TEXT \"b\"))))"}, + {"{{b|a}", "(BLOCK (PARA (TEXT \"{{b|a}\")))"}, + {"{{b\nc|a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\" (TEXT \"b\") (SOFT) (TEXT \"c\"))))"}, + {"{{b c|a#n}}", "(BLOCK (PARA (EMBED () (HOSTED \"a#n\") \"\" (TEXT \"b c\"))))"}, + {"{{a}}{go}", "(BLOCK (PARA (EMBED ((\"go\" . \"\")) (HOSTED \"a\") \"\")))"}, + {"{{{{a}}|b}}", "(BLOCK (PARA (TEXT \"{{\") (EMBED () (HOSTED \"a\") \"\") (TEXT \"|b}}\")))"}, + {"{{\\|}}", "(BLOCK (PARA (EMBED () (INVALID \"\\\\|\") \"\")))"}, + {"{{\\||a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\" (TEXT \"|\"))))"}, + {"{{b\\||a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\" (TEXT \"b|\"))))"}, + {"{{b\\|c|a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\" (TEXT \"b|c\"))))"}, + {"{{\\}}}", "(BLOCK (PARA (EMBED () (INVALID \"\\\\}\") \"\")))"}, + {"{{\\}|a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\" (TEXT \"}\"))))"}, + {"{{b\\}|a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\" (TEXT \"b}\"))))"}, + {"{{\\}\\||a}}", "(BLOCK (PARA (EMBED () (HOSTED \"a\") \"\" (TEXT \"}|\"))))"}, + {"{{http://a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"http://a\") \"\")))"}, + {"{{http://a|http://a}}", "(BLOCK (PARA (EMBED () (EXTERNAL \"http://a\") \"\" (TEXT \"http://a\"))))"}, + {"{{{{a}}}}", "(BLOCK (PARA (TEXT \"{{\") (EMBED () (HOSTED \"a\") \"\") (TEXT \"}}\")))"}, + }) +} + +func TestCite(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"[@", "(BLOCK (PARA (TEXT \"[@\")))"}, + {"[@]", "(BLOCK (PARA (TEXT \"[@]\")))"}, + {"[@a]", "(BLOCK (PARA (CITE () \"a\")))"}, + {"[@ a]", "(BLOCK (PARA (TEXT \"[@ a]\")))"}, + {"[@a ]", "(BLOCK (PARA (CITE () \"a\")))"}, + {"[@a\n]", "(BLOCK (PARA (CITE () \"a\")))"}, + {"[@a\nx]", "(BLOCK (PARA (CITE () \"a\" (SOFT) (TEXT \"x\"))))"}, + {"[@a\n\n]", "(BLOCK (PARA (TEXT \"[@a\")) (PARA (TEXT \"]\")))"}, + {"[@a,\n]", "(BLOCK (PARA (CITE () \"a\")))"}, + {"[@a,n]", "(BLOCK (PARA (CITE () \"a\" (TEXT \"n\"))))"}, + {"[@a| n]", "(BLOCK (PARA (CITE () \"a\" (TEXT \"n\"))))"}, + {"[@a|n ]", "(BLOCK (PARA (CITE () \"a\" (TEXT \"n\"))))"}, + {"[@a,[@b]]", "(BLOCK (PARA (CITE () \"a\" (CITE () \"b\"))))"}, + {"[@a]{color=green}", "(BLOCK (PARA (CITE ((\"color\" . \"green\")) \"a\")))"}, + }) + checkTcs(t, TestCases{ + {"[@a\n\n]", "(BLOCK (PARA (TEXT \"[@a\")) (PARA (TEXT \"]\")))"}, + }) +} + +func TestEndnote(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"[^", "(BLOCK (PARA (TEXT \"[^\")))"}, + {"[^]", "(BLOCK (PARA (ENDNOTE ())))"}, + {"[^abc]", "(BLOCK (PARA (ENDNOTE () (TEXT \"abc\"))))"}, + {"[^abc ]", "(BLOCK (PARA (ENDNOTE () (TEXT \"abc\"))))"}, + {"[^abc\ndef]", "(BLOCK (PARA (ENDNOTE () (TEXT \"abc\") (SOFT) (TEXT \"def\"))))"}, + {"[^abc\n\ndef]", "(BLOCK (PARA (TEXT \"[^abc\")) (PARA (TEXT \"def]\")))"}, + {"[^abc[^def]]", "(BLOCK (PARA (ENDNOTE () (TEXT \"abc\") (ENDNOTE () (TEXT \"def\")))))"}, + {"[^abc]{-}", "(BLOCK (PARA (ENDNOTE ((\"-\" . \"\")) (TEXT \"abc\"))))"}, + }) + checkTcs(t, TestCases{ + {"[^abc\n\ndef]", "(BLOCK (PARA (TEXT \"[^abc\")) (PARA (TEXT \"def]\")))"}, + }) +} + +func TestMark(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"[!", "(BLOCK (PARA (TEXT \"[!\")))"}, + {"[!\n", "(BLOCK (PARA (TEXT \"[!\")))"}, + {"[!]", "(BLOCK (PARA (MARK \"\" \"\" \"\")))"}, + {"[!][!]", "(BLOCK (PARA (MARK \"\" \"\" \"\") (MARK \"\" \"\" \"\")))"}, + {"[! ]", "(BLOCK (PARA (TEXT \"[! ]\")))"}, + {"[!a]", "(BLOCK (PARA (MARK \"a\" \"\" \"\")))"}, + {"[!a][!a]", "(BLOCK (PARA (MARK \"a\" \"\" \"\") (MARK \"a\" \"\" \"\")))"}, + {"[!a ]", "(BLOCK (PARA (TEXT \"[!a ]\")))"}, + {"[!a_]", "(BLOCK (PARA (MARK \"a_\" \"\" \"\")))"}, + {"[!a_][!a]", "(BLOCK (PARA (MARK \"a_\" \"\" \"\") (MARK \"a\" \"\" \"\")))"}, + {"[!a-b]", "(BLOCK (PARA (MARK \"a-b\" \"\" \"\")))"}, + {"[!a|b]", "(BLOCK (PARA (MARK \"a\" \"\" \"\" (TEXT \"b\"))))"}, + {"[!a|]", "(BLOCK (PARA (MARK \"a\" \"\" \"\")))"}, + {"[!|b]", "(BLOCK (PARA (MARK \"\" \"\" \"\" (TEXT \"b\"))))"}, + {"[!|b ]", "(BLOCK (PARA (MARK \"\" \"\" \"\" (TEXT \"b\"))))"}, + {"[!|b c]", "(BLOCK (PARA (MARK \"\" \"\" \"\" (TEXT \"b c\"))))"}, + }) +} + +func TestComment(t *testing.T) { + t.Parallel() + checkTcs(t, TestCases{ + {"%", "(BLOCK (PARA (TEXT \"%\")))"}, + {"%%", "(BLOCK (PARA (LITERAL-COMMENT () \"\")))"}, + {"%\n", "(BLOCK (PARA (TEXT \"%\")))"}, + {"%%\n", "(BLOCK (PARA (LITERAL-COMMENT () \"\")))"}, + {"%%a", "(BLOCK (PARA (LITERAL-COMMENT () \"a\")))"}, + {"%%%a", "(BLOCK (PARA (LITERAL-COMMENT () \"a\")))"}, + {"%% a", "(BLOCK (PARA (LITERAL-COMMENT () \"a\")))"}, + {"%%% a", "(BLOCK (PARA (LITERAL-COMMENT () \"a\")))"}, + {"%% % a", "(BLOCK (PARA (LITERAL-COMMENT () \"% a\")))"}, + {"%%a", "(BLOCK (PARA (LITERAL-COMMENT () \"a\")))"}, + {"a%%b", "(BLOCK (PARA (TEXT \"a\") (LITERAL-COMMENT () \"b\")))"}, + {"a %%b", "(BLOCK (PARA (TEXT \"a \") (LITERAL-COMMENT () \"b\")))"}, + {" %%b", "(BLOCK (PARA (LITERAL-COMMENT () \"b\")))"}, + {"%%b ", "(BLOCK (PARA (LITERAL-COMMENT () \"b \")))"}, + {"100%", "(BLOCK (PARA (TEXT \"100%\")))"}, + {"%%{=}a", "(BLOCK (PARA (LITERAL-COMMENT ((\"\" . \"\")) \"a\")))"}, + }) +} + +func TestFormat(t *testing.T) { + symMap := symbolMap{ + "_": zsx.SymFormatEmph, + "*": zsx.SymFormatStrong, + ">": zsx.SymFormatInsert, + "~": zsx.SymFormatDelete, + "^": zsx.SymFormatSuper, + ",": zsx.SymFormatSub, + "#": zsx.SymFormatMark, + ":": zsx.SymFormatSpan, + } + t.Parallel() + // Not for Insert / '>', because collision with quoted list + // Not for Quote / '"', because escaped representation. + for _, ch := range []string{"_", "*", "~", "^", ",", "#", ":"} { + checkTcs(t, replace(ch, symMap, TestCases{ + {"$", "(BLOCK (PARA (TEXT \"$\")))"}, + {"$$", "(BLOCK (PARA (TEXT \"$$\")))"}, + {"$$$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$$$", "(BLOCK (PARA ($% ())))"}, + })) + } + // Not for Quote / '"', because escaped representation. + for _, ch := range []string{"_", "*", ">", "~", "^", ",", "#", ":"} { + checkTcs(t, replace(ch, symMap, TestCases{ + {"$$a$$", "(BLOCK (PARA ($% () (TEXT \"a\"))))"}, + {"$$a$$$", "(BLOCK (PARA ($% () (TEXT \"a\")) (TEXT \"$\")))"}, + {"$$$a$$", "(BLOCK (PARA ($% () (TEXT \"$a\"))))"}, + {"$$$a$$$", "(BLOCK (PARA ($% () (TEXT \"$a\")) (TEXT \"$\")))"}, + {"$\\$", "(BLOCK (PARA (TEXT \"$$\")))"}, + {"$\\$$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$\\$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$a\\$$", "(BLOCK (PARA (TEXT \"$$a$$\")))"}, + {"$$a$\\$", "(BLOCK (PARA (TEXT \"$$a$$\")))"}, + {"$$a\\$$$", "(BLOCK (PARA ($% () (TEXT \"a$\"))))"}, + {"$$a\na$$", "(BLOCK (PARA ($% () (TEXT \"a\") (SOFT) (TEXT \"a\"))))"}, + {"$$a\n\na$$", "(BLOCK (PARA (TEXT \"$$a\")) (PARA (TEXT \"a$$\")))"}, + {"$$a$${go}", "(BLOCK (PARA ($% ((\"go\" . \"\")) (TEXT \"a\"))))"}, + })) + checkTcs(t, replace(ch, symMap, TestCases{ + {"$$a\n\na$$", "(BLOCK (PARA (TEXT \"$$a\")) (PARA (TEXT \"a$$\")))"}, + })) + } + checkTcs(t, replace(`"`, symbolMap{`"`: zsx.SymFormatQuote}, TestCases{ + {"$", "(BLOCK (PARA (TEXT \"\\\"\")))"}, + {"$$", "(BLOCK (PARA (TEXT \"\\\"\\\"\")))"}, + {"$$$", "(BLOCK (PARA (TEXT \"\\\"\\\"\\\"\")))"}, + {"$$$$", "(BLOCK (PARA ($% ())))"}, + + {"$$a$$", "(BLOCK (PARA ($% () (TEXT \"a\"))))"}, + {"$$a$$$", "(BLOCK (PARA ($% () (TEXT \"a\")) (TEXT \"\\\"\")))"}, + {"$$$a$$", "(BLOCK (PARA ($% () (TEXT \"\\\"a\"))))"}, + {"$$$a$$$", "(BLOCK (PARA ($% () (TEXT \"\\\"a\")) (TEXT \"\\\"\")))"}, + {"$\\$", "(BLOCK (PARA (TEXT \"\\\"\\\"\")))"}, + {"$\\$$", "(BLOCK (PARA (TEXT \"\\\"\\\"\\\"\")))"}, + {"$$\\$", "(BLOCK (PARA (TEXT \"\\\"\\\"\\\"\")))"}, + {"$$a\\$$", "(BLOCK (PARA (TEXT \"\\\"\\\"a\\\"\\\"\")))"}, + {"$$a$\\$", "(BLOCK (PARA (TEXT \"\\\"\\\"a\\\"\\\"\")))"}, + {"$$a\\$$$", "(BLOCK (PARA ($% () (TEXT \"a\\\"\"))))"}, + {"$$a\na$$", "(BLOCK (PARA ($% () (TEXT \"a\") (SOFT) (TEXT \"a\"))))"}, + {"$$a\n\na$$", "(BLOCK (PARA (TEXT \"\\\"\\\"a\")) (PARA (TEXT \"a\\\"\\\"\")))"}, + {"$$a$${go}", "(BLOCK (PARA ($% ((\"go\" . \"\")) (TEXT \"a\"))))"}, + })) + checkTcs(t, TestCases{ + {"__****__", "(BLOCK (PARA (FORMAT-EMPH () (FORMAT-STRONG ()))))"}, + {"__**a**__", "(BLOCK (PARA (FORMAT-EMPH () (FORMAT-STRONG () (TEXT \"a\")))))"}, + {"__**__**", "(BLOCK (PARA (TEXT \"__\") (FORMAT-STRONG () (TEXT \"__\"))))"}, + }) +} + +func TestLiteral(t *testing.T) { + symMap := symbolMap{ + "`": zsx.SymLiteralCode, + "'": zsx.SymLiteralInput, + "=": zsx.SymLiteralOutput, + } + t.Parallel() + for _, ch := range []string{"`", "'", "="} { + checkTcs(t, replace(ch, symMap, TestCases{ + {"$", "(BLOCK (PARA (TEXT \"$\")))"}, + {"$$", "(BLOCK (PARA (TEXT \"$$\")))"}, + {"$$$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$$$", "(BLOCK (PARA ($% () \"\")))"}, + {"$$a$$", "(BLOCK (PARA ($% () \"a\")))"}, + {"$$a$$$", "(BLOCK (PARA ($% () \"a\") (TEXT \"$\")))"}, + {"$$$a$$", "(BLOCK (PARA ($% () \"$a\")))"}, + {"$$$a$$$", "(BLOCK (PARA ($% () \"$a\") (TEXT \"$\")))"}, + {"$\\$", "(BLOCK (PARA (TEXT \"$$\")))"}, + {"$\\$$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$\\$", "(BLOCK (PARA (TEXT \"$$$\")))"}, + {"$$a\\$$", "(BLOCK (PARA (TEXT \"$$a$$\")))"}, + {"$$a$\\$", "(BLOCK (PARA (TEXT \"$$a$$\")))"}, + {"$$a\\$$$", "(BLOCK (PARA ($% () \"a$\")))"}, + {"$$a$${go}", "(BLOCK (PARA ($% ((\"go\" . \"\")) \"a\")))"}, + })) + } + checkTcs(t, TestCases{ + {"``