✨ Add tools.ParseDate
This commit is contained in:
		
					parent
					
						
							
								c01fb53a0e
							
						
					
				
			
			
				commit
				
					
						70b82761c2
					
				
			
		
					 8 changed files with 1067 additions and 8 deletions
				
			
		|  | @ -43,7 +43,7 @@ func Execute() { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func init() { | func init() { | ||||||
| 	cobra.OnInitialize(initConfig) | 	//cobra.OnInitialize(initConfig) | ||||||
| 
 | 
 | ||||||
| 	// Here you will define your flags and configuration settings. | 	// Here you will define your flags and configuration settings. | ||||||
| 	// Cobra supports persistent flags, which, if defined here, | 	// Cobra supports persistent flags, which, if defined here, | ||||||
|  | @ -56,9 +56,3 @@ func init() { | ||||||
| 	// when this action is called directly. | 	// when this action is called directly. | ||||||
| 	// rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") | 	// rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") | ||||||
| } | } | ||||||
| 
 |  | ||||||
| // initConfig reads in config file and ENV variables if set. |  | ||||||
| func initConfig() { |  | ||||||
| 	// @todo |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
|  |  | ||||||
							
								
								
									
										73
									
								
								cmd/test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								cmd/test.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | ||||||
|  | /* | ||||||
|  | Copyright © 2024 Dan Jones <danjones@goodevilgenius.org> | ||||||
|  | 
 | ||||||
|  | This program is free software: you can redistribute it and/or modify | ||||||
|  | it under the terms of the GNU Affero General Public License as published by | ||||||
|  | the Free Software Foundation, either version 3 of the License, or | ||||||
|  | (at your option) any later version. | ||||||
|  | 
 | ||||||
|  | This program is distributed in the hope that it will be useful, | ||||||
|  | but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | GNU Affero General Public License for more details. | ||||||
|  | 
 | ||||||
|  | You should have received a copy of the GNU Affero General Public License | ||||||
|  | along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||||
|  | */ | ||||||
|  | package cmd | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 
 | ||||||
|  | 	"codeberg.org/danjones000/my-log/config" | ||||||
|  | 	"github.com/spf13/cobra" | ||||||
|  | 	// "github.com/BurntSushi/toml" | ||||||
|  | 	dp "github.com/markusmobius/go-dateparser" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // testCmd represents the test command | ||||||
|  | var testCmd = &cobra.Command{ | ||||||
|  | 	Use:   "test", | ||||||
|  | 	Short: "A brief description of your command", | ||||||
|  | 	//Long: ``, | ||||||
|  | 	Run: func(cmd *cobra.Command, args []string) { | ||||||
|  | 		c, err := config.Load() | ||||||
|  | 		fmt.Println("error", err) | ||||||
|  | 		fmt.Printf("%+v\n", c) | ||||||
|  | 		//ne := "output.stdout.config.json = true" | ||||||
|  | 		//toml.Decode(ne, &c) | ||||||
|  | 		st, _ := c.Outputs.Stdout() | ||||||
|  | 		fmt.Printf("%+v\n", st) | ||||||
|  | 		//nc := "\n[input]\nrecurse = false\n[output.stdout.config]\njson = true" | ||||||
|  | 		//toml.Decode(nc, &c) | ||||||
|  | 		//fmt.Printf("%+v\n", c) | ||||||
|  | 		d, err := dp.Parse(nil, "now") | ||||||
|  | 		fmt.Println(d.Time, d.Period) | ||||||
|  | 		d, err = dp.Parse(nil, "today") | ||||||
|  | 		fmt.Println(d.Time, d.Period) | ||||||
|  | 		d, err = dp.Parse(nil, "2 days, 3 minutes ago") | ||||||
|  | 		fmt.Println(d.Time, d.Period) | ||||||
|  | 		d, err = dp.Parse(nil, "in 2 decades, 5 days, 3 minutes") | ||||||
|  | 		fmt.Println(d.Time, d.Period) | ||||||
|  | 		d, err = dp.Parse(nil, "3 years ago") | ||||||
|  | 		fmt.Println(d.Time, d.Period) | ||||||
|  | 		d, err = dp.Parse(nil, "1707711800") | ||||||
|  | 		fmt.Println(d.Time, d.Period) | ||||||
|  | 		d, err = dp.Parse(nil, "@1707711800") | ||||||
|  | 		fmt.Println(d.Time, d.Period) | ||||||
|  | 	}, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func init() { | ||||||
|  | 	rootCmd.AddCommand(testCmd) | ||||||
|  | 
 | ||||||
|  | 	// Here you will define your flags and configuration settings. | ||||||
|  | 
 | ||||||
|  | 	// Cobra supports Persistent Flags which will work for this command | ||||||
|  | 	// and all subcommands, e.g.: | ||||||
|  | 	// testCmd.PersistentFlags().String("foo", "", "A help for foo") | ||||||
|  | 
 | ||||||
|  | 	// Cobra supports local flags which will only run when this command | ||||||
|  | 	// is called directly, e.g.: | ||||||
|  | 	// testCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") | ||||||
|  | } | ||||||
|  | @ -3,7 +3,6 @@ package config | ||||||
| import ( | import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"os" | 	"os" | ||||||
| 	//fp "path/filepath" |  | ||||||
| 	"testing" | 	"testing" | ||||||
| 
 | 
 | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
|  |  | ||||||
							
								
								
									
										863
									
								
								cover.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										863
									
								
								cover.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,863 @@ | ||||||
|  | 
 | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html> | ||||||
|  | 	<head> | ||||||
|  | 		<meta http-equiv="Content-Type" content="text/html; charset=utf-8"> | ||||||
|  | 		<title>config: Go Coverage Report</title> | ||||||
|  | 		<style> | ||||||
|  | 			body { | ||||||
|  | 				background: black; | ||||||
|  | 				color: rgb(80, 80, 80); | ||||||
|  | 			} | ||||||
|  | 			body, pre, #legend span { | ||||||
|  | 				font-family: Menlo, monospace; | ||||||
|  | 				font-weight: bold; | ||||||
|  | 			} | ||||||
|  | 			#topbar { | ||||||
|  | 				background: black; | ||||||
|  | 				position: fixed; | ||||||
|  | 				top: 0; left: 0; right: 0; | ||||||
|  | 				height: 42px; | ||||||
|  | 				border-bottom: 1px solid rgb(80, 80, 80); | ||||||
|  | 			} | ||||||
|  | 			#content { | ||||||
|  | 				margin-top: 50px; | ||||||
|  | 			} | ||||||
|  | 			#nav, #legend { | ||||||
|  | 				float: left; | ||||||
|  | 				margin-left: 10px; | ||||||
|  | 			} | ||||||
|  | 			#legend { | ||||||
|  | 				margin-top: 12px; | ||||||
|  | 			} | ||||||
|  | 			#nav { | ||||||
|  | 				margin-top: 10px; | ||||||
|  | 			} | ||||||
|  | 			#legend span { | ||||||
|  | 				margin: 0 5px; | ||||||
|  | 			} | ||||||
|  | 			.cov0 { color: rgb(192, 0, 0) } | ||||||
|  | .cov1 { color: rgb(128, 128, 128) } | ||||||
|  | .cov2 { color: rgb(116, 140, 131) } | ||||||
|  | .cov3 { color: rgb(104, 152, 134) } | ||||||
|  | .cov4 { color: rgb(92, 164, 137) } | ||||||
|  | .cov5 { color: rgb(80, 176, 140) } | ||||||
|  | .cov6 { color: rgb(68, 188, 143) } | ||||||
|  | .cov7 { color: rgb(56, 200, 146) } | ||||||
|  | .cov8 { color: rgb(44, 212, 149) } | ||||||
|  | .cov9 { color: rgb(32, 224, 152) } | ||||||
|  | .cov10 { color: rgb(20, 236, 155) } | ||||||
|  | 
 | ||||||
|  | 		</style> | ||||||
|  | 	</head> | ||||||
|  | 	<body> | ||||||
|  | 		<div id="topbar"> | ||||||
|  | 			<div id="nav"> | ||||||
|  | 				<select id="files"> | ||||||
|  | 				 | ||||||
|  | 				<option value="file0">codeberg.org/danjones000/my-log/config/default.go (100.0%)</option> | ||||||
|  | 				 | ||||||
|  | 				<option value="file1">codeberg.org/danjones000/my-log/config/load.go (97.4%)</option> | ||||||
|  | 				 | ||||||
|  | 				<option value="file2">codeberg.org/danjones000/my-log/files/append.go (94.7%)</option> | ||||||
|  | 				 | ||||||
|  | 				<option value="file3">codeberg.org/danjones000/my-log/models/entry.go (100.0%)</option> | ||||||
|  | 				 | ||||||
|  | 				<option value="file4">codeberg.org/danjones000/my-log/models/errors.go (100.0%)</option> | ||||||
|  | 				 | ||||||
|  | 				<option value="file5">codeberg.org/danjones000/my-log/models/log.go (100.0%)</option> | ||||||
|  | 				 | ||||||
|  | 				<option value="file6">codeberg.org/danjones000/my-log/models/meta.go (100.0%)</option> | ||||||
|  | 				 | ||||||
|  | 				<option value="file7">codeberg.org/danjones000/my-log/tools/parse.go (100.0%)</option> | ||||||
|  | 				 | ||||||
|  | 				<option value="file8">codeberg.org/danjones000/my-log/tools/parse_date.go (86.7%)</option> | ||||||
|  | 				 | ||||||
|  | 				</select> | ||||||
|  | 			</div> | ||||||
|  | 			<div id="legend"> | ||||||
|  | 				<span>not tracked</span> | ||||||
|  | 			 | ||||||
|  | 				<span class="cov0">no coverage</span> | ||||||
|  | 				<span class="cov1">low coverage</span> | ||||||
|  | 				<span class="cov2">*</span> | ||||||
|  | 				<span class="cov3">*</span> | ||||||
|  | 				<span class="cov4">*</span> | ||||||
|  | 				<span class="cov5">*</span> | ||||||
|  | 				<span class="cov6">*</span> | ||||||
|  | 				<span class="cov7">*</span> | ||||||
|  | 				<span class="cov8">*</span> | ||||||
|  | 				<span class="cov9">*</span> | ||||||
|  | 				<span class="cov10">high coverage</span> | ||||||
|  | 			 | ||||||
|  | 			</div> | ||||||
|  | 		</div> | ||||||
|  | 		<div id="content"> | ||||||
|  | 		 | ||||||
|  | 		<pre class="file" id="file0" style="display: none">package config | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  |         "fmt" | ||||||
|  |         "os" | ||||||
|  |         fp "path/filepath" | ||||||
|  | 
 | ||||||
|  |         "github.com/BurntSushi/toml" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | const ConfigStr = `# Configuration for my-log | ||||||
|  | 
 | ||||||
|  | [input] | ||||||
|  | # Path to where the log files are stored | ||||||
|  | path = "%s" | ||||||
|  | # File extension for log files | ||||||
|  | ext = "txt" | ||||||
|  | # Whether to look in sub-folders | ||||||
|  | recurse = true | ||||||
|  | 
 | ||||||
|  | # config for output types | ||||||
|  | [output] | ||||||
|  | 
 | ||||||
|  | # This one just prints the logs to stdout when run | ||||||
|  | [output.stdout] | ||||||
|  | enabled = true | ||||||
|  | [output.stdout.config] | ||||||
|  | # Whether to output as JSON. Maybe useful to pipe elsewhere. | ||||||
|  | json = false | ||||||
|  | 
 | ||||||
|  | ` | ||||||
|  | 
 | ||||||
|  | func DefaultStr() string <span class="cov10" title="8">{ | ||||||
|  |         home, _ := os.UserHomeDir() | ||||||
|  |         inDir := fp.Join(home, "my-log") | ||||||
|  |         return fmt.Sprintf(ConfigStr, inDir) | ||||||
|  | }</span> | ||||||
|  | 
 | ||||||
|  | func DefaultConfig() (Config, error) <span class="cov10" title="8">{ | ||||||
|  |         s := DefaultStr() | ||||||
|  |         c := Config{} | ||||||
|  |         _, err := toml.Decode(s, &c) | ||||||
|  |         return c, err | ||||||
|  | }</span> | ||||||
|  | </pre> | ||||||
|  | 		 | ||||||
|  | 		<pre class="file" id="file1" style="display: none">package config | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  |         "encoding/json" | ||||||
|  |         "fmt" | ||||||
|  |         "os" | ||||||
|  |         fp "path/filepath" | ||||||
|  |         "time" | ||||||
|  | 
 | ||||||
|  |         "codeberg.org/danjones000/my-log/tools" | ||||||
|  |         "github.com/BurntSushi/toml" | ||||||
|  |         "github.com/caarlos0/env/v10" | ||||||
|  |         mapst "github.com/mitchellh/mapstructure" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var ConfigPath string | ||||||
|  | var Overrides = map[string]string{} | ||||||
|  | 
 | ||||||
|  | func init() <span class="cov1" title="1">{ | ||||||
|  |         conf, _ := os.UserConfigDir() | ||||||
|  |         ConfigPath = fp.Join(conf, "my-log", "config.toml") | ||||||
|  | }</span> | ||||||
|  | 
 | ||||||
|  | func Load() (Config, error) <span class="cov10" title="6">{ | ||||||
|  |         c, _ := DefaultConfig() | ||||||
|  |         _, err := os.Stat(ConfigPath) | ||||||
|  |         if !os.IsNotExist(err) </span><span class="cov4" title="2">{ | ||||||
|  |                 _, err = toml.DecodeFile(ConfigPath, &c) | ||||||
|  |                 if err != nil </span><span class="cov1" title="1">{ | ||||||
|  |                         return c, err | ||||||
|  |                 }</span> | ||||||
|  |         } | ||||||
|  |         <span class="cov9" title="5">env.Parse(&c) | ||||||
|  |         c.Outputs["stdout"] = loadStdout(c.Outputs["stdout"]) | ||||||
|  | 
 | ||||||
|  |         l := "" | ||||||
|  |         for k, v := range Overrides </span><span class="cov7" title="4">{ | ||||||
|  |                 val := tools.ParseString(v) | ||||||
|  |                 if val == nil </span><span class="cov1" title="1">{ | ||||||
|  |                         continue</span> | ||||||
|  |                 } | ||||||
|  |                 <span class="cov6" title="3">if _, isJson := val.(json.RawMessage); isJson </span><span class="cov4" title="2">{ | ||||||
|  |                         continue</span> | ||||||
|  |                 } | ||||||
|  |                 <span class="cov1" title="1">valout := fmt.Sprintf("%v", val) | ||||||
|  |                 if vals, isString := val.(string); isString </span><span class="cov1" title="1">{ | ||||||
|  |                         valout = fmt.Sprintf(`"%s"`, vals) | ||||||
|  |                 }</span> | ||||||
|  |                 <span class="cov1" title="1">if valt, isTime := val.(time.Time); isTime </span><span class="cov0" title="0">{ | ||||||
|  |                         valout = valt.Format(time.RFC3339) | ||||||
|  |                 }</span> | ||||||
|  |                 <span class="cov1" title="1">l = l + "\n" + fmt.Sprintf("%s = %s", k, valout)</span> | ||||||
|  |         } | ||||||
|  |         <span class="cov9" title="5">_, err = toml.Decode(l, &c) | ||||||
|  |         return c, err</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func loadStdout(stdout Output) Output <span class="cov9" title="5">{ | ||||||
|  |         st := stdoutEnabled{stdout.Enabled} | ||||||
|  |         env.Parse(&st) | ||||||
|  |         stdout.Enabled = st.Enabled | ||||||
|  |         var std Stdout | ||||||
|  |         mapst.Decode(stdout.Config, &std) | ||||||
|  |         env.Parse(&std) | ||||||
|  |         mapst.Decode(std, &stdout.Config) | ||||||
|  |         return stdout | ||||||
|  | }</span> | ||||||
|  | 
 | ||||||
|  | func (oo Outputs) Stdout() (s Stdout, enabled bool) <span class="cov4" title="2">{ | ||||||
|  |         o, ok := oo["stdout"] | ||||||
|  |         if !ok </span><span class="cov1" title="1">{ | ||||||
|  |                 return s, false | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov1" title="1">enabled = o.Enabled | ||||||
|  |         mapst.Decode(o.Config, &s) | ||||||
|  | 
 | ||||||
|  |         return</span> | ||||||
|  | } | ||||||
|  | </pre> | ||||||
|  | 		 | ||||||
|  | 		<pre class="file" id="file2" style="display: none">package files | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  |         "fmt" | ||||||
|  |         "os" | ||||||
|  |         fp "path/filepath" | ||||||
|  |         "strings" | ||||||
|  | 
 | ||||||
|  |         "codeberg.org/danjones000/my-log/config" | ||||||
|  |         "codeberg.org/danjones000/my-log/models" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func Append(l models.Log) error <span class="cov10" title="4">{ | ||||||
|  |         conf, err := config.Load() | ||||||
|  |         if err != nil </span><span class="cov1" title="1">{ | ||||||
|  |                 return err | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov8" title="3">filename := fmt.Sprintf("%s.%s", strings.ReplaceAll(l.Name, ".", string(os.PathSeparator)), conf.Input.Ext) | ||||||
|  |         path := fp.Join(conf.Input.Path, filename) | ||||||
|  |         dir := fp.Dir(path) | ||||||
|  |         err = os.MkdirAll(dir, 0750) | ||||||
|  |         if err != nil </span><span class="cov1" title="1">{ | ||||||
|  |                 return err | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov5" title="2">f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640) | ||||||
|  |         if err != nil </span><span class="cov1" title="1">{ | ||||||
|  |                 return err | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov1" title="1">defer f.Close() | ||||||
|  | 
 | ||||||
|  |         for _, e := range l.Entries </span><span class="cov1" title="1">{ | ||||||
|  |                 by, err := e.MarshalText() | ||||||
|  |                 if err != nil </span><span class="cov0" title="0">{ | ||||||
|  |                         continue</span> | ||||||
|  |                 } | ||||||
|  |                 <span class="cov1" title="1">f.Write(by)</span> | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         <span class="cov1" title="1">return nil</span> | ||||||
|  | } | ||||||
|  | </pre> | ||||||
|  | 		 | ||||||
|  | 		<pre class="file" id="file3" style="display: none">package models | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  |         "bufio" | ||||||
|  |         "bytes" | ||||||
|  |         "encoding/json" | ||||||
|  |         "errors" | ||||||
|  |         "regexp" | ||||||
|  |         "strings" | ||||||
|  |         "sync" | ||||||
|  |         "time" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | const DateFormat = "January 02, 2006 at 03:04:05PM -0700" | ||||||
|  | 
 | ||||||
|  | type Entry struct { | ||||||
|  |         Title       string | ||||||
|  |         Date        time.Time | ||||||
|  |         Fields      []Meta | ||||||
|  |         skipMissing bool | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func PartialEntry() Entry <span class="cov1" title="1">{ | ||||||
|  |         return Entry{skipMissing: true} | ||||||
|  | }</span> | ||||||
|  | 
 | ||||||
|  | type metaRes struct { | ||||||
|  |         out []byte | ||||||
|  |         err error | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (e Entry) getFieldMarshalChan() chan metaRes <span class="cov3" title="4">{ | ||||||
|  |         size := len(e.Fields) | ||||||
|  |         ch := make(chan metaRes, size) | ||||||
|  |         var wg sync.WaitGroup | ||||||
|  | 
 | ||||||
|  |         for i := 0; i < size; i++ </span><span class="cov4" title="5">{ | ||||||
|  |                 wg.Add(1) | ||||||
|  |                 go func(m Meta) </span><span class="cov4" title="5">{ | ||||||
|  |                         defer wg.Done() | ||||||
|  |                         if m.Key == "json" </span><span class="cov1" title="1">{ | ||||||
|  |                                 if j, ok := m.Value.(json.RawMessage); ok </span><span class="cov1" title="1">{ | ||||||
|  |                                         sub := Entry{skipMissing: true} | ||||||
|  |                                         json.Unmarshal(j, &sub) | ||||||
|  |                                         for _, subM := range sub.Fields </span><span class="cov3" title="3">{ | ||||||
|  |                                                 o, er := subM.MarshalText() | ||||||
|  |                                                 ch <- metaRes{o, er} | ||||||
|  |                                         }</span> | ||||||
|  |                                 } | ||||||
|  |                         } else<span class="cov3" title="4"> { | ||||||
|  |                                 o, er := m.MarshalText() | ||||||
|  |                                 ch <- metaRes{o, er} | ||||||
|  |                         }</span> | ||||||
|  | 
 | ||||||
|  |                 }(e.Fields[i]) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         <span class="cov3" title="4">go func() </span><span class="cov3" title="4">{ | ||||||
|  |                 wg.Wait() | ||||||
|  |                 close(ch) | ||||||
|  |         }</span>() | ||||||
|  |         <span class="cov3" title="4">return ch</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (e Entry) MarshalText() ([]byte, error) <span class="cov4" title="6">{ | ||||||
|  |         e.Title = strings.TrimSpace(e.Title) | ||||||
|  |         if e.Title == "" </span><span class="cov1" title="1">{ | ||||||
|  |                 return []byte{}, ErrorMissingTitle | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov4" title="5">if e.Date == (time.Time{}) </span><span class="cov1" title="1">{ | ||||||
|  |                 return []byte{}, ErrorMissingDate | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov3" title="4">ch := e.getFieldMarshalChan() | ||||||
|  |         buff := &bytes.Buffer{} | ||||||
|  |         buff.WriteString("\n@begin ") | ||||||
|  |         buff.WriteString(e.Date.Format(DateFormat)) | ||||||
|  |         buff.WriteString(" - ") | ||||||
|  |         buff.WriteString(e.Title) | ||||||
|  | 
 | ||||||
|  |         for res := range ch </span><span class="cov5" title="7">{ | ||||||
|  |                 if res.err == nil && len(res.out) > 0 </span><span class="cov5" title="7">{ | ||||||
|  |                         buff.WriteString("\n") | ||||||
|  |                         buff.Write(res.out) | ||||||
|  |                 }</span> | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         <span class="cov3" title="4">buff.WriteString(" @end") | ||||||
|  | 
 | ||||||
|  |         return buff.Bytes(), nil</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (m *Entry) UnmarshalText(in []byte) error <span class="cov7" title="18">{ | ||||||
|  |         re := regexp.MustCompile("(?s)^@begin (.+) - (.+?)[ \n]@") | ||||||
|  |         match := re.FindSubmatch(in) | ||||||
|  |         if len(match) == 0 </span><span class="cov2" title="2">{ | ||||||
|  |                 return newParsingError(errors.New("Failed to find title and date")) | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov6" title="16">ch := m.getFieldUnarshalChan(in) | ||||||
|  | 
 | ||||||
|  |         title := bytes.TrimSpace(match[2]) | ||||||
|  |         if len(title) == 0 </span><span class="cov1" title="1">{ | ||||||
|  |                 return ErrorMissingTitle | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov6" title="15">m.Title = string(title) | ||||||
|  |         date := string(bytes.TrimSpace(match[1])) | ||||||
|  |         if date == "" </span><span class="cov1" title="1">{ | ||||||
|  |                 return ErrorMissingDate | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov6" title="14">d, e := time.Parse(time.RFC3339, date) | ||||||
|  |         if e != nil </span><span class="cov6" title="11">{ | ||||||
|  |                 d, e = time.Parse(DateFormat, date) | ||||||
|  |                 if e != nil </span><span class="cov1" title="1">{ | ||||||
|  |                         return newParsingError(e) | ||||||
|  |                 }</span> | ||||||
|  |         } | ||||||
|  |         <span class="cov6" title="13">m.Date = d | ||||||
|  | 
 | ||||||
|  |         for meta := range ch </span><span class="cov6" title="11">{ | ||||||
|  |                 m.Fields = append(m.Fields, meta) | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov6" title="13">return nil</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func scanEntry(data []byte, atEOF bool) (advance int, token []byte, err error) <span class="cov10" title="65">{ | ||||||
|  |         if atEOF && len(data) == 0 </span><span class="cov7" title="17">{ | ||||||
|  |                 return 0, nil, nil | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov9" title="48">if i := bytes.Index(data, []byte{10, 64}); i > 0 </span><span class="cov6" title="14">{ | ||||||
|  |                 return i + 1, data[0:i], nil | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov8" title="34">if atEOF </span><span class="cov7" title="17">{ | ||||||
|  |                 end := []byte{32, 64, 101, 110, 100} | ||||||
|  |                 token = data | ||||||
|  |                 if i := bytes.Index(data, end); i >= 0 </span><span class="cov6" title="15">{ | ||||||
|  |                         token = data[0:i] | ||||||
|  |                 }</span> | ||||||
|  |                 <span class="cov7" title="17">return len(data), token, nil</span> | ||||||
|  |         } | ||||||
|  |         // Request more data. | ||||||
|  |         <span class="cov7" title="17">return 0, nil, nil</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (e *Entry) getFieldUnarshalChan(in []byte) chan Meta <span class="cov6" title="16">{ | ||||||
|  |         size := len(in) / 3 // rough estimation | ||||||
|  |         ch := make(chan Meta, size) | ||||||
|  |         var wg sync.WaitGroup | ||||||
|  | 
 | ||||||
|  |         read := bytes.NewReader(in) | ||||||
|  |         scan := bufio.NewScanner(read) | ||||||
|  |         scan.Split(scanEntry) | ||||||
|  |         scan.Scan() // throw out first line | ||||||
|  | 
 | ||||||
|  |         for scan.Scan() </span><span class="cov6" title="12">{ | ||||||
|  |                 wg.Add(1) | ||||||
|  |                 go func(field []byte) </span><span class="cov6" title="12">{ | ||||||
|  |                         defer wg.Done() | ||||||
|  |                         m := new(Meta) | ||||||
|  |                         err := m.UnmarshalText(field) | ||||||
|  |                         if err == nil </span><span class="cov5" title="10">{ | ||||||
|  |                                 if m.Key == "json" </span><span class="cov1" title="1">{ | ||||||
|  |                                         if j, ok := m.Value.(json.RawMessage); ok </span><span class="cov1" title="1">{ | ||||||
|  |                                                 sub := Entry{skipMissing: true} | ||||||
|  |                                                 json.Unmarshal(j, &sub) | ||||||
|  |                                                 for _, subM := range sub.Fields </span><span class="cov2" title="2">{ | ||||||
|  |                                                         ch <- subM | ||||||
|  |                                                 }</span> | ||||||
|  |                                         } | ||||||
|  |                                 } else<span class="cov5" title="9"> { | ||||||
|  |                                         ch <- *m | ||||||
|  |                                 }</span> | ||||||
|  |                         } | ||||||
|  |                 }(scan.Bytes()) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         <span class="cov6" title="16">go func() </span><span class="cov6" title="16">{ | ||||||
|  |                 wg.Wait() | ||||||
|  |                 close(ch) | ||||||
|  |         }</span>() | ||||||
|  |         <span class="cov6" title="16">return ch</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (e Entry) MarshalJSON() ([]byte, error) <span class="cov6" title="11">{ | ||||||
|  |         if e.Title == "" </span><span class="cov1" title="1">{ | ||||||
|  |                 return []byte{}, ErrorMissingTitle | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov5" title="10">if e.Date == (time.Time{}) </span><span class="cov1" title="1">{ | ||||||
|  |                 return []byte{}, ErrorMissingDate | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov5" title="9">out := map[string]any{} | ||||||
|  |         out["title"] = e.Title | ||||||
|  |         out["date"] = e.Date.Format(time.RFC3339) | ||||||
|  |         for _, f := range e.Fields </span><span class="cov5" title="10">{ | ||||||
|  |                 if _, ok := out[f.Key]; !ok </span><span class="cov5" title="7">{ | ||||||
|  |                         if f.Key == "json" </span><span class="cov1" title="1">{ | ||||||
|  |                                 ob := map[string]any{} | ||||||
|  |                                 if j, ok := f.Value.(json.RawMessage); ok </span><span class="cov1" title="1">{ | ||||||
|  |                                         json.Unmarshal(j, &ob) | ||||||
|  |                                 }</span> | ||||||
|  |                                 // If we couldn't get valid data from there, this will just be empty | ||||||
|  |                                 <span class="cov1" title="1">for k, v := range ob </span><span class="cov3" title="3">{ | ||||||
|  |                                         if k != "title" && k != "date" </span><span class="cov3" title="3">{ | ||||||
|  |                                                 out[k] = v | ||||||
|  |                                         }</span> | ||||||
|  |                                 } | ||||||
|  |                         } else<span class="cov4" title="6"> { | ||||||
|  |                                 out[f.Key] = f.Value | ||||||
|  |                                 if vt, ok := f.Value.(time.Time); ok </span><span class="cov1" title="1">{ | ||||||
|  |                                         out[f.Key] = vt.Format(time.RFC3339) | ||||||
|  |                                 }</span> | ||||||
|  |                         } | ||||||
|  |                 } | ||||||
|  |         } | ||||||
|  |         <span class="cov5" title="9">return json.Marshal(out)</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (e *Entry) unmarshalJsonChanHelper(m map[string]any, ch chan Meta, wg *sync.WaitGroup) <span class="cov6" title="11">{ | ||||||
|  |         for k, v := range m </span><span class="cov8" title="32">{ | ||||||
|  |                 wg.Add(1) | ||||||
|  |                 go func(key string, value any) </span><span class="cov8" title="32">{ | ||||||
|  |                         defer wg.Done() | ||||||
|  |                         if key != "json" </span><span class="cov8" title="30">{ | ||||||
|  |                                 ch <- Meta{key, value} | ||||||
|  |                                 return | ||||||
|  |                         }</span> | ||||||
|  |                         <span class="cov2" title="2">subM := map[string]any{} | ||||||
|  |                         if s, ok := value.(string); ok </span><span class="cov1" title="1">{ | ||||||
|  |                                 dec := json.NewDecoder(strings.NewReader(s)) | ||||||
|  |                                 dec.UseNumber() | ||||||
|  |                                 dec.Decode(&subM) | ||||||
|  |                         }</span> else<span class="cov1" title="1"> { | ||||||
|  |                                 subM = value.(map[string]any) | ||||||
|  |                         }</span> | ||||||
|  |                         <span class="cov2" title="2">e.unmarshalJsonChanHelper(subM, ch, wg)</span> | ||||||
|  |                 }(k, v) | ||||||
|  |         } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (e *Entry) getUnmarshalJsonChan(m map[string]any) chan Meta <span class="cov5" title="9">{ | ||||||
|  |         ch := make(chan Meta, len(m)) | ||||||
|  |         var wg sync.WaitGroup | ||||||
|  | 
 | ||||||
|  |         e.unmarshalJsonChanHelper(m, ch, &wg) | ||||||
|  |         go func() </span><span class="cov5" title="9">{ | ||||||
|  |                 wg.Wait() | ||||||
|  |                 close(ch) | ||||||
|  |         }</span>() | ||||||
|  | 
 | ||||||
|  |         <span class="cov5" title="9">return ch</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (e *Entry) UnmarshalJSON(in []byte) error <span class="cov6" title="15">{ | ||||||
|  |         out := map[string]any{} | ||||||
|  |         dec := json.NewDecoder(bytes.NewReader(in)) | ||||||
|  |         dec.UseNumber() | ||||||
|  |         err := dec.Decode(&out) | ||||||
|  |         if err != nil </span><span class="cov1" title="1">{ | ||||||
|  |                 return newParsingError(err) | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov6" title="14">title, ok := out["title"].(string) | ||||||
|  |         if (!ok || title == "") && !e.skipMissing </span><span class="cov2" title="2">{ | ||||||
|  |                 return ErrorMissingTitle | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov6" title="12">e.Title = title | ||||||
|  |         dates, ok := out["date"].(string) | ||||||
|  |         if (!ok || dates == "") && !e.skipMissing </span><span class="cov2" title="2">{ | ||||||
|  |                 return ErrorMissingDate | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov5" title="10">date, err := time.Parse(time.RFC3339, dates) | ||||||
|  |         if err != nil && !e.skipMissing </span><span class="cov1" title="1">{ | ||||||
|  |                 return newParsingError(err) | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov5" title="9">e.Date = date | ||||||
|  |         ch := e.getUnmarshalJsonChan(out) | ||||||
|  |         for m := range ch </span><span class="cov8" title="30">{ | ||||||
|  |                 if m.Key == "title" || m.Key == "date" </span><span class="cov6" title="12">{ | ||||||
|  |                         continue</span> | ||||||
|  |                 } else<span class="cov7" title="18"> if vs, ok := m.Value.(string); ok </span><span class="cov5" title="7">{ | ||||||
|  |                         if vd, err := time.Parse(time.RFC3339, vs); err == nil </span><span class="cov1" title="1">{ | ||||||
|  |                                 m.Value = vd | ||||||
|  |                         }</span> else<span class="cov4" title="6"> { | ||||||
|  |                                 m.Value = vs | ||||||
|  |                         }</span> | ||||||
|  |                 } else<span class="cov6" title="11"> if n, ok := m.Value.(json.Number); ok </span><span class="cov4" title="6">{ | ||||||
|  |                         it, _ := n.Int64() | ||||||
|  |                         fl, _ := n.Float64() | ||||||
|  |                         if float64(it) == fl </span><span class="cov4" title="5">{ | ||||||
|  |                                 m.Value = it | ||||||
|  |                         }</span> else<span class="cov1" title="1"> { | ||||||
|  |                                 m.Value = fl | ||||||
|  |                         }</span> | ||||||
|  |                 } | ||||||
|  |                 <span class="cov7" title="18">e.Fields = append(e.Fields, m)</span> | ||||||
|  |         } | ||||||
|  |         <span class="cov5" title="9">return nil</span> | ||||||
|  | } | ||||||
|  | </pre> | ||||||
|  | 		 | ||||||
|  | 		<pre class="file" id="file4" style="display: none">package models | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  |         "errors" | ||||||
|  |         "fmt" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var ErrorMissingTitle = errors.New("Missing title") | ||||||
|  | var ErrorMissingDate = errors.New("Missing date") | ||||||
|  | var ErrorParsing = errors.New("Parsing Error") | ||||||
|  | 
 | ||||||
|  | func newParsingError(err error) error <span class="cov10" title="13">{ | ||||||
|  |         return fmt.Errorf("%w: %w", ErrorParsing, err) | ||||||
|  | }</span> | ||||||
|  | </pre> | ||||||
|  | 		 | ||||||
|  | 		<pre class="file" id="file5" style="display: none">package models | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  |         "bufio" | ||||||
|  |         "bytes" | ||||||
|  |         "regexp" | ||||||
|  |         "sync" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var reg = regexp.MustCompile("(?sm)^@begin .+?(^| )@end") | ||||||
|  | 
 | ||||||
|  | type Log struct { | ||||||
|  |         Name    string | ||||||
|  |         Entries []Entry | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (l *Log) UnmarshalText(in []byte) error <span class="cov5" title="4">{ | ||||||
|  |         ch := l.getLogUnarshalChan(in) | ||||||
|  |         for entry := range ch </span><span class="cov7" title="6">{ | ||||||
|  |                 l.Entries = append(l.Entries, entry) | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov5" title="4">return nil</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func scanLog(data []byte, atEOF bool) (advance int, token []byte, err error) <span class="cov10" title="14">{ | ||||||
|  |         if atEOF && len(data) == 0 </span><span class="cov1" title="1">{ | ||||||
|  |                 // done | ||||||
|  |                 return 0, nil, nil | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov9" title="13">m := reg.FindIndex(data) | ||||||
|  |         if len(m) == 0 && atEOF </span><span class="cov4" title="3">{ | ||||||
|  |                 // all trash | ||||||
|  |                 return len(data), nil, nil | ||||||
|  |         }</span> else<span class="cov8" title="10"> if len(m) == 0 && !atEOF </span><span class="cov4" title="3">{ | ||||||
|  |                 // get more | ||||||
|  |                 return 0, nil, nil | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov7" title="7">return m[1], data[m[0]:m[1]], nil</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (l *Log) getLogUnarshalChan(in []byte) chan Entry <span class="cov5" title="4">{ | ||||||
|  |         size := len(in) / 10 // rough estimation | ||||||
|  |         ch := make(chan Entry, size) | ||||||
|  |         var wg sync.WaitGroup | ||||||
|  | 
 | ||||||
|  |         read := bytes.NewReader(in) | ||||||
|  |         scan := bufio.NewScanner(read) | ||||||
|  |         scan.Split(scanLog) | ||||||
|  | 
 | ||||||
|  |         for scan.Scan() </span><span class="cov7" title="7">{ | ||||||
|  |                 wg.Add(1) | ||||||
|  |                 go func(field []byte) </span><span class="cov7" title="7">{ | ||||||
|  |                         defer wg.Done() | ||||||
|  |                         f := new(Entry) | ||||||
|  |                         err := f.UnmarshalText(field) | ||||||
|  |                         if err != nil </span><span class="cov1" title="1">{ | ||||||
|  |                                 return | ||||||
|  |                         }</span> | ||||||
|  |                         <span class="cov7" title="6">ch <- *f</span> | ||||||
|  |                 }(scan.Bytes()) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         <span class="cov5" title="4">go func() </span><span class="cov5" title="4">{ | ||||||
|  |                 wg.Wait() | ||||||
|  |                 close(ch) | ||||||
|  |         }</span>() | ||||||
|  |         <span class="cov5" title="4">return ch</span> | ||||||
|  | } | ||||||
|  | </pre> | ||||||
|  | 		 | ||||||
|  | 		<pre class="file" id="file6" style="display: none">package models | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  |         "bytes" | ||||||
|  |         "encoding/json" | ||||||
|  |         "errors" | ||||||
|  |         "fmt" | ||||||
|  |         "regexp" | ||||||
|  |         "strconv" | ||||||
|  |         "time" | ||||||
|  | 
 | ||||||
|  |         "codeberg.org/danjones000/my-log/tools" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type Meta struct { | ||||||
|  |         Key   string | ||||||
|  |         Value any | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (m Meta) MarshalText() ([]byte, error) <span class="cov8" title="23">{ | ||||||
|  |         if regexp.MustCompile(`\s`).MatchString(m.Key) </span><span class="cov1" title="1">{ | ||||||
|  |                 return []byte{}, fmt.Errorf("whitespace is not allowed in key: %s", m.Key) | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov8" title="22">buff := &bytes.Buffer{} | ||||||
|  |         buff.WriteRune('@') | ||||||
|  |         buff.WriteString(m.Key) | ||||||
|  |         buff.WriteRune(' ') | ||||||
|  |         switch v := m.Value.(type) </span>{ | ||||||
|  |         default:<span class="cov1" title="1"> | ||||||
|  |                 return nil, fmt.Errorf("Unknown type %T", v)</span> | ||||||
|  |         case nil:<span class="cov1" title="1"> | ||||||
|  |                 return []byte{}, nil</span> | ||||||
|  |         case string:<span class="cov3" title="3"> | ||||||
|  |                 buff.WriteString(v)</span> | ||||||
|  |         case int:<span class="cov3" title="3"> | ||||||
|  |                 buff.WriteString(strconv.Itoa(v))</span> | ||||||
|  |         case int64:<span class="cov2" title="2"> | ||||||
|  |                 buff.WriteString(strconv.FormatInt(v, 10))</span> | ||||||
|  |         case float64:<span class="cov1" title="1"> | ||||||
|  |                 buff.WriteString(strconv.FormatFloat(v, 'f', -1, 64))</span> | ||||||
|  |         case json.Number:<span class="cov1" title="1"> | ||||||
|  |                 buff.WriteString(v.String())</span> | ||||||
|  |         case json.RawMessage:<span class="cov2" title="2"> | ||||||
|  |                 buff.Write(v)</span> | ||||||
|  |         case []byte:<span class="cov1" title="1"> | ||||||
|  |                 buff.Write(v)</span> | ||||||
|  |         case byte:<span class="cov1" title="1"> | ||||||
|  |                 buff.WriteByte(v)</span> | ||||||
|  |         case rune:<span class="cov1" title="1"> | ||||||
|  |                 buff.WriteString(string(v))</span> | ||||||
|  |         case bool:<span class="cov4" title="4"> | ||||||
|  |                 buff.WriteString(strconv.FormatBool(v))</span> | ||||||
|  |         case time.Time:<span class="cov1" title="1"> | ||||||
|  |                 buff.WriteString(v.Format(time.RFC3339))</span> | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         <span class="cov8" title="20">return buff.Bytes(), nil</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (m *Meta) UnmarshalText(in []byte) error <span class="cov10" title="39">{ | ||||||
|  |         if len(in) == 0 </span><span class="cov2" title="2">{ | ||||||
|  |                 return newParsingError(errors.New("Unable to Unmarshal empty string")) | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov9" title="37">re := regexp.MustCompile("(?s)^@([^ ]+) (.*)( @end)?$") | ||||||
|  |         match := re.FindSubmatch(in) | ||||||
|  |         if len(match) == 0 </span><span class="cov3" title="3">{ | ||||||
|  |                 return newParsingError(fmt.Errorf("Failed to match %s", in)) | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov9" title="34">m.Key = string(match[1]) | ||||||
|  |         return m.processMeta(match[2])</span> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (m *Meta) processMeta(in []byte) error <span class="cov9" title="34">{ | ||||||
|  |         if len(in) == 0 </span><span class="cov1" title="1">{ | ||||||
|  |                 return newParsingError(errors.New("No value found")) | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov9" title="33">v := tools.ParseBytes(in) | ||||||
|  |         if v == "" </span><span class="cov2" title="2">{ | ||||||
|  |                 return newParsingError(errors.New("No value found")) | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov9" title="31">m.Value = v | ||||||
|  |         return nil</span> | ||||||
|  | } | ||||||
|  | </pre> | ||||||
|  | 		 | ||||||
|  | 		<pre class="file" id="file7" style="display: none">package tools | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  |         "encoding/json" | ||||||
|  |         "regexp" | ||||||
|  |         "strconv" | ||||||
|  |         "strings" | ||||||
|  |         "time" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func ParseBytes(in []byte) any <span class="cov8" title="20">{ | ||||||
|  |         return ParseString(string(in)) | ||||||
|  | }</span> | ||||||
|  | 
 | ||||||
|  | func ParseString(in string) any <span class="cov10" title="40">{ | ||||||
|  |         s := strings.TrimSpace(in) | ||||||
|  |         if s == "" </span><span class="cov5" title="6">{ | ||||||
|  |                 return s | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov9" title="34">yesno := regexp.MustCompile("^(y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF)$") | ||||||
|  |         yes := regexp.MustCompile("^(y|Y|yes|Yes|YES|true|True|TRUE|on|On|ON)$") | ||||||
|  |         null := regexp.MustCompile("^(~|null|Null|NULL|none|None|NONE|nil|Nil|NIL)$") | ||||||
|  |         var j json.RawMessage | ||||||
|  |         if null.MatchString(s) </span><span class="cov6" title="10">{ | ||||||
|  |                 return nil | ||||||
|  |         }</span> else<span class="cov8" title="24"> if yesno.MatchString(s) </span><span class="cov7" title="12">{ | ||||||
|  |                 if yes.MatchString(s) </span><span class="cov5" title="6">{ | ||||||
|  |                         return true | ||||||
|  |                 }</span> else<span class="cov5" title="6"> { | ||||||
|  |                         return false | ||||||
|  |                 }</span> | ||||||
|  |         } else<span class="cov7" title="12"> if i, err := strconv.Atoi(s); err == nil </span><span class="cov2" title="2">{ | ||||||
|  |                 return i | ||||||
|  |         }</span> else<span class="cov6" title="10"> if f, err := strconv.ParseFloat(s, 64); err == nil </span><span class="cov2" title="2">{ | ||||||
|  |                 return f | ||||||
|  |         }</span> else<span class="cov6" title="8"> if t, err := time.Parse(time.RFC3339, s); err == nil </span><span class="cov2" title="2">{ | ||||||
|  |                 return t | ||||||
|  |         }</span> else<span class="cov5" title="6"> if err := json.Unmarshal([]byte(s), &j); err == nil </span><span class="cov4" title="4">{ | ||||||
|  |                 return j | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov2" title="2">return s</span> | ||||||
|  | } | ||||||
|  | </pre> | ||||||
|  | 		 | ||||||
|  | 		<pre class="file" id="file8" style="display: none">package tools | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  |         "time" | ||||||
|  | 
 | ||||||
|  |         dp "github.com/markusmobius/go-dateparser" | ||||||
|  |         "github.com/markusmobius/go-dateparser/date" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | const ( | ||||||
|  |         day = time.Hour * 24 | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // These are somewhat arbitrary, but reasonably useful min and max times | ||||||
|  | var ( | ||||||
|  |         MinTime = time.Unix(-2208988800, 0) // Jan 1, 1900 | ||||||
|  |         MaxTime = MinTime.Add(1<<63 - 1) | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func ParseDate(in string) (t time.Time, err error) <span class="cov10" title="7">{ | ||||||
|  |         if in == "min" </span><span class="cov1" title="1">{ | ||||||
|  |                 return MinTime, nil | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov9" title="6">if in == "max" </span><span class="cov1" title="1">{ | ||||||
|  |                 return MaxTime, nil | ||||||
|  |         }</span> | ||||||
|  | 
 | ||||||
|  |         <span class="cov8" title="5">d, err := dp.Parse(nil, in) | ||||||
|  |         if err != nil </span><span class="cov1" title="1">{ | ||||||
|  |                 return | ||||||
|  |         }</span> | ||||||
|  |         <span class="cov7" title="4">t = d.Time.Local() | ||||||
|  |         trunc := time.Second | ||||||
|  |         switch d.Period </span>{ | ||||||
|  |         case date.Minute:<span class="cov0" title="0"> | ||||||
|  |                 trunc = time.Minute</span> | ||||||
|  |         case date.Hour:<span class="cov0" title="0"> | ||||||
|  |                 trunc = time.Hour</span> | ||||||
|  |         case date.Day:<span class="cov6" title="3"> | ||||||
|  |                 trunc = day</span> | ||||||
|  |                 // @todo Handle other cases separately | ||||||
|  |         } | ||||||
|  |         <span class="cov7" title="4">t = t.Truncate(trunc) | ||||||
|  |         return</span> | ||||||
|  | } | ||||||
|  | </pre> | ||||||
|  | 		 | ||||||
|  | 		</div> | ||||||
|  | 	</body> | ||||||
|  | 	<script> | ||||||
|  | 	(function() { | ||||||
|  | 		var files = document.getElementById('files'); | ||||||
|  | 		var visible; | ||||||
|  | 		files.addEventListener('change', onChange, false); | ||||||
|  | 		function select(part) { | ||||||
|  | 			if (visible) | ||||||
|  | 				visible.style.display = 'none'; | ||||||
|  | 			visible = document.getElementById(part); | ||||||
|  | 			if (!visible) | ||||||
|  | 				return; | ||||||
|  | 			files.value = part; | ||||||
|  | 			visible.style.display = 'block'; | ||||||
|  | 			location.hash = part; | ||||||
|  | 		} | ||||||
|  | 		function onChange() { | ||||||
|  | 			select(files.value); | ||||||
|  | 			window.scrollTo(0, 0); | ||||||
|  | 		} | ||||||
|  | 		if (location.hash != "") { | ||||||
|  | 			select(location.hash.substr(1)); | ||||||
|  | 		} | ||||||
|  | 		if (!visible) { | ||||||
|  | 			select("file0"); | ||||||
|  | 		} | ||||||
|  | 	})(); | ||||||
|  | 	</script> | ||||||
|  | </html> | ||||||
							
								
								
									
										12
									
								
								go.mod
									
										
									
									
									
								
							
							
						
						
									
										12
									
								
								go.mod
									
										
									
									
									
								
							|  | @ -5,6 +5,7 @@ go 1.21.5 | ||||||
| require ( | require ( | ||||||
| 	github.com/BurntSushi/toml v1.3.2 | 	github.com/BurntSushi/toml v1.3.2 | ||||||
| 	github.com/caarlos0/env/v10 v10.0.0 | 	github.com/caarlos0/env/v10 v10.0.0 | ||||||
|  | 	github.com/markusmobius/go-dateparser v1.2.1 | ||||||
| 	github.com/mitchellh/mapstructure v1.5.0 | 	github.com/mitchellh/mapstructure v1.5.0 | ||||||
| 	github.com/spf13/cobra v1.8.0 | 	github.com/spf13/cobra v1.8.0 | ||||||
| 	github.com/stretchr/testify v1.8.4 | 	github.com/stretchr/testify v1.8.4 | ||||||
|  | @ -12,10 +13,21 @@ require ( | ||||||
| 
 | 
 | ||||||
| require ( | require ( | ||||||
| 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect | 	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect | ||||||
|  | 	github.com/elliotchance/pie/v2 v2.7.0 // indirect | ||||||
|  | 	github.com/hablullah/go-hijri v1.0.2 // indirect | ||||||
|  | 	github.com/hablullah/go-juliandays v1.0.0 // indirect | ||||||
| 	github.com/inconshreveable/mousetrap v1.1.0 // indirect | 	github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||||||
|  | 	github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect | ||||||
| 	github.com/kr/pretty v0.3.1 // indirect | 	github.com/kr/pretty v0.3.1 // indirect | ||||||
|  | 	github.com/magefile/mage v1.14.0 // indirect | ||||||
| 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect | 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect | ||||||
| 	github.com/spf13/pflag v1.0.5 // indirect | 	github.com/spf13/pflag v1.0.5 // indirect | ||||||
|  | 	github.com/tetratelabs/wazero v1.2.1 // indirect | ||||||
|  | 	github.com/wasilibs/go-re2 v1.3.0 // indirect | ||||||
|  | 	golang.org/x/exp v0.0.0-20220321173239-a90fa8a75705 // indirect | ||||||
|  | 	golang.org/x/text v0.10.0 // indirect | ||||||
| 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | ||||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||||
| ) | ) | ||||||
|  | 
 | ||||||
|  | replace github.com/markusmobius/go-dateparser => github.com/goodevilgenius/go-dateparser v1.2.2 | ||||||
|  |  | ||||||
							
								
								
									
										22
									
								
								go.sum
									
										
									
									
									
								
							
							
						
						
									
										22
									
								
								go.sum
									
										
									
									
									
								
							|  | @ -6,12 +6,24 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t | ||||||
| github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= | ||||||
| github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= | ||||||
| github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||||
|  | github.com/elliotchance/pie/v2 v2.7.0 h1:FqoIKg4uj0G/CrLGuMS9ejnFKa92lxE1dEgBD3pShXg= | ||||||
|  | github.com/elliotchance/pie/v2 v2.7.0/go.mod h1:18t0dgGFH006g4eVdDtWfgFZPQEgl10IoEO8YWEq3Og= | ||||||
|  | github.com/goodevilgenius/go-dateparser v1.2.2 h1:Up9KokPx/h07mesQGAZQg3Xi/8yrDVn1638h3k/lRyk= | ||||||
|  | github.com/goodevilgenius/go-dateparser v1.2.2/go.mod h1:5xYsZ1h7iB3sE1BSu8bkjYpbFST7EU1/AFxcyO3mgYg= | ||||||
|  | github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k= | ||||||
|  | github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ= | ||||||
|  | github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0= | ||||||
|  | github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc= | ||||||
| github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | ||||||
| github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | ||||||
|  | github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE= | ||||||
|  | github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE= | ||||||
| github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||||||
| github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | ||||||
| github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||||||
| github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | ||||||
|  | github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= | ||||||
|  | github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= | ||||||
| github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= | ||||||
| github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= | ||||||
| github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= | ||||||
|  | @ -26,6 +38,16 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= | ||||||
| github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||||||
| github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= | ||||||
| github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | ||||||
|  | github.com/tetratelabs/wazero v1.2.1 h1:J4X2hrGzJvt+wqltuvcSjHQ7ujQxA9gb6PeMs4qlUWs= | ||||||
|  | github.com/tetratelabs/wazero v1.2.1/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ= | ||||||
|  | github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw= | ||||||
|  | github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg= | ||||||
|  | github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ= | ||||||
|  | github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo= | ||||||
|  | golang.org/x/exp v0.0.0-20220321173239-a90fa8a75705 h1:ba9YlqfDGTTQ5aZ2fwOoQ1hf32QySyQkR6ODGDzHlnE= | ||||||
|  | golang.org/x/exp v0.0.0-20220321173239-a90fa8a75705/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= | ||||||
|  | golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= | ||||||
|  | golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= | ||||||
| gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||||
| gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= | ||||||
| gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||||||
|  |  | ||||||
							
								
								
									
										45
									
								
								tools/parse_date.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								tools/parse_date.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,45 @@ | ||||||
|  | package tools | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	dp "github.com/markusmobius/go-dateparser" | ||||||
|  | 	"github.com/markusmobius/go-dateparser/date" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | const ( | ||||||
|  | 	day = time.Hour * 24 | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // These are somewhat arbitrary, but reasonably useful min and max times | ||||||
|  | var ( | ||||||
|  | 	MinTime = time.Unix(-2208988800, 0) // Jan 1, 1900 | ||||||
|  | 	MaxTime = MinTime.Add(1<<63 - 1) | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func ParseDate(in string) (t time.Time, err error) { | ||||||
|  | 	if in == "min" { | ||||||
|  | 		return MinTime, nil | ||||||
|  | 	} | ||||||
|  | 	if in == "max" { | ||||||
|  | 		return MaxTime, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	d, err := dp.Parse(nil, in) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	t = d.Time.Local() | ||||||
|  | 	trunc := time.Second | ||||||
|  | 	switch d.Period { | ||||||
|  | 	case date.Minute: | ||||||
|  | 		trunc = time.Minute | ||||||
|  | 	case date.Hour: | ||||||
|  | 		trunc = time.Hour | ||||||
|  | 	case date.Day: | ||||||
|  | 		trunc = day | ||||||
|  | 		// @todo Handle other cases separately | ||||||
|  | 	} | ||||||
|  | 	t = t.Truncate(trunc) | ||||||
|  | 	return | ||||||
|  | } | ||||||
							
								
								
									
										51
									
								
								tools/parse_date_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								tools/parse_date_test.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | ||||||
|  | package tools | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"testing" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/stretchr/testify/assert" | ||||||
|  | 	"github.com/stretchr/testify/require" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func TestParseDate(t *testing.T) { | ||||||
|  | 	now := time.Now().Local() | ||||||
|  | 	sec := now.Truncate(time.Second) | ||||||
|  | 	today := now.Truncate(day) | ||||||
|  | 	tomorrow := today.Add(day) | ||||||
|  | 	yesterday := today.Add(-day) | ||||||
|  | 	twoMin := now.Add(2 * time.Minute).Truncate(time.Minute) | ||||||
|  | 	twoHour := now.Add(2 * time.Hour).Truncate(time.Hour) | ||||||
|  | 	tests := []struct { | ||||||
|  | 		name string | ||||||
|  | 		exp  time.Time | ||||||
|  | 		err  string | ||||||
|  | 	}{ | ||||||
|  | 		{"now", sec, ""}, | ||||||
|  | 		{"today", today, ""}, | ||||||
|  | 		{"tomorrow", tomorrow, ""}, | ||||||
|  | 		{"yesterday", yesterday, ""}, | ||||||
|  | 		{"in two minutes", twoMin, ""}, | ||||||
|  | 		{"in two hours", twoHour, ""}, | ||||||
|  | 		{"min", MinTime, ""}, | ||||||
|  | 		{"max", MaxTime, ""}, | ||||||
|  | 		{"not a date", now, fmt.Sprintf(`failed to parse "%s": unknown format`, "not a date")}, | ||||||
|  | 	} | ||||||
|  | 	for _, tt := range tests { | ||||||
|  | 		t.Run(tt.name, getDateTest(tt.name, tt.exp, tt.err)) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func getDateTest(in string, exp time.Time, err string) func(t *testing.T) { | ||||||
|  | 	return func(t *testing.T) { | ||||||
|  | 		out, er := ParseDate(in) | ||||||
|  | 		if err != "" { | ||||||
|  | 			assert.ErrorContains(t, er, err) | ||||||
|  | 		} else { | ||||||
|  | 			require.NoError(t, er) | ||||||
|  | 
 | ||||||
|  | 			assert.Equal(t, exp, out) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue