Zettelstore

Check-in Differences
Login

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Difference From version-0.0.11 To version-0.0.12

2021-04-17
12:34
Increase version to 0.0.13-dev to begin next development cycle ... (check-in: 5f0c8f2d4c user: stern tags: trunk)
2021-04-16
16:16
Version 0.0.12 ... (check-in: 86f8bc8a70 user: stern tags: trunk, release, version-0.0.12)
16:08
Show default dir place type in startup values zettel ... (check-in: 8269a7cbc4 user: stern tags: trunk)
2021-04-05
15:59
Increase version to 0.0.12-dev to begin next development cycle ... (check-in: 737632737f user: stern tags: trunk)
12:18
Version 0.0.11 ... (check-in: 6db9ad537f user: stern tags: trunk, release, version-0.0.11)
2021-04-03
17:31
Include license file and readme into zip-file for released software ... (check-in: ca6e7ae6d7 user: stern tags: trunk)

Changes to VERSION.

1
0.0.11
|
1
0.0.12

Added cmd/fd_limit.go.































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//-----------------------------------------------------------------------------
// Copyright (c) 2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// +build !darwin

package cmd

func raiseFdLimit() error { return nil }

Added cmd/fd_limit_raise.go.































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
//-----------------------------------------------------------------------------
// Copyright (c) 2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// +build darwin

package cmd

import (
	"log"
	"syscall"
)

const minFiles = 1048576

func raiseFdLimit() error {
	var rLimit syscall.Rlimit
	err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
	if err != nil {
		return err
	}
	if rLimit.Cur >= minFiles {
		return nil
	}
	rLimit.Cur = minFiles
	if rLimit.Cur > rLimit.Max {
		rLimit.Cur = rLimit.Max
	}
	err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rLimit)
	if err != nil {
		return err
	}
	err = syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rLimit)
	if err != nil {
		return err
	}
	if rLimit.Cur < minFiles {
		log.Printf("Make sure you have no more than %d files in all your places if you enabled notification\n", rLimit.Cur)
	}
	return nil
}

Changes to cmd/main.go.

10
11
12
13
14
15
16

17
18
19
20
21
22
23

package cmd

import (
	"context"
	"flag"
	"fmt"

	"os"
	"strings"

	"zettelstore.de/z/config/runtime"
	"zettelstore.de/z/config/startup"
	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/domain/meta"







>







10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

package cmd

import (
	"context"
	"flag"
	"fmt"
	"log"
	"os"
	"strings"

	"zettelstore.de/z/config/runtime"
	"zettelstore.de/z/config/startup"
	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/domain/meta"
123
124
125
126
127
128
129







130
131
132
133
134
135
136

137
138
139
140
141
142
143
144
145
146
147
148
149
150
	return cfg
}

func setupOperations(cfg *meta.Meta, withPlaces, simple bool) error {
	var mgr place.Manager
	var idx index.Indexer
	if withPlaces {







		idx = indexer.New()
		filter := index.NewMetaFilter(idx)
		p, err := manager.New(getPlaces(cfg), cfg.GetBool(startup.KeyReadOnlyMode), filter)
		if err != nil {
			return err
		}
		mgr = p

	}

	err := startup.SetupStartup(cfg, mgr, idx, simple)
	if err != nil {
		fmt.Fprintln(os.Stderr, "Unable to connect to specified places")
		return err
	}
	if withPlaces {
		if err := mgr.Start(context.Background()); err != nil {
			fmt.Fprintln(os.Stderr, "Unable to start zettel place")
			return err
		}
		runtime.SetupConfiguration(mgr)
		progplace.Setup(cfg, mgr, idx)







>
>
>
>
>
>
>


|



|
>


|
<
<
<
<







124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148




149
150
151
152
153
154
155
	return cfg
}

func setupOperations(cfg *meta.Meta, withPlaces, simple bool) error {
	var mgr place.Manager
	var idx index.Indexer
	if withPlaces {
		err := raiseFdLimit()
		if err != nil {
			log.Println("Raising some limitions did not work:", err)
			log.Println("Prepare to encounter errors. Most of them can be mitigated. See the manual for details")
			cfg.Set(startup.KeyDefaultDirPlaceType, startup.ValueDirPlaceTypeSimple)
		}
		startup.SetupStartupConfig(cfg)
		idx = indexer.New()
		filter := index.NewMetaFilter(idx)
		mgr, err = manager.New(getPlaces(cfg), cfg.GetBool(startup.KeyReadOnlyMode), filter)
		if err != nil {
			return err
		}
	} else {
		startup.SetupStartupConfig(cfg)
	}

	startup.SetupStartupService(mgr, idx, simple)




	if withPlaces {
		if err := mgr.Start(context.Background()); err != nil {
			fmt.Fprintln(os.Stderr, "Unable to start zettel place")
			return err
		}
		runtime.SetupConfiguration(mgr)
		progplace.Setup(cfg, mgr, idx)

Changes to config/startup/startup.go.

20
21
22
23
24
25
26
27
28
29
30
31

32
33
34
35
36
37
38



39
40
41
42
43
44

45
46
47
48
49
50
51
52
53
54






55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75











76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92







93
94
95
96
97
98
99
100
101
102
103
	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/domain/meta"
	"zettelstore.de/z/index"
	"zettelstore.de/z/place"
)

var config struct {
	simple        bool // was started without run command
	verbose       bool
	readonlyMode  bool
	urlPrefix     string
	listenAddress string

	owner         id.Zid
	withAuth      bool
	secret        []byte
	insecCookie   bool
	persistCookie bool
	htmlLifetime  time.Duration
	apiLifetime   time.Duration



	manager       place.Manager
	indexer       index.Indexer
}

// Predefined keys for startup zettel
const (

	KeyInsecureCookie    = "insecure-cookie"
	KeyListenAddress     = "listen-addr"
	KeyOwner             = "owner"
	KeyPersistentCookie  = "persistent-cookie"
	KeyPlaceOneURI       = "place-1-uri"
	KeyReadOnlyMode      = "read-only-mode"
	KeyTokenLifetimeHTML = "token-lifetime-html"
	KeyTokenLifetimeAPI  = "token-lifetime-api"
	KeyURLPrefix         = "url-prefix"
	KeyVerbose           = "verbose"






)

// SetupStartup initializes the startup data.
func SetupStartup(cfg *meta.Meta, manager place.Manager, idx index.Indexer, simple bool) error {
	if config.urlPrefix != "" {
		panic("startup.config already set")
	}
	config.simple = simple
	config.verbose = cfg.GetBool(KeyVerbose)
	config.readonlyMode = cfg.GetBool(KeyReadOnlyMode)
	config.urlPrefix = cfg.GetDefault(KeyURLPrefix, "/")
	if prefix, ok := cfg.Get(KeyURLPrefix); ok &&
		len(prefix) > 0 && prefix[0] == '/' && prefix[len(prefix)-1] == '/' {
		config.urlPrefix = prefix
	} else {
		config.urlPrefix = "/"
	}
	if val, ok := cfg.Get(KeyListenAddress); ok {
		config.listenAddress = val // TODO: check for valid string
	} else {
		config.listenAddress = "127.0.0.1:23123"











	}
	config.owner = id.Invalid
	if owner, ok := cfg.Get(KeyOwner); ok {
		if zid, err := id.Parse(owner); err == nil {
			config.owner = zid
			config.withAuth = true
		}
	}
	if config.withAuth {
		config.insecCookie = cfg.GetBool(KeyInsecureCookie)
		config.persistCookie = cfg.GetBool(KeyPersistentCookie)
		config.secret = calcSecret(cfg)
		config.htmlLifetime = getDuration(
			cfg, KeyTokenLifetimeHTML, 1*time.Hour, 1*time.Minute, 30*24*time.Hour)
		config.apiLifetime = getDuration(
			cfg, KeyTokenLifetimeAPI, 10*time.Minute, 0, 1*time.Hour)
	}







	config.simple = simple && !config.withAuth
	config.manager = manager
	config.indexer = idx
	return nil
}

func calcSecret(cfg *meta.Meta) []byte {
	h := fnv.New128()
	if secret, ok := cfg.Get("secret"); ok {
		io.WriteString(h, secret)
	}







|
|
|
|
|
>
|
|
|
|
|
|
|
>
>
>
|
|




>
|
|
|
|
|
|
|
|
|
|
>
>
>
>
>
>


|
|



<













>
>
>
>
>
>
>
>
>
>
>

















>
>
>
>
>
>
>



<







20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72

73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123

124
125
126
127
128
129
130
	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/domain/meta"
	"zettelstore.de/z/index"
	"zettelstore.de/z/place"
)

var config struct {
	// Set in SetupStartupConfig
	verbose             bool
	readonlyMode        bool
	urlPrefix           string
	listenAddress       string
	defaultDirPlaceType string
	owner               id.Zid
	withAuth            bool
	secret              []byte
	insecCookie         bool
	persistCookie       bool
	htmlLifetime        time.Duration
	apiLifetime         time.Duration

	// Set in SetupStartupService
	simple  bool // was started without run command
	manager place.Manager
	indexer index.Indexer
}

// Predefined keys for startup zettel
const (
	KeyDefaultDirPlaceType = "default-dir-place-type"
	KeyInsecureCookie      = "insecure-cookie"
	KeyListenAddress       = "listen-addr"
	KeyOwner               = "owner"
	KeyPersistentCookie    = "persistent-cookie"
	KeyPlaceOneURI         = "place-1-uri"
	KeyReadOnlyMode        = "read-only-mode"
	KeyTokenLifetimeHTML   = "token-lifetime-html"
	KeyTokenLifetimeAPI    = "token-lifetime-api"
	KeyURLPrefix           = "url-prefix"
	KeyVerbose             = "verbose"
)

// Important values for some keys.
const (
	ValueDirPlaceTypeNotify = "notify"
	ValueDirPlaceTypeSimple = "simple"
)

// SetupStartupConfig initializes the startup data with content of config file.
func SetupStartupConfig(cfg *meta.Meta) {
	if config.urlPrefix != "" {
		panic("startup.config already set")
	}

	config.verbose = cfg.GetBool(KeyVerbose)
	config.readonlyMode = cfg.GetBool(KeyReadOnlyMode)
	config.urlPrefix = cfg.GetDefault(KeyURLPrefix, "/")
	if prefix, ok := cfg.Get(KeyURLPrefix); ok &&
		len(prefix) > 0 && prefix[0] == '/' && prefix[len(prefix)-1] == '/' {
		config.urlPrefix = prefix
	} else {
		config.urlPrefix = "/"
	}
	if val, ok := cfg.Get(KeyListenAddress); ok {
		config.listenAddress = val // TODO: check for valid string
	} else {
		config.listenAddress = "127.0.0.1:23123"
	}
	if defaultType, ok := cfg.Get(KeyDefaultDirPlaceType); ok {
		switch defaultType {
		case ValueDirPlaceTypeNotify:
		case ValueDirPlaceTypeSimple:
		default:
			defaultType = ValueDirPlaceTypeNotify
		}
		config.defaultDirPlaceType = defaultType
	} else {
		config.defaultDirPlaceType = ValueDirPlaceTypeNotify
	}
	config.owner = id.Invalid
	if owner, ok := cfg.Get(KeyOwner); ok {
		if zid, err := id.Parse(owner); err == nil {
			config.owner = zid
			config.withAuth = true
		}
	}
	if config.withAuth {
		config.insecCookie = cfg.GetBool(KeyInsecureCookie)
		config.persistCookie = cfg.GetBool(KeyPersistentCookie)
		config.secret = calcSecret(cfg)
		config.htmlLifetime = getDuration(
			cfg, KeyTokenLifetimeHTML, 1*time.Hour, 1*time.Minute, 30*24*time.Hour)
		config.apiLifetime = getDuration(
			cfg, KeyTokenLifetimeAPI, 10*time.Minute, 0, 1*time.Hour)
	}
}

// SetupStartupService initializes the startup data with internal services.
func SetupStartupService(manager place.Manager, idx index.Indexer, simple bool) {
	if config.urlPrefix == "" {
		panic("startup.config not set")
	}
	config.simple = simple && !config.withAuth
	config.manager = manager
	config.indexer = idx

}

func calcSecret(cfg *meta.Meta) []byte {
	h := fnv.New128()
	if secret, ok := cfg.Get("secret"); ok {
		io.WriteString(h, secret)
	}
139
140
141
142
143
144
145



146
147
148
149
150
151
152
// URLPrefix returns the configured prefix to be used when providing URL to
// the service.
func URLPrefix() string { return config.urlPrefix }

// ListenAddress returns the string that specifies the the network card and the ip port
// where the server listens for requests
func ListenAddress() string { return config.listenAddress }




// WithAuth returns true if user authentication is enabled.
func WithAuth() bool { return config.withAuth }

// SecureCookie returns whether the web app should set cookies to secure mode.
func SecureCookie() bool { return config.withAuth && !config.insecCookie }








>
>
>







166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
// URLPrefix returns the configured prefix to be used when providing URL to
// the service.
func URLPrefix() string { return config.urlPrefix }

// ListenAddress returns the string that specifies the the network card and the ip port
// where the server listens for requests
func ListenAddress() string { return config.listenAddress }

// DefaultDirPlaceType returns the default value for a directory place type.
func DefaultDirPlaceType() string { return config.defaultDirPlaceType }

// WithAuth returns true if user authentication is enabled.
func WithAuth() bool { return config.withAuth }

// SecureCookie returns whether the web app should set cookies to secure mode.
func SecureCookie() bool { return config.withAuth && !config.insecCookie }

Changes to docs/manual/00001004010000.zettel.

11
12
13
14
15
16
17





18
19
20
21
22
23
24
Therefore only the owner of the computer on which Zettelstore runs can change this information.

The file for start-up configuration must be created via a text editor in advance.

The syntax of the configuration file is the same as for any zettel metadata.
The following keys are supported:






; [!insecure-cookie]''insecure-cookie''
: Must be set to ''true'', if authentication is enabled and Zettelstore is not accessible not via HTTPS (but via HTTP).
  Otherwise web browser are free to ignore the authentication cookie.

  Default: ''false''
; [!listen-addr]''listen-addr''
: Configures the network address, where is zettel web service is listening for requests.







>
>
>
>
>







11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Therefore only the owner of the computer on which Zettelstore runs can change this information.

The file for start-up configuration must be created via a text editor in advance.

The syntax of the configuration file is the same as for any zettel metadata.
The following keys are supported:

; [!default-dir-place-type]''default-dir-place-type''
: Specifies the default value for the (sub-) type of [[directory places|00001004011400#type]].
  Zettel are typically stored in such places.

  Default: ''notify''
; [!insecure-cookie]''insecure-cookie''
: Must be set to ''true'', if authentication is enabled and Zettelstore is not accessible not via HTTPS (but via HTTP).
  Otherwise web browser are free to ignore the authentication cookie.

  Default: ''false''
; [!listen-addr]''listen-addr''
: Configures the network address, where is zettel web service is listening for requests.

Changes to docs/manual/00001004011400.zettel.

1
2

3
4
5
6
7
8
9
10
11
12

13
14
15
16

















17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35


36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51





52
53
54
55
56
57
58
id: 00001004011400
title: Configure file directory places

tags: #configuration #manual #zettelstore
syntax: zmk
role: manual

Under certain circumstances, it is preferable to further configure a file directory place.
This is done by appending query parameters after the base place URI ''dir:\//DIR''.

The following parameters are supported:

|= Parameter:|Description|Default value:|

|rescan|Time (in seconds) after which the directory should be scanned fully|600
|worker|Number of worker that can access the directory in parallel|17
|readonly|Allow only operations that do not change a zettel or create a new zettel|n/a


















=== Rescan
On most platforms, Zettelstore automatically detects changes to zettel files that originates from other software[^This includes most Linux distributions, macOS, and Windows].
It is done on a ""best-effort"" basis.
Under certain circumstances it is possible that Zettelstore does not detect a change done by another software.

To cope with this unlikely, but still possible event, Zettelstore re-scans periodically the file diectory.
The time interval is configured by the ''rescan'' parameter, e.g.
```
place-1-uri: dir:///home/zettel?rescan=300
```
This makes Zettelstore to re-scan the directory ''/home/zettel/'' every 300 seconds, i.e. 5 minutes.

For a Zettelstore with many zettel, re-scanning the directory may take a while, especially if it is stored on a remote computer (e.g. via CIFS/SMB or NFS).
In this case, you should adjust the parameter value.

Please note that a directory re-scan invalidates all internal data of a Zettelstore.
It might trigger a re-build of the backlink database (and other internal databases).
Therefore a large value if preferred.



=== Worker
Internally, Zettelstore parallels concurrent requests for a zettel or its metadata.
The number of parallel activities is configured by the ''worker'' parameter.
Its default value 17 is a good compromise when you think about the high variability of possible Zettelstore environments.

A computer contains a limited number of internal processing units (CPU).
Its number ranges from 1 to (currently) 128, e.g. in bigger server environments.
Zettelstore typically runs on a system with 1 to 8 CPUs.
Access to zettel file is ultimately managed by the underlying operating system.
Depending on the hardware, only a limited number of parallel accesses are desireable.
Since Zettelstore is a single-user software, the value 17 is quite reasonable, even for higher use.

On smaller hardware[^In comparison to a normal desktop or laptop computer], such as the [[Raspberry Zero|https://www.raspberrypi.org/products/raspberry-pi-zero/]], a smaller value might be appropriate.
Every worker needs some amount of main memory (RAM) and some amount of processing power.
On bigger hardware, with some fast file services, a bigger value could result in higher performance, if needed.






=== Readonly
Sometimes you may want to provide zettel from a file directory place, but you want to disallow any changes.
If you provide the query parameter ''readonly'' (with or without a corresponding value), the place will disallow any changes.
```
place-1-uri: dir:///home/zettel?readonly
```
If you put the whole Zettelstore in [[read-only|00001004010000]] [[mode|00001004051000]], all configured file directory places will be in read-only mode too, even if not explicitly configured.


>


<







>

|


>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>

|



|













>
>



<





|
<





>
>
>
>
>







1
2
3
4
5

6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

59
60
61
62
63
64

65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
id: 00001004011400
title: Configure file directory places
role: manual
tags: #configuration #manual #zettelstore
syntax: zmk


Under certain circumstances, it is preferable to further configure a file directory place.
This is done by appending query parameters after the base place URI ''dir:\//DIR''.

The following parameters are supported:

|= Parameter:|Description|Default value:|
|type|(Sub-) Type of the directory service|(value of ''[[default-dir-place-type|00001004010000#default-dir-place-type]]'')
|rescan|Time (in seconds) after which the directory should be scanned fully|600
|worker|Number of worker that can access the directory in parallel|(depends on type)
|readonly|Allow only operations that do not change a zettel or create a new zettel|n/a

=== Type
On some operating systems, Zettelstore tries to detect changes to zettel files outside of Zettelstore's control[^This includes Linux, Windows, and macOS.].
On other operating systems, this may be not possible, due to technical limitations.
Automatic detection of external changes is also not possible, if zettel files are placed on an external service, such as a file server accessed via SMD/CIFS or NFS.

To cope with this uncertainty, Zettelstore provides various internal implementations of a directory place.
The default values should match the needs of different users, as explained in the [[installation part|00001003000000]] of this manual.
The following values are supported:

; simple
: Is not able to detect external changes.
  Works on all platforms.
  Is a little slower than other implementations (up to three times slower).
; notify
: Automatically detect external changes.
  Tries to optimize performance, at a little cost of main memory (RAM).

=== Rescan
When the parameter ''type'' is set to ""notify"", Zettelstore automatically detects changes to zettel files that originates from other software.
It is done on a ""best-effort"" basis.
Under certain circumstances it is possible that Zettelstore does not detect a change done by another software.

To cope with this unlikely, but still possible event, Zettelstore re-scans periodically the file directory.
The time interval is configured by the ''rescan'' parameter, e.g.
```
place-1-uri: dir:///home/zettel?rescan=300
```
This makes Zettelstore to re-scan the directory ''/home/zettel/'' every 300 seconds, i.e. 5 minutes.

For a Zettelstore with many zettel, re-scanning the directory may take a while, especially if it is stored on a remote computer (e.g. via CIFS/SMB or NFS).
In this case, you should adjust the parameter value.

Please note that a directory re-scan invalidates all internal data of a Zettelstore.
It might trigger a re-build of the backlink database (and other internal databases).
Therefore a large value if preferred.

This value is ignored for other directory place type, such as ""simple"".

=== Worker
Internally, Zettelstore parallels concurrent requests for a zettel or its metadata.
The number of parallel activities is configured by the ''worker'' parameter.


A computer contains a limited number of internal processing units (CPU).
Its number ranges from 1 to (currently) 128, e.g. in bigger server environments.
Zettelstore typically runs on a system with 1 to 8 CPUs.
Access to zettel file is ultimately managed by the underlying operating system.
Depending on the hardware and on the type of the directory place, only a limited number of parallel accesses are desirable.


On smaller hardware[^In comparison to a normal desktop or laptop computer], such as the [[Raspberry Zero|https://www.raspberrypi.org/products/raspberry-pi-zero/]], a smaller value might be appropriate.
Every worker needs some amount of main memory (RAM) and some amount of processing power.
On bigger hardware, with some fast file services, a bigger value could result in higher performance, if needed.

For a directory place of type ""notify"", the default value is: 7.
The directory place type ""simple"" limits the value to a maximum of 1, i.e. no concurrency is possible with this type of directory place.

For various reasons, the value should be a prime number, with a maximum value of 1499.

=== Readonly
Sometimes you may want to provide zettel from a file directory place, but you want to disallow any changes.
If you provide the query parameter ''readonly'' (with or without a corresponding value), the place will disallow any changes.
```
place-1-uri: dir:///home/zettel?readonly
```
If you put the whole Zettelstore in [[read-only|00001004010000]] [[mode|00001004051000]], all configured file directory places will be in read-only mode too, even if not explicitly configured.

Changes to go.mod.

1
2
3
4
5
6
7
8
9
10
11
12
module zettelstore.de/z

go 1.16

require (
	github.com/fsnotify/fsnotify v1.4.9
	github.com/pascaldekloe/jwt v1.10.0
	github.com/yuin/goldmark v1.3.3
	golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
	golang.org/x/term v0.0.0-20201117132131-f5c789dd3221
	golang.org/x/text v0.3.6
)







|




1
2
3
4
5
6
7
8
9
10
11
12
module zettelstore.de/z

go 1.16

require (
	github.com/fsnotify/fsnotify v1.4.9
	github.com/pascaldekloe/jwt v1.10.0
	github.com/yuin/goldmark v1.3.5
	golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83
	golang.org/x/term v0.0.0-20201117132131-f5c789dd3221
	golang.org/x/text v0.3.6
)

Changes to go.sum.

1
2
3
4
5
6
7
8
9
10
11
12
13
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/pascaldekloe/jwt v1.10.0 h1:ktcIUV4TPvh404R5dIBEnPCsSwj0sqi3/0+XafE5gJs=
github.com/pascaldekloe/jwt v1.10.0/go.mod h1:TKhllgThT7TOP5rGr2zMLKEDZRAgJfBbtKyVeRsNB9A=
github.com/yuin/goldmark v1.3.3 h1:37BdQwPx8VOSic8eDSWee6QL9mRpZRm9VJp/QugNrW0=
github.com/yuin/goldmark v1.3.3/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 h1:/ZScEX8SfEmUGRHs0gxpqteO5nfNW6axyZbBdw9A12g=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=




|
|







1
2
3
4
5
6
7
8
9
10
11
12
13
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/pascaldekloe/jwt v1.10.0 h1:ktcIUV4TPvh404R5dIBEnPCsSwj0sqi3/0+XafE5gJs=
github.com/pascaldekloe/jwt v1.10.0/go.mod h1:TKhllgThT7TOP5rGr2zMLKEDZRAgJfBbtKyVeRsNB9A=
github.com/yuin/goldmark v1.3.5 h1:dPmz1Snjq0kmkz159iL7S6WzdahUTHnHB5M56WFVifs=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 h1:/ZScEX8SfEmUGRHs0gxpqteO5nfNW6axyZbBdw9A12g=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=

Changes to place/dirplace/directory/directory.go.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66


67
68
69
70
71
72
73
74
75
76
77

78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

94
95
96
97
98
99
100
101
102
103
104
105

106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
//-----------------------------------------------------------------------------
// Copyright (c) 2020-2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package directory manages the directory part of a dirstore.
package directory

import (
	"time"

	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/place/change"
)

// Service specifies a directory scan service.
type Service struct {
	dirPath    string
	rescanTime time.Duration
	done       chan struct{}
	cmds       chan dirCmd
	infos      chan<- change.Info
}

// NewService creates a new directory service.
func NewService(directoryPath string, rescanTime time.Duration, chci chan<- change.Info) *Service {
	srv := &Service{
		dirPath:    directoryPath,
		rescanTime: rescanTime,
		cmds:       make(chan dirCmd),
		infos:      chci,
	}
	return srv
}

// Start makes the directory service operational.
func (srv *Service) Start() {
	tick := make(chan struct{})
	rawEvents := make(chan *fileEvent)
	events := make(chan *fileEvent)

	ready := make(chan int)
	go srv.directoryService(events, ready)
	go collectEvents(events, rawEvents)
	go watchDirectory(srv.dirPath, rawEvents, tick)

	if srv.done != nil {
		panic("src.done already set")
	}
	srv.done = make(chan struct{})
	go ping(tick, srv.rescanTime, srv.done)
	<-ready
}

// Stop stops the directory service.
func (srv *Service) Stop() {
	close(srv.done)
	srv.done = nil
}

func (srv *Service) notifyChange(reason change.Reason, zid id.Zid) {


	if chci := srv.infos; chci != nil {
		chci <- change.Info{Reason: reason, Zid: zid}
	}
}

// NumEntries returns the number of managed zettel.
func (srv *Service) NumEntries() int {
	resChan := make(chan resNumEntries)
	srv.cmds <- &cmdNumEntries{resChan}
	return <-resChan
}


// GetEntries returns an unsorted list of all current directory entries.
func (srv *Service) GetEntries() []Entry {
	resChan := make(chan resGetEntries)
	srv.cmds <- &cmdGetEntries{resChan}
	return <-resChan
}

// GetEntry returns the entry with the specified zettel id. If there is no such
// zettel id, an empty entry is returned.
func (srv *Service) GetEntry(zid id.Zid) Entry {
	resChan := make(chan resGetEntry)
	srv.cmds <- &cmdGetEntry{zid, resChan}
	return <-resChan
}


// GetNew returns an entry with a new zettel id.
func (srv *Service) GetNew() Entry {
	resChan := make(chan resNewEntry)
	srv.cmds <- &cmdNewEntry{resChan}
	return <-resChan
}

// UpdateEntry notifies the directory of an updated entry.
func (srv *Service) UpdateEntry(entry *Entry) {
	resChan := make(chan struct{})
	srv.cmds <- &cmdUpdateEntry{entry, resChan}
	<-resChan

}

// RenameEntry notifies the directory of an renamed entry.
func (srv *Service) RenameEntry(curEntry, newEntry *Entry) error {
	resChan := make(chan resRenameEntry)
	srv.cmds <- &cmdRenameEntry{curEntry, newEntry, resChan}
	return <-resChan
}

// DeleteEntry removes a zettel id from the directory of entries.
func (srv *Service) DeleteEntry(zid id.Zid) {
	resChan := make(chan struct{})
	srv.cmds <- &cmdDeleteEntry{zid, resChan}
	<-resChan
}










|


<
<
<
|
<
|
|
<
|
<
<
<
<
<
<
|
<
<
|
<
<
<
|
<
|
<
|
<
|
<
<
|
|
<
<
<
<
|
<
<
|
<
<
<
|
|
<
<
<
|
<

|
>
>
|
<
<
<
|
<
<
<
<
<
<
>
|
<
<
<
<
<
|
|
|
|
<
<
<
<
<
|
>
|
<
<
<
<
<
|
<
<
<
<
<
>


|
<
|
<
<
<
|
<
<
<
<
<

1
2
3
4
5
6
7
8
9
10
11
12
13



14

15
16

17






18


19



20

21

22

23


24
25




26


27



28
29



30

31
32
33
34
35



36






37
38





39
40
41
42





43
44
45





46





47
48
49
50

51



52





53
//-----------------------------------------------------------------------------
// Copyright (c) 2020-2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package directory manages the directory interface of a dirstore.
package directory




import "zettelstore.de/z/domain/id"


// Service is the interface of a directory service.

type Service interface {






	Start() error


	Stop() error



	NumEntries() (int, error)

	GetEntries() ([]*Entry, error)

	GetEntry(zid id.Zid) (*Entry, error)

	GetNew() (*Entry, error)


	UpdateEntry(entry *Entry) error
	RenameEntry(curEntry, newEntry *Entry) error




	DeleteEntry(zid id.Zid) error


}




// MetaSpec defines all possibilities where meta data can be stored.



type MetaSpec int


// Constants for MetaSpec
const (
	_              MetaSpec = iota
	MetaSpecNone            // no meta information



	MetaSpecFile            // meta information is in meta file






	MetaSpecHeader          // meta information is in header
)






// Entry stores everything for a directory entry.
type Entry struct {
	Zid         id.Zid





	MetaSpec    MetaSpec // location of meta information
	MetaPath    string   // file path of meta information
	ContentPath string   // file path of zettel content





	ContentExt  string   // (normalized) file extension of zettel content





	Duplicates  bool     // multiple content files
}

// IsValid checks whether the entry is valid.

func (e *Entry) IsValid() bool {



	return e != nil && e.Zid.IsValid()





}

Deleted place/dirplace/directory/entry.go.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//-----------------------------------------------------------------------------
// Copyright (c) 2020-2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package directory manages the directory part of a dirstore.
package directory

import "zettelstore.de/z/domain/id"

// MetaSpec defines all possibilities where meta data can be stored.
type MetaSpec int

// Constants for MetaSpec
const (
	MetaSpecUnknown MetaSpec = iota
	MetaSpecNone             // no meta information
	MetaSpecFile             // meta information is in meta file
	MetaSpecHeader           // meta information is in header
)

// Entry stores everything for a directory entry.
type Entry struct {
	Zid         id.Zid
	MetaSpec    MetaSpec // location of meta information
	MetaPath    string   // file path of meta information
	ContentPath string   // file path of zettel content
	ContentExt  string   // (normalized) file extension of zettel content
	Duplicates  bool     // multiple content files
}

// IsValid checks whether the entry is valid.
func (e *Entry) IsValid() bool {
	return e.Zid.IsValid()
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
















































































Deleted place/dirplace/directory/service.go.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
//-----------------------------------------------------------------------------
// Copyright (c) 2020-2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package directory manages the directory part of a directory place.
package directory

import (
	"log"
	"time"

	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/place"
	"zettelstore.de/z/place/change"
)

// ping sends every tick a signal to reload the directory list
func ping(tick chan<- struct{}, rescanTime time.Duration, done <-chan struct{}) {
	ticker := time.NewTicker(rescanTime)
	defer close(tick)
	for {
		select {
		case _, ok := <-ticker.C:
			if !ok {
				return
			}
			tick <- struct{}{}
		case _, ok := <-done:
			if !ok {
				ticker.Stop()
				return
			}
		}
	}
}

func newEntry(ev *fileEvent) *Entry {
	de := new(Entry)
	de.Zid = ev.zid
	updateEntry(de, ev)
	return de
}

func updateEntry(de *Entry, ev *fileEvent) {
	if ev.ext == "meta" {
		de.MetaSpec = MetaSpecFile
		de.MetaPath = ev.path
		return
	}
	if de.ContentExt != "" && de.ContentExt != ev.ext {
		de.Duplicates = true
		return
	}
	if de.MetaSpec != MetaSpecFile {
		if ev.ext == "zettel" {
			de.MetaSpec = MetaSpecHeader
		} else {
			de.MetaSpec = MetaSpecNone
		}
	}
	de.ContentPath = ev.path
	de.ContentExt = ev.ext
}

type dirMap map[id.Zid]*Entry

func dirMapUpdate(dm dirMap, ev *fileEvent) {
	de := dm[ev.zid]
	if de == nil {
		dm[ev.zid] = newEntry(ev)
		return
	}
	updateEntry(de, ev)
}

func deleteFromMap(dm dirMap, ev *fileEvent) {
	if ev.ext == "meta" {
		if entry, ok := dm[ev.zid]; ok {
			if entry.MetaSpec == MetaSpecFile {
				entry.MetaSpec = MetaSpecNone
				return
			}
		}
	}
	delete(dm, ev.zid)
}

// directoryService is the main service.
func (srv *Service) directoryService(events <-chan *fileEvent, ready chan<- int) {
	curMap := make(dirMap)
	var newMap dirMap
	for {
		select {
		case ev, ok := <-events:
			if !ok {
				return
			}
			switch ev.status {
			case fileStatusReloadStart:
				newMap = make(dirMap)
			case fileStatusReloadEnd:
				curMap = newMap
				newMap = nil
				if ready != nil {
					ready <- len(curMap)
					close(ready)
					ready = nil
				}
				srv.notifyChange(change.OnReload, id.Invalid)
			case fileStatusError:
				log.Println("DIRPLACE", "ERROR", ev.err)
			case fileStatusUpdate:
				srv.processFileUpdateEvent(ev, curMap, newMap)
			case fileStatusDelete:
				srv.processFileDeleteEvent(ev, curMap, newMap)
			}
		case cmd, ok := <-srv.cmds:
			if ok {
				cmd.run(curMap)
			}
		}
	}
}

func (srv *Service) processFileUpdateEvent(ev *fileEvent, curMap, newMap dirMap) {
	if newMap != nil {
		dirMapUpdate(newMap, ev)
	} else {
		dirMapUpdate(curMap, ev)
		srv.notifyChange(change.OnUpdate, ev.zid)
	}
}

func (srv *Service) processFileDeleteEvent(ev *fileEvent, curMap, newMap dirMap) {
	if newMap != nil {
		deleteFromMap(newMap, ev)
	} else {
		deleteFromMap(curMap, ev)
		srv.notifyChange(change.OnDelete, ev.zid)
	}
}

type dirCmd interface {
	run(m dirMap)
}

type cmdNumEntries struct {
	result chan<- resNumEntries
}
type resNumEntries = int

func (cmd *cmdNumEntries) run(m dirMap) {
	cmd.result <- len(m)
}

type cmdGetEntries struct {
	result chan<- resGetEntries
}
type resGetEntries []Entry

func (cmd *cmdGetEntries) run(m dirMap) {
	res := make([]Entry, 0, len(m))
	for _, de := range m {
		res = append(res, *de)
	}
	cmd.result <- res
}

type cmdGetEntry struct {
	zid    id.Zid
	result chan<- resGetEntry
}
type resGetEntry = Entry

func (cmd *cmdGetEntry) run(m dirMap) {
	entry := m[cmd.zid]
	if entry == nil {
		cmd.result <- Entry{Zid: id.Invalid}
	} else {
		cmd.result <- *entry
	}
}

type cmdNewEntry struct {
	result chan<- resNewEntry
}
type resNewEntry = Entry

func (cmd *cmdNewEntry) run(m dirMap) {
	zid := id.New(false)
	if _, ok := m[zid]; !ok {
		entry := &Entry{Zid: zid, MetaSpec: MetaSpecUnknown}
		m[zid] = entry
		cmd.result <- *entry
		return
	}
	for {
		zid = id.New(true)
		if _, ok := m[zid]; !ok {
			entry := &Entry{Zid: zid, MetaSpec: MetaSpecUnknown}
			m[zid] = entry
			cmd.result <- *entry
			return
		}
		// TODO: do not wait here, but in a non-blocking goroutine.
		time.Sleep(100 * time.Millisecond)
	}
}

type cmdUpdateEntry struct {
	entry  *Entry
	result chan<- struct{}
}

func (cmd *cmdUpdateEntry) run(m dirMap) {
	entry := *cmd.entry
	m[entry.Zid] = &entry
	cmd.result <- struct{}{}
}

type cmdRenameEntry struct {
	curEntry *Entry
	newEntry *Entry
	result   chan<- resRenameEntry
}

type resRenameEntry = error

func (cmd *cmdRenameEntry) run(m dirMap) {
	newEntry := *cmd.newEntry
	newZid := newEntry.Zid
	if _, found := m[newZid]; found {
		cmd.result <- &place.ErrInvalidID{Zid: newZid}
		return
	}
	delete(m, cmd.curEntry.Zid)
	m[newZid] = &newEntry
	cmd.result <- nil
}

type cmdDeleteEntry struct {
	zid    id.Zid
	result chan<- struct{}
}

func (cmd *cmdDeleteEntry) run(m dirMap) {
	delete(m, cmd.zid)
	cmd.result <- struct{}{}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






























































































































































































































































































































































































































































































































Deleted place/dirplace/directory/watch.go.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
//-----------------------------------------------------------------------------
// Copyright (c) 2020-2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package directory manages the directory part of a directory place.
package directory

import (
	"os"
	"path/filepath"
	"regexp"
	"time"

	"github.com/fsnotify/fsnotify"

	"zettelstore.de/z/domain/id"
)

var validFileName = regexp.MustCompile(`^(\d{14}).*(\.(.+))$`)

func matchValidFileName(name string) []string {
	return validFileName.FindStringSubmatch(name)
}

type fileStatus int

const (
	fileStatusNone fileStatus = iota
	fileStatusReloadStart
	fileStatusReloadEnd
	fileStatusError
	fileStatusUpdate
	fileStatusDelete
)

type fileEvent struct {
	status fileStatus
	path   string // Full file path
	zid    id.Zid
	ext    string // File extension
	err    error  // Error if Status == fileStatusError
}

type sendResult int

const (
	sendDone sendResult = iota
	sendReload
	sendExit
)

func watchDirectory(directory string, events chan<- *fileEvent, tick <-chan struct{}) {
	defer close(events)

	var watcher *fsnotify.Watcher
	defer func() {
		if watcher != nil {
			watcher.Close()
		}
	}()

	sendEvent := func(ev *fileEvent) sendResult {
		select {
		case events <- ev:
		case _, ok := <-tick:
			if ok {
				return sendReload
			}
			return sendExit
		}
		return sendDone
	}

	sendError := func(err error) sendResult {
		return sendEvent(&fileEvent{status: fileStatusError, err: err})
	}

	sendFileEvent := func(status fileStatus, path string, match []string) sendResult {
		zid, err := id.Parse(match[1])
		if err != nil {
			return sendDone
		}
		event := &fileEvent{
			status: status,
			path:   path,
			zid:    zid,
			ext:    match[3],
		}
		return sendEvent(event)
	}

	reloadStartEvent := &fileEvent{status: fileStatusReloadStart}
	reloadEndEvent := &fileEvent{status: fileStatusReloadEnd}
	reloadFiles := func() bool {
		entries, err := os.ReadDir(directory)
		if err != nil {
			if res := sendError(err); res != sendDone {
				return res == sendReload
			}
			return true
		}

		if res := sendEvent(reloadStartEvent); res != sendDone {
			return res == sendReload
		}

		if watcher != nil {
			watcher.Close()
		}
		watcher, err = fsnotify.NewWatcher()
		if err != nil {
			if res := sendError(err); res != sendDone {
				return res == sendReload
			}
		}

		for _, entry := range entries {
			if entry.IsDir() {
				continue
			}
			if info, err1 := entry.Info(); err1 != nil || !info.Mode().IsRegular() {
				continue
			}
			name := entry.Name()
			match := matchValidFileName(name)
			if len(match) > 0 {
				path := filepath.Join(directory, name)
				if res := sendFileEvent(fileStatusUpdate, path, match); res != sendDone {
					return res == sendReload
				}
			}
		}

		if watcher != nil {
			err = watcher.Add(directory)
			if err != nil {
				if res := sendError(err); res != sendDone {
					return res == sendReload
				}
			}
		}
		if res := sendEvent(reloadEndEvent); res != sendDone {
			return res == sendReload
		}
		return true
	}

	handleEvents := func() bool {
		const createOps = fsnotify.Create | fsnotify.Write
		const deleteOps = fsnotify.Remove | fsnotify.Rename

		for {
			select {
			case wevent, ok := <-watcher.Events:
				if !ok {
					return false
				}
				path := filepath.Clean(wevent.Name)
				match := matchValidFileName(filepath.Base(path))
				if len(match) == 0 {
					continue
				}
				if wevent.Op&createOps != 0 {
					if fi, err := os.Lstat(path); err != nil || !fi.Mode().IsRegular() {
						continue
					}
					if res := sendFileEvent(
						fileStatusUpdate, path, match); res != sendDone {
						return res == sendReload
					}
				}
				if wevent.Op&deleteOps != 0 {
					if res := sendFileEvent(
						fileStatusDelete, path, match); res != sendDone {
						return res == sendReload
					}
				}
			case err, ok := <-watcher.Errors:
				if !ok {
					return false
				}
				if res := sendError(err); res != sendDone {
					return res == sendReload
				}
			case _, ok := <-tick:
				return ok
			}
		}
	}

	for {
		if !reloadFiles() {
			return
		}
		if watcher == nil {
			if _, ok := <-tick; !ok {
				return
			}
		} else {
			if !handleEvents() {
				return
			}
		}
	}
}

func sendCollectedEvents(out chan<- *fileEvent, events []*fileEvent) {
	for _, ev := range events {
		if ev.status != fileStatusNone {
			out <- ev
		}
	}
}

func addEvent(events []*fileEvent, ev *fileEvent) []*fileEvent {
	switch ev.status {
	case fileStatusNone:
		return events
	case fileStatusReloadStart:
		events = events[0:0]
	case fileStatusUpdate, fileStatusDelete:
		if len(events) > 0 && mergeEvents(events, ev) {
			return events
		}
	}
	return append(events, ev)
}

func mergeEvents(events []*fileEvent, ev *fileEvent) bool {
	for i := len(events) - 1; i >= 0; i-- {
		oev := events[i]
		switch oev.status {
		case fileStatusReloadStart, fileStatusReloadEnd:
			return false
		case fileStatusUpdate, fileStatusDelete:
			if ev.path == oev.path {
				if ev.status == oev.status {
					return true
				}
				oev.status = fileStatusNone
				return false
			}
		}
	}
	return false
}

func collectEvents(out chan<- *fileEvent, in <-chan *fileEvent) {
	defer close(out)

	var sendTime time.Time
	sendTimeSet := false
	ticker := time.NewTicker(500 * time.Millisecond)
	defer ticker.Stop()

	events := make([]*fileEvent, 0, 32)
	buffer := false
	for {
		select {
		case ev, ok := <-in:
			if !ok {
				sendCollectedEvents(out, events)
				return
			}
			if ev.status == fileStatusReloadStart {
				buffer = false
				events = events[0:0]
			}
			if buffer {
				if !sendTimeSet {
					sendTime = time.Now().Add(1500 * time.Millisecond)
					sendTimeSet = true
				}
				events = addEvent(events, ev)
				if len(events) > 1024 {
					sendCollectedEvents(out, events)
					events = events[0:0]
					sendTimeSet = false
				}
				continue
			}
			out <- ev
			if ev.status == fileStatusReloadEnd {
				buffer = true
			}
		case now := <-ticker.C:
			if sendTimeSet && now.After(sendTime) {
				sendCollectedEvents(out, events)
				events = events[0:0]
				sendTimeSet = false
			}
		}
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
























































































































































































































































































































































































































































































































































































































Deleted place/dirplace/directory/watch_test.go.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//-----------------------------------------------------------------------------
// Copyright (c) 2020 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package directory manages the directory part of a directory place.
package directory

import (
	"testing"
)

func sameStringSlices(sl1, sl2 []string) bool {
	if len(sl1) != len(sl2) {
		return false
	}
	for i := 0; i < len(sl1); i++ {
		if sl1[i] != sl2[i] {
			return false
		}
	}
	return true
}

func TestMatchValidFileName(t *testing.T) {
	testcases := []struct {
		name string
		exp  []string
	}{
		{"", []string{}},
		{".txt", []string{}},
		{"12345678901234.txt", []string{"12345678901234", ".txt", "txt"}},
		{"12345678901234abc.txt", []string{"12345678901234", ".txt", "txt"}},
		{"12345678901234.abc.txt", []string{"12345678901234", ".txt", "txt"}},
	}

	for i, tc := range testcases {
		got := matchValidFileName(tc.name)
		if len(got) == 0 {
			if len(tc.exp) > 0 {
				t.Errorf("TC=%d, name=%q, exp=%v, got=%v", i, tc.name, tc.exp, got)
			}
		} else {
			if got[0] != tc.name {
				t.Errorf("TC=%d, name=%q, got=%v", i, tc.name, got)
			}
			if !sameStringSlices(got[1:], tc.exp) {
				t.Errorf("TC=%d, name=%q, exp=%v, got=%v", i, tc.name, tc.exp, got)
			}
		}
	}
}
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


















































































































Changes to place/dirplace/dirplace.go.

22
23
24
25
26
27
28

29
30
31
32
33
34
35
36
37
38
39
40

41
42
43
44
45
46
47

48
49
50
51
52









53
54
55
56
57
58
59
	"time"

	"zettelstore.de/z/config/runtime"
	"zettelstore.de/z/domain"
	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/domain/meta"
	"zettelstore.de/z/place"

	"zettelstore.de/z/place/dirplace/directory"
	"zettelstore.de/z/place/fileplace"
	"zettelstore.de/z/place/manager"
	"zettelstore.de/z/search"
)

func init() {
	manager.Register("dir", func(u *url.URL, cdata *manager.ConnectData) (place.ManagedPlace, error) {
		path := getDirPath(u)
		if _, err := os.Stat(path); os.IsNotExist(err) {
			return nil, err
		}

		dp := dirPlace{
			u:        u,
			readonly: getQueryBool(u, "readonly"),
			cdata:    *cdata,
			dir:      path,
			dirRescan: time.Duration(
				getQueryInt(u, "rescan", 60, 3600, 30*24*60*60)) * time.Second,

			fSrvs: uint32(getQueryInt(u, "worker", 1, 17, 1499)),
		}
		return &dp, nil
	})
}










func getDirPath(u *url.URL) string {
	if u.Opaque != "" {
		return filepath.Clean(u.Opaque)
	}
	return filepath.Clean(u.Path)
}







>












>

|
|
|
|
<
|
>
|




>
>
>
>
>
>
>
>
>







22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
	"time"

	"zettelstore.de/z/config/runtime"
	"zettelstore.de/z/domain"
	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/domain/meta"
	"zettelstore.de/z/place"
	"zettelstore.de/z/place/change"
	"zettelstore.de/z/place/dirplace/directory"
	"zettelstore.de/z/place/fileplace"
	"zettelstore.de/z/place/manager"
	"zettelstore.de/z/search"
)

func init() {
	manager.Register("dir", func(u *url.URL, cdata *manager.ConnectData) (place.ManagedPlace, error) {
		path := getDirPath(u)
		if _, err := os.Stat(path); os.IsNotExist(err) {
			return nil, err
		}
		dirSrvSpec, defWorker, maxWorker := getDirSrvInfo(u.Query().Get("type"))
		dp := dirPlace{
			location:   u.String(),
			readonly:   getQueryBool(u, "readonly"),
			cdata:      *cdata,
			dir:        path,

			dirRescan:  time.Duration(getQueryInt(u, "rescan", 60, 3600, 30*24*60*60)) * time.Second,
			dirSrvSpec: dirSrvSpec,
			fSrvs:      uint32(getQueryInt(u, "worker", 1, defWorker, maxWorker)),
		}
		return &dp, nil
	})
}

type directoryServiceSpec int

const (
	_ directoryServiceSpec = iota
	dirSrvAny
	dirSrvSimple
	dirSrvNotify
)

func getDirPath(u *url.URL) string {
	if u.Opaque != "" {
		return filepath.Clean(u.Opaque)
	}
	return filepath.Clean(u.Path)
}
79
80
81
82
83
84
85
86
87
88
89
90

91

92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110



111









112








113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145




146
147
148
149
150
151
152
153
154

155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186



187
188
189
190
191
192
193
194
195



196
197
198
199
200
201
202
203
204
205
206
		return max
	}
	return iVal
}

// dirPlace uses a directory to store zettel as files.
type dirPlace struct {
	u         *url.URL
	readonly  bool
	cdata     manager.ConnectData
	dir       string
	dirRescan time.Duration

	dirSrv    *directory.Service

	fSrvs     uint32
	fCmds     []chan fileCmd
	mxCmds    sync.RWMutex
}

func (dp *dirPlace) Location() string {
	return dp.u.String()
}

func (dp *dirPlace) Start(ctx context.Context) error {
	dp.mxCmds.Lock()
	dp.fCmds = make([]chan fileCmd, 0, dp.fSrvs)
	for i := uint32(0); i < dp.fSrvs; i++ {
		cc := make(chan fileCmd)
		go fileService(i, cc)
		dp.fCmds = append(dp.fCmds, cc)
	}
	dp.dirSrv = directory.NewService(dp.dir, dp.dirRescan, dp.cdata.Notify)
	dp.mxCmds.Unlock()



	dp.dirSrv.Start()









	return nil








}

func (dp *dirPlace) getFileChan(zid id.Zid) chan fileCmd {
	// Based on https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
	var sum uint32 = 2166136261 ^ uint32(zid)
	sum *= 16777619
	sum ^= uint32(zid >> 32)
	sum *= 16777619

	dp.mxCmds.RLock()
	defer dp.mxCmds.RUnlock()
	return dp.fCmds[sum%dp.fSrvs]
}

func (dp *dirPlace) Stop(ctx context.Context) error {
	dirSrv := dp.dirSrv
	dp.dirSrv = nil
	dirSrv.Stop()
	for _, c := range dp.fCmds {
		close(c)
	}
	return nil
}

func (dp *dirPlace) CanCreateZettel(ctx context.Context) bool {
	return !dp.readonly
}

func (dp *dirPlace) CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error) {
	if dp.readonly {
		return id.Invalid, place.ErrReadOnly
	}





	meta := zettel.Meta
	entry := dp.dirSrv.GetNew()
	meta.Zid = entry.Zid
	dp.updateEntryFromMeta(&entry, meta)

	err := setZettel(dp, &entry, zettel)
	if err == nil {
		dp.dirSrv.UpdateEntry(&entry)
	}

	return meta.Zid, err
}

func (dp *dirPlace) GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error) {
	entry := dp.dirSrv.GetEntry(zid)
	if !entry.IsValid() {
		return domain.Zettel{}, place.ErrNotFound
	}
	m, c, err := getMetaContent(dp, &entry, zid)
	if err != nil {
		return domain.Zettel{}, err
	}
	dp.cleanupMeta(ctx, m)
	zettel := domain.Zettel{Meta: m, Content: domain.NewContent(c)}
	return zettel, nil
}

func (dp *dirPlace) GetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error) {
	entry := dp.dirSrv.GetEntry(zid)
	if !entry.IsValid() {
		return nil, place.ErrNotFound
	}
	m, err := getMeta(dp, &entry, zid)
	if err != nil {
		return nil, err
	}
	dp.cleanupMeta(ctx, m)
	return m, nil
}

func (dp *dirPlace) FetchZids(ctx context.Context) (id.Set, error) {
	entries := dp.dirSrv.GetEntries()



	result := id.NewSetCap(len(entries))
	for _, entry := range entries {
		result[entry.Zid] = true
	}
	return result, nil
}

func (dp *dirPlace) SelectMeta(ctx context.Context, match search.MetaMatchFunc) (res []*meta.Meta, err error) {
	entries := dp.dirSrv.GetEntries()



	res = make([]*meta.Meta, 0, len(entries))
	// The following loop could be parallelized if needed for performance.
	for _, entry := range entries {
		m, err1 := getMeta(dp, &entry, entry.Zid)
		err = err1
		if err != nil {
			continue
		}
		dp.cleanupMeta(ctx, m)
		dp.cdata.Filter.Enrich(ctx, m)








|
|
|
|
|
>
|
>
|
|
|



|










|

>
>
>
|
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>














<
<
<
<
<
<
<
<
<
<









>
>
>
>

<

|

|

|

>




|
|


|









|
|


|








|
>
>
>








|
>
>
>



|







90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159










160
161
162
163
164
165
166
167
168
169
170
171
172
173

174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
		return max
	}
	return iVal
}

// dirPlace uses a directory to store zettel as files.
type dirPlace struct {
	location   string
	readonly   bool
	cdata      manager.ConnectData
	dir        string
	dirRescan  time.Duration
	dirSrvSpec directoryServiceSpec
	dirSrv     directory.Service
	mustNotify bool
	fSrvs      uint32
	fCmds      []chan fileCmd
	mxCmds     sync.RWMutex
}

func (dp *dirPlace) Location() string {
	return dp.location
}

func (dp *dirPlace) Start(ctx context.Context) error {
	dp.mxCmds.Lock()
	dp.fCmds = make([]chan fileCmd, 0, dp.fSrvs)
	for i := uint32(0); i < dp.fSrvs; i++ {
		cc := make(chan fileCmd)
		go fileService(i, cc)
		dp.fCmds = append(dp.fCmds, cc)
	}
	dp.setupDirService()
	dp.mxCmds.Unlock()
	if dp.dirSrv == nil {
		panic("No directory service")
	}
	return dp.dirSrv.Start()
}

func (dp *dirPlace) Stop(ctx context.Context) error {
	dirSrv := dp.dirSrv
	dp.dirSrv = nil
	err := dirSrv.Stop()
	for _, c := range dp.fCmds {
		close(c)
	}
	return err
}

func (dp *dirPlace) notifyChanged(reason change.Reason, zid id.Zid) {
	if dp.mustNotify {
		if chci := dp.cdata.Notify; chci != nil {
			chci <- change.Info{Reason: reason, Zid: zid}
		}
	}
}

func (dp *dirPlace) getFileChan(zid id.Zid) chan fileCmd {
	// Based on https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
	var sum uint32 = 2166136261 ^ uint32(zid)
	sum *= 16777619
	sum ^= uint32(zid >> 32)
	sum *= 16777619

	dp.mxCmds.RLock()
	defer dp.mxCmds.RUnlock()
	return dp.fCmds[sum%dp.fSrvs]
}











func (dp *dirPlace) CanCreateZettel(ctx context.Context) bool {
	return !dp.readonly
}

func (dp *dirPlace) CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error) {
	if dp.readonly {
		return id.Invalid, place.ErrReadOnly
	}

	entry, err := dp.dirSrv.GetNew()
	if err != nil {
		return id.Invalid, err
	}
	meta := zettel.Meta

	meta.Zid = entry.Zid
	dp.updateEntryFromMeta(entry, meta)

	err = setZettel(dp, entry, zettel)
	if err == nil {
		dp.dirSrv.UpdateEntry(entry)
	}
	dp.notifyChanged(change.OnUpdate, meta.Zid)
	return meta.Zid, err
}

func (dp *dirPlace) GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error) {
	entry, err := dp.dirSrv.GetEntry(zid)
	if err != nil || !entry.IsValid() {
		return domain.Zettel{}, place.ErrNotFound
	}
	m, c, err := getMetaContent(dp, entry, zid)
	if err != nil {
		return domain.Zettel{}, err
	}
	dp.cleanupMeta(ctx, m)
	zettel := domain.Zettel{Meta: m, Content: domain.NewContent(c)}
	return zettel, nil
}

func (dp *dirPlace) GetMeta(ctx context.Context, zid id.Zid) (*meta.Meta, error) {
	entry, err := dp.dirSrv.GetEntry(zid)
	if err != nil || !entry.IsValid() {
		return nil, place.ErrNotFound
	}
	m, err := getMeta(dp, entry, zid)
	if err != nil {
		return nil, err
	}
	dp.cleanupMeta(ctx, m)
	return m, nil
}

func (dp *dirPlace) FetchZids(ctx context.Context) (id.Set, error) {
	entries, err := dp.dirSrv.GetEntries()
	if err != nil {
		return nil, err
	}
	result := id.NewSetCap(len(entries))
	for _, entry := range entries {
		result[entry.Zid] = true
	}
	return result, nil
}

func (dp *dirPlace) SelectMeta(ctx context.Context, match search.MetaMatchFunc) (res []*meta.Meta, err error) {
	entries, err := dp.dirSrv.GetEntries()
	if err != nil {
		return nil, err
	}
	res = make([]*meta.Meta, 0, len(entries))
	// The following loop could be parallelized if needed for performance.
	for _, entry := range entries {
		m, err1 := getMeta(dp, entry, entry.Zid)
		err = err1
		if err != nil {
			continue
		}
		dp.cleanupMeta(ctx, m)
		dp.cdata.Filter.Enrich(ctx, m)

223
224
225
226
227
228
229
230



231
232
233
234
235
236
237
238
239
240
241




242
243
244
245
246
247
248
249
		return place.ErrReadOnly
	}

	meta := zettel.Meta
	if !meta.Zid.IsValid() {
		return &place.ErrInvalidID{Zid: meta.Zid}
	}
	entry := dp.dirSrv.GetEntry(meta.Zid)



	if !entry.IsValid() {
		// Existing zettel, but new in this place.
		entry.Zid = meta.Zid
		dp.updateEntryFromMeta(&entry, meta)
	} else if entry.MetaSpec == directory.MetaSpecNone {
		defaultMeta := fileplace.CalcDefaultMeta(entry.Zid, entry.ContentExt)
		if !meta.Equal(defaultMeta, true) {
			dp.updateEntryFromMeta(&entry, meta)
			dp.dirSrv.UpdateEntry(&entry)
		}
	}




	return setZettel(dp, &entry, zettel)
}

func (dp *dirPlace) updateEntryFromMeta(entry *directory.Entry, meta *meta.Meta) {
	entry.MetaSpec, entry.ContentExt = calcSpecExt(meta)
	basePath := filepath.Join(dp.dir, entry.Zid.String())
	if entry.MetaSpec == directory.MetaSpecFile {
		entry.MetaPath = basePath + ".meta"







|
>
>
>


|
|



|
|


>
>
>
>
|







256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
		return place.ErrReadOnly
	}

	meta := zettel.Meta
	if !meta.Zid.IsValid() {
		return &place.ErrInvalidID{Zid: meta.Zid}
	}
	entry, err := dp.dirSrv.GetEntry(meta.Zid)
	if err != nil {
		return err
	}
	if !entry.IsValid() {
		// Existing zettel, but new in this place.
		entry = &directory.Entry{Zid: meta.Zid}
		dp.updateEntryFromMeta(entry, meta)
	} else if entry.MetaSpec == directory.MetaSpecNone {
		defaultMeta := fileplace.CalcDefaultMeta(entry.Zid, entry.ContentExt)
		if !meta.Equal(defaultMeta, true) {
			dp.updateEntryFromMeta(entry, meta)
			dp.dirSrv.UpdateEntry(entry)
		}
	}
	err = setZettel(dp, entry, zettel)
	if err == nil {
		dp.notifyChanged(change.OnUpdate, meta.Zid)
	}
	return err
}

func (dp *dirPlace) updateEntryFromMeta(entry *directory.Entry, meta *meta.Meta) {
	entry.MetaSpec, entry.ContentExt = calcSpecExt(meta)
	basePath := filepath.Join(dp.dir, entry.Zid.String())
	if entry.MetaSpec == directory.MetaSpecFile {
		entry.MetaPath = basePath + ".meta"
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316





317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337



338
339
340
341
342
343
344
345
346
347
348
349
350
func (dp *dirPlace) RenameZettel(ctx context.Context, curZid, newZid id.Zid) error {
	if dp.readonly {
		return place.ErrReadOnly
	}
	if curZid == newZid {
		return nil
	}
	curEntry := dp.dirSrv.GetEntry(curZid)
	if !curEntry.IsValid() {
		return place.ErrNotFound
	}

	// Check whether zettel with new ID already exists in this place
	if _, err := dp.GetMeta(ctx, newZid); err == nil {
		return &place.ErrInvalidID{Zid: newZid}
	}

	oldMeta, oldContent, err := getMetaContent(dp, &curEntry, curZid)
	if err != nil {
		return err
	}

	newEntry := directory.Entry{
		Zid:         newZid,
		MetaSpec:    curEntry.MetaSpec,
		MetaPath:    renamePath(curEntry.MetaPath, curZid, newZid),
		ContentPath: renamePath(curEntry.ContentPath, curZid, newZid),
		ContentExt:  curEntry.ContentExt,
	}

	if err := dp.dirSrv.RenameEntry(&curEntry, &newEntry); err != nil {
		return err
	}
	oldMeta.Zid = newZid
	newZettel := domain.Zettel{Meta: oldMeta, Content: domain.NewContent(oldContent)}
	if err := setZettel(dp, &newEntry, newZettel); err != nil {
		// "Rollback" rename. No error checking...
		dp.dirSrv.RenameEntry(&newEntry, &curEntry)
		return err
	}
	return deleteZettel(dp, &curEntry, curZid)





}

func (dp *dirPlace) CanDeleteZettel(ctx context.Context, zid id.Zid) bool {
	if dp.readonly {
		return false
	}
	entry := dp.dirSrv.GetEntry(zid)
	return entry.IsValid()
}

func (dp *dirPlace) DeleteZettel(ctx context.Context, zid id.Zid) error {
	if dp.readonly {
		return place.ErrReadOnly
	}

	entry := dp.dirSrv.GetEntry(zid)
	if !entry.IsValid() {
		return nil
	}
	dp.dirSrv.DeleteEntry(zid)
	err := deleteZettel(dp, &entry, zid)



	return err
}

func (dp *dirPlace) ReadStats(st *place.Stats) {
	st.ReadOnly = dp.readonly
	st.Zettel = dp.dirSrv.NumEntries()
}

func (dp *dirPlace) cleanupMeta(ctx context.Context, m *meta.Meta) {
	if role, ok := m.Get(meta.KeyRole); !ok || role == "" {
		m.Set(meta.KeyRole, runtime.GetDefaultRole())
	}
	if syntax, ok := m.Get(meta.KeySyntax); !ok || syntax == "" {







|
|




|



|












|




|

|


|
>
>
>
>
>






|
|







|
|



|
>
>
>





|







316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
func (dp *dirPlace) RenameZettel(ctx context.Context, curZid, newZid id.Zid) error {
	if dp.readonly {
		return place.ErrReadOnly
	}
	if curZid == newZid {
		return nil
	}
	curEntry, err := dp.dirSrv.GetEntry(curZid)
	if err != nil || !curEntry.IsValid() {
		return place.ErrNotFound
	}

	// Check whether zettel with new ID already exists in this place
	if _, err = dp.GetMeta(ctx, newZid); err == nil {
		return &place.ErrInvalidID{Zid: newZid}
	}

	oldMeta, oldContent, err := getMetaContent(dp, curEntry, curZid)
	if err != nil {
		return err
	}

	newEntry := directory.Entry{
		Zid:         newZid,
		MetaSpec:    curEntry.MetaSpec,
		MetaPath:    renamePath(curEntry.MetaPath, curZid, newZid),
		ContentPath: renamePath(curEntry.ContentPath, curZid, newZid),
		ContentExt:  curEntry.ContentExt,
	}

	if err = dp.dirSrv.RenameEntry(curEntry, &newEntry); err != nil {
		return err
	}
	oldMeta.Zid = newZid
	newZettel := domain.Zettel{Meta: oldMeta, Content: domain.NewContent(oldContent)}
	if err = setZettel(dp, &newEntry, newZettel); err != nil {
		// "Rollback" rename. No error checking...
		dp.dirSrv.RenameEntry(&newEntry, curEntry)
		return err
	}
	err = deleteZettel(dp, curEntry, curZid)
	if err == nil {
		dp.notifyChanged(change.OnDelete, curZid)
		dp.notifyChanged(change.OnUpdate, newZid)
	}
	return err
}

func (dp *dirPlace) CanDeleteZettel(ctx context.Context, zid id.Zid) bool {
	if dp.readonly {
		return false
	}
	entry, err := dp.dirSrv.GetEntry(zid)
	return err == nil && entry.IsValid()
}

func (dp *dirPlace) DeleteZettel(ctx context.Context, zid id.Zid) error {
	if dp.readonly {
		return place.ErrReadOnly
	}

	entry, err := dp.dirSrv.GetEntry(zid)
	if err != nil || !entry.IsValid() {
		return nil
	}
	dp.dirSrv.DeleteEntry(zid)
	err = deleteZettel(dp, entry, zid)
	if err == nil {
		dp.notifyChanged(change.OnDelete, zid)
	}
	return err
}

func (dp *dirPlace) ReadStats(st *place.Stats) {
	st.ReadOnly = dp.readonly
	st.Zettel, _ = dp.dirSrv.NumEntries()
}

func (dp *dirPlace) cleanupMeta(ctx context.Context, m *meta.Meta) {
	if role, ok := m.Get(meta.KeyRole); !ok || role == "" {
		m.Set(meta.KeyRole, runtime.GetDefaultRole())
	}
	if syntax, ok := m.Get(meta.KeySyntax); !ok || syntax == "" {

Added place/dirplace/makedir.go.























































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//-----------------------------------------------------------------------------
// Copyright (c) 2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package dirplace provides a directory-based zettel place.
package dirplace

import (
	"zettelstore.de/z/config/startup"
	"zettelstore.de/z/place/dirplace/notifydir"
	"zettelstore.de/z/place/dirplace/simpledir"
)

func getDirSrvInfo(dirType string) (directoryServiceSpec, int, int) {
	for count := 0; count < 2; count++ {
		switch dirType {
		case startup.ValueDirPlaceTypeNotify:
			return dirSrvNotify, 7, 1499
		case startup.ValueDirPlaceTypeSimple:
			return dirSrvSimple, 1, 1
		default:
			dirType = startup.DefaultDirPlaceType()
		}
	}
	panic("unable to set default dir place type: " + dirType)
}

func (dp *dirPlace) setupDirService() {
	switch dp.dirSrvSpec {
	case dirSrvSimple:
		dp.dirSrv = simpledir.NewService(dp.dir)
		dp.mustNotify = true
	default:
		dp.dirSrv = notifydir.NewService(dp.dir, dp.dirRescan, dp.cdata.Notify)
		dp.mustNotify = false
	}
}

Added place/dirplace/notifydir/notifydir.go.





























































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
//-----------------------------------------------------------------------------
// Copyright (c) 2020-2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package notifydir manages the notified directory part of a dirstore.
package notifydir

import (
	"time"

	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/place/change"
	"zettelstore.de/z/place/dirplace/directory"
)

// notifyService specifies a directory scan service.
type notifyService struct {
	dirPath    string
	rescanTime time.Duration
	done       chan struct{}
	cmds       chan dirCmd
	infos      chan<- change.Info
}

// NewService creates a new directory service.
func NewService(directoryPath string, rescanTime time.Duration, chci chan<- change.Info) directory.Service {
	srv := &notifyService{
		dirPath:    directoryPath,
		rescanTime: rescanTime,
		cmds:       make(chan dirCmd),
		infos:      chci,
	}
	return srv
}

// Start makes the directory service operational.
func (srv *notifyService) Start() error {
	tick := make(chan struct{})
	rawEvents := make(chan *fileEvent)
	events := make(chan *fileEvent)

	ready := make(chan int)
	go srv.directoryService(events, ready)
	go collectEvents(events, rawEvents)
	go watchDirectory(srv.dirPath, rawEvents, tick)

	if srv.done != nil {
		panic("src.done already set")
	}
	srv.done = make(chan struct{})
	go ping(tick, srv.rescanTime, srv.done)
	<-ready
	return nil
}

// Stop stops the directory service.
func (srv *notifyService) Stop() error {
	close(srv.done)
	srv.done = nil
	return nil
}

func (srv *notifyService) notifyChange(reason change.Reason, zid id.Zid) {
	if chci := srv.infos; chci != nil {
		chci <- change.Info{Reason: reason, Zid: zid}
	}
}

// NumEntries returns the number of managed zettel.
func (srv *notifyService) NumEntries() (int, error) {
	resChan := make(chan resNumEntries)
	srv.cmds <- &cmdNumEntries{resChan}
	return <-resChan, nil
}

// GetEntries returns an unsorted list of all current directory entries.
func (srv *notifyService) GetEntries() ([]*directory.Entry, error) {
	resChan := make(chan resGetEntries)
	srv.cmds <- &cmdGetEntries{resChan}
	return <-resChan, nil
}

// GetEntry returns the entry with the specified zettel id. If there is no such
// zettel id, an empty entry is returned.
func (srv *notifyService) GetEntry(zid id.Zid) (*directory.Entry, error) {
	resChan := make(chan resGetEntry)
	srv.cmds <- &cmdGetEntry{zid, resChan}
	return <-resChan, nil
}

// GetNew returns an entry with a new zettel id.
func (srv *notifyService) GetNew() (*directory.Entry, error) {
	resChan := make(chan resNewEntry)
	srv.cmds <- &cmdNewEntry{resChan}
	result := <-resChan
	return result.entry, result.err
}

// UpdateEntry notifies the directory of an updated entry.
func (srv *notifyService) UpdateEntry(entry *directory.Entry) error {
	resChan := make(chan struct{})
	srv.cmds <- &cmdUpdateEntry{entry, resChan}
	<-resChan
	return nil
}

// RenameEntry notifies the directory of an renamed entry.
func (srv *notifyService) RenameEntry(curEntry, newEntry *directory.Entry) error {
	resChan := make(chan resRenameEntry)
	srv.cmds <- &cmdRenameEntry{curEntry, newEntry, resChan}
	return <-resChan
}

// DeleteEntry removes a zettel id from the directory of entries.
func (srv *notifyService) DeleteEntry(zid id.Zid) error {
	resChan := make(chan struct{})
	srv.cmds <- &cmdDeleteEntry{zid, resChan}
	<-resChan
	return nil
}

Added place/dirplace/notifydir/service.go.

































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
//-----------------------------------------------------------------------------
// Copyright (c) 2020-2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package notifydir manages the notified directory part of a dirstore.
package notifydir

import (
	"log"
	"time"

	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/place"
	"zettelstore.de/z/place/change"
	"zettelstore.de/z/place/dirplace/directory"
)

// ping sends every tick a signal to reload the directory list
func ping(tick chan<- struct{}, rescanTime time.Duration, done <-chan struct{}) {
	ticker := time.NewTicker(rescanTime)
	defer close(tick)
	for {
		select {
		case _, ok := <-ticker.C:
			if !ok {
				return
			}
			tick <- struct{}{}
		case _, ok := <-done:
			if !ok {
				ticker.Stop()
				return
			}
		}
	}
}

func newEntry(ev *fileEvent) *directory.Entry {
	de := new(directory.Entry)
	de.Zid = ev.zid
	updateEntry(de, ev)
	return de
}

func updateEntry(de *directory.Entry, ev *fileEvent) {
	if ev.ext == "meta" {
		de.MetaSpec = directory.MetaSpecFile
		de.MetaPath = ev.path
		return
	}
	if de.ContentExt != "" && de.ContentExt != ev.ext {
		de.Duplicates = true
		return
	}
	if de.MetaSpec != directory.MetaSpecFile {
		if ev.ext == "zettel" {
			de.MetaSpec = directory.MetaSpecHeader
		} else {
			de.MetaSpec = directory.MetaSpecNone
		}
	}
	de.ContentPath = ev.path
	de.ContentExt = ev.ext
}

type dirMap map[id.Zid]*directory.Entry

func dirMapUpdate(dm dirMap, ev *fileEvent) {
	de := dm[ev.zid]
	if de == nil {
		dm[ev.zid] = newEntry(ev)
		return
	}
	updateEntry(de, ev)
}

func deleteFromMap(dm dirMap, ev *fileEvent) {
	if ev.ext == "meta" {
		if entry, ok := dm[ev.zid]; ok {
			if entry.MetaSpec == directory.MetaSpecFile {
				entry.MetaSpec = directory.MetaSpecNone
				return
			}
		}
	}
	delete(dm, ev.zid)
}

// directoryService is the main service.
func (srv *notifyService) directoryService(events <-chan *fileEvent, ready chan<- int) {
	curMap := make(dirMap)
	var newMap dirMap
	for {
		select {
		case ev, ok := <-events:
			if !ok {
				return
			}
			switch ev.status {
			case fileStatusReloadStart:
				newMap = make(dirMap)
			case fileStatusReloadEnd:
				curMap = newMap
				newMap = nil
				if ready != nil {
					ready <- len(curMap)
					close(ready)
					ready = nil
				}
				srv.notifyChange(change.OnReload, id.Invalid)
			case fileStatusError:
				log.Println("DIRPLACE", "ERROR", ev.err)
			case fileStatusUpdate:
				srv.processFileUpdateEvent(ev, curMap, newMap)
			case fileStatusDelete:
				srv.processFileDeleteEvent(ev, curMap, newMap)
			}
		case cmd, ok := <-srv.cmds:
			if ok {
				cmd.run(curMap)
			}
		}
	}
}

func (srv *notifyService) processFileUpdateEvent(ev *fileEvent, curMap, newMap dirMap) {
	if newMap != nil {
		dirMapUpdate(newMap, ev)
	} else {
		dirMapUpdate(curMap, ev)
		srv.notifyChange(change.OnUpdate, ev.zid)
	}
}

func (srv *notifyService) processFileDeleteEvent(ev *fileEvent, curMap, newMap dirMap) {
	if newMap != nil {
		deleteFromMap(newMap, ev)
	} else {
		deleteFromMap(curMap, ev)
		srv.notifyChange(change.OnDelete, ev.zid)
	}
}

type dirCmd interface {
	run(m dirMap)
}

type cmdNumEntries struct {
	result chan<- resNumEntries
}
type resNumEntries = int

func (cmd *cmdNumEntries) run(m dirMap) {
	cmd.result <- len(m)
}

type cmdGetEntries struct {
	result chan<- resGetEntries
}
type resGetEntries []*directory.Entry

func (cmd *cmdGetEntries) run(m dirMap) {
	res := make([]*directory.Entry, len(m))
	i := 0
	for _, de := range m {
		entry := *de
		res[i] = &entry
		i++
	}
	cmd.result <- res
}

type cmdGetEntry struct {
	zid    id.Zid
	result chan<- resGetEntry
}
type resGetEntry = *directory.Entry

func (cmd *cmdGetEntry) run(m dirMap) {
	entry := m[cmd.zid]
	if entry == nil {
		cmd.result <- nil
	} else {
		result := *entry
		cmd.result <- &result
	}
}

type cmdNewEntry struct {
	result chan<- resNewEntry
}
type resNewEntry struct {
	entry *directory.Entry
	err   error
}

func (cmd *cmdNewEntry) run(m dirMap) {
	zid, err := place.GetNewZid(func(zid id.Zid) (bool, error) {
		_, ok := m[zid]
		return !ok, nil
	})
	if err != nil {
		cmd.result <- resNewEntry{nil, err}
		return
	}
	entry := &directory.Entry{Zid: zid}
	m[zid] = entry
	cmd.result <- resNewEntry{&directory.Entry{Zid: zid}, nil}
}

type cmdUpdateEntry struct {
	entry  *directory.Entry
	result chan<- struct{}
}

func (cmd *cmdUpdateEntry) run(m dirMap) {
	entry := *cmd.entry
	m[entry.Zid] = &entry
	cmd.result <- struct{}{}
}

type cmdRenameEntry struct {
	curEntry *directory.Entry
	newEntry *directory.Entry
	result   chan<- resRenameEntry
}

type resRenameEntry = error

func (cmd *cmdRenameEntry) run(m dirMap) {
	newEntry := *cmd.newEntry
	newZid := newEntry.Zid
	if _, found := m[newZid]; found {
		cmd.result <- &place.ErrInvalidID{Zid: newZid}
		return
	}
	delete(m, cmd.curEntry.Zid)
	m[newZid] = &newEntry
	cmd.result <- nil
}

type cmdDeleteEntry struct {
	zid    id.Zid
	result chan<- struct{}
}

func (cmd *cmdDeleteEntry) run(m dirMap) {
	delete(m, cmd.zid)
	cmd.result <- struct{}{}
}

Added place/dirplace/notifydir/watch.go.

























































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
//-----------------------------------------------------------------------------
// Copyright (c) 2020-2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package notifydir manages the notified directory part of a dirstore.
package notifydir

import (
	"os"
	"path/filepath"
	"regexp"
	"time"

	"github.com/fsnotify/fsnotify"

	"zettelstore.de/z/domain/id"
)

var validFileName = regexp.MustCompile(`^(\d{14}).*(\.(.+))$`)

func matchValidFileName(name string) []string {
	return validFileName.FindStringSubmatch(name)
}

type fileStatus int

const (
	fileStatusNone fileStatus = iota
	fileStatusReloadStart
	fileStatusReloadEnd
	fileStatusError
	fileStatusUpdate
	fileStatusDelete
)

type fileEvent struct {
	status fileStatus
	path   string // Full file path
	zid    id.Zid
	ext    string // File extension
	err    error  // Error if Status == fileStatusError
}

type sendResult int

const (
	sendDone sendResult = iota
	sendReload
	sendExit
)

func watchDirectory(directory string, events chan<- *fileEvent, tick <-chan struct{}) {
	defer close(events)

	var watcher *fsnotify.Watcher
	defer func() {
		if watcher != nil {
			watcher.Close()
		}
	}()

	sendEvent := func(ev *fileEvent) sendResult {
		select {
		case events <- ev:
		case _, ok := <-tick:
			if ok {
				return sendReload
			}
			return sendExit
		}
		return sendDone
	}

	sendError := func(err error) sendResult {
		return sendEvent(&fileEvent{status: fileStatusError, err: err})
	}

	sendFileEvent := func(status fileStatus, path string, match []string) sendResult {
		zid, err := id.Parse(match[1])
		if err != nil {
			return sendDone
		}
		event := &fileEvent{
			status: status,
			path:   path,
			zid:    zid,
			ext:    match[3],
		}
		return sendEvent(event)
	}

	reloadStartEvent := &fileEvent{status: fileStatusReloadStart}
	reloadEndEvent := &fileEvent{status: fileStatusReloadEnd}
	reloadFiles := func() bool {
		entries, err := os.ReadDir(directory)
		if err != nil {
			if res := sendError(err); res != sendDone {
				return res == sendReload
			}
			return true
		}

		if res := sendEvent(reloadStartEvent); res != sendDone {
			return res == sendReload
		}

		if watcher != nil {
			watcher.Close()
		}
		watcher, err = fsnotify.NewWatcher()
		if err != nil {
			if res := sendError(err); res != sendDone {
				return res == sendReload
			}
		}

		for _, entry := range entries {
			if entry.IsDir() {
				continue
			}
			if info, err1 := entry.Info(); err1 != nil || !info.Mode().IsRegular() {
				continue
			}
			name := entry.Name()
			match := matchValidFileName(name)
			if len(match) > 0 {
				path := filepath.Join(directory, name)
				if res := sendFileEvent(fileStatusUpdate, path, match); res != sendDone {
					return res == sendReload
				}
			}
		}

		if watcher != nil {
			err = watcher.Add(directory)
			if err != nil {
				if res := sendError(err); res != sendDone {
					return res == sendReload
				}
			}
		}
		if res := sendEvent(reloadEndEvent); res != sendDone {
			return res == sendReload
		}
		return true
	}

	handleEvents := func() bool {
		const createOps = fsnotify.Create | fsnotify.Write
		const deleteOps = fsnotify.Remove | fsnotify.Rename

		for {
			select {
			case wevent, ok := <-watcher.Events:
				if !ok {
					return false
				}
				path := filepath.Clean(wevent.Name)
				match := matchValidFileName(filepath.Base(path))
				if len(match) == 0 {
					continue
				}
				if wevent.Op&createOps != 0 {
					if fi, err := os.Lstat(path); err != nil || !fi.Mode().IsRegular() {
						continue
					}
					if res := sendFileEvent(
						fileStatusUpdate, path, match); res != sendDone {
						return res == sendReload
					}
				}
				if wevent.Op&deleteOps != 0 {
					if res := sendFileEvent(
						fileStatusDelete, path, match); res != sendDone {
						return res == sendReload
					}
				}
			case err, ok := <-watcher.Errors:
				if !ok {
					return false
				}
				if res := sendError(err); res != sendDone {
					return res == sendReload
				}
			case _, ok := <-tick:
				return ok
			}
		}
	}

	for {
		if !reloadFiles() {
			return
		}
		if watcher == nil {
			if _, ok := <-tick; !ok {
				return
			}
		} else {
			if !handleEvents() {
				return
			}
		}
	}
}

func sendCollectedEvents(out chan<- *fileEvent, events []*fileEvent) {
	for _, ev := range events {
		if ev.status != fileStatusNone {
			out <- ev
		}
	}
}

func addEvent(events []*fileEvent, ev *fileEvent) []*fileEvent {
	switch ev.status {
	case fileStatusNone:
		return events
	case fileStatusReloadStart:
		events = events[0:0]
	case fileStatusUpdate, fileStatusDelete:
		if len(events) > 0 && mergeEvents(events, ev) {
			return events
		}
	}
	return append(events, ev)
}

func mergeEvents(events []*fileEvent, ev *fileEvent) bool {
	for i := len(events) - 1; i >= 0; i-- {
		oev := events[i]
		switch oev.status {
		case fileStatusReloadStart, fileStatusReloadEnd:
			return false
		case fileStatusUpdate, fileStatusDelete:
			if ev.path == oev.path {
				if ev.status == oev.status {
					return true
				}
				oev.status = fileStatusNone
				return false
			}
		}
	}
	return false
}

func collectEvents(out chan<- *fileEvent, in <-chan *fileEvent) {
	defer close(out)

	var sendTime time.Time
	sendTimeSet := false
	ticker := time.NewTicker(500 * time.Millisecond)
	defer ticker.Stop()

	events := make([]*fileEvent, 0, 32)
	buffer := false
	for {
		select {
		case ev, ok := <-in:
			if !ok {
				sendCollectedEvents(out, events)
				return
			}
			if ev.status == fileStatusReloadStart {
				buffer = false
				events = events[0:0]
			}
			if buffer {
				if !sendTimeSet {
					sendTime = time.Now().Add(1500 * time.Millisecond)
					sendTimeSet = true
				}
				events = addEvent(events, ev)
				if len(events) > 1024 {
					sendCollectedEvents(out, events)
					events = events[0:0]
					sendTimeSet = false
				}
				continue
			}
			out <- ev
			if ev.status == fileStatusReloadEnd {
				buffer = true
			}
		case now := <-ticker.C:
			if sendTimeSet && now.After(sendTime) {
				sendCollectedEvents(out, events)
				events = events[0:0]
				sendTimeSet = false
			}
		}
	}
}

Added place/dirplace/notifydir/watch_test.go.















































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//-----------------------------------------------------------------------------
// Copyright (c) 2020 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package notifydir manages the notified directory part of a dirstore.
package notifydir

import "testing"

func sameStringSlices(sl1, sl2 []string) bool {
	if len(sl1) != len(sl2) {
		return false
	}
	for i := 0; i < len(sl1); i++ {
		if sl1[i] != sl2[i] {
			return false
		}
	}
	return true
}

func TestMatchValidFileName(t *testing.T) {
	testcases := []struct {
		name string
		exp  []string
	}{
		{"", []string{}},
		{".txt", []string{}},
		{"12345678901234.txt", []string{"12345678901234", ".txt", "txt"}},
		{"12345678901234abc.txt", []string{"12345678901234", ".txt", "txt"}},
		{"12345678901234.abc.txt", []string{"12345678901234", ".txt", "txt"}},
	}

	for i, tc := range testcases {
		got := matchValidFileName(tc.name)
		if len(got) == 0 {
			if len(tc.exp) > 0 {
				t.Errorf("TC=%d, name=%q, exp=%v, got=%v", i, tc.name, tc.exp, got)
			}
		} else {
			if got[0] != tc.name {
				t.Errorf("TC=%d, name=%q, got=%v", i, tc.name, got)
			}
			if !sameStringSlices(got[1:], tc.exp) {
				t.Errorf("TC=%d, name=%q, exp=%v, got=%v", i, tc.name, tc.exp, got)
			}
		}
	}
}

Changes to place/dirplace/service.go.

143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
		err = cmd.runMetaSpecFile()
	case directory.MetaSpecHeader:
		err = cmd.runMetaSpecHeader()
	case directory.MetaSpecNone:
		// TODO: if meta has some additional infos: write meta to new .meta;
		// update entry in dir
		err = writeFileContent(cmd.entry.ContentPath, cmd.zettel.Content.AsString())
	case directory.MetaSpecUnknown:
		panic("TODO: ???")
	}
	cmd.rc <- err
}

func (cmd *fileSetZettel) runMetaSpecFile() error {
	f, err := openFileWrite(cmd.entry.MetaPath)







|







143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
		err = cmd.runMetaSpecFile()
	case directory.MetaSpecHeader:
		err = cmd.runMetaSpecHeader()
	case directory.MetaSpecNone:
		// TODO: if meta has some additional infos: write meta to new .meta;
		// update entry in dir
		err = writeFileContent(cmd.entry.ContentPath, cmd.zettel.Content.AsString())
	default:
		panic("TODO: ???")
	}
	cmd.rc <- err
}

func (cmd *fileSetZettel) runMetaSpecFile() error {
	f, err := openFileWrite(cmd.entry.MetaPath)
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
		if err == nil {
			err = err1
		}
	case directory.MetaSpecHeader:
		err = os.Remove(cmd.entry.ContentPath)
	case directory.MetaSpecNone:
		err = os.Remove(cmd.entry.ContentPath)
	case directory.MetaSpecUnknown:
		panic("TODO: ???")
	}
	cmd.rc <- err
}

// Utility functions ----------------------------------------








|







215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
		if err == nil {
			err = err1
		}
	case directory.MetaSpecHeader:
		err = os.Remove(cmd.entry.ContentPath)
	case directory.MetaSpecNone:
		err = os.Remove(cmd.entry.ContentPath)
	default:
		panic("TODO: ???")
	}
	cmd.rc <- err
}

// Utility functions ----------------------------------------

Added place/dirplace/simpledir/simpledir.go.



















































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
//-----------------------------------------------------------------------------
// Copyright (c) 2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package simpledir manages the directory part of a dirstore.
package simpledir

import (
	"os"
	"path/filepath"
	"regexp"
	"sync"

	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/place"
	"zettelstore.de/z/place/dirplace/directory"
)

// simpleService specifies a directory service without scanning.
type simpleService struct {
	dirPath string
	mx      sync.Mutex
}

// NewService creates a new directory service.
func NewService(directoryPath string) directory.Service {
	return &simpleService{
		dirPath: directoryPath,
	}
}

func (ss *simpleService) Start() error {
	ss.mx.Lock()
	defer ss.mx.Unlock()
	_, err := os.ReadDir(ss.dirPath)
	return err
}

func (ss *simpleService) Stop() error {
	return nil
}

func (ss *simpleService) NumEntries() (int, error) {
	ss.mx.Lock()
	defer ss.mx.Unlock()
	entries, err := ss.getEntries()
	if err == nil {
		return len(entries), nil
	}
	return 0, err
}

func (ss *simpleService) GetEntries() ([]*directory.Entry, error) {
	ss.mx.Lock()
	defer ss.mx.Unlock()
	entrySet, err := ss.getEntries()
	if err != nil {
		return nil, err
	}
	result := make([]*directory.Entry, 0, len(entrySet))
	for _, entry := range entrySet {
		result = append(result, entry)
	}
	return result, nil
}
func (ss *simpleService) getEntries() (map[id.Zid]*directory.Entry, error) {
	dirEntries, err := os.ReadDir(ss.dirPath)
	if err != nil {
		return nil, err
	}
	entrySet := make(map[id.Zid]*directory.Entry)
	for _, dirEntry := range dirEntries {
		if dirEntry.IsDir() {
			continue
		}
		if info, err1 := dirEntry.Info(); err1 != nil || !info.Mode().IsRegular() {
			continue
		}
		name := dirEntry.Name()
		match := matchValidFileName(name)
		if len(match) == 0 {
			continue
		}
		zid, err := id.Parse(match[1])
		if err != nil {
			continue
		}
		var entry *directory.Entry
		if e, ok := entrySet[zid]; ok {
			entry = e
		} else {
			entry = &directory.Entry{Zid: zid}
			entrySet[zid] = entry
		}
		updateEntry(entry, filepath.Join(ss.dirPath, name), match[3])
	}
	return entrySet, nil
}

var validFileName = regexp.MustCompile(`^(\d{14}).*(\.(.+))$`)

func matchValidFileName(name string) []string {
	return validFileName.FindStringSubmatch(name)
}

func updateEntry(entry *directory.Entry, path, ext string) {
	if ext == "meta" {
		entry.MetaSpec = directory.MetaSpecFile
		entry.MetaPath = path
	} else if entry.ContentExt != "" && entry.ContentExt != ext {
		entry.Duplicates = true
	} else {
		if entry.MetaSpec != directory.MetaSpecFile {
			if ext == "zettel" {
				entry.MetaSpec = directory.MetaSpecHeader
			} else {
				entry.MetaSpec = directory.MetaSpecNone
			}
		}
		entry.ContentPath = path
		entry.ContentExt = ext
	}
}

func (ss *simpleService) GetEntry(zid id.Zid) (*directory.Entry, error) {
	ss.mx.Lock()
	defer ss.mx.Unlock()
	return ss.getEntry(zid)
}
func (ss *simpleService) getEntry(zid id.Zid) (*directory.Entry, error) {
	pattern := filepath.Join(ss.dirPath, zid.String()) + "*.*"
	paths, err := filepath.Glob(pattern)
	if err != nil {
		return nil, err
	}
	if len(paths) == 0 {
		return nil, nil
	}
	entry := &directory.Entry{Zid: zid}
	for _, path := range paths {
		ext := filepath.Ext(path)
		if len(ext) > 0 && ext[0] == '.' {
			ext = ext[1:]
		}
		updateEntry(entry, path, ext)
	}
	return entry, nil
}

func (ss *simpleService) GetNew() (*directory.Entry, error) {
	ss.mx.Lock()
	defer ss.mx.Unlock()
	zid, err := place.GetNewZid(func(zid id.Zid) (bool, error) {
		entry, err := ss.getEntry(zid)
		if err != nil {
			return false, nil
		}
		return !entry.IsValid(), nil
	})
	if err != nil {
		return nil, err
	}
	return &directory.Entry{Zid: zid}, nil
}

func (ss *simpleService) UpdateEntry(entry *directory.Entry) error {
	// Noting to to, since the actual file update is done by dirplace
	return nil
}

func (ss *simpleService) RenameEntry(curEntry, newEntry *directory.Entry) error {
	// Noting to to, since the actual file rename is done by dirplace
	return nil
}

func (ss *simpleService) DeleteEntry(zid id.Zid) error {
	// Noting to to, since the actual file delete is done by dirplace
	return nil
}

Added place/helper.go.











































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//-----------------------------------------------------------------------------
// Copyright (c) 2021 Detlef Stern
//
// This file is part of zettelstore.
//
// Zettelstore is licensed under the latest version of the EUPL (European Union
// Public License). Please see file LICENSE.txt for your rights and obligations
// under this license.
//-----------------------------------------------------------------------------

// Package place provides a generic interface to zettel places.
package place

import (
	"time"

	"zettelstore.de/z/domain/id"
)

// GetNewZid calculates a new and unused zettel identifier, based on the current date and time.
func GetNewZid(testZid func(id.Zid) (bool, error)) (id.Zid, error) {
	withSeconds := false
	for i := 0; i < 90; i++ { // Must be completed within 9 seconds (less than web/server.writeTimeout)
		zid := id.New(withSeconds)
		found, err := testZid(zid)
		if err != nil {
			return id.Invalid, err
		}
		if found {
			return zid, nil
		}
		// TODO: do not wait here unconditionally.
		time.Sleep(100 * time.Millisecond)
		withSeconds = true
	}
	return id.Invalid, ErrTimeout
}

Changes to place/memplace/memplace.go.

11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Package memplace stores zettel volatile in main memory.
package memplace

import (
	"context"
	"net/url"
	"sync"
	"time"

	"zettelstore.de/z/domain"
	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/domain/meta"
	"zettelstore.de/z/place"
	"zettelstore.de/z/place/change"
	"zettelstore.de/z/place/manager"







<







11
12
13
14
15
16
17

18
19
20
21
22
23
24
// Package memplace stores zettel volatile in main memory.
package memplace

import (
	"context"
	"net/url"
	"sync"


	"zettelstore.de/z/domain"
	"zettelstore.de/z/domain/id"
	"zettelstore.de/z/domain/meta"
	"zettelstore.de/z/place"
	"zettelstore.de/z/place/change"
	"zettelstore.de/z/place/manager"
65
66
67
68
69
70
71








72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
	return nil
}

func (mp *memPlace) CanCreateZettel(ctx context.Context) bool { return true }

func (mp *memPlace) CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error) {
	mp.mx.Lock()








	meta := zettel.Meta.Clone()
	meta.Zid = mp.calcNewZid()
	zettel.Meta = meta
	mp.zettel[meta.Zid] = zettel
	mp.mx.Unlock()
	mp.notifyChanged(change.OnUpdate, meta.Zid)
	return meta.Zid, nil
}

func (mp *memPlace) calcNewZid() id.Zid {
	zid := id.New(false)
	if _, ok := mp.zettel[zid]; !ok {
		return zid
	}
	for {
		zid = id.New(true)
		if _, ok := mp.zettel[zid]; !ok {
			return zid
		}
		time.Sleep(100 * time.Millisecond)
	}
}

func (mp *memPlace) GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error) {
	mp.mx.RLock()
	zettel, ok := mp.zettel[zid]
	mp.mx.RUnlock()
	if !ok {







>
>
>
>
>
>
>
>

|

|

|
<
<
<
<
<
<
|
<
<
<
<
<
<
<
<







64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84






85








86
87
88
89
90
91
92
	return nil
}

func (mp *memPlace) CanCreateZettel(ctx context.Context) bool { return true }

func (mp *memPlace) CreateZettel(ctx context.Context, zettel domain.Zettel) (id.Zid, error) {
	mp.mx.Lock()
	zid, err := place.GetNewZid(func(zid id.Zid) (bool, error) {
		_, ok := mp.zettel[zid]
		return !ok, nil
	})
	if err != nil {
		mp.mx.Unlock()
		return id.Invalid, err
	}
	meta := zettel.Meta.Clone()
	meta.Zid = zid
	zettel.Meta = meta
	mp.zettel[zid] = zettel
	mp.mx.Unlock()
	mp.notifyChanged(change.OnUpdate, zid)






	return zid, nil








}

func (mp *memPlace) GetZettel(ctx context.Context, zid id.Zid) (domain.Zettel, error) {
	mp.mx.RLock()
	zettel, ok := mp.zettel[zid]
	mp.mx.RUnlock()
	if !ok {

Changes to place/place.go.

166
167
168
169
170
171
172




173
174
175
176
177
var ErrStopped = errors.New("place is stopped")

// ErrReadOnly is returned if there is an attepmt to write to a read-only place.
var ErrReadOnly = errors.New("read-only place")

// ErrNotFound is returned if a zettel was not found in the place.
var ErrNotFound = errors.New("zettel not found")





// ErrInvalidID is returned if the zettel id is not appropriate for the place operation.
type ErrInvalidID struct{ Zid id.Zid }

func (err *ErrInvalidID) Error() string { return "invalid Zettel id: " + err.Zid.String() }







>
>
>
>





166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
var ErrStopped = errors.New("place is stopped")

// ErrReadOnly is returned if there is an attepmt to write to a read-only place.
var ErrReadOnly = errors.New("read-only place")

// ErrNotFound is returned if a zettel was not found in the place.
var ErrNotFound = errors.New("zettel not found")

// ErrTimeout is returned if a place operation takes too long.
// One example: if calculating a new zettel identifier takes too long.
var ErrTimeout = errors.New("timeout")

// ErrInvalidID is returned if the zettel id is not appropriate for the place operation.
type ErrInvalidID struct{ Zid id.Zid }

func (err *ErrInvalidID) Error() string { return "invalid Zettel id: " + err.Zid.String() }

Changes to place/progplace/config.go.

77
78
79
80
81
82
83

84
85
	fmt.Fprintf(&sb, "|Listen address| %v\n", startup.ListenAddress())
	fmt.Fprintf(&sb, "|Authentication enabled|%v\n", startup.WithAuth())
	fmt.Fprintf(&sb, "|Secure cookie|%v\n", startup.SecureCookie())
	fmt.Fprintf(&sb, "|Persistent Cookie|%v\n", startup.PersistentCookie())
	html, api := startup.TokenLifetime()
	fmt.Fprintf(&sb, "|API Token lifetime|%v\n", api)
	fmt.Fprintf(&sb, "|HTML Token lifetime|%v\n", html)

	return sb.String()
}







>


77
78
79
80
81
82
83
84
85
86
	fmt.Fprintf(&sb, "|Listen address| %v\n", startup.ListenAddress())
	fmt.Fprintf(&sb, "|Authentication enabled|%v\n", startup.WithAuth())
	fmt.Fprintf(&sb, "|Secure cookie|%v\n", startup.SecureCookie())
	fmt.Fprintf(&sb, "|Persistent Cookie|%v\n", startup.PersistentCookie())
	html, api := startup.TokenLifetime()
	fmt.Fprintf(&sb, "|API Token lifetime|%v\n", api)
	fmt.Fprintf(&sb, "|HTML Token lifetime|%v\n", html)
	fmt.Fprintf(&sb, "|Default directory place type|%v", startup.DefaultDirPlaceType())
	return sb.String()
}

Changes to tests/regression_test.go.

11
12
13
14
15
16
17

18
19
20
21
22
23

24
25
26
27
28
29
30
// Package tests provides some higher-level tests.
package tests

import (
	"context"
	"fmt"
	"io"

	"os"
	"path/filepath"
	"strings"
	"testing"

	"zettelstore.de/z/ast"

	"zettelstore.de/z/domain"
	"zettelstore.de/z/domain/meta"
	"zettelstore.de/z/encoder"
	"zettelstore.de/z/parser"
	"zettelstore.de/z/place"
	"zettelstore.de/z/place/manager"








>






>







11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Package tests provides some higher-level tests.
package tests

import (
	"context"
	"fmt"
	"io"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"zettelstore.de/z/ast"
	"zettelstore.de/z/config/startup"
	"zettelstore.de/z/domain"
	"zettelstore.de/z/domain/meta"
	"zettelstore.de/z/encoder"
	"zettelstore.de/z/parser"
	"zettelstore.de/z/place"
	"zettelstore.de/z/place/manager"

47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
		panic(err)
	}

	cdata := manager.ConnectData{Filter: &noFilter{}, Notify: nil}
	for _, entry := range entries {
		if entry.IsDir() {
			place, err := manager.Connect(
				"dir://"+filepath.Join(root, entry.Name()),
				false,
				&cdata,
			)
			if err != nil {
				panic(err)
			}
			places = append(places, place)







|







49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
		panic(err)
	}

	cdata := manager.ConnectData{Filter: &noFilter{}, Notify: nil}
	for _, entry := range entries {
		if entry.IsDir() {
			place, err := manager.Connect(
				"dir://"+filepath.Join(root, entry.Name())+"?type="+startup.ValueDirPlaceTypeSimple,
				false,
				&cdata,
			)
			if err != nil {
				panic(err)
			}
			places = append(places, place)
127
128
129
130
131
132
133
134




135
136
137
138
139
140
141

	if gotFirst != gotSecond {
		t.Errorf("\n1st: %q\n2nd: %q", gotFirst, gotSecond)
	}
}

func getPlaceName(p place.ManagedPlace, root string) string {
	return p.Location()[len("dir://")+len(root):]




}

func match(*meta.Meta) bool { return true }

func checkContentPlace(t *testing.T, p place.ManagedPlace, wd, placeName string) {
	ss := p.(place.StartStopper)
	if err := ss.Start(context.Background()); err != nil {







|
>
>
>
>







129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147

	if gotFirst != gotSecond {
		t.Errorf("\n1st: %q\n2nd: %q", gotFirst, gotSecond)
	}
}

func getPlaceName(p place.ManagedPlace, root string) string {
	u, err := url.Parse(p.Location())
	if err != nil {
		panic("Unable to parse URL '" + p.Location() + "': " + err.Error())
	}
	return u.Path[len(root):]
}

func match(*meta.Meta) bool { return true }

func checkContentPlace(t *testing.T, p place.ManagedPlace, wd, placeName string) {
	ss := p.(place.StartStopper)
	if err := ss.Start(context.Background()); err != nil {

Changes to web/adapter/response.go.

55
56
57
58
59
60
61



62
63
	}
	if err1, ok := err.(*ErrBadRequest); ok {
		return http.StatusBadRequest, err1.Text
	}
	if err == place.ErrStopped {
		return http.StatusInternalServerError, fmt.Sprintf("Zettelstore not operational: %v.", err)
	}



	return http.StatusInternalServerError, err.Error()
}







>
>
>


55
56
57
58
59
60
61
62
63
64
65
66
	}
	if err1, ok := err.(*ErrBadRequest); ok {
		return http.StatusBadRequest, err1.Text
	}
	if err == place.ErrStopped {
		return http.StatusInternalServerError, fmt.Sprintf("Zettelstore not operational: %v.", err)
	}
	if err == place.ErrTimeout {
		return http.StatusLoopDetected, "Zettelstore operation took too long"
	}
	return http.StatusInternalServerError, err.Error()
}

Changes to www/changes.wiki.

1
2



3
4
















5
6
7
8
9
10
11
<title>Change Log</title>




<a name="0_0_12"></a>
<h2>Changes for Version 0.0.12 (pending)</h2>

















<a name="0_0_11"></a>
<h2>Changes for Version 0.0.11 (2021-04-05)</h2>
  *  New place schema "file" allows to read zettel from a ZIP file.
     A zettel collection can now be packaged and distributed easier.
     (major: server)
  *  Non-restricted search is a full-text search. The search string will


>
>
>

|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<title>Change Log</title>

<a name="0_0_13"></a>
<h2>Changes for Version 0.0.13 (pending)</h2>

<a name="0_0_12"></a>
<h2>Changes for Version 0.0.12 (2021-04-16)</h2>
  *  Raise the per-process limit of open files on macOS to 1.048.576. This
     allows most macOS users to use at least 500.000 zettel. That should be
     enough for the near future.
     (major)
  *  Mitigate the shortcomings of the macOS version by introducing types of
     directory places. The original directory place type is now called "notify"
     (the default value). There is a new type called "simple". This new type
     does not notify Zettelstore when some of the underlying Zettel files
     change.
     (major)
  *  Add new startup configuration <tt>default-dir-place-type</tt>, which gives
     the default value for specifying a directory place type. The default value
     is &ldquo;notify&rdquo;. On macOS, the default value may be changed
     &ldquo;simple&rdquo; if some errors occur while raising the per-process
     limit of open files.
     (minor)

<a name="0_0_11"></a>
<h2>Changes for Version 0.0.11 (2021-04-05)</h2>
  *  New place schema "file" allows to read zettel from a ZIP file.
     A zettel collection can now be packaged and distributed easier.
     (major: server)
  *  Non-restricted search is a full-text search. The search string will
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
     or the whole string. If a search value begins with '#', only zettel with the exact
     tag will be returned. Otherwise a zettel will be returned if the search string
     just matches the prefix of only one of its tags.
     (minor: api, webui)
  *  Many smaller bug fixes and inprovements, to the software and to the documentation.

A note for users of macOS: in the current release and with macOS's default values,
a zettel directory place must not contain more than approx. 250 files. There are four options
to mitigate this limitation temporarily:
  #  You [https://zettelstore.de/manual/h/00001004010000|re-configure] your Zettelstore to use more
     than one directory place.
  #  You update the per-process limit of open files on macOS.
  #  You setup a virtualization environment to run Zettelstore on Linux or Windows.
  #  You wait for version 0.0.12 which addresses this issue.

<a name="0_0_10"></a>
<h2>Changes for Version 0.0.10 (2021-02-26)</h2>
  *  Menu item &ldquo;Home&rdquo; now redirects to a home zettel.







|

<
<







51
52
53
54
55
56
57
58
59


60
61
62
63
64
65
66
     or the whole string. If a search value begins with '#', only zettel with the exact
     tag will be returned. Otherwise a zettel will be returned if the search string
     just matches the prefix of only one of its tags.
     (minor: api, webui)
  *  Many smaller bug fixes and inprovements, to the software and to the documentation.

A note for users of macOS: in the current release and with macOS's default values,
a zettel directory place must not contain more than approx. 250 files. There are three options
to mitigate this limitation temporarily:


  #  You update the per-process limit of open files on macOS.
  #  You setup a virtualization environment to run Zettelstore on Linux or Windows.
  #  You wait for version 0.0.12 which addresses this issue.

<a name="0_0_10"></a>
<h2>Changes for Version 0.0.10 (2021-02-26)</h2>
  *  Menu item &ldquo;Home&rdquo; now redirects to a home zettel.

Changes to www/download.wiki.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<title>Download</title>
<h1>Download of Zettelstore Software</h1>
<h2>Foreword</h2>
  *  Zettelstore is free/libre open source software, licensed under EUPL-1.2-or-later.
  *  The software is provided as-is.
  *  There is no guarantee that it will not damage your system.
  *  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.

<h2>ZIP-ped Executables</h2>
Build: <code>v0.0.11</code> (2021-04-05).

  *  [/uv/zettelstore-0.0.11-linux-amd64.zip|Linux] (amd64)
  *  [/uv/zettelstore-0.0.11-linux-arm.zip|Linux] (arm6, e.g. Raspberry Pi)
  *  [/uv/zettelstore-0.0.11-windows-amd64.zip|Windows] (amd64)
  *  [/uv/zettelstore-0.0.11-darwin-amd64.zip|macOS] (amd64)
  *  [/uv/zettelstore-0.0.11-darwin-arm64.zip|macOS] (arm64, aka Apple silicon)

Unzip the appropriate file, install and execute Zettelstore according to the manual.

<h2>Zettel for the manual</h2>
As a starter, you can download the zettel for the manual [/uv/manual-0.0.11.zip|here].
Just unzip the contained files and put them into your zettel folder or configure
a file place to read the zettel directly from the ZIP file.











|

|
|
|
|
|







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<title>Download</title>
<h1>Download of Zettelstore Software</h1>
<h2>Foreword</h2>
  *  Zettelstore is free/libre open source software, licensed under EUPL-1.2-or-later.
  *  The software is provided as-is.
  *  There is no guarantee that it will not damage your system.
  *  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.

<h2>ZIP-ped Executables</h2>
Build: <code>v0.0.12</code> (2021-04-16).

  *  [/uv/zettelstore-0.0.12-linux-amd64.zip|Linux] (amd64)
  *  [/uv/zettelstore-0.0.12-linux-arm.zip|Linux] (arm6, e.g. Raspberry Pi)
  *  [/uv/zettelstore-0.0.12-windows-amd64.zip|Windows] (amd64)
  *  [/uv/zettelstore-0.0.12-darwin-amd64.zip|macOS] (amd64)
  *  [/uv/zettelstore-0.0.12-darwin-arm64.zip|macOS] (arm64, aka Apple silicon)

Unzip the appropriate file, install and execute Zettelstore according to the manual.

<h2>Zettel for the manual</h2>
As a starter, you can download the zettel for the manual [/uv/manual-0.0.11.zip|here].
Just unzip the contained files and put them into your zettel folder or configure
a file place to read the zettel directly from the ZIP file.

Changes to www/index.wiki.

13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
It is a live example of the zettelstore software, running in read-only mode.

The software, including the manual, is licensed under the
[/file?name=LICENSE.txt&ci=trunk|European Union Public License 1.2 (or later)].

[https://twitter.com/zettelstore|Stay tuned]&hellip;
<hr>
<h3>Latest Release: 0.0.11 (2021-04-05)</h3>
  *  [./download.wiki|Download]
  *  [./changes.wiki#0_0_11|Change Summary]
  *  [/timeline?p=version-0.0.11&bt=version-0.0.10&y=ci|Check-ins for version 0.0.11],
     [/vdiff?to=version-0.0.11&from=version-0.0.10|content diff]
  *  [/timeline?df=version-0.0.11&y=ci|Check-ins derived from the 0.0.11 release],
     [/vdiff?from=version-0.0.11&to=trunk|content diff]
  *  [./plan.wiki|Limitations and planned Improvements]
  *  [/timeline?t=release|Timeline of all past releases]

<hr>
<h2>Build instructions</h2>
Just install [https://fossil-scm.org|Fossil],
[https://golang.org/dl/|Go] and some Go-based tools. Please read the
[./build.md|instructions] for details.

  *  [/dir?ci=trunk|Source Code]
  *  [/download|Download the source code] as a Tarball or a ZIP file
     (you must [/login|login] as user &quot;anonymous&quot;).







|

|
|
|
|
|












13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
It is a live example of the zettelstore software, running in read-only mode.

The software, including the manual, is licensed under the
[/file?name=LICENSE.txt&ci=trunk|European Union Public License 1.2 (or later)].

[https://twitter.com/zettelstore|Stay tuned]&hellip;
<hr>
<h3>Latest Release: 0.0.12 (2021-04-16)</h3>
  *  [./download.wiki|Download]
  *  [./changes.wiki#0_0_12|Change Summary]
  *  [/timeline?p=version-0.0.12&bt=version-0.0.11&y=ci|Check-ins for version 0.0.12],
     [/vdiff?to=version-0.0.12&from=version-0.0.11|content diff]
  *  [/timeline?df=version-0.0.12&y=ci|Check-ins derived from the 0.0.12 release],
     [/vdiff?from=version-0.0.12&to=trunk|content diff]
  *  [./plan.wiki|Limitations and planned Improvements]
  *  [/timeline?t=release|Timeline of all past releases]

<hr>
<h2>Build instructions</h2>
Just install [https://fossil-scm.org|Fossil],
[https://golang.org/dl/|Go] and some Go-based tools. Please read the
[./build.md|instructions] for details.

  *  [/dir?ci=trunk|Source Code]
  *  [/download|Download the source code] as a Tarball or a ZIP file
     (you must [/login|login] as user &quot;anonymous&quot;).