Index: VERSION ================================================================== --- VERSION +++ VERSION @@ -1,1 +1,1 @@ -0.14.0 +0.15.0 Index: auth/policy/box.go ================================================================== --- auth/policy/box.go +++ auth/policy/box.go @@ -155,6 +155,14 @@ user := server.GetUser(ctx) if pp.policy.CanRefresh(user) { return pp.box.Refresh(ctx) } return box.NewErrNotAllowed("Refresh", user, id.Invalid) +} +func (pp *polBox) ReIndex(ctx context.Context, zid id.Zid) error { + user := server.GetUser(ctx) + if pp.policy.CanRefresh(user) { + // If a user is allowed to refresh all data, it it also allowed to re-index a zettel. + return pp.box.ReIndex(ctx, zid) + } + return box.NewErrNotAllowed("ReIndex", user, zid) } Index: box/box.go ================================================================== --- box/box.go +++ box/box.go @@ -29,26 +29,13 @@ type BaseBox interface { // Location returns some information where the box is located. // Format is dependent of the box. Location() string - // CanCreateZettel returns true, if box could possibly create a new zettel. - CanCreateZettel(ctx context.Context) bool - - // CreateZettel creates a new zettel. - // Returns the new zettel id (and an error indication). - CreateZettel(ctx context.Context, zettel zettel.Zettel) (id.Zid, error) - // GetZettel retrieves a specific zettel. GetZettel(ctx context.Context, zid id.Zid) (zettel.Zettel, error) - // CanUpdateZettel returns true, if box could possibly update the given zettel. - CanUpdateZettel(ctx context.Context, zettel zettel.Zettel) bool - - // UpdateZettel updates an existing zettel. - UpdateZettel(ctx context.Context, zettel zettel.Zettel) error - // AllowRenameZettel returns true, if box will not disallow renaming the zettel. AllowRenameZettel(ctx context.Context, zid id.Zid) bool // RenameZettel changes the current Zid to a new Zid. RenameZettel(ctx context.Context, curZid, newZid id.Zid) error @@ -57,10 +44,26 @@ CanDeleteZettel(ctx context.Context, zid id.Zid) bool // DeleteZettel removes the zettel from the box. DeleteZettel(ctx context.Context, zid id.Zid) error } + +// WriteBox is a box that can create / update zettel content. +type WriteBox interface { + // CanCreateZettel returns true, if box could possibly create a new zettel. + CanCreateZettel(ctx context.Context) bool + + // CreateZettel creates a new zettel. + // Returns the new zettel id (and an error indication). + CreateZettel(ctx context.Context, zettel zettel.Zettel) (id.Zid, error) + + // CanUpdateZettel returns true, if box could possibly update the given zettel. + CanUpdateZettel(ctx context.Context, zettel zettel.Zettel) bool + + // UpdateZettel updates an existing zettel. + UpdateZettel(ctx context.Context, zettel zettel.Zettel) error +} // ZidFunc is a function that processes identifier of a zettel. type ZidFunc func(id.Zid) // MetaFunc is a function that processes metadata of a zettel. @@ -127,10 +130,11 @@ } // Box is to be used outside the box package and its descendants. type Box interface { BaseBox + WriteBox // FetchZids returns the set of all zettel identifer managed by the box. FetchZids(ctx context.Context) (id.Set, error) // GetMeta returns the metadata of the zettel with the given identifier. @@ -143,10 +147,13 @@ // GetAllZettel retrieves a specific zettel from all managed boxes. GetAllZettel(ctx context.Context, zid id.Zid) ([]zettel.Zettel, error) // Refresh the data from the box and from its managed sub-boxes. Refresh(context.Context) error + + // ReIndex one zettel to update its index data. + ReIndex(context.Context, id.Zid) error } // Stats record stattistics about a box. type Stats struct { // ReadOnly indicates that boxes cannot be modified. @@ -304,12 +311,10 @@ // ErrZettelNotFound is returned if a zettel was not found in the box. type ErrZettelNotFound struct{ Zid id.Zid } func (eznf ErrZettelNotFound) Error() string { return "zettel not found: " + eznf.Zid.String() } -//var ErrZettelNotFound = errors.New("zettel not found") - // ErrConflict is returned if a box operation detected a conflict.. // One example: if calculating a new zettel identifier takes too long. var ErrConflict = errors.New("conflict") // ErrCapacity is returned if a box has reached its capacity. Index: box/compbox/compbox.go ================================================================== --- box/compbox/compbox.go +++ box/compbox/compbox.go @@ -68,17 +68,10 @@ // Setup remembers important values. func Setup(cfg *meta.Meta) { myConfig = cfg.Clone() } func (*compBox) Location() string { return "" } -func (*compBox) CanCreateZettel(context.Context) bool { return false } - -func (cb *compBox) CreateZettel(context.Context, zettel.Zettel) (id.Zid, error) { - cb.log.Trace().Err(box.ErrReadOnly).Msg("CreateZettel") - return id.Invalid, box.ErrReadOnly -} - func (cb *compBox) GetZettel(_ context.Context, zid id.Zid) (zettel.Zettel, error) { if gen, ok := myZettel[zid]; ok && gen.meta != nil { if m := gen.meta(zid); m != nil { updateMeta(m) if genContent := gen.content; genContent != nil { @@ -132,17 +125,10 @@ } } return nil } -func (*compBox) CanUpdateZettel(context.Context, zettel.Zettel) bool { return false } - -func (cb *compBox) UpdateZettel(context.Context, zettel.Zettel) error { - cb.log.Trace().Err(box.ErrReadOnly).Msg("UpdateZettel") - return box.ErrReadOnly -} - func (*compBox) AllowRenameZettel(_ context.Context, zid id.Zid) bool { _, ok := myZettel[zid] return !ok } Index: box/constbox/constbox.go ================================================================== --- box/constbox/constbox.go +++ box/constbox/constbox.go @@ -55,17 +55,10 @@ enricher box.Enricher } func (*constBox) Location() string { return "const:" } -func (*constBox) CanCreateZettel(context.Context) bool { return false } - -func (cb *constBox) CreateZettel(context.Context, zettel.Zettel) (id.Zid, error) { - cb.log.Trace().Err(box.ErrReadOnly).Msg("CreateZettel") - return id.Invalid, box.ErrReadOnly -} - func (cb *constBox) GetZettel(_ context.Context, zid id.Zid) (zettel.Zettel, error) { if z, ok := cb.zettel[zid]; ok { cb.log.Trace().Msg("GetZettel") return zettel.Zettel{Meta: meta.NewWithData(zid, z.header), Content: z.content}, nil } @@ -99,17 +92,10 @@ } } return nil } -func (*constBox) CanUpdateZettel(context.Context, zettel.Zettel) bool { return false } - -func (cb *constBox) UpdateZettel(context.Context, zettel.Zettel) error { - cb.log.Trace().Err(box.ErrReadOnly).Msg("UpdateZettel") - return box.ErrReadOnly -} - func (cb *constBox) AllowRenameZettel(_ context.Context, zid id.Zid) bool { _, ok := cb.zettel[zid] return !ok } @@ -220,11 +206,11 @@ constHeader{ api.KeyTitle: "Zettelstore Info HTML Template", api.KeyRole: api.ValueRoleConfiguration, api.KeySyntax: meta.SyntaxSxn, api.KeyCreated: "20200804111624", - api.KeyModified: "20230907203300", + api.KeyModified: "20231023152000", api.KeyVisibility: api.ValueVisibilityExpert, }, zettel.NewContent(contentInfoSxn)}, id.FormTemplateZid: { constHeader{ @@ -260,11 +246,11 @@ constHeader{ api.KeyTitle: "Zettelstore List Zettel HTML Template", api.KeyRole: api.ValueRoleConfiguration, api.KeySyntax: meta.SyntaxSxn, api.KeyCreated: "20230704122100", - api.KeyModified: "20230829223600", + api.KeyModified: "20231002120600", api.KeyVisibility: api.ValueVisibilityExpert, }, zettel.NewContent(contentListZettelSxn)}, id.ErrorTemplateZid: { constHeader{ @@ -281,24 +267,36 @@ api.KeyTitle: "Zettelstore Sxn Start Code", api.KeyRole: api.ValueRoleConfiguration, api.KeySyntax: meta.SyntaxSxn, api.KeyCreated: "20230824160700", api.KeyVisibility: api.ValueVisibilityExpert, - api.KeyPrecursor: id.BaseSxnZid.String(), + api.KeyPrecursor: string(api.ZidSxnBase), }, zettel.NewContent(contentStartCodeSxn)}, id.BaseSxnZid: { constHeader{ api.KeyTitle: "Zettelstore Sxn Base Code", api.KeyRole: api.ValueRoleConfiguration, api.KeySyntax: meta.SyntaxSxn, api.KeyCreated: "20230619132800", - api.KeyModified: "20230907203100", + api.KeyModified: "20231012154500", + api.KeyReadOnly: api.ValueTrue, + api.KeyVisibility: api.ValueVisibilityExpert, + api.KeyPrecursor: string(api.ZidSxnPrelude), + }, + zettel.NewContent(contentBaseCodeSxn)}, + id.PreludeSxnZid: { + constHeader{ + api.KeyTitle: "Zettelstore Sxn Prelude", + api.KeyRole: api.ValueRoleConfiguration, + api.KeySyntax: meta.SyntaxSxn, + api.KeyCreated: "20231006181700", + api.KeyModified: "20231019140400", api.KeyReadOnly: api.ValueTrue, api.KeyVisibility: api.ValueVisibilityExpert, }, - zettel.NewContent(contentBaseCodeSxn)}, + zettel.NewContent(contentPreludeSxn)}, id.MustParse(api.ZidBaseCSS): { constHeader{ api.KeyTitle: "Zettelstore Base CSS", api.KeyRole: api.ValueRoleConfiguration, api.KeySyntax: meta.SyntaxCSS, @@ -335,15 +333,28 @@ api.KeyVisibility: api.ValueVisibilityCreator, }, zettel.NewContent(contentNewTOCZettel)}, id.MustParse(api.ZidTemplateNewZettel): { constHeader{ - api.KeyTitle: "New Zettel", - api.KeyRole: api.ValueRoleZettel, - api.KeySyntax: meta.SyntaxZmk, - api.KeyCreated: "20201028185209", - api.KeyVisibility: api.ValueVisibilityCreator, + api.KeyTitle: "New Zettel", + api.KeyRole: api.ValueRoleConfiguration, + api.KeySyntax: meta.SyntaxZmk, + api.KeyCreated: "20201028185209", + api.KeyModified: "20230929132900", + meta.NewPrefix + api.KeyRole: api.ValueRoleZettel, + api.KeyVisibility: api.ValueVisibilityCreator, + }, + zettel.NewContent(nil)}, + id.MustParse(api.ZidTemplateNewTag): { + constHeader{ + api.KeyTitle: "New Tag", + api.KeyRole: api.ValueRoleConfiguration, + api.KeySyntax: meta.SyntaxZmk, + api.KeyCreated: "20230929132400", + meta.NewPrefix + api.KeyRole: api.ValueRoleTag, + meta.NewPrefix + api.KeyTitle: "#", + api.KeyVisibility: api.ValueVisibilityCreator, }, zettel.NewContent(nil)}, id.MustParse(api.ZidTemplateNewUser): { constHeader{ api.KeyTitle: "New User", @@ -407,10 +418,13 @@ var contentStartCodeSxn []byte //go:embed wuicode.sxn var contentBaseCodeSxn []byte +//go:embed prelude.sxn +var contentPreludeSxn []byte + //go:embed base.css var contentBaseCSS []byte //go:embed emoji_spin.gif var contentEmoji []byte Index: box/constbox/info.sxn ================================================================== --- box/constbox/info.sxn +++ box/constbox/info.sxn @@ -3,10 +3,11 @@ (p (a (@ (href ,web-url)) "Web") (@H " · ") (a (@ (href ,context-url)) "Context") ,@(if (bound? 'edit-url) `((@H " · ") (a (@ (href ,edit-url)) "Edit"))) ,@(ROLE-DEFAULT-actions (current-environment)) + ,@(if (bound? 'reindex-url) `((@H " · ") (a (@ (href ,reindex-url)) "Reindex"))) ,@(if (bound? 'rename-url) `((@H " · ") (a (@ (href ,rename-url)) "Rename"))) ,@(if (bound? 'delete-url) `((@H " · ") (a (@ (href ,delete-url)) "Delete"))) ) ) (h2 "Interpreted Metadata") Index: box/constbox/listzettel.sxn ================================================================== --- box/constbox/listzettel.sxn +++ box/constbox/listzettel.sxn @@ -3,10 +3,13 @@ (form (@ (action ,search-url)) (input (@ (class "zs-input") (type "text") (placeholder "Search..") (name ,query-key-query) (value ,query-value) (dir "auto")))) ,@(if (bound? 'tag-zettel) `((p (@ (class "zs-tag-zettel")) "Tag zettel: " ,@tag-zettel)) ) + ,@(if (bound? 'create-tag-zettel) + `((p (@ (class "zs-tag-zettel")) "Create tag zettel: " ,@create-tag-zettel)) + ) ,@content ,@endnotes (form (@ (action ,(if (bound? 'create-url) create-url))) "Other encodings: " (a (@ (href ,data-url)) "data") Index: box/constbox/newtoc.zettel ================================================================== --- box/constbox/newtoc.zettel +++ box/constbox/newtoc.zettel @@ -1,4 +1,5 @@ This zettel lists all zettel that should act as a template for new zettel. These zettel will be included in the ""New"" menu of the WebUI. * [[New Zettel|00000000090001]] +* [[New Template|00000000090003]] * [[New User|00000000090002]] ADDED box/constbox/prelude.sxn Index: box/constbox/prelude.sxn ================================================================== --- /dev/null +++ box/constbox/prelude.sxn @@ -0,0 +1,55 @@ +;;;---------------------------------------------------------------------------- +;;; Copyright (c) 2023-present Detlef Stern +;;; +;;; This file is part of Zettelstore. +;;; +;;; Zettelstore is licensed under the latest version of the EUPL (European +;;; Union Public License). Please see file LICENSE.txt for your rights and +;;; obligations under this license. +;;;---------------------------------------------------------------------------- + +;;; This zettel contains all sxn definitions that are independent of specific +;;; subsystems, such as WebUI, API, or other. It just contains generic code to +;;; be used elsewhere. + +;; Constants NIL and T +(defconst NIL ()) +(defconst T 'T) + +;; Function not +(defun not (x) (if x NIL T)) +(defconst not not) + +;; let macro +;; +;; (let (BINDING ...) EXPR ...), where BINDING is a list of two elements +;; (SYMBOL EXPR) +(defmacro let (bindings . body) + `((lambda ,(map car bindings) ,@body) ,@(map cadr bindings))) + +;; let* macro +;; +;; (let* (BINDING ...) EXPR ...), where SYMBOL may occur in later bindings. +(defmacro let* (bindings . body) + (if (null? bindings) + `((lambda () ,@body)) + `((lambda (,(caar bindings)) + (let* ,(cdr bindings) ,@body)) + ,(cadar bindings)))) + +;; and macro +;; +;; (and EXPR ...) +(defmacro and args + (cond ((null? args) T) + ((null? (cdr args)) (car args)) + (T `(if ,(car args) (and ,@(cdr args)))))) + + +;; or macro +;; +;; (or EXPR ...) +(defmacro or args + (cond ((null? args) NIL) + ((null? (cdr args)) (car args)) + (T `(if ,(car args) T (or ,@(cdr args)))))) Index: box/constbox/wuicode.sxn ================================================================== --- box/constbox/wuicode.sxn +++ box/constbox/wuicode.sxn @@ -6,106 +6,107 @@ ;;; Zettelstore is licensed under the latest version of the EUPL (European ;;; Union Public License). Please see file LICENSE.txt for your rights and ;;; obligations under this license. ;;;---------------------------------------------------------------------------- +;; Contains WebUI specific code, but not related to a specific template. + ;; wui-list-item returns the argument as a HTML list item. -(define (wui-item s) `(li ,s)) +(defun wui-item (s) `(li ,s)) ;; wui-table-row takes a pair and translates it into a HTML table row with ;; two columns. -(define (wui-table-row p) +(defun wui-table-row (p) `(tr (td ,(car p)) (td ,(cdr p)))) ;; wui-valid-link translates a local link into a HTML link. A link is a pair ;; (valid . url). If valid is not truish, only the invalid url is returned. -(define (wui-valid-link l) +(defun wui-valid-link (l) (if (car l) `(li (a (@ (href ,(cdr l))) ,(cdr l))) `(li ,(cdr l)))) ;; wui-link takes a link (title . url) and returns a HTML reference. -(define (wui-link q) +(defun wui-link (q) `(a (@ (href ,(cdr q))) ,(car q))) ;; wui-item-link taks a pair (text . url) and returns a HTML link inside ;; a list item. -(define (wui-item-link q) `(li ,(wui-link q))) +(defun wui-item-link (q) `(li ,(wui-link q))) ;; wui-tdata-link taks a pair (text . url) and returns a HTML link inside ;; a table data item. -(define (wui-tdata-link q) `(td ,(wui-link q))) +(defun wui-tdata-link (q) `(td ,(wui-link q))) ;; wui-item-popup-link is like 'wui-item-link, but the HTML link will open ;; a new tab / window. -(define (wui-item-popup-link e) +(defun wui-item-popup-link (e) `(li (a (@ (href ,e) (target "_blank") (rel "noopener noreferrer")) ,e))) ;; wui-option-value returns a value for an HTML option element. -(define (wui-option-value v) `(option (@ (value ,v)))) +(defun wui-option-value (v) `(option (@ (value ,v)))) ;; wui-datalist returns a HTML datalist with the given HTML identifier and a ;; list of values. -(define (wui-datalist id lst) +(defun wui-datalist (id lst) (if lst `((datalist (@ (id ,id)) ,@(map wui-option-value lst))))) ;; wui-pair-desc-item takes a pair '(term . text) and returns a list with ;; a HTML description term and a HTML description data. -(define (wui-pair-desc-item p) `((dt ,(car p)) (dd ,(cdr p)))) +(defun wui-pair-desc-item (p) `((dt ,(car p)) (dd ,(cdr p)))) ;; wui-meta-desc returns a HTML description list made from the list of pairs ;; given. -(define (wui-meta-desc l) +(defun wui-meta-desc (l) `(dl ,@(apply append (map wui-pair-desc-item l)))) ;; wui-enc-matrix returns the HTML table of all encodings and parts. -(define (wui-enc-matrix matrix) +(defun wui-enc-matrix (matrix) `(table ,@(map (lambda (row) `(tr (th ,(car row)) ,@(map wui-tdata-link (cdr row)))) matrix))) ;; CSS-ROLE-map is a mapping (pair list, assoc list) of role names to zettel ;; identifier. It is used in the base template to update the metadata of the ;; HTML page to include some role specific CSS code. ;; Referenced in function "ROLE-DEFAULT-meta". -(define CSS-ROLE-map '()) +(defvar CSS-ROLE-map '()) ;; ROLE-DEFAULT-meta returns some metadata for the base template. Any role ;; specific code should include the returned list of this function. -(define (ROLE-DEFAULT-meta env) - `(,@(let (meta-role (environment-lookup 'meta-role env)) - (let (entry (assoc CSS-ROLE-map meta-role)) - (if (pair? entry) - `((link (@ (rel "stylesheet") (href ,(zid-content-path (cdr entry)))))) - ) - ) +(defun ROLE-DEFAULT-meta (env) + `(,@(let* ((meta-role (environment-lookup 'meta-role env)) + (entry (assoc CSS-ROLE-map meta-role))) + (if (pair? entry) + `((link (@ (rel "stylesheet") (href ,(zid-content-path (cdr entry)))))) + ) ) ) ) ;;; ACTION-SEPARATOR defines a HTML value that separates actions links. -(define ACTION-SEPARATOR '(@H " · ")) +(defvar ACTION-SEPARATOR '(@H " · ")) ;;; ROLE-DEFAULT-actions returns the default text for actions. -(define (ROLE-DEFAULT-actions env) - `(,@(let (copy-url (environment-lookup 'copy-url env)) +(defun ROLE-DEFAULT-actions (env) + `(,@(let ((copy-url (environment-lookup 'copy-url env))) (if (defined? copy-url) `((@H " · ") (a (@ (href ,copy-url)) "Copy")))) - ,@(let (version-url (environment-lookup 'version-url env)) + ,@(let ((version-url (environment-lookup 'version-url env))) (if (defined? version-url) `((@H " · ") (a (@ (href ,version-url)) "Version")))) - ,@(let (child-url (environment-lookup 'child-url env)) + ,@(let ((child-url (environment-lookup 'child-url env))) (if (defined? child-url) `((@H " · ") (a (@ (href ,child-url)) "Child")))) - ,@(let (folge-url (environment-lookup 'folge-url env)) + ,@(let ((folge-url (environment-lookup 'folge-url env))) (if (defined? folge-url) `((@H " · ") (a (@ (href ,folge-url)) "Folge")))) ) ) ;;; ROLE-tag-actions returns an additional action "Zettel" for zettel with role "tag". -(define (ROLE-tag-actions env) +(defun ROLE-tag-actions (env) `(,@(ROLE-DEFAULT-actions env) - ,@(let (title (environment-lookup 'title env)) + ,@(let ((title (environment-lookup 'title env))) (if (and (defined? title) title) `(,ACTION-SEPARATOR (a (@ (href ,(query->url (string-append "tags:" title)))) "Zettel")) ) ) ) @@ -112,12 +113,12 @@ ) ;;; ROLE-DEFAULT-heading returns the default text for headings, below the ;;; references of a zettel. In most cases it should be called from an ;;; overwriting function. -(define (ROLE-DEFAULT-heading env) - `(,@(let (meta-url (environment-lookup 'meta-url env)) +(defun ROLE-DEFAULT-heading (env) + `(,@(let ((meta-url (environment-lookup 'meta-url env))) (if (defined? meta-url) `((br) "URL: " ,(url-to-html meta-url)))) - ,@(let (meta-author (environment-lookup 'meta-author env)) + ,@(let ((meta-author (environment-lookup 'meta-author env))) (if (and (defined? meta-author) meta-author) `((br) "By " ,meta-author))) ) ) Index: box/filebox/zipbox.go ================================================================== --- box/filebox/zipbox.go +++ box/filebox/zipbox.go @@ -80,18 +80,10 @@ func (zb *zipBox) Stop(context.Context) { zb.dirSrv.Stop() zb.dirSrv = nil } -func (*zipBox) CanCreateZettel(context.Context) bool { return false } - -func (zb *zipBox) CreateZettel(context.Context, zettel.Zettel) (id.Zid, error) { - err := box.ErrReadOnly - zb.log.Trace().Err(err).Msg("CreateZettel") - return id.Invalid, err -} - func (zb *zipBox) GetZettel(_ context.Context, zid id.Zid) (zettel.Zettel, error) { entry := zb.dirSrv.GetDirEntry(zid) if !entry.IsValid() { return zettel.Zettel{}, box.ErrZettelNotFound{Zid: zid} } @@ -173,18 +165,10 @@ handle(m) } return nil } -func (*zipBox) CanUpdateZettel(context.Context, zettel.Zettel) bool { return false } - -func (zb *zipBox) UpdateZettel(context.Context, zettel.Zettel) error { - err := box.ErrReadOnly - zb.log.Trace().Err(err).Msg("UpdateZettel") - return err -} - func (zb *zipBox) AllowRenameZettel(_ context.Context, zid id.Zid) bool { entry := zb.dirSrv.GetDirEntry(zid) return !entry.IsValid() } Index: box/manager/box.go ================================================================== --- box/manager/box.go +++ box/manager/box.go @@ -39,13 +39,19 @@ return sb.String() } // CanCreateZettel returns true, if box could possibly create a new zettel. func (mgr *Manager) CanCreateZettel(ctx context.Context) bool { + if mgr.State() != box.StartStateStarted { + return false + } mgr.mgrMx.RLock() defer mgr.mgrMx.RUnlock() - return mgr.State() == box.StartStateStarted && mgr.boxes[0].CanCreateZettel(ctx) + if box, isWriteBox := mgr.boxes[0].(box.WriteBox); isWriteBox { + return box.CanCreateZettel(ctx) + } + return false } // CreateZettel creates a new zettel. func (mgr *Manager) CreateZettel(ctx context.Context, zettel zettel.Zettel) (id.Zid, error) { mgr.mgrLog.Debug().Msg("CreateZettel") @@ -52,11 +58,19 @@ if mgr.State() != box.StartStateStarted { return id.Invalid, box.ErrStopped } mgr.mgrMx.RLock() defer mgr.mgrMx.RUnlock() - return mgr.boxes[0].CreateZettel(ctx, zettel) + if box, isWriteBox := mgr.boxes[0].(box.WriteBox); isWriteBox { + zettel.Meta = mgr.cleanMetaProperties(zettel.Meta) + zid, err := box.CreateZettel(ctx, zettel) + if err == nil { + mgr.idxUpdateZettel(ctx, zettel) + } + return zid, err + } + return id.Invalid, box.ErrReadOnly } // GetZettel retrieves a specific zettel. func (mgr *Manager) GetZettel(ctx context.Context, zid id.Zid) (zettel.Zettel, error) { mgr.mgrLog.Debug().Zid(zid).Msg("GetZettel") @@ -195,29 +209,37 @@ return result, nil } // CanUpdateZettel returns true, if box could possibly update the given zettel. func (mgr *Manager) CanUpdateZettel(ctx context.Context, zettel zettel.Zettel) bool { + if mgr.State() != box.StartStateStarted { + return false + } mgr.mgrMx.RLock() defer mgr.mgrMx.RUnlock() - return mgr.State() == box.StartStateStarted && mgr.boxes[0].CanUpdateZettel(ctx, zettel) + if box, isWriteBox := mgr.boxes[0].(box.WriteBox); isWriteBox { + return box.CanUpdateZettel(ctx, zettel) + } + return false + } // UpdateZettel updates an existing zettel. func (mgr *Manager) UpdateZettel(ctx context.Context, zettel zettel.Zettel) error { mgr.mgrLog.Debug().Zid(zettel.Meta.Zid).Msg("UpdateZettel") if mgr.State() != box.StartStateStarted { return box.ErrStopped } - // Remove all (computed) properties from metadata before storing the zettel. - zettel.Meta = zettel.Meta.Clone() - for _, p := range zettel.Meta.ComputedPairsRest() { - if mgr.propertyKeys.Has(p.Key) { - zettel.Meta.Delete(p.Key) - } - } - return mgr.boxes[0].UpdateZettel(ctx, zettel) + if box, isWriteBox := mgr.boxes[0].(box.WriteBox); isWriteBox { + zettel.Meta = mgr.cleanMetaProperties(zettel.Meta) + if err := box.UpdateZettel(ctx, zettel); err != nil { + return err + } + mgr.idxUpdateZettel(ctx, zettel) + return nil + } + return box.ErrReadOnly } // AllowRenameZettel returns true, if box will not disallow renaming the zettel. func (mgr *Manager) AllowRenameZettel(ctx context.Context, zid id.Zid) bool { if mgr.State() != box.StartStateStarted { @@ -249,10 +271,11 @@ mgr.boxes[j].RenameZettel(ctx, newZid, curZid) } return err } } + mgr.idxRenameZettel(ctx, curZid, newZid) return nil } // CanDeleteZettel returns true, if box could possibly delete the given zettel. func (mgr *Manager) CanDeleteZettel(ctx context.Context, zid id.Zid) bool { @@ -278,14 +301,26 @@ mgr.mgrMx.RLock() defer mgr.mgrMx.RUnlock() for _, p := range mgr.boxes { err := p.DeleteZettel(ctx, zid) if err == nil { + mgr.idxDeleteZettel(ctx, zid) return nil } var errZNF box.ErrZettelNotFound if !errors.As(err, &errZNF) && !errors.Is(err, box.ErrReadOnly) { return err } } return box.ErrZettelNotFound{Zid: zid} } + +// Remove all (computed) properties from metadata before storing the zettel. +func (mgr *Manager) cleanMetaProperties(m *meta.Meta) *meta.Meta { + result := m.Clone() + for _, p := range result.ComputedPairsRest() { + if mgr.propertyKeys.Has(p.Key) { + result.Delete(p.Key) + } + } + return result +} Index: box/manager/indexer.go ================================================================== --- box/manager/indexer.go +++ box/manager/indexer.go @@ -120,11 +120,11 @@ // Zettel was deleted or is not accessible b/c of other reasons mgr.idxLog.Trace().Zid(zid).Msg("delete") mgr.idxMx.Lock() mgr.idxSinceReload++ mgr.idxMx.Unlock() - mgr.idxDeleteZettel(zid) + mgr.idxDeleteZettel(ctx, zid) continue } mgr.idxLog.Trace().Zid(zid).Msg("update") mgr.idxMx.Lock() if arRoomNum == roomNum { @@ -224,15 +224,20 @@ return } zi.AddInverseRef(inverseKey, zid) } -func (mgr *Manager) idxDeleteZettel(zid id.Zid) { - toCheck := mgr.idxStore.DeleteZettel(context.Background(), zid) +func (mgr *Manager) idxRenameZettel(ctx context.Context, curZid, newZid id.Zid) { + toCheck := mgr.idxStore.RenameZettel(ctx, curZid, newZid) + mgr.idxCheckZettel(toCheck) +} + +func (mgr *Manager) idxDeleteZettel(ctx context.Context, zid id.Zid) { + toCheck := mgr.idxStore.DeleteZettel(ctx, zid) mgr.idxCheckZettel(toCheck) } func (mgr *Manager) idxCheckZettel(s id.Set) { for zid := range s { mgr.idxAr.EnqueueZettel(zid) } } Index: box/manager/manager.go ================================================================== --- box/manager/manager.go +++ box/manager/manager.go @@ -354,20 +354,30 @@ func (mgr *Manager) Refresh(ctx context.Context) error { mgr.mgrLog.Debug().Msg("Refresh") if mgr.State() != box.StartStateStarted { return box.ErrStopped } + mgr.infos <- box.UpdateInfo{Reason: box.OnReload, Zid: id.Invalid} mgr.mgrMx.Lock() defer mgr.mgrMx.Unlock() - mgr.infos <- box.UpdateInfo{Reason: box.OnReload, Zid: id.Invalid} for _, bx := range mgr.boxes { if rb, ok := bx.(box.Refresher); ok { rb.Refresh(ctx) } } return nil } + +// ReIndex data of the given zettel. +func (mgr *Manager) ReIndex(_ context.Context, zid id.Zid) error { + mgr.mgrLog.Debug().Msg("ReIndex") + if mgr.State() != box.StartStateStarted { + return box.ErrStopped + } + mgr.infos <- box.UpdateInfo{Reason: box.OnReload, Zid: zid} + return nil +} // ReadStats populates st with box statistics. func (mgr *Manager) ReadStats(st *box.Stats) { mgr.mgrLog.Debug().Msg("ReadStats") mgr.mgrMx.RLock() Index: box/manager/memstore/memstore.go ================================================================== --- box/manager/memstore/memstore.go +++ box/manager/memstore/memstore.go @@ -31,17 +31,17 @@ forward id.Slice backward id.Slice } type zettelData struct { - meta *meta.Meta - dead id.Slice - forward id.Slice - backward id.Slice + meta *meta.Meta // a local copy of the metadata, without computed keys + dead id.Slice // list of dead references in this zettel + forward id.Slice // list of forward references in this zettel + backward id.Slice // list of zettel that reference with zettel otherRefs map[string]bidiRefs - words []string - urls []string + words []string // list of words of this zettel + urls []string // list of urls of this zettel } type stringRefs map[string]id.Slice type memStore struct { @@ -291,12 +291,12 @@ ms.updateDeadReferences(zidx, zi) ids := ms.updateForwardBackwardReferences(zidx, zi) toCheck = toCheck.Copy(ids) ids = ms.updateMetadataReferences(zidx, zi) toCheck = toCheck.Copy(ids) - zi.words = updateWordSet(zidx.Zid, ms.words, zi.words, zidx.GetWords()) - zi.urls = updateWordSet(zidx.Zid, ms.urls, zi.urls, zidx.GetUrls()) + zi.words = updateStrings(zidx.Zid, ms.words, zi.words, zidx.GetWords()) + zi.urls = updateStrings(zidx.Zid, ms.urls, zi.urls, zidx.GetUrls()) // Check if zi must be inserted into ms.idx if !ziExist { ms.idx[zidx.Zid] = zi } @@ -412,11 +412,11 @@ ms.removeInverseMeta(zidx.Zid, key, remRefs) } return toCheck } -func updateWordSet(zid id.Zid, srefs stringRefs, prev []string, next store.WordSet) []string { +func updateStrings(zid id.Zid, srefs stringRefs, prev []string, next store.WordSet) []string { newWords, removeWords := next.Diff(prev) for _, word := range newWords { if refs, ok := srefs[word]; ok { srefs[word] = addRef(refs, zid) continue @@ -445,28 +445,101 @@ } zi := &zettelData{} ms.idx[zid] = zi return zi } + +func (ms *memStore) RenameZettel(_ context.Context, curZid, newZid id.Zid) id.Set { + ms.mx.Lock() + defer ms.mx.Unlock() + + curZi, curFound := ms.idx[curZid] + _, newFound := ms.idx[newZid] + if !curFound || newFound { + return nil + } + newZi := &zettelData{ + meta: copyMeta(curZi.meta, newZid), + dead: ms.copyDeadReferences(curZi.dead), + forward: ms.copyForward(curZi.forward, newZid), + backward: nil, // will be done through tocheck + otherRefs: nil, // TODO: check if this will be done through toCheck + words: copyStrings(ms.words, curZi.words, newZid), + urls: copyStrings(ms.urls, curZi.urls, newZid), + } + + ms.idx[newZid] = newZi + toCheck := ms.doDeleteZettel(curZid) + toCheck = toCheck.CopySlice(ms.dead[newZid]) + delete(ms.dead, newZid) + toCheck = toCheck.Add(newZid) // should update otherRefs + return toCheck +} +func copyMeta(m *meta.Meta, newZid id.Zid) *meta.Meta { + result := m.Clone() + result.Zid = newZid + return result +} +func (ms *memStore) copyDeadReferences(curDead id.Slice) id.Slice { + // Must only be called if ms.mx is write-locked! + if l := len(curDead); l > 0 { + result := make(id.Slice, l) + for i, ref := range curDead { + result[i] = ref + ms.dead[ref] = addRef(ms.dead[ref], ref) + } + return result + } + return nil +} +func (ms *memStore) copyForward(curForward id.Slice, newZid id.Zid) id.Slice { + // Must only be called if ms.mx is write-locked! + if l := len(curForward); l > 0 { + result := make(id.Slice, l) + for i, ref := range curForward { + result[i] = ref + if fzi, found := ms.idx[ref]; found { + fzi.backward = addRef(fzi.backward, newZid) + } + } + return result + } + return nil +} +func copyStrings(msStringMap stringRefs, curStrings []string, newZid id.Zid) []string { + // Must only be called if ms.mx is write-locked! + if l := len(curStrings); l > 0 { + result := make([]string, l) + for i, s := range curStrings { + result[i] = s + msStringMap[s] = addRef(msStringMap[s], newZid) + } + return result + } + return nil +} func (ms *memStore) DeleteZettel(_ context.Context, zid id.Zid) id.Set { ms.mx.Lock() defer ms.mx.Unlock() + return ms.doDeleteZettel(zid) +} +func (ms *memStore) doDeleteZettel(zid id.Zid) id.Set { + // Must only be called if ms.mx is write-locked! zi, ok := ms.idx[zid] if !ok { return nil } ms.deleteDeadSources(zid, zi) toCheck := ms.deleteForwardBackward(zid, zi) - if len(zi.otherRefs) > 0 { - for key, mrefs := range zi.otherRefs { - ms.removeInverseMeta(zid, key, mrefs.forward) - } + for key, mrefs := range zi.otherRefs { + ms.removeInverseMeta(zid, key, mrefs.forward) } - ms.deleteWords(zid, zi.words) + deleteStrings(ms.words, zi.words, zid) + deleteStrings(ms.urls, zi.urls, zid) delete(ms.idx, zid) return toCheck } func (ms *memStore) deleteDeadSources(zid id.Zid, zi *zettelData) { @@ -483,23 +556,20 @@ } } func (ms *memStore) deleteForwardBackward(zid id.Zid, zi *zettelData) id.Set { // Must only be called if ms.mx is write-locked! - var toCheck id.Set for _, ref := range zi.forward { if fzi, ok := ms.idx[ref]; ok { fzi.backward = remRef(fzi.backward, zid) } } + var toCheck id.Set for _, ref := range zi.backward { if bzi, ok := ms.idx[ref]; ok { bzi.forward = remRef(bzi.forward, zid) - if toCheck == nil { - toCheck = id.NewSet() - } - toCheck.Add(ref) + toCheck = toCheck.Add(ref) } } return toCheck } @@ -524,23 +594,23 @@ } } } } -func (ms *memStore) deleteWords(zid id.Zid, words []string) { +func deleteStrings(msStringMap stringRefs, curStrings []string, zid id.Zid) { // Must only be called if ms.mx is write-locked! - for _, word := range words { - refs, ok := ms.words[word] + for _, word := range curStrings { + refs, ok := msStringMap[word] if !ok { continue } refs2 := remRef(refs, zid) if len(refs2) == 0 { - delete(ms.words, word) + delete(msStringMap, word) continue } - ms.words[word] = refs2 + msStringMap[word] = refs2 } } func (ms *memStore) ReadStats(st *store.Stats) { ms.mx.RLock() Index: box/manager/store/store.go ================================================================== --- box/manager/store/store.go +++ box/manager/store/store.go @@ -47,10 +47,14 @@ Enrich(ctx context.Context, m *meta.Meta) // UpdateReferences for a specific zettel. // Returns set of zettel identifier that must also be checked for changes. UpdateReferences(context.Context, *ZettelIndex) id.Set + + // RenameZettel changes all references of current zettel identifier to new + // zettel identifier. + RenameZettel(_ context.Context, curZid, newZid id.Zid) id.Set // DeleteZettel removes index data for given zettel. // Returns set of zettel identifier that must also be checked for changes. DeleteZettel(context.Context, id.Zid) id.Set Index: cmd/cmd_run.go ================================================================== --- cmd/cmd_run.go +++ cmd/cmd_run.go @@ -64,16 +64,18 @@ ucGetZettel := usecase.NewGetZettel(protectedBoxManager) ucParseZettel := usecase.NewParseZettel(rtConfig, ucGetZettel) ucQuery := usecase.NewQuery(protectedBoxManager) ucEvaluate := usecase.NewEvaluate(rtConfig, &ucGetZettel, &ucQuery) ucQuery.SetEvaluate(&ucEvaluate) + ucTagZettel := usecase.NewTagZettel(protectedBoxManager, &ucQuery) ucListSyntax := usecase.NewListSyntax(protectedBoxManager) ucListRoles := usecase.NewListRoles(protectedBoxManager) ucDelete := usecase.NewDeleteZettel(logUc, protectedBoxManager) ucUpdate := usecase.NewUpdateZettel(logUc, protectedBoxManager) ucRename := usecase.NewRenameZettel(logUc, protectedBoxManager) ucRefresh := usecase.NewRefresh(logUc, protectedBoxManager) + ucReIndex := usecase.NewReIndex(logUc, protectedBoxManager) ucVersion := usecase.NewVersion(kernel.Main.GetConfig(kernel.CoreService, kernel.CoreVersion).(string)) a := api.New( webLog.Clone().Str("adapter", "api").Child(), webSrv, authManager, authManager, rtConfig, authPolicy) @@ -101,11 +103,11 @@ webSrv.AddZettelRoute('d', server.MethodPost, wui.MakePostDeleteZettelHandler(&ucDelete)) webSrv.AddZettelRoute('e', server.MethodGet, wui.MakeEditGetZettelHandler(ucGetZettel, ucListRoles, ucListSyntax)) webSrv.AddZettelRoute('e', server.MethodPost, wui.MakeEditSetZettelHandler(&ucUpdate)) } webSrv.AddListRoute('g', server.MethodGet, wui.MakeGetGoActionHandler(&ucRefresh)) - webSrv.AddListRoute('h', server.MethodGet, wui.MakeListHTMLMetaHandler(&ucQuery)) + webSrv.AddListRoute('h', server.MethodGet, wui.MakeListHTMLMetaHandler(&ucQuery, &ucTagZettel, &ucReIndex)) webSrv.AddZettelRoute('h', server.MethodGet, wui.MakeGetHTMLZettelHandler(&ucEvaluate, ucGetZettel)) webSrv.AddListRoute('i', server.MethodGet, wui.MakeGetLoginOutHandler()) webSrv.AddListRoute('i', server.MethodPost, wui.MakePostLoginHandler(&ucAuthenticate)) webSrv.AddZettelRoute('i', server.MethodGet, wui.MakeGetInfoHandler( ucParseZettel, &ucEvaluate, ucGetZettel, ucGetAllZettel, &ucQuery)) @@ -113,11 +115,11 @@ // API webSrv.AddListRoute('a', server.MethodPost, a.MakePostLoginHandler(&ucAuthenticate)) webSrv.AddListRoute('a', server.MethodPut, a.MakeRenewAuthHandler()) webSrv.AddListRoute('x', server.MethodGet, a.MakeGetDataHandler(ucVersion)) webSrv.AddListRoute('x', server.MethodPost, a.MakePostCommandHandler(&ucIsAuth, &ucRefresh)) - webSrv.AddListRoute('z', server.MethodGet, a.MakeQueryHandler(&ucQuery)) + webSrv.AddListRoute('z', server.MethodGet, a.MakeQueryHandler(&ucQuery, &ucTagZettel, &ucReIndex)) webSrv.AddZettelRoute('z', server.MethodGet, a.MakeGetZettelHandler(ucGetZettel, ucParseZettel, ucEvaluate)) if !authManager.IsReadonly() { webSrv.AddListRoute('z', server.MethodPost, a.MakePostCreateZettelHandler(&ucCreateZettel)) webSrv.AddZettelRoute('z', server.MethodPut, a.MakeUpdateZettelHandler(&ucUpdate)) webSrv.AddZettelRoute('z', server.MethodDelete, a.MakeDeleteZettelHandler(&ucDelete)) Index: docs/manual/00001000000000.zettel ================================================================== --- docs/manual/00001000000000.zettel +++ docs/manual/00001000000000.zettel @@ -1,11 +1,12 @@ id: 00001000000000 title: Zettelstore Manual role: manual tags: #manual #zettelstore syntax: zmk -modified: 20220803183647 +created: 00010101000000 +modified: 20231002143058 * [[Introduction|00001001000000]] * [[Design goals|00001002000000]] * [[Installation|00001003000000]] * [[Configuration|00001004000000]] @@ -17,7 +18,9 @@ * [[API|00001012000000]] * [[Web user interface|00001014000000]] * [[Tips and Tricks|00001017000000]] * [[Troubleshooting|00001018000000]] * Frequently asked questions + +Version: {{00001000000001}}. Licensed under the EUPL-1.2-or-later. ADDED docs/manual/00001000000001.zettel Index: docs/manual/00001000000001.zettel ================================================================== --- /dev/null +++ docs/manual/00001000000001.zettel @@ -0,0 +1,8 @@ +id: 00001000000001 +title: Manual Version +role: configuration +syntax: zmk +created: 20231002142915 +modified: 20231002142948 + +To be set by build tool. Index: docs/manual/00001005090000.zettel ================================================================== --- docs/manual/00001005090000.zettel +++ docs/manual/00001005090000.zettel @@ -2,11 +2,11 @@ title: List of predefined zettel role: manual tags: #manual #reference #zettelstore syntax: zmk created: 20210126175322 -modified: 20230827212840 +modified: 20231002104819 The following table lists all predefined zettel with their purpose. |= Identifier :|= Title | Purpose | [[00000000000001]] | Zettelstore Version | Contains the version string of the running Zettelstore @@ -34,12 +34,13 @@ | [[00000000019990]] | Zettelstore Sxn Base Code | Base sxn functions to build the templates | [[00000000020001]] | Zettelstore Base CSS | System-defined CSS file that is included by the [[Base HTML Template|00000000010100]] | [[00000000025001]] | Zettelstore User CSS | User-defined CSS file that is included by the [[Base HTML Template|00000000010100]] | [[00000000040001]] | Generic Emoji | Image that is shown if [[original image reference|00001007040322]] is invalid | [[00000000090000]] | New Menu | Contains items that should contain in the zettel template menu -| [[00000000090001]] | New Zettel | Template for a new zettel with role ""[[zettel|00001006020100]]"" +| [[00000000090001]] | New Zettel | Template for a new zettel with role ""[[zettel|00001006020100#zettel]]"" | [[00000000090002]] | New User | Template for a new [[user zettel|00001010040200]] +| [[00000000090003]] | New Tag | Template for a new [[tag zettel|00001006020100#tag]] | [[00010000000000]] | Home | Default home zettel, contains some welcome information If a zettel is not linked, it is not accessible for the current user. **Important:** All identifier may change until a stable version of the software is released. Index: docs/manual/00001007031140.zettel ================================================================== --- docs/manual/00001007031140.zettel +++ docs/manual/00001007031140.zettel @@ -2,11 +2,11 @@ title: Zettelmarkup: Query Transclusion role: manual tags: #manual #search #zettelmarkup #zettelstore syntax: zmk created: 20220809132350 -modified: 20230116183656 +modified: 20231023163751 A query transclusion is specified by the following sequence, starting at the first position in a line: ''{{{query:query-expression}}}''. The line must literally start with the sequence ''{{{query:''. Everything after this prefix is interpreted as a [[query expression|00001007700000]]. @@ -47,10 +47,13 @@ ; ''RSS'' (aggregate) : Transform the zettel list into a [[RSS 2.0|https://www.rssboard.org/rss-specification]]-conformant document / feed. The document is embedded into the referencing zettel. ; ''KEYS'' (aggregate) : Emit a list of all metadata keys, together with the number of zettel having the key. +; ''REINDEX'' (aggregate) +: Will be ignored. + This action may have been copied from an existing [[API query call|00001012051400]] (or from a WebUI query), but is here superfluous (and possibly harmful). ; Any [[metadata key|00001006020000]] of type [[Word|00001006035500]], [[WordSet|00001006036000]], or [[TagSet|00001006034000]] (aggregates) : Emit an aggregate of the given metadata key. The key can be given in any letter case[^Except if the key name collides with one of the above names. In this case use at least one lower case letter.]. Example: Index: docs/manual/00001007702000.zettel ================================================================== --- docs/manual/00001007702000.zettel +++ docs/manual/00001007702000.zettel @@ -2,11 +2,11 @@ title: Search term role: manual tags: #manual #search #zettelstore syntax: zmk created: 20220805150154 -modified: 20230612180954 +modified: 20230925173539 A search term allows you to specify one search restriction. The result [[search expression|00001007700000]], which contains more than one search term, will be the applications of all restrictions. A search term can be one of the following (the first three term are collectively called __search literals__): @@ -44,11 +44,11 @@ Example: ''PICK 5 PICK 3'' will be interpreted as ''PICK 3''. * The string ''ORDER'', followed by a non-empty sequence of spaces and the name of a metadata key, will specify an ordering of the result list. If you include the string ''REVERSE'' after ''ORDER'' but before the metadata key, the ordering will be reversed. - Example: ''ORDER published'' will order the resulting list based on the publishing data, while ''ORDER REVERSED published'' will return a reversed result order. + Example: ''ORDER published'' will order the resulting list based on the publishing data, while ''ORDER REVERSE published'' will return a reversed result order. An explicit order field will take precedence over the random order described below. If no random order is effective, a ``ORDER REVERSE id`` will be added. This makes the sort stable. Index: docs/manual/00001007721200.zettel ================================================================== --- docs/manual/00001007721200.zettel +++ docs/manual/00001007721200.zettel @@ -1,12 +1,12 @@ id: 00001007721200 title: Query: Unlinked Directive role: manual -tags: #api #manual #zettelstore +tags: #manual #zettelstore syntax: zmk created: 20211119133357 -modified: 20230731163343 +modified: 20230928190540 The value of a personal Zettelstore is determined in part by explicit connections between related zettel. If the number of zettel grow, some of these connections are missing. There are various reasons for this. Maybe, you forgot that a zettel exists. Index: docs/manual/00001012000000.zettel ================================================================== --- docs/manual/00001012000000.zettel +++ docs/manual/00001012000000.zettel @@ -2,11 +2,11 @@ title: API role: manual tags: #api #manual #zettelstore syntax: zmk created: 20210126175322 -modified: 20230807171136 +modified: 20230928183244 The API (short for ""**A**pplication **P**rogramming **I**nterface"") is the primary way to communicate with a running Zettelstore. Most integration with other systems and services is done through the API. The [[web user interface|00001014000000]] is just an alternative, secondary way of interacting with a Zettelstore. @@ -22,10 +22,11 @@ * [[Provide an access token|00001012050600]] when doing an API call === Zettel lists * [[List all zettel|00001012051200]] * [[Query the list of all zettel|00001012051400]] +* [[Determine a tag zettel|00001012051600]] === Working with zettel * [[Create a new zettel|00001012053200]] * [[Retrieve metadata and content of an existing zettel|00001012053300]] * [[Retrieve metadata of an existing zettel|00001012053400]] Index: docs/manual/00001012051400.zettel ================================================================== --- docs/manual/00001012051400.zettel +++ docs/manual/00001012051400.zettel @@ -2,11 +2,11 @@ title: API: Query the list of all zettel role: manual tags: #api #manual #zettelstore syntax: zmk created: 20220912111111 -modified: 20230807170638 +modified: 20231023162927 precursor: 00001012051200 The [[endpoint|00001012920000]] ''/z'' also allows you to filter the list of all zettel[^If [[authentication is enabled|00001010040100]], you must include the a valid [[access token|00001012050200]] in the ''Authorization'' header] and optionally to provide some actions. A [[query|00001007700000]] is an optional [[search expression|00001007700000#search-expression]], together with an optional [[list of actions|00001007700000#action-list]] (described below). @@ -107,10 +107,14 @@ : Emit only those values with at least __n__ aggregated values. __n__ must be a positive integer, ''MIN'' must be given in upper-case letters. ; ''MAXn'' (parameter) : Emit only those values with at most __n__ aggregated values. __n__ must be a positive integer, ''MAX'' must be given in upper-case letters. +; ''REINDEX'' (aggregate) +: Updates the internal search index for the selected zettel, roughly similar to the [[refresh|00001012080500]] API call. + It is not really an aggregate, since it is used only for its side effect. + It is allowed to specify another aggregate. ; Any [[metadata key|00001006020000]] of type [[Word|00001006035500]], [[WordSet|00001006036000]], or [[TagSet|00001006034000]] (aggregates) : Emit an aggregate of the given metadata key. The key can be given in any letter case. Only the first aggregate action will be executed. ADDED docs/manual/00001012051600.zettel Index: docs/manual/00001012051600.zettel ================================================================== --- /dev/null +++ docs/manual/00001012051600.zettel @@ -0,0 +1,82 @@ +id: 00001012051600 +title: API: Determine a tag zettel +role: manual +tags: #api #manual #zettelstore +syntax: zmk +created: 20230928183339 +modified: 20230929114937 + +The [[endpoint|00001012920000]] ''/z'' also allows you to determine a ""tag zettel"", i.e. a zettel that documents a given tag. + +The query parameter ""''tag''"" allows you to specify a value that is interpreted as the name of a tag. +Zettelstore tries to determine the corresponding tag zettel. + +A tag zettel is a zettel with the [[''role''|00001006020100]] value ""tag"" and a title that names the tag. +If there is more than one zettel that qualifies, the zettel with the highest zettel identifier is used. + +For example, if you want to determine the tag zettel for the tag ""#api"", your request will be: +```sh +# curl -i 'http://127.0.0.1:23123/z?tag=%23api' +HTTP/1.1 302 Found +Content-Type: text/plain; charset=utf-8 +Location: /z/00001019990010 +Content-Length: 14 + +00001019990010 +``` + +Alternatively, you can omit the ''#'' character at the beginning of the tag: +```sh +# curl -i 'http://127.0.0.1:23123/z?tag=api' +HTTP/1.1 302 Found +Content-Type: text/plain; charset=utf-8 +Location: /z/00001019990010 +Content-Length: 14 + +00001019990010 +``` + +If there is a corresponding tag zettel, the response will use the HTTP status code 302 (""Found""), the HTTP response header ''Location'' will contain the URL of the tag zettel. +Its zettel identifier will be returned in the HTTP response body. + +If you specified some more query parameter, these will be part of the URL in the response header ''Location'': + +```sh +# curl -i 'http://127.0.0.1:23123/z?tag=%23api&part=zettel' +HTTP/1.1 302 Found +Content-Type: text/plain; charset=utf-8 +Location: /z/00001019990010?part=zettel +Content-Length: 14 + +00001019990010 +``` + +Otherwise, if no tag zettel was found, the response will use the HTTP status code 404 (""Not found""). + +```sh +# curl -i 'http://127.0.0.1:23123/z?tag=notag' +HTTP/1.1 404 Not Found +Content-Type: text/plain; charset=utf-8 +Content-Length: 29 + +Tag zettel not found: #notag +``` + +To fulfill this service, Zettelstore will evaluate internally the query ''role:tag title=TAG'', there ''TAG'' is the actual tag. + +Of course, if you are interested in the URL of the tag zettel, you can make use of the HTTP ''HEAD'' method: + +```sh +# curl -I 'http://127.0.0.1:23123/z?tag=%23api' +HTTP/1.1 302 Found +Content-Type: text/plain; charset=utf-8 +Location: /z/00001019990010 +Content-Length: 14 +``` + +=== HTTP Status codes +; ''302'' +: Tag zettel was found. + The HTTP header ''Location'' contains its URL, the body of the response contains its zettel identifier. +; ''404'' +: No zettel for the given tag was found. Index: docs/manual/00001012070500.zettel ================================================================== --- docs/manual/00001012070500.zettel +++ docs/manual/00001012070500.zettel @@ -1,12 +1,12 @@ id: 00001012070500 -title: Retrieve administrative data +title: API: Retrieve administrative data role: manual tags: #api #manual #zettelstore syntax: zmk -created: 00010101000000 -modified: 20230701160903 +created: 20220304164242 +modified: 20230928190516 The [[endpoint|00001012920000]] ''/x'' allows you to retrieve some (administrative) data. Currently, you can only request Zettelstore version data. Index: docs/manual/00001017000000.zettel ================================================================== --- docs/manual/00001017000000.zettel +++ docs/manual/00001017000000.zettel @@ -2,11 +2,11 @@ title: Tips and Tricks role: manual tags: #manual #zettelstore syntax: zmk created: 20220803170112 -modified: 20230827225202 +modified: 20231012154803 === Welcome Zettel * **Problem:** You want to put your Zettelstore into the public and need a starting zettel for your users. In addition, you still want a ""home zettel"", with all your references to internal, non-public zettel. Zettelstore only allows to specify one [[''home-zettel''|00001004020000#home-zettel]]. @@ -38,14 +38,14 @@ Let's assume, the newly created CSS zettel got the identifier ''20220825200100''. Now, you have to map this freshly created zettel to a role, for example ""zettel"". Since you have enabled ''expert-mode'', you are allowed to modify the zettel ""[[Zettelstore Sxn Start Code|00000000019000]]"". - Add the following code to the Sxn Start Code zettel: ``(define CSS-ROLE-map '(("zettel" . "20220825200100")))``. + Add the following code to the Sxn Start Code zettel: ``(set! CSS-ROLE-map '(("zettel" . "20220825200100")))``. In general, the mapping must follow the pattern: ``(ROLE . ID)``, where ''ROLE'' is the placeholder for the role, and ''ID'' for the zettel identifier containing CSS code. - For example, if you also want the role ""configuration"" to be rendered using that CSS, the code should be something like ``(define CSS-ROLE-map '(("zettel" . "20220825200100") ("configuration" . "20220825200100")))``. + For example, if you also want the role ""configuration"" to be rendered using that CSS, the code should be something like ``(set! CSS-ROLE-map '(("zettel" . "20220825200100") ("configuration" . "20220825200100")))``. * **Discussion:** you have to ensure that the CSS zettel is allowed to be read by the intended audience of the zettel with that given role. For example, if you made zettel with a specific role public visible, the CSS zettel must also have a [[''visibility: public''|00001010070200]] metadata. === Zettel synchronization with iCloud (Apple) * **Problem:** You use Zettelstore on various macOS computers and you want to use the sameset of zettel across all computers. ADDED docs/manual/00001019990010.zettel Index: docs/manual/00001019990010.zettel ================================================================== --- /dev/null +++ docs/manual/00001019990010.zettel @@ -0,0 +1,8 @@ +id: 00001019990010 +title: #api +role: tag +syntax: zmk +created: 20230928185004 +modified: 20230928185204 + +Zettel with the tag ''#api'' contain a description of the [[API|00001012000000]]. Index: encoder/encoder_block_test.go ================================================================== --- encoder/encoder_block_test.go +++ encoder/encoder_block_test.go @@ -297,10 +297,22 @@ encoderSHTML: `((dl (dt "Zettel") (dd (p "Paper") (p "Note")) (dt "Zettelkasten") (dd (p "Slip" " " "box"))))`, encoderText: "Zettel\nPaper\nNote\nZettelkasten\nSlip box", encoderZmk: useZmk, }, }, + { + descr: "Description List with keys, but no descriptions", + zmk: "; K1\n: D11\n: D12\n; K2\n; K3\n: D31", + expect: expectMap{ + encoderHTML: "
K1

D11

D12

K2
K3

D31

", + encoderMD: "", + encoderSz: `(BLOCK (DESCRIPTION (INLINE (TEXT "K1")) (BLOCK (BLOCK (PARA (TEXT "D11"))) (BLOCK (PARA (TEXT "D12")))) (INLINE (TEXT "K2")) (BLOCK) (INLINE (TEXT "K3")) (BLOCK (BLOCK (PARA (TEXT "D31"))))))`, + encoderSHTML: `((dl (dt "K1") (dd (p "D11")) (dd (p "D12")) (dt "K2") (dt "K3") (dd (p "D31"))))`, + encoderText: "K1\nD11\nD12\nK2\nK3\nD31", + encoderZmk: useZmk, + }, + }, { descr: "Simple Table", zmk: "|c1|c2|c3\n|d1||d3", expect: expectMap{ encoderHTML: `
c1c2c3
d1d3
`, Index: evaluator/list.go ================================================================== --- evaluator/list.go +++ evaluator/list.go @@ -64,10 +64,13 @@ } } if act == "TITLE" && i+1 < len(actions) { ap.title = strings.Join(actions[i+1:], " ") break + } + if act == "REINDEX" { + continue } acts = append(acts, act) } var firstUnknownKey string for _, act := range acts { Index: go.mod ================================================================== --- go.mod +++ go.mod @@ -1,15 +1,15 @@ module zettelstore.de/z go 1.21 require ( - github.com/fsnotify/fsnotify v1.6.0 + github.com/fsnotify/fsnotify v1.7.0 github.com/yuin/goldmark v1.5.6 - golang.org/x/crypto v0.13.0 - golang.org/x/term v0.12.0 + golang.org/x/crypto v0.14.0 + golang.org/x/term v0.13.0 golang.org/x/text v0.13.0 - zettelstore.de/client.fossil v0.0.0-20230915174443-f535d4cb1549 - zettelstore.de/sx.fossil v0.0.0-20230915173519-fa23195f2b56 + zettelstore.de/client.fossil v0.0.0-20231026155719-8c6fa07a0d0f + zettelstore.de/sx.fossil v0.0.0-20231026154942-e6a183740a4f ) -require golang.org/x/sys v0.12.0 // indirect +require golang.org/x/sys v0.13.0 // indirect Index: go.sum ================================================================== --- go.sum +++ go.sum @@ -1,17 +1,16 @@ -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/yuin/goldmark v1.5.6 h1:COmQAWTCcGetChm3Ig7G/t8AFAN00t+o8Mt4cf7JpwA= github.com/yuin/goldmark v1.5.6/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -zettelstore.de/client.fossil v0.0.0-20230915174443-f535d4cb1549 h1:gvn0RjOeQoQxV4/OsGaZbtvYMy2mMj4jAtL+UZcMueE= -zettelstore.de/client.fossil v0.0.0-20230915174443-f535d4cb1549/go.mod h1:XtlEvc8JZQLJH4DO9Lvdwv5wEpr96gJ/NYyfyutnf0Y= -zettelstore.de/sx.fossil v0.0.0-20230915173519-fa23195f2b56 h1:Y1cw0BCmxDZMImE5c5jGDIOz1XdTpG5/GBBjMkZZUo8= -zettelstore.de/sx.fossil v0.0.0-20230915173519-fa23195f2b56/go.mod h1:Uw3OLM1ufOM4Xe0G51mvkTDUv2okd+HyDBMx+0ZG7ME= +zettelstore.de/client.fossil v0.0.0-20231026155719-8c6fa07a0d0f h1:eW8wEMcqR+LvIwWxE1rl+6LZbXRM9wgBGQ9pPw/k1j8= +zettelstore.de/client.fossil v0.0.0-20231026155719-8c6fa07a0d0f/go.mod h1:uYFsUH4hQ/TLEjDFxzOLJkT/sltvcQ5aIM29XNkjR+c= +zettelstore.de/sx.fossil v0.0.0-20231026154942-e6a183740a4f h1:NFblKWyhNnXDDcF7C6zx7cDyIJ/7GGAusIwR6uZrfkM= +zettelstore.de/sx.fossil v0.0.0-20231026154942-e6a183740a4f/go.mod h1:Uw3OLM1ufOM4Xe0G51mvkTDUv2okd+HyDBMx+0ZG7ME= Index: parser/plain/plain.go ================================================================== --- parser/plain/plain.go +++ parser/plain/plain.go @@ -134,15 +134,10 @@ return svgSrc } func parseSxnBlocks(inp *input.Input, _ *meta.Meta, syntax string) ast.BlockSlice { rd := sxreader.MakeReader(bytes.NewReader(inp.Src)) - sf := rd.SymbolFactory() - sxbuiltins.InstallQuasiQuoteReader(rd, - sf.MustMake("quasiquote"), '`', - sf.MustMake("unquote"), ',', - sf.MustMake("unquote-splicing"), '@') objs, err := rd.ReadAll() if err != nil { return ast.BlockSlice{ &ast.VerbatimNode{ Kind: ast.VerbatimProg, Index: query/parser.go ================================================================== --- query/parser.go +++ query/parser.go @@ -40,11 +40,11 @@ inp *input.Input } func (ps *parserState) mustStop() bool { return ps.inp.Ch == input.EOS } func (ps *parserState) acceptSingleKw(s string) bool { - if ps.inp.Accept(s) && (ps.isSpace() || ps.mustStop()) { + if ps.inp.Accept(s) && (ps.isSpace() || ps.isActionSep() || ps.mustStop()) { return true } return false } func (ps *parserState) acceptKwArgs(s string) bool { @@ -185,11 +185,11 @@ q = s continue } } inp.SetPos(pos) - if isActionSep(inp.Ch) { + if ps.isActionSep() { q = ps.parseActions(q) break } q = ps.parseText(q) } @@ -365,11 +365,11 @@ if len(key) > 0 { // Assert: hasOp == false op, hasOp = ps.scanSearchOp() // Assert hasOp == true if op == cmpExist || op == cmpNotExist { - if ps.isSpace() || isActionSep(inp.Ch) || ps.mustStop() { + if ps.isSpace() || ps.isActionSep() || ps.mustStop() { return q.addKey(string(key), op) } ps.inp.SetPos(pos) hasOp = false text = ps.scanWord() @@ -404,11 +404,11 @@ func (ps *parserState) scanSearchTextOrKey(hasOp bool) ([]byte, []byte) { inp := ps.inp pos := inp.Pos allowKey := !hasOp - for !ps.isSpace() && !isActionSep(inp.Ch) && !ps.mustStop() { + for !ps.isSpace() && !ps.isActionSep() && !ps.mustStop() { if allowKey { switch inp.Ch { case searchOperatorNotChar, existOperatorChar, searchOperatorEqualChar, searchOperatorHasChar, searchOperatorPrefixChar, searchOperatorSuffixChar, searchOperatorMatchChar, @@ -425,11 +425,11 @@ } func (ps *parserState) scanWord() []byte { inp := ps.inp pos := inp.Pos - for !ps.isSpace() && !isActionSep(inp.Ch) && !ps.mustStop() { + for !ps.isSpace() && !ps.isActionSep() && !ps.mustStop() { inp.Next() } return inp.Src[pos:inp.Pos] } @@ -502,26 +502,25 @@ return op.negate(), true } return op, true } -func (ps *parserState) isSpace() bool { - return isSpace(ps.inp.Ch) -} - -func isSpace(ch rune) bool { - switch ch { - case input.EOS: - return false - case ' ', '\t', '\n', '\r': - return true - } - return input.IsSpace(ch) -} - func (ps *parserState) skipSpace() { for ps.isSpace() { ps.inp.Next() } } -func isActionSep(ch rune) bool { return ch == actionSeparatorChar } +func (ps *parserState) isSpace() bool { + switch ch := ps.inp.Ch; ch { + case input.EOS: + return false + case ' ', '\t', '\n', '\r': + return true + default: + return input.IsSpace(ch) + } +} + +func (ps *parserState) isActionSep() bool { + return ps.inp.Ch == actionSeparatorChar +} Index: query/parser_test.go ================================================================== --- query/parser_test.go +++ query/parser_test.go @@ -20,14 +20,15 @@ t.Parallel() testcases := []struct { spec string exp string }{ - {"1", "1"}, // Just a number will transform to search for that numer in all zettel + {"1", "1"}, // Just a number will transform to search for that number in all zettel {"1 IDENT", "00000000000001 IDENT"}, {"IDENT", "IDENT"}, + {"1 IDENT|REINDEX", "00000000000001 IDENT | REINDEX"}, {"1 ITEMS", "00000000000001 ITEMS"}, {"ITEMS", "ITEMS"}, {"CONTEXT", "CONTEXT"}, {"CONTEXT a", "CONTEXT a"}, @@ -42,10 +43,11 @@ {"1 CONTEXT MAX 5 COST 7", "00000000000001 CONTEXT COST 7 MAX 5"}, {"1 CONTEXT | N", "00000000000001 CONTEXT | N"}, {"1 1 CONTEXT", "00000000000001 CONTEXT"}, {"1 2 CONTEXT", "00000000000001 00000000000002 CONTEXT"}, {"2 1 CONTEXT", "00000000000002 00000000000001 CONTEXT"}, + {"1 CONTEXT|N", "00000000000001 CONTEXT | N"}, {"CONTEXT 0", "CONTEXT 0"}, {"1 UNLINKED", "00000000000001 UNLINKED"}, {"UNLINKED", "UNLINKED"}, ADDED testdata/testbox/20230929102100.zettel Index: testdata/testbox/20230929102100.zettel ================================================================== --- /dev/null +++ testdata/testbox/20230929102100.zettel @@ -0,0 +1,7 @@ +id: 20230929102100 +title: #test +role: tag +syntax: zmk +created: 20230929102125 + +Zettel with this tag are testing the Zettelstore. Index: tests/client/client_test.go ================================================================== --- tests/client/client_test.go +++ tests/client/client_test.go @@ -47,15 +47,15 @@ } } func TestListZettel(t *testing.T) { const ( - ownerZettel = 47 - configRoleZettel = 29 - writerZettel = ownerZettel - 23 - readerZettel = ownerZettel - 23 - creatorZettel = 7 + ownerZettel = 50 + configRoleZettel = 32 + writerZettel = ownerZettel - 24 + readerZettel = ownerZettel - 24 + creatorZettel = 8 publicZettel = 4 ) testdata := []struct { user string @@ -213,16 +213,17 @@ _, _, metaSeq, err := c.QueryZettelData(context.Background(), string(api.ZidTOCNewTemplate)+" "+api.ItemsDirective) if err != nil { t.Error(err) return } - if got := len(metaSeq); got != 2 { - t.Errorf("Expected list of length 2, got %d", got) + if got := len(metaSeq); got != 3 { + t.Errorf("Expected list of length 3, got %d", got) return } checkListZid(t, metaSeq, 0, api.ZidTemplateNewZettel) - checkListZid(t, metaSeq, 1, api.ZidTemplateNewUser) + checkListZid(t, metaSeq, 1, api.ZidTemplateNewTag) + checkListZid(t, metaSeq, 2, api.ZidTemplateNewUser) } // func TestGetZettelContext(t *testing.T) { // const ( // allUserZid = api.ZettelID("20211019200500") @@ -337,11 +338,11 @@ {"#invisible", 1}, {"#user", 4}, {"#test", 4}, } if len(agg) != len(tags) { - t.Errorf("Expected %d different tags, but got only %d (%v)", len(tags), len(agg), agg) + t.Errorf("Expected %d different tags, but got %d (%v)", len(tags), len(agg), agg) } for _, tag := range tags { if zl, ok := agg[tag.key]; !ok { t.Errorf("No tag %v: %v", tag.key, agg) } else if len(zl) != tag.size { @@ -352,10 +353,31 @@ if id != agg["#test"][i] { t.Errorf("Tags #user and #test have different content: %v vs %v", agg["#user"], agg["#test"]) } } } + +func TestTagZettel(t *testing.T) { + t.Parallel() + c := getClient() + c.AllowRedirect(true) + c.SetAuth("owner", "owner") + ctx := context.Background() + zid, err := c.TagZettel(ctx, "nosuchtag") + if err != nil { + t.Error(err) + } else if zid != "" { + t.Errorf("no zid expected, but got %q", zid) + } + zid, err = c.TagZettel(ctx, "#test") + exp := api.ZettelID("20230929102100") + if err != nil { + t.Error(err) + } else if zid != exp { + t.Errorf("tag zettel for #test should be %q, but got %q", exp, zid) + } +} func TestListRoles(t *testing.T) { t.Parallel() c := getClient() c.SetAuth("owner", "owner") @@ -362,13 +384,13 @@ agg, err := c.QueryAggregate(context.Background(), api.ActionSeparator+api.KeyRole) if err != nil { t.Error(err) return } - exp := []string{"configuration", "user", "zettel"} + exp := []string{"configuration", "user", "tag", "zettel"} if len(agg) != len(exp) { - t.Errorf("Expected %d different tags, but got only %d (%v)", len(exp), len(agg), agg) + t.Errorf("Expected %d different roles, but got %d (%v)", len(exp), len(agg), agg) } for _, id := range exp { if _, found := agg[id]; !found { t.Errorf("Role map expected key %q", id) } Index: tools/build.go ================================================================== --- tools/build.go +++ tools/build.go @@ -11,10 +11,11 @@ // Package main provides a command to build and run the software. package main import ( "archive/zip" + "bytes" "errors" "flag" "fmt" "io" "io/fs" @@ -23,11 +24,15 @@ "os/exec" "path/filepath" "strings" "time" + "zettelstore.de/client.fossil/api" + "zettelstore.de/z/input" "zettelstore.de/z/strfun" + "zettelstore.de/z/zettel/id" + "zettelstore.de/z/zettel/meta" ) var envDirectProxy = []string{"GOPROXY=direct"} var envGoVCS = []string{"GOVCS=zettelstore.de:fossil"} @@ -375,10 +380,12 @@ return err } } return nil } + +const versionZid = "00001000000001" func createManualZipEntry(path string, entry fs.DirEntry, zipWriter *zip.Writer) error { info, err := entry.Info() if err != nil { return err @@ -385,22 +392,48 @@ } fh, err := zip.FileInfoHeader(info) if err != nil { return err } - fh.Name = entry.Name() + name := entry.Name() + fh.Name = name fh.Method = zip.Deflate w, err := zipWriter.CreateHeader(fh) if err != nil { return err } - manualFile, err := os.Open(filepath.Join(path, entry.Name())) + manualFile, err := os.Open(filepath.Join(path, name)) if err != nil { return err } defer manualFile.Close() - _, err = io.Copy(w, manualFile) + + if name != versionZid+".zettel" { + _, err = io.Copy(w, manualFile) + return err + } + + data, err := io.ReadAll(manualFile) + if err != nil { + return err + } + inp := input.NewInput(data) + m := meta.NewFromInput(id.MustParse(versionZid), inp) + m.SetNow(api.KeyModified) + + var buf bytes.Buffer + if _, err = fmt.Fprintf(&buf, "id: %s\n", versionZid); err != nil { + return err + } + if _, err = m.WriteComputed(&buf); err != nil { + return err + } + version := getVersion() + if _, err = fmt.Fprintf(&buf, "\n%s", version); err != nil { + return err + } + _, err = io.Copy(w, &buf) return err } func getReleaseVersionData() string { if fossil := getFossilDirty(); fossil != "" { Index: usecase/create_zettel.go ================================================================== --- usecase/create_zettel.go +++ usecase/create_zettel.go @@ -44,12 +44,13 @@ } } // PrepareCopy the zettel for further modification. func (*CreateZettel) PrepareCopy(origZettel zettel.Zettel) zettel.Zettel { - m := origZettel.Meta.Clone() - if title, found := m.Get(api.KeyTitle); found { + origMeta := origZettel.Meta + m := origMeta.Clone() + if title, found := origMeta.Get(api.KeyTitle); found { m.Set(api.KeyTitle, prependTitle(title, "Copy", "Copy of ")) } setReadonly(m) content := origZettel.Content content.TrimSpace() @@ -80,21 +81,21 @@ } // PrepareChild the zettel for further modification. func (*CreateZettel) PrepareChild(origZettel zettel.Zettel) zettel.Zettel { origMeta := origZettel.Meta - m := origMeta.Clone() - if title, found := m.Get(api.KeyTitle); found { + m := meta.New(id.Invalid) + if title, found := origMeta.Get(api.KeyTitle); found { m.Set(api.KeyTitle, prependTitle(title, "Child", "Child of ")) } updateMetaRoleTagsSyntax(m, origMeta) m.Set(api.KeySuperior, origMeta.Zid.String()) return zettel.Zettel{Meta: m, Content: zettel.NewContent(nil)} } // PrepareNew the zettel for further modification. -func (*CreateZettel) PrepareNew(origZettel zettel.Zettel) zettel.Zettel { +func (*CreateZettel) PrepareNew(origZettel zettel.Zettel, newTitle string) zettel.Zettel { m := meta.New(id.Invalid) om := origZettel.Meta m.SetNonEmpty(api.KeyTitle, om.GetDefault(api.KeyTitle, "")) updateMetaRoleTagsSyntax(m, om) @@ -102,10 +103,13 @@ for _, pair := range om.PairsRest() { if key := pair.Key; len(key) > prefixLen && key[0:prefixLen] == meta.NewPrefix { m.Set(key[prefixLen:], pair.Value) } } + if newTitle != "" { + m.Set(api.KeyTitle, newTitle) + } content := origZettel.Content content.TrimSpace() return zettel.Zettel{Meta: m, Content: content} } ADDED usecase/get_special_zettel.go Index: usecase/get_special_zettel.go ================================================================== --- /dev/null +++ usecase/get_special_zettel.go @@ -0,0 +1,66 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2023-present Detlef Stern +// +// This file is part of Zettelstore. +// +// Zettelstore is licensed under the latest version of the EUPL (European Union +// Public License). Please see file LICENSE.txt for your rights and obligations +// under this license. +//----------------------------------------------------------------------------- + +package usecase + +import ( + "context" + + "zettelstore.de/client.fossil/api" + "zettelstore.de/z/query" + "zettelstore.de/z/zettel" + "zettelstore.de/z/zettel/id" + "zettelstore.de/z/zettel/meta" +) + +// TagZettel is the usecase of retrieving a "tag zettel", i.e. a zettel that +// describes a given tag. A tag zettel must habe the tag's name in its title +// and must have a role=tag. + +// TagZettelPort is the interface used by this use case. +type TagZettelPort interface { + // GetZettel retrieves a specific zettel. + GetZettel(ctx context.Context, zid id.Zid) (zettel.Zettel, error) +} + +// TagZettel is the data for this use case. +type TagZettel struct { + port GetZettelPort + query *Query +} + +// NewTagZettel creates a new use case. +func NewTagZettel(port GetZettelPort, query *Query) TagZettel { + return TagZettel{port: port, query: query} +} + +// Run executes the use case. +func (uc TagZettel) Run(ctx context.Context, tag string) (zettel.Zettel, error) { + tag = meta.NormalizeTag(tag) + q := query.Parse( + api.KeyTitle + api.SearchOperatorEqual + tag + " " + + api.KeyRole + api.SearchOperatorHas + api.ValueRoleTag) + ml, err := uc.query.Run(ctx, q) + if err != nil { + return zettel.Zettel{}, err + } + for _, m := range ml { + z, errZ := uc.port.GetZettel(ctx, m.Zid) + if errZ == nil { + return z, nil + } + } + return zettel.Zettel{}, ErrTagZettelNotFound{Tag: tag} +} + +// ErrTagZettelNotFound is returned if a tag zettel was not found. +type ErrTagZettelNotFound struct{ Tag string } + +func (etznf ErrTagZettelNotFound) Error() string { return "tag zettel not found: " + etznf.Tag } ADDED usecase/reindex.go Index: usecase/reindex.go ================================================================== --- /dev/null +++ usecase/reindex.go @@ -0,0 +1,41 @@ +//----------------------------------------------------------------------------- +// Copyright (c) 2023-present Detlef Stern +// +// This file is part of Zettelstore. +// +// Zettelstore is licensed under the latest version of the EUPL (European Union +// Public License). Please see file LICENSE.txt for your rights and obligations +// under this license. +//----------------------------------------------------------------------------- + +package usecase + +import ( + "context" + + "zettelstore.de/z/logger" + "zettelstore.de/z/zettel/id" +) + +// ReIndexPort is the interface used by this use case. +type ReIndexPort interface { + ReIndex(context.Context, id.Zid) error +} + +// ReIndex is the data for this use case. +type ReIndex struct { + log *logger.Logger + port ReIndexPort +} + +// NewReIndex creates a new use case. +func NewReIndex(log *logger.Logger, port ReIndexPort) ReIndex { + return ReIndex{log: log, port: port} +} + +// Run executes the use case. +func (uc *ReIndex) Run(ctx context.Context, zid id.Zid) error { + err := uc.port.ReIndex(ctx, zid) + uc.log.Sense().User(ctx).Err(err).Zid(zid).Msg("ReIndex zettel") + return err +} Index: web/adapter/api/query.go ================================================================== --- web/adapter/api/query.go +++ web/adapter/api/query.go @@ -13,10 +13,11 @@ import ( "bytes" "fmt" "io" "net/http" + "net/url" "strconv" "strings" "zettelstore.de/client.fossil/api" "zettelstore.de/client.fossil/sexp" @@ -23,29 +24,34 @@ "zettelstore.de/sx.fossil" "zettelstore.de/z/query" "zettelstore.de/z/usecase" "zettelstore.de/z/web/adapter" "zettelstore.de/z/web/content" + "zettelstore.de/z/zettel/id" "zettelstore.de/z/zettel/meta" ) // MakeQueryHandler creates a new HTTP handler to perform a query. -func (a *API) MakeQueryHandler(queryMeta *usecase.Query) http.HandlerFunc { +func (a *API) MakeQueryHandler(queryMeta *usecase.Query, tagZettel *usecase.TagZettel, reIndex *usecase.ReIndex) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - q := r.URL.Query() - sq := adapter.GetQuery(q) + urlQuery := r.URL.Query() + if a.handleTagZettel(w, r, tagZettel, urlQuery) { + return + } + + sq := adapter.GetQuery(urlQuery) metaSeq, err := queryMeta.Run(ctx, sq) if err != nil { a.reportUsecaseError(w, err) return } var encoder zettelEncoder var contentType string - switch enc, _ := getEncoding(r, q); enc { + switch enc, _ := getEncoding(r, urlQuery); enc { case api.EncoderPlain: encoder = &plainZettelEncoder{} contentType = content.PlainText case api.EncoderData: @@ -60,21 +66,22 @@ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } var buf bytes.Buffer - err = queryAction(&buf, encoder, metaSeq, sq) + err = queryAction(&buf, encoder, metaSeq, sq, func(zid id.Zid) error { return reIndex.Run(ctx, zid) }) if err != nil { a.log.Error().Err(err).Str("query", sq.String()).Msg("execute query action") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } - err = writeBuffer(w, &buf, contentType) - a.log.IfErr(err).Msg("write result buffer") + if err = writeBuffer(w, &buf, contentType); err != nil { + a.log.Error().Err(err).Msg("write result buffer") + } } } -func queryAction(w io.Writer, enc zettelEncoder, ml []*meta.Meta, sq *query.Query) error { +func queryAction(w io.Writer, enc zettelEncoder, ml []*meta.Meta, sq *query.Query, reindex func(id.Zid) error) error { min, max := -1, -1 if actions := sq.Actions(); len(actions) > 0 { acts := make([]string, 0, len(actions)) for _, act := range actions { if strings.HasPrefix(act, "MIN") { @@ -93,10 +100,16 @@ } for _, act := range acts { switch act { case "KEYS": return encodeKeysArrangement(w, enc, ml, act) + case "REINDEX": + for _, m := range ml { + if err := reindex(m.Zid); err != nil { + return err + } + } } switch key := strings.ToLower(act); meta.Type(key) { case meta.TypeWord, meta.TypeTagSet: return encodeMetaKeyArrangement(w, enc, ml, key, min, max) } @@ -217,5 +230,34 @@ sx.MakeList(sf.MustMake("human"), sx.String(dze.sq.Human())), result.Cons(sf.MustMake("list")), )) return err } + +func (a *API) handleTagZettel(w http.ResponseWriter, r *http.Request, tagZettel *usecase.TagZettel, vals url.Values) bool { + tag := vals.Get(api.QueryKeyTag) + if tag == "" { + return false + } + ctx := r.Context() + z, err := tagZettel.Run(ctx, tag) + if err != nil { + a.reportUsecaseError(w, err) + return true + } + zid := z.Meta.Zid.String() + w.Header().Set(api.HeaderContentType, content.PlainText) + newURL := a.NewURLBuilder('z').SetZid(api.ZettelID(zid)) + for key, slVals := range vals { + if key == api.QueryKeyTag { + continue + } + for _, val := range slVals { + newURL.AppendKVQuery(key, val) + } + } + http.Redirect(w, r, newURL.String(), http.StatusFound) + if _, err = io.WriteString(w, zid); err != nil { + a.log.Error().Err(err).Msg("redirect body") + } + return true +} Index: web/adapter/response.go ================================================================== --- web/adapter/response.go +++ web/adapter/response.go @@ -69,10 +69,14 @@ } var ezin usecase.ErrZidInUse if errors.As(err, &ezin) { return http.StatusBadRequest, fmt.Sprintf("Zettel-ID %q already in use", ezin.Zid) } + var etznf usecase.ErrTagZettelNotFound + if errors.As(err, &etznf) { + return http.StatusNotFound, "Tag zettel not found: " + etznf.Tag + } var ebr ErrBadRequest if errors.As(err, &ebr) { return http.StatusBadRequest, ebr.Text } if errors.Is(err, box.ErrStopped) { Index: web/adapter/webui/const.go ================================================================== --- web/adapter/webui/const.go +++ web/adapter/webui/const.go @@ -10,11 +10,11 @@ package webui // WebUI related constants. -const queryKeyAction = "action" +const queryKeyAction = "_action" // Values for queryKeyAction const ( valueActionChild = "child" valueActionCopy = "copy" Index: web/adapter/webui/create_zettel.go ================================================================== --- web/adapter/webui/create_zettel.go +++ web/adapter/webui/create_zettel.go @@ -59,11 +59,12 @@ wui.renderZettelForm(ctx, w, createZettel.PrepareCopy(origZettel), "Copy Zettel", "", roleData, syntaxData) case actionFolge: wui.renderZettelForm(ctx, w, createZettel.PrepareFolge(origZettel), "Folge Zettel", "", roleData, syntaxData) case actionNew: title := parser.NormalizedSpacedText(origZettel.Meta.GetTitle()) - wui.renderZettelForm(ctx, w, createZettel.PrepareNew(origZettel), title, "", roleData, syntaxData) + newTitle := parser.NormalizedSpacedText(q.Get(api.KeyTitle)) + wui.renderZettelForm(ctx, w, createZettel.PrepareNew(origZettel, newTitle), title, "", roleData, syntaxData) case actionVersion: wui.renderZettelForm(ctx, w, createZettel.PrepareVersion(origZettel), "Version Zettel", "", roleData, syntaxData) } } } Index: web/adapter/webui/delete_zettel.go ================================================================== --- web/adapter/webui/delete_zettel.go +++ web/adapter/webui/delete_zettel.go @@ -56,10 +56,12 @@ } wui.bindCommonZettelData(ctx, &rb, user, m, nil) if rb.err == nil { err = wui.renderSxnTemplate(ctx, w, id.DeleteTemplateZid, env) + } else { + err = rb.err } if err != nil { wui.reportError(ctx, w, err) } } Index: web/adapter/webui/forms.go ================================================================== --- web/adapter/webui/forms.go +++ web/adapter/webui/forms.go @@ -55,13 +55,11 @@ m.Set(api.KeyTitle, meta.RemoveNonGraphic(postTitle)) } if postTags, ok := trimmedFormValue(r, "tags"); ok { if tags := meta.ListFromValue(meta.RemoveNonGraphic(postTags)); len(tags) > 0 { for i, tag := range tags { - if tag[0] != '#' { - tags[i] = "#" + tag - } + tags[i] = meta.NormalizeTag(tag) } m.SetList(api.KeyTags, tags) } } if postRole, ok := trimmedFormValue(r, "role"); ok { Index: web/adapter/webui/get_info.go ================================================================== --- web/adapter/webui/get_info.go +++ web/adapter/webui/get_info.go @@ -105,10 +105,12 @@ rb.bindString("enc-parsed", wui.infoAPIMatrixParsed(zid, encTexts)) rb.bindString("shadow-links", shadowLinks) wui.bindCommonZettelData(ctx, &rb, user, zn.InhMeta, &zn.Content) if rb.err == nil { err = wui.renderSxnTemplate(ctx, w, id.InfoTemplateZid, env) + } else { + err = rb.err } if err != nil { wui.reportError(ctx, w, err) } } Index: web/adapter/webui/get_zettel.go ================================================================== --- web/adapter/webui/get_zettel.go +++ web/adapter/webui/get_zettel.go @@ -78,10 +78,12 @@ } } wui.bindCommonZettelData(ctx, &rb, user, zn.InhMeta, &zn.Content) if rb.err == nil { err = wui.renderSxnTemplate(ctx, w, id.ZettelTemplateZid, env) + } else { + err = rb.err } if err != nil { wui.reportError(ctx, w, err) } } Index: web/adapter/webui/htmlmeta.go ================================================================== --- web/adapter/webui/htmlmeta.go +++ web/adapter/webui/htmlmeta.go @@ -57,15 +57,11 @@ sx.MakeList(wui.sf.MustMake(sxhtml.NameSymNoEscape), sx.String(ts.Format("2006-01-02 15:04:05"))), ) } return sx.Nil() case meta.TypeURL: - text := sx.String(value) - if res, err := wui.url2html([]sx.Object{text}); err == nil { - return res - } - return text + return wui.url2html(sx.String(value)) case meta.TypeWord: return wui.transformLink(key, value, value) case meta.TypeWordSet: return wui.transformWordSet(key, meta.ListFromValue(value)) case meta.TypeZettelmarkup: Index: web/adapter/webui/lists.go ================================================================== --- web/adapter/webui/lists.go +++ web/adapter/webui/lists.go @@ -12,10 +12,11 @@ import ( "context" "io" "net/http" + "net/url" "slices" "strconv" "strings" "zettelstore.de/client.fossil/api" @@ -32,28 +33,48 @@ "zettelstore.de/z/zettel/id" "zettelstore.de/z/zettel/meta" ) // MakeListHTMLMetaHandler creates a HTTP handler for rendering the list of zettel as HTML. -func (wui *WebUI) MakeListHTMLMetaHandler(queryMeta *usecase.Query) http.HandlerFunc { +func (wui *WebUI) MakeListHTMLMetaHandler(queryMeta *usecase.Query, tagZettel *usecase.TagZettel, reIndex *usecase.ReIndex) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - q := adapter.GetQuery(r.URL.Query()) + urlQuery := r.URL.Query() + if wui.handleTagZettel(w, r, tagZettel, urlQuery) { + return + } + q := adapter.GetQuery(urlQuery) q = q.SetDeterministic() ctx := r.Context() metaSeq, err := queryMeta.Run(ctx, q) if err != nil { wui.reportError(ctx, w, err) return } if actions := q.Actions(); len(actions) > 0 { - switch actions[0] { - case "ATOM": - wui.renderAtom(w, q, metaSeq) - return - case "RSS": - wui.renderRSS(ctx, w, q, metaSeq) - return + var tempActions []string + for _, act := range actions { + if act == "REINDEX" { + for _, m := range metaSeq { + if err = reIndex.Run(ctx, m.Zid); err != nil { + wui.reportError(ctx, w, err) + return + } + } + continue + } + tempActions = append(tempActions, act) + } + actions = tempActions + if len(actions) > 0 { + switch actions[0] { + case "ATOM": + wui.renderAtom(w, q, metaSeq) + return + case "RSS": + wui.renderRSS(ctx, w, q, metaSeq) + return + } } } var content, endnotes *sx.Pair if bn := evaluator.QueryAction(ctx, q, metaSeq, wui.rtConfig); bn != nil { enc := wui.getSimpleHTMLEncoder() @@ -76,11 +97,17 @@ q.PrintHuman(&sb) rb.bindString("heading", sx.String(sb.String())) } rb.bindString("query-value", sx.String(q.String())) if tzl := q.GetMetaValues(api.KeyTags); len(tzl) > 0 { - rb.bindString("tag-zettel", wui.transformTagZettelList(tzl)) + sxTzl, sxNoTzl := wui.transformTagZettelList(ctx, tagZettel, tzl) + if !sx.IsNil(sxTzl) { + rb.bindString("tag-zettel", sxTzl) + } + if !sx.IsNil(sxNoTzl) { + rb.bindString("create-tag-zettel", sxNoTzl) + } } rb.bindString("content", content) rb.bindString("endnotes", endnotes) apiURL := wui.NewURLBuilder('z').AppendQuery(q.String()) seed, found := q.GetSeed() @@ -95,39 +122,46 @@ rb.bindString("create-url", sx.String(wui.createNewURL)) rb.bindString("seed", sx.Int64(seed)) } if rb.err == nil { err = wui.renderSxnTemplate(ctx, w, id.ListTemplateZid, env) + } else { + err = rb.err } if err != nil { wui.reportError(ctx, w, err) } } } -func (wui *WebUI) transformTagZettelList(tags []string) *sx.Pair { - result := sx.Nil() +func (wui *WebUI) transformTagZettelList(ctx context.Context, tagZettel *usecase.TagZettel, tags []string) (withZettel, withoutZettel *sx.Pair) { slices.Reverse(tags) for _, tag := range tags { - u := wui.NewURLBuilder('h').AppendQuery( - api.KeyTitle + api.SearchOperatorEqual + tag + " " + - api.KeyRole + api.SearchOperatorHas + api.ValueRoleTag, - ) - link := sx.MakeList( - wui.symA, - sx.MakeList( - wui.symAttr, - sx.Cons(wui.symHref, sx.String(u.String())), - ), - sx.String(tag), - ) - if result != nil { - result = result.Cons(sx.String(", ")) - } - result = result.Cons(link) - } - return result + if _, err := tagZettel.Run(ctx, tag); err == nil { + u := wui.NewURLBuilder('h').AppendKVQuery(api.QueryKeyTag, tag) + withZettel = wui.prependTagZettel(withZettel, tag, u) + } else { + u := wui.NewURLBuilder('c').SetZid(api.ZidTemplateNewTag).AppendKVQuery(queryKeyAction, valueActionNew).AppendKVQuery(api.KeyTitle, tag) + withoutZettel = wui.prependTagZettel(withoutZettel, tag, u) + } + } + return withZettel, withoutZettel +} + +func (wui *WebUI) prependTagZettel(sxZtl *sx.Pair, tag string, u *api.URLBuilder) *sx.Pair { + link := sx.MakeList( + wui.symA, + sx.MakeList( + wui.symAttr, + sx.Cons(wui.symHref, sx.String(u.String())), + ), + sx.String(tag), + ) + if sxZtl != nil { + sxZtl = sxZtl.Cons(sx.String(", ")) + } + return sxZtl.Cons(link) } func (wui *WebUI) renderRSS(ctx context.Context, w http.ResponseWriter, q *query.Query, ml []*meta.Meta) { var rssConfig rss.Configuration rssConfig.Setup(ctx, wui.rtConfig) @@ -163,5 +197,20 @@ } if err != nil { wui.log.IfErr(err).Msg("unable to write Atom data") } } + +func (wui *WebUI) handleTagZettel(w http.ResponseWriter, r *http.Request, tagZettel *usecase.TagZettel, vals url.Values) bool { + tag := vals.Get(api.QueryKeyTag) + if tag == "" { + return false + } + ctx := r.Context() + z, err := tagZettel.Run(ctx, tag) + if err != nil { + wui.reportError(ctx, w, err) + return true + } + wui.redirectFound(w, r, wui.NewURLBuilder('h').SetZid(api.ZettelID(z.Meta.Zid.String()))) + return true +} Index: web/adapter/webui/rename_zettel.go ================================================================== --- web/adapter/webui/rename_zettel.go +++ web/adapter/webui/rename_zettel.go @@ -48,10 +48,12 @@ wui.rtConfig.Get(ctx, nil, api.KeyLang), "Rename Zettel "+m.Zid.String(), user) rb.bindString("incoming", wui.encodeIncoming(m, wui.makeGetTextTitle(ctx, getZettel))) wui.bindCommonZettelData(ctx, &rb, user, m, nil) if rb.err == nil { err = wui.renderSxnTemplate(ctx, w, id.RenameTemplateZid, env) + } else { + err = rb.err } if err != nil { wui.reportError(ctx, w, err) } } Index: web/adapter/webui/sxn_code.go ================================================================== --- web/adapter/webui/sxn_code.go +++ web/adapter/webui/sxn_code.go @@ -29,14 +29,18 @@ if err != nil { return nil, err } return z.Meta, nil } - dg := buildSxnCodeDigraph(ctx, id.StartSxnZid, id.BaseSxnZid, getMeta) + dg := buildSxnCodeDigraph(ctx, id.StartSxnZid, getMeta) if dg == nil { return nil, wui.engine.RootEnvironment(), nil } + dg = dg.AddVertex(id.BaseSxnZid).AddEdge(id.StartSxnZid, id.BaseSxnZid) + dg = dg.AddVertex(id.PreludeSxnZid).AddEdge(id.BaseSxnZid, id.PreludeSxnZid) + dg = dg.TransitiveClosure(id.StartSxnZid) + if zid, isDAG := dg.IsDAG(); !isDAG { return nil, nil, fmt.Errorf("zettel %v is part of a dependency cycle", zid) } env := sxeval.MakeChildEnvironment(wui.engine.RootEnvironment(), "zettel", 128) for _, zid := range dg.SortReverse() { @@ -47,11 +51,11 @@ return dg, env, nil } type getMetaFunc func(context.Context, id.Zid) (*meta.Meta, error) -func buildSxnCodeDigraph(ctx context.Context, startZid, baseZid id.Zid, getMeta getMetaFunc) id.Digraph { +func buildSxnCodeDigraph(ctx context.Context, startZid id.Zid, getMeta getMetaFunc) id.Digraph { m, err := getMeta(ctx, startZid) if err != nil { return nil } var marked id.Set @@ -61,11 +65,11 @@ curr := stack[pos] stack = stack[:pos] if marked.Contains(curr.Zid) { continue } - marked.Add(curr.Zid) + marked = marked.Add(curr.Zid) if precursors, hasPrecursor := curr.GetList(api.KeyPrecursor); hasPrecursor && len(precursors) > 0 { for _, pre := range precursors { if preZid, errParse := id.Parse(pre); errParse == nil { m, err = getMeta(ctx, preZid) if err != nil { @@ -76,13 +80,11 @@ dg.AddEdge(curr.Zid, preZid) } } } } - dg = dg.AddVertex(baseZid) - dg = dg.AddEdge(startZid, baseZid) - return dg.TransitiveClosure(startZid) + return dg } func (wui *WebUI) loadSxnCodeZettel(ctx context.Context, zid id.Zid, env sxeval.Environment) error { rdr, err := wui.makeZettelReader(ctx, zid) if err != nil { Index: web/adapter/webui/template.go ================================================================== --- web/adapter/webui/template.go +++ web/adapter/webui/template.go @@ -33,67 +33,101 @@ "zettelstore.de/z/zettel/id" "zettelstore.de/z/zettel/meta" ) func (wui *WebUI) createRenderEngine() *sxeval.Engine { - root := sxeval.MakeRootEnvironment(len(syntaxes) + len(builtinsFA) + len(builtinsA) + 1) + root := sxeval.MakeRootEnvironment(len(syntaxes) + len(builtins) + 3) engine := sxeval.MakeEngine(wui.sf, root) - engine.SetQuote(wui.symQuote) - sxbuiltins.InstallQuasiQuoteSyntax(root, wui.symQQ, wui.symUQ, wui.symUQS) - for _, b := range syntaxes { - engine.BindSyntax(b.name, b.fn) - } - for _, b := range builtinsFA { - engine.BindBuiltinFA(b.name, b.fn) - } - for _, b := range builtinsA { - engine.BindBuiltinA(b.name, b.fn) - } - engine.BindBuiltinA("url-to-html", wui.url2html) - engine.BindBuiltinA("zid-content-path", wui.zidContentPath) - engine.BindBuiltinA("query->url", wui.queryToURL) + for _, syntax := range syntaxes { + engine.BindSyntax(syntax) + } + for _, b := range builtins { + engine.BindBuiltin(b) + } + engine.BindBuiltin(&sxeval.Builtin{ + Name: "url-to-html", + MinArity: 1, + MaxArity: 1, + IsPure: true, + Fn: func(_ *sxeval.Frame, args []sx.Object) (sx.Object, error) { + text, err := sxbuiltins.GetString(args, 0) + if err != nil { + return nil, err + } + return wui.url2html(text), nil + }, + }) + engine.BindBuiltin(&sxeval.Builtin{ + Name: "zid-content-path", + MinArity: 1, + MaxArity: 1, + IsPure: true, + Fn: func(_ *sxeval.Frame, args []sx.Object) (sx.Object, error) { + s, err := sxbuiltins.GetString(args, 0) + if err != nil { + return nil, err + } + zid, err := id.Parse(s.String()) + if err != nil { + return nil, fmt.Errorf("parsing zettel identfier %q: %w", s, err) + } + ub := wui.NewURLBuilder('z').SetZid(api.ZettelID(zid.String())) + return sx.String(ub.String()), nil + }, + }) + engine.BindBuiltin(&sxeval.Builtin{ + Name: "query->url", + MinArity: 1, + MaxArity: 1, + IsPure: true, + Fn: func(_ *sxeval.Frame, args []sx.Object) (sx.Object, error) { + qs, err := sxbuiltins.GetString(args, 0) + if err != nil { + return nil, err + } + u := wui.NewURLBuilder('h').AppendQuery(qs.String()) + return sx.String(u.String()), nil + }, + }) root.Freeze() return engine } var ( - syntaxes = []struct { - name string - fn sxeval.SyntaxFn - }{ - {"if", sxbuiltins.IfS}, - {"and", sxbuiltins.AndS}, {"or", sxbuiltins.OrS}, - {"lambda", sxbuiltins.LambdaS}, {"let", sxbuiltins.LetS}, - {"define", sxbuiltins.DefineS}, - } - builtinsFA = []struct { - name string - fn sxeval.BuiltinFA - }{ - {"bound?", sxbuiltins.BoundP}, {"current-environment", sxbuiltins.CurrentEnv}, - {"environment-lookup", sxbuiltins.EnvLookup}, - {"map", sxbuiltins.Map}, {"apply", sxbuiltins.Apply}, - } - builtinsA = []struct { - name string - fn sxeval.BuiltinA - }{ - {"pair?", sxbuiltins.PairP}, - {"list", sxbuiltins.List}, {"append", sxbuiltins.Append}, - {"car", sxbuiltins.Car}, {"cdr", sxbuiltins.Cdr}, - {"assoc", sxbuiltins.Assoc}, - {"string-append", sxbuiltins.StringAppend}, - {"defined?", sxbuiltins.DefinedP}, - } -) - -func (wui *WebUI) url2html(args []sx.Object) (sx.Object, error) { - err := sxbuiltins.CheckArgs(args, 1, 1) - text, err := sxbuiltins.GetString(err, args, 0) - if err != nil { - return nil, err - } + syntaxes = []*sxeval.Syntax{ + &sxbuiltins.QuoteS, &sxbuiltins.QuasiquoteS, // quote, quasiquote + &sxbuiltins.UnquoteS, &sxbuiltins.UnquoteSplicingS, // unquote, unquote-splicing + &sxbuiltins.DefVarS, &sxbuiltins.DefConstS, // defvar, defconst + &sxbuiltins.SetXS, // set! + &sxbuiltins.DefineS, // define (DEPRECATED) + &sxbuiltins.DefunS, &sxbuiltins.LambdaS, // defun, lambda + &sxbuiltins.CondS, // cond + &sxbuiltins.IfS, // if + &sxbuiltins.DefMacroS, // defmacro + } + builtins = []*sxeval.Builtin{ + &sxbuiltins.Identical, // == + &sxbuiltins.NullP, // null? + &sxbuiltins.PairP, // pair? + &sxbuiltins.Car, &sxbuiltins.Cdr, // car, cdr + &sxbuiltins.Caar, &sxbuiltins.Cadr, &sxbuiltins.Cdar, &sxbuiltins.Cddr, + &sxbuiltins.Caaar, &sxbuiltins.Caadr, &sxbuiltins.Cadar, &sxbuiltins.Caddr, + &sxbuiltins.Cdaar, &sxbuiltins.Cdadr, &sxbuiltins.Cddar, &sxbuiltins.Cdddr, + &sxbuiltins.List, // list + &sxbuiltins.Append, // append + &sxbuiltins.Assoc, // assoc + &sxbuiltins.Map, // map + &sxbuiltins.Apply, // apply + &sxbuiltins.StringAppend, // string-append + &sxbuiltins.BoundP, // bound? + &sxbuiltins.Defined, // defined? + &sxbuiltins.CurrentEnv, // current-environment + &sxbuiltins.EnvLookup, // environment-lookup + } +) + +func (wui *WebUI) url2html(text sx.String) sx.Object { if u, errURL := url.Parse(text.String()); errURL == nil { if us := u.String(); us != "" { return sx.MakeList( wui.symA, sx.MakeList( @@ -100,59 +134,38 @@ wui.symAttr, sx.Cons(wui.symHref, sx.String(us)), sx.Cons(wui.sf.MustMake("target"), sx.String("_blank")), sx.Cons(wui.sf.MustMake("rel"), sx.String("noopener noreferrer")), ), - text), nil - } - } - return text, nil -} -func (wui *WebUI) zidContentPath(args []sx.Object) (sx.Object, error) { - err := sxbuiltins.CheckArgs(args, 1, 1) - s, err := sxbuiltins.GetString(err, args, 0) - if err != nil { - return nil, err - } - zid, err := id.Parse(s.String()) - if err != nil { - return nil, fmt.Errorf("parsing zettel identfier %q: %w", s, err) - } - ub := wui.NewURLBuilder('z').SetZid(api.ZettelID(zid.String())) - return sx.String(ub.String()), nil -} -func (wui *WebUI) queryToURL(args []sx.Object) (sx.Object, error) { - err := sxbuiltins.CheckArgs(args, 1, 1) - qs, err := sxbuiltins.GetString(err, args, 0) - if err != nil { - return nil, err - } - u := wui.NewURLBuilder('h').AppendQuery(qs.String()) - return sx.String(u.String()), nil -} - -func (wui *WebUI) getParentEnv(ctx context.Context) sxeval.Environment { + text) + } + } + return text +} + +func (wui *WebUI) getParentEnv(ctx context.Context) (sxeval.Environment, error) { wui.mxZettelEnv.Lock() defer wui.mxZettelEnv.Unlock() if parentEnv := wui.zettelEnv; parentEnv != nil { - return parentEnv + return parentEnv, nil } dag, zettelEnv, err := wui.loadAllSxnCodeZettel(ctx) if err != nil { - wui.log.IfErr(err).Msg("loading zettel sxn") - return wui.engine.RootEnvironment() + wui.log.Error().Err(err).Msg("loading zettel sxn") + return nil, err } wui.dag = dag wui.zettelEnv = zettelEnv - return zettelEnv + return zettelEnv, nil } // createRenderEnv creates a new environment and populates it with all relevant data for the base template. func (wui *WebUI) createRenderEnv(ctx context.Context, name, lang, title string, user *meta.Meta) (sxeval.Environment, renderBinder) { userIsValid, userZettelURL, userIdent := wui.getUserRenderData(user) - env := sxeval.MakeChildEnvironment(wui.getParentEnv(ctx), name, 128) - rb := makeRenderBinder(wui.sf, env, nil) + parentEnv, err := wui.getParentEnv(ctx) + env := sxeval.MakeChildEnvironment(parentEnv, name, 128) + rb := makeRenderBinder(wui.sf, env, err) rb.bindString("lang", sx.String(lang)) rb.bindString("css-base-url", sx.String(wui.cssBaseURL)) rb.bindString("css-user-url", sx.String(wui.cssUserURL)) rb.bindString("title", sx.String(title)) rb.bindString("home-url", sx.String(wui.homeURL)) @@ -234,33 +247,36 @@ strZid := m.Zid.String() apiZid := api.ZettelID(strZid) newURLBuilder := wui.NewURLBuilder rb.bindString("zid", sx.String(strZid)) - rb.bindString("web-url", sx.String(wui.NewURLBuilder('h').SetZid(apiZid).String())) + rb.bindString("web-url", sx.String(newURLBuilder('h').SetZid(apiZid).String())) if content != nil && wui.canWrite(ctx, user, m, *content) { rb.bindString("edit-url", sx.String(newURLBuilder('e').SetZid(apiZid).String())) } rb.bindString("info-url", sx.String(newURLBuilder('i').SetZid(apiZid).String())) if wui.canCreate(ctx, user) { if content != nil && !content.IsBinary() { - rb.bindString("copy-url", sx.String(wui.NewURLBuilder('c').SetZid(apiZid).AppendKVQuery(queryKeyAction, valueActionCopy).String())) + rb.bindString("copy-url", sx.String(newURLBuilder('c').SetZid(apiZid).AppendKVQuery(queryKeyAction, valueActionCopy).String())) } - rb.bindString("version-url", sx.String(wui.NewURLBuilder('c').SetZid(apiZid).AppendKVQuery(queryKeyAction, valueActionVersion).String())) - rb.bindString("child-url", sx.String(wui.NewURLBuilder('c').SetZid(apiZid).AppendKVQuery(queryKeyAction, valueActionChild).String())) - rb.bindString("folge-url", sx.String(wui.NewURLBuilder('c').SetZid(apiZid).AppendKVQuery(queryKeyAction, valueActionFolge).String())) + rb.bindString("version-url", sx.String(newURLBuilder('c').SetZid(apiZid).AppendKVQuery(queryKeyAction, valueActionVersion).String())) + rb.bindString("child-url", sx.String(newURLBuilder('c').SetZid(apiZid).AppendKVQuery(queryKeyAction, valueActionChild).String())) + rb.bindString("folge-url", sx.String(newURLBuilder('c').SetZid(apiZid).AppendKVQuery(queryKeyAction, valueActionFolge).String())) } if wui.canRename(ctx, user, m) { - rb.bindString("rename-url", sx.String(wui.NewURLBuilder('b').SetZid(apiZid).String())) + rb.bindString("rename-url", sx.String(newURLBuilder('b').SetZid(apiZid).String())) } if wui.canDelete(ctx, user, m) { - rb.bindString("delete-url", sx.String(wui.NewURLBuilder('d').SetZid(apiZid).String())) + rb.bindString("delete-url", sx.String(newURLBuilder('d').SetZid(apiZid).String())) } if val, found := m.Get(api.KeyUselessFiles); found { rb.bindString("useless", sx.Cons(sx.String(val), nil)) } - rb.bindString("context-url", sx.String(wui.NewURLBuilder('h').AppendQuery(strZid+" "+api.ContextDirective).String())) + rb.bindString("context-url", sx.String(newURLBuilder('h').AppendQuery(strZid+" "+api.ContextDirective).String())) + if wui.canRefresh(user) { + rb.bindString("reindex-url", sx.String(newURLBuilder('h').AppendQuery(strZid+" "+api.IdentDirective+api.ActionSeparator+"REINDEX").String())) + } // Ensure to have title, role, tags, and syntax included as "meta-*" rb.bindKeyValue(api.KeyTitle, m.GetDefault(api.KeyTitle, "")) rb.bindKeyValue(api.KeyRole, m.GetDefault(api.KeyRole, "")) rb.bindKeyValue(api.KeyTags, m.GetDefault(api.KeyTags, "")) @@ -342,21 +358,20 @@ t, err := wui.engine.Parse(env, objs[0]) if err != nil { return nil, err } - wui.setSxnCache(zid, wui.engine.Rework(t)) + wui.setSxnCache(zid, wui.engine.Rework(env, t)) return t, nil } func (wui *WebUI) makeZettelReader(ctx context.Context, zid id.Zid) (*sxreader.Reader, error) { ztl, err := wui.box.GetZettel(ctx, zid) if err != nil { return nil, err } reader := sxreader.MakeReader(bytes.NewReader(ztl.Content.AsBytes()), sxreader.WithSymbolFactory(wui.sf)) - sxbuiltins.InstallQuasiQuoteReader(reader, wui.symQQ, '`', wui.symUQ, ',', wui.symUQS, '@') return reader, nil } func (wui *WebUI) evalSxnTemplate(ctx context.Context, zid id.Zid, env sxeval.Environment) (sx.Object, error) { templateExpr, err := wui.getSxnTemplate(ctx, zid, env) @@ -406,14 +421,28 @@ rb.bindString("heading", sx.String(http.StatusText(code))) rb.bindString("message", sx.String(text)) if rb.err == nil { rb.err = wui.renderSxnTemplateStatus(ctx, w, code, id.ErrorTemplateZid, env) } - if errBind := rb.err; errBind != nil { - wui.log.Error().Err(errBind).Msg("while rendering error message") - fmt.Fprintf(w, "Error while rendering error message: %v", errBind) + errSx := rb.err + if errSx == nil { + return } + wui.log.Error().Err(errSx).Msg("while rendering error message") + + // if errBind != nil, the HTTP header was not written + wui.prepareAndWriteHeader(w, http.StatusInternalServerError) + fmt.Fprintf( + w, + ` + +Internal server error + +

Internal server error

+

When generating error code %d with message:

%v

an error occured:

%v
+ +`, code, text, errSx) } func makeStringList(sl []string) *sx.Pair { if len(sl) == 0 { return nil Index: web/adapter/webui/webui.go ================================================================== --- web/adapter/webui/webui.go +++ web/adapter/webui/webui.go @@ -69,17 +69,15 @@ mxZettelEnv sync.Mutex zettelEnv sxeval.Environment dag id.Digraph genHTML *sxhtml.Generator - symQuote, symQQ *sx.Symbol - symUQ, symUQS *sx.Symbol - symMetaHeader *sx.Symbol - symDetail *sx.Symbol - symA, symHref *sx.Symbol - symSpan *sx.Symbol - symAttr *sx.Symbol + symMetaHeader *sx.Symbol + symDetail *sx.Symbol + symA, symHref *sx.Symbol + symSpan *sx.Symbol + symAttr *sx.Symbol } // webuiBox contains all box methods that are needed for WebUI operation. // // Note: these function must not do auth checking. @@ -127,14 +125,10 @@ createNewURL: ab.NewURLBuilder('c').String(), sf: sf, zettelEnv: nil, genHTML: sxhtml.NewGenerator(sf, sxhtml.WithNewline), - symQuote: sf.MustMake("quote"), - symQQ: sf.MustMake("quasiquote"), - symUQ: sf.MustMake("unquote"), - symUQS: sf.MustMake("unquote-splicing"), symDetail: sf.MustMake("DETAIL"), symMetaHeader: sf.MustMake("META-HEADER"), symA: sf.MustMake("a"), symHref: sf.MustMake("href"), symSpan: sf.MustMake("span"), Index: www/changes.wiki ================================================================== --- www/changes.wiki +++ www/changes.wiki @@ -1,9 +1,36 @@ Change Log + +

Changes for Version 0.16.0 (pending)

+ -

Changes for Version 0.15.0 (pending)

+

Changes for Version 0.15.0 (2023-10-26)

+ * Sx function define is now deprecated. It will be removed in + version 0.16. Use defvar or defun instead. Otherwise + the WebUI will not work in version 0.16. + (major: webui, deprecated) + * Zettel can be re-indexed via WebUI or API query action REINDEX. + The info page of a zettel contains a link to re-index the zettel. In + a query transclusion, this action is ignored. + (major: api, webui). + * Allow to determine a tag zettel for a given tag. + (major: api, webui) + * Present user the option to create a (missing) tag zettel (in list view). + Results in a new predefined zettel with identifier 00000000090003, which + is a template for new tag zettel. + (minor: webui) + * ZIP file with manual now contains a zettel 00001000000000 that contains + its build date (metadata key created) and version (in the zettel + content) + (minor) + * If an error page cannot be created due to template errors (or similar), a + plain text error page is delivered instead. It shows the original error + and the error that occured durng rendering the original error page. + (minor: webui) + * Some smaller bug fixes and improvements, to the software and to the + documentation.

Changes for Version 0.14.0 (2023-09-22)

* Remove support for JSON. This was marked deprecated in version 0.12.0. Use the data encoding instead, a form of symbolic expressions. Index: www/download.wiki ================================================================== --- www/download.wiki +++ www/download.wiki @@ -7,20 +7,20 @@ * However, it is in use by the main developer since March 2020 without any damage. * It may be useful for you. It is useful for me. * Take a look at the [https://zettelstore.de/manual/|manual] to know how to start and use it.

ZIP-ped Executables

-Build: v0.14.0 (2023-09-22). +Build: v0.15.0 (2023-10-26). - * [/uv/zettelstore-0.14.0-linux-amd64.zip|Linux] (amd64) - * [/uv/zettelstore-0.14.0-linux-arm.zip|Linux] (arm6, e.g. Raspberry Pi) - * [/uv/zettelstore-0.14.0-darwin-arm64.zip|macOS] (arm64) - * [/uv/zettelstore-0.14.0-darwin-amd64.zip|macOS] (amd64) - * [/uv/zettelstore-0.14.0-windows-amd64.zip|Windows] (amd64) + * [/uv/zettelstore-0.15.0-linux-amd64.zip|Linux] (amd64) + * [/uv/zettelstore-0.15.0-linux-arm.zip|Linux] (arm6, e.g. Raspberry Pi) + * [/uv/zettelstore-0.15.0-darwin-arm64.zip|macOS] (arm64) + * [/uv/zettelstore-0.15.0-darwin-amd64.zip|macOS] (amd64) + * [/uv/zettelstore-0.15.0-windows-amd64.zip|Windows] (amd64) Unzip the appropriate file, install and execute Zettelstore according to the manual.

Zettel for the manual

As a starter, you can download the zettel for the manual -[/uv/manual-0.14.0.zip|here]. +[/uv/manual-0.15.0.zip|here]. Just unzip the contained files and put them into your zettel folder or configure a file box to read the zettel directly from the ZIP file. Index: www/index.wiki ================================================================== --- www/index.wiki +++ www/index.wiki @@ -24,17 +24,17 @@ * [https://zettelstore.de/sx|Sx] provides an evaluator for symbolic expressions, which is unsed for HTML templates and more. [https://mastodon.social/tags/Zettelstore|Stay tuned] …
-

Latest Release: 0.14.0 (2023-09-22)

+

Latest Release: 0.15.0 (2023-10-26)

* [./download.wiki|Download] - * [./changes.wiki#0_14|Change summary] - * [/timeline?p=v0.14.0&bt=v0.13.0&y=ci|Check-ins for version 0.14.0], - [/vdiff?to=v0.14.0&from=v0.13.0|content diff] - * [/timeline?df=v0.14.0&y=ci|Check-ins derived from the 0.14.0 release], - [/vdiff?from=v0.14.0&to=trunk|content diff] + * [./changes.wiki#0_15|Change summary] + * [/timeline?p=v0.15.0&bt=v0.14.0&y=ci|Check-ins for version 0.15.0], + [/vdiff?to=v0.15.0&from=v0.14.0|content diff] + * [/timeline?df=v0.15.0&y=ci|Check-ins derived from the 0.15.0 release], + [/vdiff?from=v0.15.0&to=trunk|content diff] * [./plan.wiki|Limitations and planned improvements] * [/timeline?t=release|Timeline of all past releases]

Build instructions

Index: zettel/id/digraph.go ================================================================== --- zettel/id/digraph.go +++ zettel/id/digraph.go @@ -8,11 +8,14 @@ // under this license. //----------------------------------------------------------------------------- package id -import "maps" +import ( + "maps" + "slices" +) // Digraph relates zettel identifier in a directional way. type Digraph map[Zid]Set // AddVertex adds an edge / vertex to the digraph. @@ -23,10 +26,20 @@ if _, found := dg[zid]; !found { dg[zid] = nil } return dg } + +// RemoveVertex removes a vertex and all its edges from the digraph. +func (dg Digraph) RemoveVertex(zid Zid) { + if len(dg) > 0 { + delete(dg, zid) + for vertex, closure := range dg { + dg[vertex] = closure.Remove(zid) + } + } +} // AddEdge adds a connection from `zid1` to `zid2`. // Both vertices must be added before. Otherwise the function may panic. func (dg Digraph) AddEdge(fromZid, toZid Zid) Digraph { if dg == nil { @@ -56,10 +69,22 @@ // Equal returns true if both digraphs have the same vertices and edges. func (dg Digraph) Equal(other Digraph) bool { return maps.EqualFunc(dg, other, func(cg, co Set) bool { return cg.Equal(co) }) } + +// Clone a digraph. +func (dg Digraph) Clone() Digraph { + if len(dg) == 0 { + return nil + } + copyDG := make(Digraph, len(dg)) + for vertex, closure := range dg { + copyDG[vertex] = closure.Clone() + } + return copyDG +} // HasVertex returns true, if `zid` is a vertex of the digraph. func (dg Digraph) HasVertex(zid Zid) bool { if len(dg) == 0 { return false @@ -170,10 +195,22 @@ return vertex, false } } return Invalid, true } + +// Reverse returns a graph with reversed edges. +func (dg Digraph) Reverse() (revDg Digraph) { + for vertex, closure := range dg { + revDg = revDg.AddVertex(vertex) + for next := range closure { + revDg = revDg.AddVertex(next) + revDg = revDg.AddEdge(next, vertex) + } + } + return revDg +} // SortReverse returns a deterministic, topological, reverse sort of the // digraph. // // Works only if digraph is a DAG. Otherwise the algorithm will not terminate @@ -180,28 +217,20 @@ // or returns an arbitrary value. func (dg Digraph) SortReverse() (sl Slice) { if len(dg) == 0 { return nil } - var done Set - onStack := dg.Originators() - stack := onStack.Sorted() - for pos := len(stack) - 1; pos >= 0; pos = len(stack) - 1 { - curr := stack[pos] - closure := dg[curr] - for next := range closure { - if done.Contains(next) || onStack.Contains(next) { - closure = closure.Remove(next) - } - } - if len(closure) == 0 { - sl = append(sl, curr) - done = done.Add(curr) - stack = stack[:pos] - onStack = onStack.Remove(curr) - continue - } - stack = append(stack, closure.Sorted()...) - onStack.Copy(closure) + tempDg := dg.Clone() + for len(tempDg) > 0 { + terms := tempDg.Terminators() + if len(terms) == 0 { + break + } + termSlice := terms.Sorted() + slices.Reverse(termSlice) + sl = append(sl, termSlice...) + for t := range terms { + tempDg.RemoveVertex(t) + } } return sl } Index: zettel/id/digraph_test.go ================================================================== --- zettel/id/digraph_test.go +++ zettel/id/digraph_test.go @@ -96,11 +96,10 @@ t.Run(tc.name, func(t *testing.T) { dg := createDigraph(tc.pairs) if got := dg.TransitiveClosure(tc.start).Edges().Sort(); !got.Equal(tc.exp) { t.Errorf("\n%v, but got:\n%v", tc.exp, got) } - }) } } func TestIsDAG(t *testing.T) { @@ -121,10 +120,36 @@ t.Errorf("expected %v, but got %v (%v)", tc.exp, got, zid) } }) } } + +func TestDigraphReverse(t *testing.T) { + t.Parallel() + testcases := []struct { + name string + dg id.EdgeSlice + exp id.EdgeSlice + }{ + {"empty", nil, nil}, + {"single-edge", zps{{1, 2}}, zps{{2, 1}}}, + {"single-loop", zps{{1, 1}}, zps{{1, 1}}}, + {"end-loop", zps{{1, 2}, {2, 2}}, zps{{2, 1}, {2, 2}}}, + {"long-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {5, 2}}, zps{{2, 1}, {2, 5}, {3, 2}, {4, 3}, {5, 4}}}, + {"sect-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {4, 2}}, zps{{2, 1}, {2, 4}, {3, 2}, {4, 3}, {5, 4}}}, + {"two-islands", zps{{1, 2}, {2, 3}, {4, 5}}, zps{{2, 1}, {3, 2}, {5, 4}}}, + {"direct-indirect", zps{{1, 2}, {1, 3}, {3, 2}}, zps{{2, 1}, {2, 3}, {3, 1}}}, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + dg := createDigraph(tc.dg) + if got := dg.Reverse().Edges().Sort(); !got.Equal(tc.exp) { + t.Errorf("\n%v, but got:\n%v", tc.exp, got) + } + }) + } +} func TestDigraphSortReverse(t *testing.T) { t.Parallel() testcases := []struct { name string @@ -132,18 +157,19 @@ exp id.Slice }{ {"empty", nil, nil}, {"single-edge", zps{{1, 2}}, id.Slice{2, 1}}, {"single-loop", zps{{1, 1}}, nil}, - {"end-loop", zps{{1, 2}, {2, 2}}, id.Slice{2, 1}}, - {"long-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {5, 2}}, id.Slice{5, 4, 3, 2, 1}}, - {"sect-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {4, 2}}, id.Slice{5, 4, 3, 2, 1}}, - {"two-islands", zps{{1, 2}, {2, 3}, {4, 5}}, id.Slice{5, 4, 3, 2, 1}}, + {"end-loop", zps{{1, 2}, {2, 2}}, id.Slice{}}, + {"long-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {5, 2}}, id.Slice{}}, + {"sect-loop", zps{{1, 2}, {2, 3}, {3, 4}, {4, 5}, {4, 2}}, id.Slice{5}}, + {"two-islands", zps{{1, 2}, {2, 3}, {4, 5}}, id.Slice{5, 3, 4, 2, 1}}, + {"direct-indirect", zps{{1, 2}, {1, 3}, {3, 2}}, id.Slice{2, 3, 1}}, } for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { if got := createDigraph(tc.dg).SortReverse(); !got.Equal(tc.exp) { t.Errorf("expected:\n%v, but got:\n%v", tc.exp, got) } }) } } Index: zettel/id/id.go ================================================================== --- zettel/id/id.go +++ zettel/id/id.go @@ -46,10 +46,11 @@ RenameTemplateZid = MustParse(api.ZidRenameTemplate) DeleteTemplateZid = MustParse(api.ZidDeleteTemplate) ErrorTemplateZid = MustParse(api.ZidErrorTemplate) StartSxnZid = MustParse(api.ZidSxnStart) BaseSxnZid = MustParse(api.ZidSxnBase) + PreludeSxnZid = MustParse(api.ZidSxnPrelude) EmojiZid = MustParse(api.ZidEmoji) TOCNewTemplateZid = MustParse(api.ZidTOCNewTemplate) DefaultHomeZid = MustParse(api.ZidDefaultHome) ) Index: zettel/id/set.go ================================================================== --- zettel/id/set.go +++ zettel/id/set.go @@ -96,24 +96,28 @@ } // Copy adds all member from the other set. func (s Set) Copy(other Set) Set { if s == nil { + if len(other) == 0 { + return nil + } s = NewSetCap(len(other)) } maps.Copy(s, other) return s } // CopySlice adds all identifier of the given slice to the set. -func (s Set) CopySlice(sl Slice) { +func (s Set) CopySlice(sl Slice) Set { if s == nil { s = NewSetCap(len(sl)) } for _, zid := range sl { s[zid] = struct{}{} } + return s } // Sorted returns the set as a sorted slice of zettel identifier. func (s Set) Sorted() Slice { if l := len(s); l > 0 { Index: zettel/meta/values.go ================================================================== --- zettel/meta/values.go +++ zettel/meta/values.go @@ -10,10 +10,11 @@ package meta import ( "fmt" + "strings" "zettelstore.de/client.fossil/api" ) // Supported syntax values. @@ -106,5 +107,13 @@ if ur, ok := urMap[val]; ok { return ur } return UserRoleUnknown } + +// NormalizeTag adds a missing prefix "#" to the tag +func NormalizeTag(tag string) string { + if strings.HasPrefix(tag, "#") { + return tag + } + return "#" + tag +}