├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── app └── kaliber.go ├── cmdline.go ├── config.go ├── config_test.go ├── css ├── dark.css ├── fonts.css ├── light.css └── stylesheet.css ├── datalist.go ├── datalist_test.go ├── db ├── README.md ├── document.go ├── document_test.go ├── go.mod ├── go.sum ├── metadata.go ├── metadata_test.go ├── pool.go ├── pool_test.go ├── queryoptions.go ├── queryoptions_test.go ├── search.go ├── search_test.go ├── sqlite3.go ├── sqlite3_test.go └── syncdb.go ├── doc.go ├── fonts ├── Hack-Bold.ttf ├── Hack-BoldItalic.ttf ├── Hack-Italic.ttf ├── Hack-Regular.ttf ├── Hack.README ├── Noto.README.md ├── NotoSans-Bold.ttf ├── NotoSans-BoldItalic.ttf ├── NotoSans-Italic.ttf ├── NotoSans-Regular.ttf ├── NotoSerif-Bold.ttf ├── NotoSerif-BoldItalic.ttf ├── NotoSerif-Italic.ttf └── NotoSerif-Regular.ttf ├── go.mod ├── go.sum ├── img ├── calibre.gif ├── favicon.ico ├── first.gif ├── last.gif ├── next.gif └── prev.gif ├── kaliber-server.service ├── kaliber.ini ├── pagehandler.go ├── pagehandler_test.go ├── robots.txt ├── sessions └── .placeholder ├── thumbnails.go ├── thumbnails_test.go ├── views.go ├── views ├── 404.gohtml ├── document.gohtml ├── error.gohtml ├── faq.gohtml ├── help.gohtml ├── imprint.gohtml ├── index.gohtml ├── layout │ ├── 01htmlpage.gohtml │ ├── 02header.gohtml │ ├── 03footer.gohtml │ ├── 04naviline.gohtml │ ├── 05gridlayout.gohtml │ ├── 06listlayout.gohtml │ └── 07backline.gohtml ├── licence.gohtml └── privacy.gohtml └── views_test.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | 0replace 3 | access.read.mwat.de 4 | *.bak 5 | certs/* 6 | *.db 7 | dummy.go 8 | error.read.mwat.de 9 | .git/ 10 | img/* 11 | *.ini 12 | kaliber* 13 | *.log 14 | metadata* 15 | *.min.css 16 | sessions/ 17 | *.sql 18 | TODO* 19 | .vscode/ 20 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code Of Conduct 2 | 3 | > 4 | > _[de]_ **Handle nur nach derjenigen Maxime, durch die du zugleich wollen kannst, dass sie ein allgemeines Gesetz werde.**1) 5 | > 6 | > _[en]_ **Act only according to that maxim whereby you can, at the same time, will that it should become a universal law.**2) 7 | > 8 | > _[es]_ **Obra sólo según aquella máxima por la cual puedas querer que al mismo tiempo se convierta en ley universal.**3) 9 | > 10 | > _[fr]_ **Agis uniquement daprès la maxime qui fait que tu puisses vouloir en même temps quelle devienne une loi universelle.**4) 11 | > 12 | 13 | 'nough said … 14 | 15 | ---- 16 | 17 | 1) [Kategorischer Imperativ](https://de.wikipedia.org/wiki/Kategorischer_Imperativ) – Kant, Immanuel (1785): Grundlegung zur Metaphysik der Sitten; Verlag J. F. Hartknoch 18 | 19 | 2) [Categorical imperative](https://en.wikipedia.org/wiki/Kategorischer_Imperativ) – Kant, Immanuel (1993) [1785]: Grounding for the Metaphysics of Morals; Translated by Ellington, James W. (3rd ed.); Hackett. p. 30 20 | 21 | 3) [Imperativo categórico](https://es.wikipedia.org/wiki/Imperativo_categ%C3%B3rico) – Kant, Immanuel (1999): Fundamentación de la metafísica de las costumbres; Traducido por José Mardomingo (edición bilingüe); Barcelona: Ariel 22 | 23 | 4) [Impératif catégorique](https://fr.wikipedia.org/wiki/Imp%C3%A9ratif_cat%C3%A9gorique) – Fondation de la métaphysique des mœurs in Métaphysique des mœurs, I, Fondation, Introduction, trad. Alain Renaut, p. 97 24 | -------------------------------------------------------------------------------- /app/kaliber.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2023 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package main 8 | 9 | //lint:file-ignore ST1017 - I prefer Yoda conditions 10 | 11 | import ( 12 | "context" 13 | "crypto/tls" 14 | "fmt" 15 | "log" 16 | "net/http" 17 | "os" 18 | "os/signal" 19 | "path/filepath" 20 | "runtime" 21 | "syscall" 22 | "time" 23 | 24 | "github.com/NYTimes/gziphandler" 25 | "github.com/mwat56/apachelogger" 26 | "github.com/mwat56/errorhandler" 27 | "github.com/mwat56/kaliber" 28 | "github.com/mwat56/sessions" 29 | ) 30 | 31 | // `exit()` logs `aMessage` and terminates the program. 32 | func exit(aMessage string) { 33 | apachelogger.Err("Kaliber/main", aMessage) 34 | runtime.Gosched() // let the logger write 35 | log.Fatalln(aMessage) 36 | } // exit() 37 | 38 | // `userCmdline()` checks for and executes user/password handling functions. 39 | func userCmdline() { 40 | if 0 == len(kaliber.AppArgs.PassFile) { 41 | return // without user file nothing to do 42 | } 43 | 44 | // All the following `kaliber.UserXxx()` calls terminate the program: 45 | if 0 < len(kaliber.AppArgs.UserAdd) { 46 | kaliber.UserAdd(kaliber.AppArgs.UserAdd, kaliber.AppArgs.PassFile) 47 | } 48 | if 0 < len(kaliber.AppArgs.UserCheck) { 49 | kaliber.UserCheck(kaliber.AppArgs.UserCheck, kaliber.AppArgs.PassFile) 50 | } 51 | if 0 < len(kaliber.AppArgs.UserDelete) { 52 | kaliber.UserDelete(kaliber.AppArgs.UserDelete, kaliber.AppArgs.PassFile) 53 | } 54 | if kaliber.AppArgs.UserList { 55 | kaliber.ListUsers(kaliber.AppArgs.PassFile) 56 | } 57 | if 0 < len(kaliber.AppArgs.UserUpdate) { 58 | kaliber.UserUpdate(kaliber.AppArgs.UserUpdate, kaliber.AppArgs.PassFile) 59 | } 60 | } // userCmdline() 61 | 62 | // `setupSignals()` configures the capture of the interrupts `SIGINT` 63 | // and `SIGTERM` to terminate the program gracefully. 64 | // 65 | // `aServer` The server instance to shutdown if a signal arrives. 66 | func setupSignals(aServer *http.Server) { 67 | // handle `CTRL-C` and `kill(15)`. 68 | c := make(chan os.Signal, 2) 69 | signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) 70 | 71 | go func() { 72 | for signal := range c { 73 | msg := fmt.Sprintf("%s captured '%v', 'stopping program and exiting ...'", os.Args[0], signal) 74 | apachelogger.Err(`Kaliber/catchSignals`, msg) 75 | log.Println(msg) 76 | runtime.Gosched() // let the logger write 77 | if err := aServer.Shutdown(context.Background()); nil != err { 78 | exit(fmt.Sprintf("%s: %v", os.Args[0], err)) 79 | } 80 | } 81 | }() 82 | } // setupSignals() 83 | 84 | func main() { 85 | var ( 86 | err error 87 | ph *kaliber.TPageHandler 88 | ) 89 | Me, _ := filepath.Abs(os.Args[0]) 90 | 91 | // Read INI file(s) and commandline options: 92 | kaliber.InitConfig() 93 | 94 | // Handle commandline user/password maintenance: 95 | userCmdline() 96 | 97 | if ph, err = kaliber.NewPageHandler(); nil != err { 98 | kaliber.ShowHelp() 99 | exit(fmt.Sprintf("%s: %v", Me, err)) 100 | } 101 | // Setup the errorpage handler: 102 | handler := errorhandler.Wrap(ph, ph) 103 | 104 | // Inspect `sessiondir` config option and setup the session handler 105 | if 0 < len(kaliber.AppArgs.SessionDir) { 106 | // an empty string means: no automatic session handling 107 | handler = sessions.Wrap(handler, kaliber.AppArgs.SessionDir) 108 | } 109 | 110 | // Inspect `gzip` config option and setup the Gzip handler: 111 | if kaliber.AppArgs.GZip { 112 | // a FALSE means: no gzip compression 113 | handler = gziphandler.GzipHandler(handler) 114 | } 115 | 116 | // Use logging config options and setup the `ApacheLogger`: 117 | handler = apachelogger.Wrap(handler, 118 | kaliber.AppArgs.AccessLog, kaliber.AppArgs.ErrorLog) 119 | 120 | // We need a `server` reference to use it in `setupSignals()` 121 | // and to set some reasonable timeouts: 122 | server := &http.Server{ 123 | Addr: kaliber.AppArgs.Addr, 124 | Handler: handler, 125 | IdleTimeout: 0, 126 | ReadHeaderTimeout: 20 * time.Second, 127 | ReadTimeout: 20 * time.Second, 128 | // enough time for book download with little bandwidth: 129 | WriteTimeout: 20 * time.Minute, 130 | } 131 | apachelogger.SetErrLog(server) 132 | setupSignals(server) 133 | 134 | if (0 < len(kaliber.AppArgs.CertKey)) && (0 < len(kaliber.AppArgs.CertPem)) { 135 | // see: 136 | // https://ssl-config.mozilla.org/#server=golang&version=1.14.1&config=old&guideline=5.4 137 | server.TLSConfig = &tls.Config{ 138 | MinVersion: tls.VersionTLS10, 139 | PreferServerCipherSuites: true, 140 | CipherSuites: []uint16{ 141 | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, 142 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, 143 | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, 144 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 145 | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, 146 | tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256, 147 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256, 148 | tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, 149 | tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, 150 | tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA, 151 | tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA, 152 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA, 153 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA, 154 | tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA, 155 | tls.TLS_RSA_WITH_AES_256_GCM_SHA384, 156 | tls.TLS_RSA_WITH_AES_128_GCM_SHA256, 157 | tls.TLS_RSA_WITH_AES_128_CBC_SHA256, 158 | tls.TLS_RSA_WITH_AES_256_CBC_SHA, 159 | tls.TLS_RSA_WITH_AES_128_CBC_SHA, 160 | tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, 161 | tls.TLS_RSA_WITH_RC4_128_SHA, 162 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, // #nosec G402 163 | }, 164 | } // #nosec G402 165 | server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) 166 | 167 | if s := fmt.Sprintf("%s listening HTTPS at %s", Me, server.Addr); 0 < len(s) { 168 | log.Println(s) 169 | apachelogger.Log("Kaliber/main", s) 170 | } 171 | exit(fmt.Sprintf("%s: %v", Me, 172 | server.ListenAndServeTLS(kaliber.AppArgs.CertPem, kaliber.AppArgs.CertKey))) 173 | return 174 | } 175 | 176 | if s := fmt.Sprintf("%s listening HTTP at %s", Me, server.Addr); 0 < len(s) { 177 | log.Println(s) 178 | apachelogger.Log("Kaliber/main", s) 179 | } 180 | exit(fmt.Sprintf("%s: %v", Me, server.ListenAndServe())) 181 | } // main() 182 | 183 | /* _EoF_ */ 184 | -------------------------------------------------------------------------------- /cmdline.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2020 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package kaliber 8 | 9 | //lint:file-ignore ST1017 - I prefer Yoda conditions 10 | 11 | /* 12 | * This file provides functions to maintain a user/password file. 13 | */ 14 | 15 | import ( 16 | "github.com/mwat56/passlist" 17 | ) 18 | 19 | // ListUsers reads `aFilename` and lists all users stored in there. 20 | // 21 | // NOTE: This function does not return but terminates the program 22 | // with error code `0` (zero) if successful, or `1` (one) otherwise. 23 | // 24 | // `aFilename` name of the password file to use. 25 | func ListUsers(aFilename string) { 26 | passlist.ListUsers(aFilename) 27 | } // ListUsers() 28 | 29 | // UserAdd reads a password for `aUser` from the commandline 30 | // and adds it to `aFilename`. 31 | // 32 | // NOTE: This function does not return but terminates the program 33 | // with error code `0` (zero) if successful, or `1` (one) otherwise. 34 | // 35 | // `aUser` the username to add to the password file. 36 | // `aFilename` name of the password file to use. 37 | func UserAdd(aUser, aFilename string) { 38 | passlist.AddUser(aUser, aFilename) 39 | } // UserAdd() 40 | 41 | // UserCheck reads a password for `aUser` from the commandline 42 | // and compares it with the one stored in `aFilename`. 43 | // 44 | // NOTE: This function does not return but terminates the program 45 | // with error code `0` (zero) if successful, or `1` (one) otherwise. 46 | // 47 | // `aUser` the username to check in the password file. 48 | // `aFilename` name of the password file to use. 49 | func UserCheck(aUser, aFilename string) { 50 | passlist.CheckUser(aUser, aFilename) 51 | } // UserCheck() 52 | 53 | // UserDelete removes the entry for `aUser` from the password 54 | // list `aFilename`. 55 | // 56 | // NOTE: This function does not return but terminates the program 57 | // with error code `0` (zero) if successful, or `1` (one) otherwise. 58 | // 59 | // `aUser` the username to remove from the password file. 60 | // `aFilename` name of the password file to use. 61 | func UserDelete(aUser, aFilename string) { 62 | passlist.DeleteUser(aUser, aFilename) 63 | } // UserDelete() 64 | 65 | // UserUpdate reads a password for `aUser` from the commandline 66 | // and updates the entry in the password list `aFilename`. 67 | // 68 | // NOTE: This function does not return but terminates the program 69 | // with error code `0` (zero) if successful, or `1` (one) otherwise. 70 | // 71 | // `aUser` the username to remove from the password file. 72 | // `aFilename` name of the password file to use. 73 | func UserUpdate(aUser, aFilename string) { 74 | passlist.UpdateUser(aUser, aFilename) 75 | } // UserUpdate() 76 | 77 | /* _EoF_ */ 78 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020, 2023 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package kaliber 8 | 9 | //lint:file-ignore ST1017 - I prefer Yoda conditions 10 | 11 | import ( 12 | "flag" 13 | "path/filepath" 14 | "reflect" 15 | "testing" 16 | 17 | "github.com/mwat56/ini" 18 | ) 19 | 20 | // `parseFlagDebug()` calls `parseFlags()` and returns `AppArgs`. 21 | // 22 | // This function is meant for unit testing only. 23 | func parseFlagDebug() *TAppArgs { 24 | flag.CommandLine = flag.NewFlagSet(`Kaliber`, flag.ExitOnError) 25 | 26 | // Define some flags used by `testing` to avoid 27 | // bailing out during the test. 28 | var coverprofile, panic, run, testlogfile, timeout string 29 | flag.CommandLine.StringVar(&coverprofile, `test.coverprofile`, coverprofile, 30 | "coverprofile for tests") 31 | flag.CommandLine.StringVar(&panic, `test.paniconexit0`, panic, 32 | "panic for tests") 33 | flag.CommandLine.StringVar(&run, `test.run`, run, 34 | "run for tests") 35 | flag.CommandLine.StringVar(&testlogfile, `test.testlogfile`, testlogfile, 36 | "testlogfile for tests") 37 | flag.CommandLine.StringVar(&timeout, `test.timeout`, timeout, 38 | "timeout for tests") 39 | 40 | parseFlags() 41 | 42 | return &AppArgs 43 | } // parseFlagDebug 44 | 45 | // `readFlagsDebug()` calls `readFlags()` and returns `AppArgs`. 46 | // 47 | // This function is meant for unit testing only. 48 | func readFlagsDebug() *TAppArgs { 49 | flag.CommandLine = flag.NewFlagSet(`Kaliber`, flag.ExitOnError) 50 | AppArgs = TAppArgs{} 51 | // Set up some required values: 52 | AppArgs.DataDir, _ = filepath.Abs(`./`) 53 | // AppArgs.dump = true 54 | AppArgs.LibName = `testing` 55 | AppArgs.libPath = `/var/opt/Calibre` 56 | 57 | readFlags() 58 | 59 | return &AppArgs 60 | } // readFlagsDebug() 61 | 62 | // `setFlagsDebug()` calls `setFlags()` and returns `AppArgs`. 63 | // 64 | // This function is meant for unit testing only. 65 | func setFlagsDebug() *TAppArgs { 66 | flag.CommandLine = flag.NewFlagSet(`Kaliber`, flag.ExitOnError) 67 | 68 | var ini1 ini.TIniList 69 | // Clear/reset the INI values to simulate missing INI file(s): 70 | iniValues = tArguments{*ini1.GetSection(``)} 71 | 72 | setFlags() 73 | 74 | return &AppArgs 75 | } // setFlagsDebug() 76 | 77 | /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ 78 | 79 | func Test_parseFlagDebug(t *testing.T) { 80 | expected := &TAppArgs{} 81 | tests := []struct { 82 | name string 83 | want *TAppArgs 84 | }{ 85 | // TODO: Add test cases. 86 | {" 1", expected}, 87 | } 88 | for _, tt := range tests { 89 | t.Run(tt.name, func(t *testing.T) { 90 | if got := parseFlagDebug(); !reflect.DeepEqual(got, tt.want) { 91 | t.Errorf("parseFlagDebug() = %s,\nwant %s", got, tt.want) 92 | } 93 | }) 94 | } 95 | } // Test_parseFlagDebug() 96 | 97 | func Test_readFlagsDebug(t *testing.T) { 98 | expected := &TAppArgs{ 99 | Addr: `:8383`, 100 | BooksPerPage: 24, 101 | DataDir: `/home/matthias/devel/Go/src/github.com/mwat56/kaliber`, 102 | Lang: `en`, 103 | LibName: `testing`, 104 | libPath: `/var/opt/Calibre`, 105 | port: 8383, 106 | Realm: `eBooks Host`, 107 | SessionDir: `/home/matthias/devel/Go/src/github.com/mwat56/kaliber/sessions`, 108 | sessionTTL: 1200, 109 | sidName: `sid`, 110 | Theme: `dark`, 111 | } 112 | tests := []struct { 113 | name string 114 | want *TAppArgs 115 | }{ 116 | // TODO: Add test cases. 117 | {" 1", expected}, 118 | } 119 | for _, tt := range tests { 120 | t.Run(tt.name, func(t *testing.T) { 121 | if got := readFlagsDebug(); !reflect.DeepEqual(got, tt.want) { 122 | t.Errorf("readFlagsDebug() = %s,\nwant %s", got, tt.want) 123 | } 124 | }) 125 | } 126 | 127 | AppArgs = TAppArgs{} // clear/reset the structure 128 | } // Test_readFlagsDebug() 129 | 130 | func Test_readIniFiles(t *testing.T) { 131 | tests := []struct { 132 | name string 133 | }{ 134 | // TODO: Add test cases. 135 | {" 1"}, 136 | } 137 | for _, tt := range tests { 138 | t.Run(tt.name, func(t *testing.T) { 139 | readIniFiles() 140 | }) 141 | } 142 | } // Test_readIniFiles() 143 | 144 | func Test_setFlagsDebug(t *testing.T) { 145 | expected := &TAppArgs{ 146 | AuthAll: true, 147 | BooksPerPage: 24, 148 | DataDir: `/home/matthias/devel/Go/src/github.com/mwat56/kaliber`, 149 | delWhitespace: true, 150 | GZip: true, 151 | Lang: `en`, 152 | libPath: `/var/opt/Calibre`, 153 | listen: `127.0.0.1`, 154 | port: 8383, 155 | Realm: `eBooks Host`, 156 | sessionTTL: 1200, 157 | sidName: `sid`, 158 | Theme: `dark`, 159 | } 160 | tests := []struct { 161 | name string 162 | want *TAppArgs 163 | }{ 164 | // TODO: Add test cases. 165 | {" 1", expected}, 166 | } 167 | for _, tt := range tests { 168 | t.Run(tt.name, func(t *testing.T) { 169 | if got := setFlagsDebug(); !reflect.DeepEqual(got, tt.want) { 170 | t.Errorf("setFlagsDebug() = %s,\nwant %s", got, tt.want) 171 | } 172 | }) 173 | } 174 | 175 | AppArgs = TAppArgs{} // clear/reset the structure 176 | } // Test_setFlagsDebug() 177 | 178 | func TestShowHelp(t *testing.T) { 179 | _ = setFlagsDebug() 180 | tests := []struct { 181 | name string 182 | }{ 183 | // TODO: Add test cases. 184 | {" 1"}, 185 | } 186 | for _, tt := range tests { 187 | t.Run(tt.name, func(t *testing.T) { 188 | ShowHelp() 189 | }) 190 | } 191 | } // TestShowHelp() 192 | -------------------------------------------------------------------------------- /css/dark.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2023 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | html,body,a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video { 7 | color: #fffff0; 8 | } 9 | body { 10 | background: #161010; 11 | } 12 | a:link, a[href] { 13 | color: #06f; 14 | } 15 | a:hover { 16 | color: #cc0; 17 | } 18 | a:visited { 19 | color: #f30; 20 | } 21 | article.even, footer, header { 22 | background: #201919; 23 | } 24 | article div.cover img.cover { 25 | border-color: #330; 26 | } 27 | article:hover div.cover img.cover, 28 | article div.cover:hover img.cover, 29 | article div.cover img.cover:hover { 30 | border-color: #999; 31 | } 32 | article.overview div.meta p { 33 | border-bottom-color: #333; 34 | } 35 | a.button { 36 | background: #301000 none; 37 | border-color: #ccc; 38 | color: #eeeeee; 39 | } 40 | a.button:hover { 41 | background: #333 none; 42 | border-color: #ccc; 43 | color: #fff; 44 | } 45 | code, .code, kbd, pre, textarea { 46 | background: #333; 47 | color: #eeeeee; 48 | } 49 | del { 50 | color: #999; 51 | } 52 | div.meta table.meta tr { 53 | border-bottom-color: #333; 54 | } 55 | div#search_box { 56 | border-bottom-color: #ffc; 57 | } 58 | dl dd { 59 | background: #201919; 60 | border-left-color: #666; 61 | } 62 | footer, header { 63 | border-color: #ffc; 64 | border-right: 0; 65 | border-left: 0; 66 | } 67 | h1, h2, h3, h4, h5, h6 { 68 | color: #ffeedd; 69 | } 70 | h3, h2, h1 { 71 | text-shadow: -0.1ex -0.1ex 0.1ex #fff; 72 | } 73 | header { 74 | color: #fff; 75 | } 76 | hr { 77 | border-color: #ffc; 78 | color: #ffc; 79 | } 80 | input[type="reset"], 81 | input[type="search"], 82 | input[type="submit"], 83 | input[type="text"], 84 | select, select option { 85 | background: #030; 86 | color: #fff; 87 | } 88 | input[type="reset"]:focus, 89 | input[type="search"]:focus, 90 | input[type="submit"]:focus, 91 | input[type="text"]:focus, 92 | select:focus, 93 | select option:focus, 94 | input[type="reset"]:hover, 95 | input[type="search"]:hover, 96 | input[type="submit"]:hover, 97 | input[type="text"]:hover, 98 | select:hover, 99 | select option:hover { 100 | background: #fff; 101 | color: #030; 102 | } 103 | p#mainlinks { 104 | border-top-color: #ffc; 105 | } 106 | pre { 107 | border-left-color: #666; 108 | } 109 | table.prevnext tr td a { 110 | background: #c0c0c0; /* matching the IMG's background */ 111 | border-color: #c0c0c0; 112 | } 113 | 114 | /* _EoF_ */ 115 | -------------------------------------------------------------------------------- /css/fonts.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2023 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | /* fonts (keep at bottom) */ 7 | 8 | @font-face { 9 | font-family: "Noto Sans"; 10 | font-weight: normal; 11 | font-style: normal; 12 | src: local('Noto Sans Regular'), local('Noto-Sans-Regular'), url(/fonts/NotoSans-Regular.ttf) format('truetype'); 13 | } 14 | @font-face { 15 | font-family: "Noto Sans"; 16 | font-style: normal; 17 | font-weight: bold; 18 | src: local('Noto Sans Bold'), local('Noto-Sans-Bold'), url(/fonts/NotoSans-Bold.ttf) format('truetype'); 19 | } 20 | @font-face { 21 | font-family: "Noto Sans"; 22 | font-weight: bold; 23 | font-style: italic; 24 | src: local('Noto Sans BoldItalic'), local('Noto-Sans-BoldItalic'), url(/fonts/NotoSans-BoldItalic.ttf) format('truetype'); 25 | } 26 | @font-face { 27 | font-family: "Noto Sans"; 28 | font-weight: normal; 29 | font-style: italic; 30 | src: local('Noto Sans Italic'), local('Noto-Sans-Italic'), url('/fonts/NotoSans-Italic.ttf') format('truetype'); 31 | } 32 | 33 | @font-face { 34 | font-family: "Noto Serif"; 35 | font-weight: normal; 36 | font-style: normal; 37 | src: local('Noto Serif Regular'), local('Noto-Serif-Regular'), url('/fonts/NotoSerif-Regular.ttf') format('truetype'); 38 | } 39 | @font-face { 40 | font-family: "Noto Serif Bold"; 41 | font-style: normal; 42 | font-weight: bold; 43 | src: local('Noto Serif Bold'), local('Noto-Serif-Bold'), url('/fonts/NotoSerif-Bold.ttf') format('truetype'); 44 | } 45 | @font-face { 46 | font-family: "Noto Serif"; 47 | font-weight: bold; 48 | font-style: italic; 49 | src: local('Noto Serif BoldItalic'), local('Noto-Serif-BoldItalic'), url('/fonts/NotoSerif-BoldItalic.ttf') format('truetype'); 50 | } 51 | @font-face { 52 | font-family: "Noto Serif"; 53 | font-weight: normal; 54 | font-style: italic; 55 | src: local('Noto Serif Italic'), local('Noto-Serif-Italic'), url('/fonts/NotoSerif-Italic.ttf') format('truetype'); 56 | } 57 | 58 | @font-face { 59 | font-family: "Hack Mono"; 60 | font-weight: normal; 61 | font-style: normal; 62 | src: local('Hack Mono Regular'), local('Hack-Mono-Regular'), url('/fonts/Hack-Regular.ttf') format('truetype'); 63 | } 64 | @font-face { 65 | font-family: "Hack Mono"; 66 | font-style: normal; 67 | font-weight: bold; 68 | src: local('Hack Mono Bold'), local('Hack-Mono-Bold'), url('/fonts/Hack-Bold.ttf') format('truetype'); 69 | } 70 | @font-face { 71 | font-family: "Hack Mono"; 72 | font-weight: bold; 73 | font-style: italic; 74 | src: local('Hack Mono BoldItalic'), local('Hack-Mono-BoldItalic'), url('/fonts/Hack-BoldItalic.ttf') format('truetype'); 75 | } 76 | @font-face { 77 | font-family: "Hack Mono"; 78 | font-weight: normal; 79 | font-style: italic; 80 | src: local('Hack Mono Italic'), local('Hack-Mono-Italic'), url('/fonts/Hack-Italic.ttf') format('truetype'); 81 | } 82 | 83 | html, body, p { 84 | font-family: "Noto Serif", serif; 85 | font-size: 12pt; 86 | line-height: 1.3; 87 | } 88 | b, .bold, strong, .strong, .strong p { 89 | font-family: "Noto Serif Bold", serif; 90 | } 91 | code, .code, input, kbd, pre, textarea { 92 | font-family: "Hack Mono", monospace; 93 | } 94 | div#search_box { 95 | font-family: "Noto Sans", sans-serif; 96 | } 97 | em, .em, i, .italic { 98 | font-family: "Noto Serif Italic", serif; 99 | } 100 | h1, h2, h3, h4, h5, h6 { 101 | font-family: "Noto Serif Bold", serif; 102 | } 103 | h5 { 104 | font-family: "Noto Serif BoldItalic", serif; 105 | } 106 | pre em, pre .em, pre i, pre.italic { 107 | font-family: "Hack Mono Italic", monospace; 108 | } 109 | pre b, pre.bold, pre strong, pre.strong { 110 | font-family: "Hack Mono Bold", monospace; 111 | } 112 | 113 | /* _EoF_ */ 114 | -------------------------------------------------------------------------------- /css/light.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2023 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | html,body,a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video { 7 | color: #000; 8 | } 9 | body { 10 | background: #f9f9f3; 11 | } 12 | a:link, a[href] { 13 | color: #03c; 14 | } 15 | a:hover { 16 | color: #c30; 17 | } 18 | a:visited { 19 | color: #300; 20 | } 21 | article.even, footer, header { 22 | background: #dedee6; 23 | } 24 | article div.cover img.cover { 25 | border-color: #699; 26 | } 27 | article:hover div.cover img.cover, 28 | article div.cover:hover img.cover, 29 | article div.cover img.cover:hover { 30 | border-color: #ccc; 31 | } 32 | article.overview div.meta p { 33 | border-bottom-color: #ccc; 34 | } 35 | a.button { 36 | background: #e0f0ff none; 37 | border-color: #333; 38 | color: #000; 39 | } 40 | a.button:hover { 41 | background: #fefeff none; 42 | border-color: #333; 43 | color: #003; 44 | } 45 | code, .code, kbd, pre, textarea { 46 | background: #ebebeb none; 47 | color: #333; 48 | } 49 | del { 50 | color: #666; 51 | } 52 | div.meta table.meta tr { 53 | border-bottom-color: #ccc; 54 | } 55 | div#search_box { 56 | border-bottom-color: #333; 57 | } 58 | dl dd { 59 | background: #dedee6; 60 | border-left-color: #999; 61 | } 62 | footer, header { 63 | border-color: #333; 64 | border-right: 0; 65 | border-left: 0; 66 | } 67 | h1, h2, h3, h4, h5, h6 { 68 | color: #033; 69 | } 70 | h3, h2, h1 { 71 | text-shadow: -0.1ex -0.1ex 0.1ex #666; 72 | } 73 | header { 74 | color: #000; 75 | } 76 | hr { 77 | border-color: #ccc; 78 | color: #ccc; 79 | } 80 | input[type="reset"], 81 | input[type="search"], 82 | input[type="submit"], 83 | input[type="text"], 84 | select, select option { 85 | background: #fff; 86 | color: #030; 87 | } 88 | input[type="reset"]:focus, 89 | input[type="search"]:focus, 90 | input[type="submit"]:focus, 91 | input[type="text"]:focus, 92 | select:focus, 93 | select option:focus, 94 | input[type="reset"]:hover, 95 | input[type="search"]:hover, 96 | input[type="submit"]:hover, 97 | input[type="text"]:hover, 98 | select:hover, 99 | select option:hover { 100 | background: #030; 101 | color: #fff; 102 | } 103 | p#mainlinks { 104 | border-top-color: #333; 105 | } 106 | pre { 107 | border-left-color: #999; 108 | } 109 | table.prevnext tr td a { 110 | background: #c0c0c0; /* matching the IMG's background */ 111 | border-color: #c0c0c0; 112 | } 113 | 114 | /* _EoF_ */ 115 | -------------------------------------------------------------------------------- /css/stylesheet.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2023 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | /* Reset all page elements to a reasonable default, see: 7 | * http://meyerweb.com/eric/thoughts/2007/04/18/reset-reasoning/ 8 | */ 9 | html,body,a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video { 10 | margin:0; 11 | padding:0; 12 | border:0; 13 | font:inherit; 14 | font-size:100%; 15 | font-weight:normal; 16 | line-height:1.3; 17 | vertical-align:baseline; } 18 | article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section { display:block; } 19 | ol,ul { list-style:none; } 20 | blockquote,q { quotes:none; } 21 | blockquote:before,blockquote:after,q:before,q:after { content:none; } 22 | table { border-collapse:collapse;border-spacing:0.1ex; } 23 | 24 | /* individual site styles */ 25 | html, body { 26 | font-family: serif, sans-serif, monospace; 27 | font-size: 12pt; 28 | padding: 0 0.3ex; 29 | } 30 | a:link, a[href] { 31 | text-decoration: none; 32 | } 33 | a:hover { 34 | text-decoration: underline; 35 | } 36 | blockquote { 37 | display: block; 38 | font-size: 98%; 39 | line-height: 1.23; 40 | margin: 0.5ex 1ex 0.5ex 2ex; 41 | padding: 0.3ex; 42 | text-align: left; 43 | text-indent: 0; 44 | } 45 | b, .bold, strong, .strong, .strong p { 46 | color: inherit; 47 | font-style: inherit; 48 | font-weight: 900; 49 | line-height: inherit; 50 | } 51 | br, div { 52 | border: 0; 53 | display: block; 54 | margin: 0; 55 | padding: 0; 56 | text-indent: 0; 57 | } 58 | code, .code, input, kbd, pre, textarea { 59 | /*color: inherit; /* */ 60 | font-family: monospace; 61 | font-size: 0.987em; 62 | font-weight: inherit; 63 | letter-spacing: normal; 64 | line-height: 1.123; 65 | padding: 0.1ex 0.3ex; 66 | white-space: normal; 67 | } 68 | dl, ol, ul { 69 | font-size: 1em; 70 | margin: 1ex 0 1ex 1ex; 71 | page-break-before: avoid; 72 | } 73 | dl, dd { 74 | background: transparent; 75 | margin-left: 2.6ex; 76 | line-height: 1.4; 77 | text-align: justify; 78 | } 79 | dl dd { 80 | border-left: thick solid transparent; 81 | display: block; 82 | padding: 0 0.6ex; 83 | } 84 | dt { 85 | font-weight: 600; 86 | margin: 1ex 0 0.6ex 0; 87 | padding-top: 0.3ex; 88 | } 89 | em, .em, i, .italic { 90 | color: inherit; 91 | font-style: italic; 92 | font-weight: inherit; 93 | } 94 | footer, header { 95 | border: thin solid transparent; 96 | border-radius: 1ex; 97 | clear: both; 98 | padding: 0.3ex; 99 | } 100 | footer { 101 | margin: 1ex 0 1ex 0; 102 | } 103 | footer p { 104 | text-align: right; 105 | } 106 | h1, h2, h3, h4, h5, h6 { 107 | border: 0; 108 | font-weight: 600; 109 | padding: 0; 110 | page-break-after: avoid; 111 | page-break-inside: avoid; 112 | } 113 | h6 { 114 | font-size: 1em; 115 | margin-bottom: 0.6em; 116 | } 117 | h5 { 118 | font-size: 1.1em; 119 | font-style: italic; 120 | } 121 | h4 { 122 | font-size: 1.2em; 123 | margin-top: 1.1em; 124 | } 125 | h3 { 126 | font-size: 1.4em; 127 | letter-spacing: 0.1ex; 128 | } 129 | h2 { 130 | font-size: 1.6em; 131 | letter-spacing: 0.2ex; 132 | } 133 | h1 { 134 | font-size: 1.9em; 135 | letter-spacing: 0.3ex; 136 | page-break-before: always; 137 | } 138 | h1 + p, h2 + p, h3 + p, h4 + p, h5 + p, h6 + p { 139 | margin-top: 2ex; 140 | text-indent: 0; 141 | } 142 | 143 | header { 144 | margin: 0 0 1em 0; 145 | } 146 | hr { 147 | border-style: inset; 148 | border-width: 1pt 0 0 0; 149 | clear: both; 150 | display: block; 151 | max-height: 1pt; 152 | max-width: 46%; 153 | margin: 1em auto; 154 | padding: 0; 155 | text-align: center; 156 | text-indent: 0; 157 | } 158 | img, svg { 159 | vertical-align: middle; 160 | max-width: 99%; 161 | max-height: 99%; 162 | height: auto; 163 | width: auto; 164 | } 165 | input[type="reset"], 166 | input[type="search"], 167 | input[type="submit"], 168 | input[type="text"], 169 | select, 170 | select option { 171 | border-radius: 0.66ex; 172 | border: thin outset transparent; 173 | font-size: inherit; 174 | hyphens: none; 175 | padding: 0 0.2ex 0.1ex 0.2ex; 176 | vertical-align: baseline; 177 | } 178 | input[type="reset"], 179 | input[type="search"], 180 | input[type="submit"], 181 | input[type="text"], 182 | select { 183 | height: 3ex; 184 | } 185 | input[type="search"], 186 | input[type="text"], 187 | select { 188 | font-size: 89%; 189 | } 190 | input[type="reset"]:focus, 191 | input[type="search"]:focus, 192 | input[type="submit"]:focus, 193 | input[type="text"]:focus, 194 | select:focus, 195 | select option:focus, 196 | input[type="reset"]:hover, 197 | input[type="search"]:hover, 198 | input[type="submit"]:hover, 199 | input[type="text"]:hover, 200 | select:hover, 201 | select option:hover { 202 | border-style: inset; 203 | } 204 | input[type="submit"] { 205 | padding: 0 1ex 0.1ex 1ex; 206 | } 207 | ol, ul { 208 | list-style-type: decimal; 209 | list-style-position: outside; 210 | } 211 | dl dt, dl dd, ol li, ul li { 212 | text-indent: 0.3ex; 213 | } 214 | p { 215 | font-family: serif; 216 | font-size: 1em; 217 | hyphens: auto; 218 | line-height: 1.4; 219 | margin: 0.2ex 0; 220 | padding: 0.2ex 0; 221 | text-align: inherit; 222 | } 223 | blockquote p { 224 | margin: 0.4ex 0; 225 | padding: 0.4ex 0; 226 | } 227 | pre, textarea { 228 | border-radius: 1ex; 229 | display: block; 230 | font-family: monospace; 231 | font-size: 88%; 232 | font-weight: inherit; 233 | letter-spacing: normal; 234 | line-height: 1.36; 235 | margin: 2ex 1ex 2ex 0; 236 | padding: 0.5ex 1ex; 237 | overflow: visible; 238 | text-align: left; 239 | text-indent: 0; 240 | white-space: pre-wrap; 241 | } 242 | pre { 243 | border-left: thick solid transparent; 244 | } 245 | pre + p { 246 | text-indent: 0; 247 | } 248 | small, .small { 249 | color: inherit; 250 | font-size: 79%; 251 | font-weight: inherit; 252 | } 253 | table { 254 | border-collapse: collapse; 255 | border-spacing: 1pt; 256 | display: table; 257 | line-height: 1.23; 258 | text-align: left; 259 | text-indent: 0; 260 | vertical-align: middle; 261 | width: 100% 262 | } 263 | textarea { 264 | padding: 0.5ex; 265 | } 266 | u, .underline { 267 | text-decoration: underline; 268 | } 269 | ul, menu, dir { 270 | list-style-type: disc; 271 | } 272 | ul li ul { 273 | list-style-type: circle; 274 | } 275 | ul li ul li ul { 276 | list-style-type: square; 277 | } 278 | ul li { 279 | display: list-item; 280 | margin-left: 1ex; 281 | } 282 | li p { 283 | margin-top: 0; 284 | } 285 | 286 | body, #body { 287 | margin: 0 auto; 288 | padding: 0; 289 | line-height: 1.3; 290 | width: 541pt; /* absolute value due to DIN A4 width */ 291 | max-width: 599pt; /* dito. */ 292 | } 293 | #bodypage { 294 | min-height: 3em; 295 | width: 100%; 296 | } 297 | #faqpage dl dd, 298 | #helppage dl dd { 299 | border-radius: 1ex; 300 | line-height: 1.5; 301 | margin: 0 2ex 0 4ex; 302 | padding: 0.3ex 1ex; 303 | } 304 | #helppage dl dt { 305 | text-decoration: underline; 306 | } 307 | #imprint, #imprint p { 308 | hyphens: none; 309 | text-indent: 0; 310 | } 311 | img#logo { 312 | max-height: 96px; 313 | max-width: 96px; 314 | } 315 | 316 | div#search_box { 317 | border-bottom: thin solid transparent; 318 | font-size: 84%; 319 | /* we need this to allow for wrapping on smaller screens: */ 320 | line-height: 2.0; 321 | padding: 0 0 3pt 0; 322 | text-align: center; 323 | } 324 | div#search_box div.gi { /* grid item */ 325 | display: inline-block; 326 | margin: 1pt; 327 | padding: 1pt; 328 | } 329 | 330 | div.back p.back { 331 | margin: 0 0 1ex 0; 332 | padding: 0 0 3pt 1ex; 333 | text-align: left; 334 | } 335 | a.button { 336 | border: thin outset transparent; 337 | border-radius: 0.3ex; 338 | cursor: pointer; 339 | font-size: 98%; 340 | hyphens: none; 341 | margin: 0 1pt 0 0; 342 | padding: 0 0.3ex 0.1ex 0.2ex; 343 | text-align: center; 344 | text-decoration: none; 345 | vertical-align: baseline; 346 | white-space: nowrap; 347 | } 348 | a.button:hover { 349 | border-style: inset; 350 | text-decoration: none; 351 | } 352 | article.document { 353 | width: 100%; 354 | } 355 | article.document h2 { 356 | text-align: center; 357 | margin: 1em auto; 358 | } 359 | .centered, .error { 360 | text-align: center; 361 | } 362 | blockquote.comment { 363 | margin: 1ex 0; 364 | max-height: 10em; 365 | overflow: auto; 366 | text-align: justify; 367 | } 368 | article .comment h6 { 369 | font-size: 2ex; 370 | margin: 1ex auto; 371 | } 372 | article .comment h5 { 373 | font-size: 2.1ex; 374 | margin: 1ex auto; 375 | } 376 | article .comment h4 { 377 | font-size: 2.2ex; 378 | margin: 1ex auto; 379 | } 380 | article .comment h3 { 381 | font-size: 2.3ex; 382 | margin: 1ex auto; 383 | } 384 | article .comment h2 { 385 | font-size: 2.4ex; 386 | margin: 1ex auto; 387 | } 388 | article.comment h1 { 389 | font-size: 2.5ex; 390 | margin: 1ex auto; 391 | } 392 | article.document div.meta div.comment { 393 | margin: 1em 1ex 0 0; 394 | } 395 | div.cover { 396 | display: inline-block; 397 | vertical-align: top; 398 | } 399 | article.document div.cover { 400 | float: right; 401 | max-width: 38%; 402 | text-align: right; 403 | } 404 | div.cover img.cover { 405 | border-radius: 0.6ex; 406 | max-height: 99.9%; 407 | max-width: 99.9%; 408 | } 409 | 410 | article.grid { 411 | border: thin solid transparent; 412 | padding: 0 0.3ex; 413 | } 414 | article.grid { 415 | display: inline-block; 416 | margin: 0; 417 | text-align: center; 418 | width: 32%; 419 | } 420 | article.grid div.cover img.cover { 421 | max-height: 46ex; 422 | } 423 | 424 | article div.cover img.cover { 425 | border: thick outset transparent; 426 | border-radius: 1.5ex; 427 | opacity: 0.8; 428 | transition: opacity ease-in-out 0.25s; 429 | } 430 | article:hover div.cover img.cover, 431 | article div.cover:hover img.cover, 432 | article div.cover img.cover:hover { 433 | border-style: inset; 434 | opacity: 1; 435 | transition: opacity ease-in-out 0.25s; 436 | } 437 | 438 | article.overview { 439 | max-height: 40ex; 440 | overflow: auto; 441 | max-width: 541pt; /* absolute value due to DIN A4 width */ 442 | } 443 | article.overview { 444 | border-radius: 1ex; 445 | border: thin solid transparent; 446 | margin: 0 auto 1ex auto; /* align text column centered */ 447 | padding: 0 0 1ex 0; 448 | } 449 | article.overview div.cover { 450 | padding: 1ex 0 0 0; 451 | width: 20%; 452 | } 453 | article.overview div.meta p { 454 | border-bottom: thin dashed transparent; 455 | } 456 | .error { 457 | padding: 1em 0 1em 0; 458 | } 459 | .justified { 460 | text-align: justify; 461 | } 462 | td.label { 463 | width: 20%; 464 | } 465 | .left { 466 | text-align: left; 467 | } 468 | p#mainlinks { 469 | border-top: thin solid transparent; 470 | margin: 0.5ex 0 0 0; 471 | } 472 | div.meta { 473 | display: inline-block; 474 | padding: 0 0.5ex; 475 | vertical-align: top; 476 | } 477 | .document div.meta { 478 | width: 60%; 479 | } 480 | .overview div.meta { 481 | max-height: 22em; 482 | margin: 0 0 0 1ex; 483 | width: 76%; 484 | } 485 | .overview .meta p, 486 | table.meta tr td { 487 | line-height: 1.5; /* make room for the button links */ 488 | padding: 0 0 0.2ex 0; 489 | } 490 | div.meta table.meta tr { 491 | border-bottom: thin dashed transparent; 492 | } 493 | .overview .meta .comment { 494 | border: 0; 495 | margin: 0; 496 | } 497 | .overview .meta .comment, 498 | .overview .meta .comment p { 499 | line-height: 1.4; 500 | } 501 | div.naviline { 502 | min-height: 1em; 503 | } 504 | p.naviline { 505 | font-size: 94%; 506 | letter-spacing: 0.2ex; 507 | text-align: center; 508 | } 509 | 510 | table.prevnext { 511 | margin: 0 auto; 512 | width: 98%; 513 | } 514 | table.prevnext tr td { 515 | text-align: center; 516 | width: 24%; 517 | } 518 | table.prevnext tr td a { 519 | background: transparent; 520 | border: thin outset transparent; 521 | padding: 1pt 4ex; /* we need padding for small screens */ 522 | } 523 | table.prevnext tr td a:hover { 524 | border-style: inset; 525 | } 526 | table.prevnext tr td a img { 527 | margin: 1pt; 528 | } 529 | 530 | .right { 531 | text-align: right; 532 | margin-right: 1ex; 533 | } 534 | .smaller { 535 | color: inherit; 536 | font-size: 89%; 537 | font-weight: inherit; 538 | } 539 | 540 | /* _EoF_ */ 541 | -------------------------------------------------------------------------------- /datalist.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package kaliber 8 | 9 | /* 10 | * This file provides an object whose properties are to be inserted 11 | * into templates. 12 | */ 13 | 14 | type ( 15 | // TemplateData is a list of values to be injected into a template. 16 | TemplateData map[string]interface{} 17 | ) 18 | 19 | // Set inserts `aValue` identified by `aKey` to the list. 20 | // 21 | // If there's already a list entry with `aKey` its current value 22 | // gets replaced by `aValue`. 23 | // 24 | // `aKey` is the values's identifier (as used as placeholder in the template). 25 | // 26 | // `aValue` contains the data entry's value. 27 | func (dl *TemplateData) Set(aKey string, aValue interface{}) *TemplateData { 28 | (*dl)[aKey] = aValue 29 | 30 | return dl 31 | } // Set() 32 | 33 | /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ 34 | 35 | // NewTemplateData returns a new (empty) `TDataList` instance. 36 | func NewTemplateData() *TemplateData { 37 | result := make(TemplateData, 32) 38 | 39 | return &result 40 | } // NewTemplateData() 41 | 42 | /* _EoF_ */ 43 | -------------------------------------------------------------------------------- /datalist_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package kaliber 8 | 9 | import ( 10 | "reflect" 11 | "testing" 12 | ) 13 | 14 | func TestNewDataList(t *testing.T) { 15 | d1 := &TemplateData{} 16 | tests := []struct { 17 | name string 18 | want *TemplateData 19 | }{ 20 | // TODO: Add test cases. 21 | {" 1", d1}, 22 | } 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | if got := NewTemplateData(); !reflect.DeepEqual(got, tt.want) { 26 | t.Errorf("NewDataList() = %v,\nwant %v", got, tt.want) 27 | } 28 | }) 29 | } 30 | } // TestNewDataList() 31 | 32 | func TestTDataList_Set(t *testing.T) { 33 | d1 := NewTemplateData() 34 | w1 := NewTemplateData() 35 | (*w1)["Title"] = "Testing" 36 | type args struct { 37 | aKey string 38 | aValue interface{} 39 | } 40 | tests := []struct { 41 | name string 42 | d *TemplateData 43 | args args 44 | want *TemplateData 45 | }{ 46 | // TODO: Add test cases. 47 | {" 1", d1, args{"Title", "Testing"}, w1}, 48 | } 49 | for _, tt := range tests { 50 | t.Run(tt.name, func(t *testing.T) { 51 | if got := tt.d.Set(tt.args.aKey, tt.args.aValue); !reflect.DeepEqual(got, tt.want) { 52 | t.Errorf("TDataList.Add() = %v, want\n%v", got, tt.want) 53 | } 54 | }) 55 | } 56 | } // TestTDataList_Set() 57 | -------------------------------------------------------------------------------- /db/README.md: -------------------------------------------------------------------------------- 1 | # Kaliber/DB 2 | 3 | [![golang](https://img.shields.io/badge/Language-Go-green.svg)](https://golang.org/) 4 | [![GoDoc](https://godoc.org/github.com/mwat56/kaliber/db?status.svg)](https://godoc.org/github.com/mwat56/kaliber/db) 5 | [![Go Report](https://goreportcard.com/badge/github.com/mwat56/kaliber/db)](https://goreportcard.com/report/github.com/mwat56/kaliber/db) 6 | 7 | - [Kaliber/DB](#kaliberdb) 8 | - [Purpose](#purpose) 9 | - [Usage](#usage) 10 | - [Libraries](#libraries) 11 | - [Licence](#licence) 12 | 13 | ---- 14 | 15 | ## Purpose 16 | 17 | This internal package consolidates the functions needed to access the `Calibre` database. 18 | I wanted all this files together in one place to better design their interactions and perform tests as needed. 19 | 20 | ## Usage 21 | 22 | This internal package is _not_ meant to be used from outside `Kaliber`. 23 | 24 | ## Libraries 25 | 26 | The following external libraries were used building `kaliber/db`: 27 | 28 | * [ApacheLogger](https://github.com/mwat56/apachelogger) 29 | * [SQLite3](https://github.com/mattn/go-sqlite3) 30 | 31 | ## Licence 32 | 33 | Copyright © 2019, 2024 M.Watermann, 10247 Berlin, Germany 34 | All rights reserved 35 | EMail : 36 | 37 | > This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3 of the License, or (at your option) any later version. 38 | > 39 | > This software 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. 40 | > 41 | > You should have received a copy of the GNU General Public License along with this program. If not, see the [GNU General Public License](http://www.gnu.org/licenses/gpl.html) for details. 42 | 43 | ---- 44 | [![GFDL](https://www.gnu.org/graphics/gfdl-logo-tiny.png)](http://www.gnu.org/copyleft/fdl.html) 45 | -------------------------------------------------------------------------------- /db/document.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2020 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package db 8 | 9 | //lint:file-ignore ST1017 - I prefer Yoda conditions 10 | 11 | import ( 12 | "errors" 13 | "fmt" 14 | "html/template" 15 | "net/url" 16 | "path/filepath" 17 | "strings" 18 | "time" 19 | ) 20 | 21 | /* 22 | * This file provides methods to handle a single document. 23 | */ 24 | 25 | type ( 26 | // TID is the database index type (i.e. `int`). 27 | TID = int 28 | 29 | // TEntity is a basic entity structure. 30 | TEntity struct { 31 | ID TID // database row ID 32 | Name string // name of the column/field 33 | URL string // local URL to access this entity 34 | } 35 | 36 | // TEntityList is a list of entities 37 | TEntityList []TEntity 38 | 39 | // A list of authors 40 | tAuthorList = TEntityList 41 | 42 | // A list of formats 43 | tFormatList = TEntityList 44 | 45 | // A list of identifiers 46 | tIdentifierList = TEntityList 47 | 48 | // A list of language codes 49 | tLanguageList = TEntityList 50 | 51 | // TStringMap is a map of strings indexed by string. 52 | TStringMap map[string]string 53 | 54 | // tPathMap is a map of document formats holding the 55 | // respective library file. 56 | tPathMap = TStringMap 57 | 58 | // A single publisher 59 | tPublisher = TEntity 60 | 61 | // A single series 62 | tSeries = TEntity 63 | 64 | // A list of tags 65 | tTagList = TEntityList 66 | 67 | // TDocument represents a single document (e.g. book) 68 | TDocument struct { 69 | ID TID 70 | acquisition time.Time // SQL: timestamp 71 | authors *tAuthorList 72 | authorSort string 73 | comments string 74 | flags int 75 | formats *tFormatList 76 | hasCover bool 77 | identifiers *tIdentifierList 78 | ISBN string 79 | languages *tLanguageList 80 | lccn string 81 | lastModified time.Time // SQL: timestamp 82 | Pages int 83 | path string 84 | pubdate time.Time // SQL: timestamp 85 | publisher *tPublisher 86 | Rating int 87 | Size int64 88 | series *tSeries 89 | seriesindex float32 // SQL: real 90 | tags *tTagList 91 | Title string 92 | titleSort string 93 | uuid string 94 | } 95 | ) 96 | 97 | // AuthorList returns a CSV list of the document's author(s). 98 | func (doc *TDocument) AuthorList() string { 99 | if nil == doc.authors { 100 | return "" 101 | } 102 | 103 | lLen, result := len(*doc.authors)-1, "" 104 | for idx, author := range *doc.authors { 105 | if idx < lLen { 106 | result += author.Name + ", " 107 | } else { 108 | result += author.Name 109 | } 110 | } 111 | 112 | return result 113 | } // AuthorList() 114 | 115 | // Authors returns a list of ID/Name/URL author fields. 116 | func (doc *TDocument) Authors() *TEntityList { 117 | if nil == doc.authors { 118 | return nil 119 | } 120 | result := make(TEntityList, 0, len(*doc.authors)) 121 | for _, author := range *doc.authors { 122 | ent := TEntity{ 123 | ID: author.ID, 124 | Name: author.Name, 125 | URL: fmt.Sprintf("/authors/%d/%s", author.ID, url.PathEscape(author.Name)), 126 | } 127 | result = append(result, ent) 128 | } 129 | 130 | return &result 131 | } // Authors() 132 | 133 | // Comment returns the comments of the document. 134 | func (doc *TDocument) Comment() template.HTML { 135 | return template.HTML(doc.comments) // #nosec G203 136 | } // Comment() 137 | 138 | // Cover returns the URL path/filename for the document's cover image. 139 | func (doc *TDocument) Cover() string { 140 | return fmt.Sprintf("/cover/%d/cover.gif", doc.ID) 141 | } // Cover() 142 | 143 | // CoverAbs returns the path/filename of the document's cover image. 144 | // 145 | // If `aRelative` is `true` the function result is the path/filename 146 | // relative to `CalibreLibraryPath()`, otherwise it's the document 147 | // cover's complete path/filename. 148 | // 149 | // `aRelative` Flag indicating a complete or relative path/filename 150 | // of the document's cover. 151 | func (doc *TDocument) CoverAbs(aRelative bool) (string, error) { 152 | dir := filepath.Join(CalibreLibraryPath(), doc.path) 153 | if 0 <= strings.Index(dir, `[`) { 154 | // make sure to escape the meta-character 155 | dir = strings.Replace(dir, `[`, `\[`, -1) 156 | } 157 | filenames, err := filepath.Glob(dir + "/cover.*") 158 | if nil != err { 159 | return ``, err 160 | } 161 | if 0 == len(filenames) { 162 | return ``, errors.New(`TDocument.CoverAbs(): no matching filenames found in ` + dir) 163 | } 164 | if !aRelative { 165 | return filenames[0], nil 166 | } 167 | if dir, err = filepath.Rel(CalibreLibraryPath(), filenames[0]); nil != err { 168 | return ``, err 169 | } 170 | 171 | return dir, nil 172 | } // CoverAbs() 173 | 174 | // CoverFile returns the complete path/filename of the document's cover file. 175 | func (doc *TDocument) CoverFile() (string, error) { 176 | return doc.CoverAbs(false) 177 | } // CoverFile() 178 | 179 | // DocLink returns a link to this document's page. 180 | func (doc *TDocument) DocLink() string { 181 | return fmt.Sprintf("/doc/%d/doc.html", doc.ID) 182 | } // DocLink() 183 | 184 | // Filename returns the path-/filename of the document's `aFormat`. 185 | func (doc *TDocument) Filename(aFormat string) string { 186 | list := *doc.filenames() 187 | if pName, ok := list[strings.ToUpper(aFormat)]; ok { 188 | if fName, err := filepath.Rel(CalibreLibraryPath(), pName); nil == err { 189 | return fName 190 | } 191 | } 192 | 193 | return "" 194 | } // Filename() 195 | 196 | // `filenames()` returns a list of path-/filenames for this document. 197 | func (doc *TDocument) filenames() *tPathMap { 198 | result := make(tPathMap, len(*doc.formats)) 199 | dir := filepath.Join(CalibreLibraryPath(), doc.path) 200 | for _, format := range *doc.formats { 201 | if "ORIGINAL_EPUB" == format.Name { 202 | continue // we ignore this internal file type 203 | } 204 | ext := strings.ToLower(format.Name) 205 | if filenames, err := filepath.Glob(dir + "/*." + ext); (nil == err) && (0 < len(filenames)) { 206 | result[format.Name] = filenames[0] 207 | } 208 | } 209 | 210 | return &result 211 | } // filenames() 212 | 213 | // Files returns a list of ID/Name/URL fields for doc format files. 214 | func (doc *TDocument) Files() *TEntityList { 215 | if nil == doc.formats { 216 | return nil 217 | } 218 | 219 | result := make(TEntityList, 0, len(*doc.formats)) 220 | for _, format := range *doc.formats { 221 | if "ORIGINAL_EPUB" == format.Name { 222 | continue // we ignore this format 223 | } 224 | 225 | // Build the filename to download: 226 | al := doc.AuthorList() 227 | if 0 < len(al) { 228 | al += `_-_` 229 | } 230 | fName := url.PathEscape( 231 | strings.Replace( 232 | strings.Replace(al+doc.Title, ` `, `_`, -1), `/`, `-`, -1)) + `.` + strings.ToLower(format.Name) 233 | ent := TEntity{ 234 | ID: format.ID, 235 | Name: format.Name, 236 | URL: fmt.Sprintf("/file/%d/%s/%s", doc.ID, format.Name, fName), 237 | } 238 | result = append(result, ent) 239 | } 240 | if 0 < len(result) { 241 | return &result 242 | } 243 | 244 | return nil 245 | } // Files() 246 | 247 | // Formats returns a list of ID/Name/URL fields for doc formats. 248 | func (doc *TDocument) Formats() *TEntityList { 249 | if nil == doc.formats { 250 | return nil 251 | } 252 | 253 | result := make(TEntityList, 0, len(*doc.formats)) 254 | for _, format := range *doc.formats { 255 | if "ORIGINAL_EPUB" == format.Name { 256 | continue // we ignore this format 257 | } 258 | ent := TEntity{ 259 | ID: format.ID, 260 | Name: format.Name, 261 | URL: fmt.Sprintf("/format/%d/%s", format.ID, format.Name), 262 | } 263 | result = append(result, ent) 264 | } 265 | if 0 < len(result) { 266 | return &result 267 | } 268 | 269 | return nil 270 | } // Formats() 271 | 272 | // Identifiers returns a list of ID/Name/URL identifier fields. 273 | func (doc *TDocument) Identifiers() *TEntityList { 274 | if nil == doc.identifiers { 275 | return nil 276 | } 277 | 278 | result := make(TEntityList, 0, len(*doc.identifiers)) 279 | for _, ident := range *doc.identifiers { 280 | ent := TEntity{ 281 | ID: ident.ID, 282 | Name: ident.Name, 283 | } 284 | switch ident.Name { 285 | case "amazon", "mobi-asin": 286 | ent.URL = fmt.Sprintf("https://www.amazon.com/dp/%s", ident.URL) 287 | case "amazon_uk": 288 | ent.URL = fmt.Sprintf("https://www.amazon.co.uk/dp/%s", ident.URL) 289 | case "amazon_de": 290 | ent.URL = fmt.Sprintf("https://www.amazon.de/dp/%s", ident.URL) 291 | case "barnesnoble": 292 | ent.URL = fmt.Sprintf("https://www.barnesandnoble.com/%s", ident.URL) 293 | case "edelweiss": 294 | ent.URL = fmt.Sprintf("https://www.edelweiss.plus/#sku=%s&page=1", ident.URL) 295 | case "google": 296 | ent.URL = fmt.Sprintf("https://books.google.com/books?id=%s", ident.URL) 297 | case "isbn": 298 | ent.URL = fmt.Sprintf("https://www.worldcat.org/isbn/%s", ident.URL) 299 | case "issn": 300 | ent.URL = fmt.Sprintf("https://www.worldcat.org/issn/%s", ident.URL) 301 | case "uri", "url": 302 | ent.URL = strings.ReplaceAll(ident.URL, "|", ":") 303 | 304 | default: 305 | continue 306 | } 307 | result = append(result, ent) 308 | } 309 | if 0 < len(result) { 310 | return &result 311 | } 312 | 313 | return nil 314 | } // Identifiers() 315 | 316 | // Languages returns an ID/Name/URL language struct. 317 | func (doc *TDocument) Languages() *TEntityList { 318 | if nil == doc.languages { 319 | return nil 320 | } 321 | 322 | result := make(TEntityList, 0, len(*doc.languages)) 323 | for _, language := range *doc.languages { 324 | ent := TEntity{ 325 | ID: language.ID, 326 | Name: language.Name, 327 | URL: fmt.Sprintf("/languages/%d/%s", language.ID, language.Name), 328 | } 329 | result = append(result, ent) 330 | } 331 | 332 | return &result 333 | } // Languages() 334 | 335 | // LastModified returns the last-modified date/time of the document. 336 | func (doc *TDocument) LastModified() string { 337 | return doc.lastModified.Format(time.RFC1123) 338 | } // LastModified() 339 | 340 | // PubDate returns the document's formatted publication date. 341 | func (doc *TDocument) PubDate() string { 342 | y, m, _ := doc.pubdate.Date() 343 | if 101 == y { 344 | return "" 345 | } 346 | 347 | return fmt.Sprintf("%d-%02d", y, m) 348 | } // PubDate() 349 | 350 | // Publisher returns an ID/Name/URL publisher struct. 351 | func (doc *TDocument) Publisher() *TEntity { 352 | if nil == doc.publisher { 353 | return nil 354 | } 355 | 356 | result := TEntity{ 357 | ID: doc.publisher.ID, 358 | Name: doc.publisher.Name, 359 | URL: fmt.Sprintf("/publisher/%d/%s", doc.publisher.ID, url.PathEscape(doc.publisher.Name)), 360 | } 361 | 362 | return &result 363 | } // Publisher () 364 | 365 | // Series returns an ID/Name/URL series struct. 366 | func (doc *TDocument) Series() *TEntity { 367 | if nil == doc.series { 368 | return nil 369 | } 370 | 371 | result := TEntity{ 372 | ID: doc.series.ID, 373 | Name: doc.series.Name, 374 | URL: fmt.Sprintf("/series/%d/%s", doc.series.ID, url.PathEscape(doc.series.Name)), 375 | } 376 | 377 | return &result 378 | } // Series() 379 | 380 | // SeriesIndex returns the document's series index as formatted string. 381 | func (doc *TDocument) SeriesIndex() string { 382 | result := fmt.Sprintf("%.2f", doc.seriesindex) 383 | parts := strings.Split(result, `.`) 384 | if "00" == parts[1] { 385 | return parts[0] 386 | } 387 | 388 | return result 389 | } // SeriesIndex() 390 | 391 | // SetPath sets the document's file/path. 392 | func (doc *TDocument) SetPath(aPath string) { 393 | if p := strings.TrimSpace(aPath); 0 < len(p) { 394 | doc.path = p 395 | } 396 | } // SetPath() 397 | 398 | // Tags returns a list of ID/Name/URL tag fields. 399 | func (doc *TDocument) Tags() *TEntityList { 400 | if nil == doc.tags { 401 | return nil 402 | } 403 | 404 | result := make(TEntityList, 0, len(*doc.tags)) 405 | for _, tag := range *doc.tags { 406 | ent := TEntity{ 407 | ID: tag.ID, 408 | Name: tag.Name, 409 | URL: fmt.Sprintf("/tags/%d/%s", tag.ID, url.PathEscape(tag.Name)), 410 | } 411 | result = append(result, ent) 412 | } 413 | if 0 < len(result) { 414 | return &result 415 | } 416 | 417 | return nil 418 | } // Tags() 419 | 420 | // Thumb returns the path-filename of the document's thumbnail image. 421 | func (doc *TDocument) Thumb() string { 422 | return fmt.Sprintf("/thumb/%d/cover.jpg", doc.ID) 423 | } // Thumb() 424 | 425 | // Timestamp returns the formatted `acquisition` property. 426 | func (doc *TDocument) Timestamp() string { 427 | return doc.acquisition.Format("2006-01-02 15:04:05") 428 | } // Timestamp() 429 | 430 | /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ 431 | 432 | // NewDocument returns a new `TDocument` instance. 433 | func NewDocument() *TDocument { 434 | result := &TDocument{} 435 | 436 | return result 437 | } // NewDocument() 438 | 439 | /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ 440 | 441 | type ( 442 | // TDocList is a list of `TDocument` instances. 443 | TDocList []TDocument 444 | ) 445 | 446 | // Add appends a document to the list of documents. 447 | // 448 | // `aDocument` The document to add to the list. 449 | func (dl *TDocList) Add(aDocument *TDocument) *TDocList { 450 | *dl = append(*dl, *aDocument) 451 | 452 | return dl 453 | } // Add() 454 | 455 | /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ 456 | 457 | // NewDocList returns a new `TDocList` instance. 458 | func NewDocList() *TDocList { 459 | result := make(TDocList, 0, 63) 460 | 461 | return &result 462 | } // NewDocList() 463 | 464 | /* _EoF_ */ 465 | -------------------------------------------------------------------------------- /db/document_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2020 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package db 8 | 9 | //lint:file-ignore ST1017 - I prefer Yoda conditions 10 | 11 | import ( 12 | "path/filepath" 13 | "reflect" 14 | "testing" 15 | ) 16 | 17 | func TestTDocument_Cover(t *testing.T) { 18 | SetCalibreLibraryPath("/var/opt/Calibre/") 19 | d1 := TDocument{ 20 | ID: 7628, 21 | path: "/Spiegel/Der Spiegel (2019-06-01) 23_2019 (7628)", 22 | } 23 | w1 := "/cover/7628/cover.gif" 24 | d2 := TDocument{ 25 | ID: 6730, 26 | path: "/John Scalzi/Zoe's Tale (6730)", 27 | } 28 | w2 := "/cover/6730/cover.gif" 29 | d3 := TDocument{ 30 | ID: 4793, 31 | path: "/Gail Carriger/Soulless [1] (4793)", 32 | } 33 | w3 := "/cover/4793/cover.gif" 34 | tests := []struct { 35 | name string 36 | fields TDocument 37 | want string 38 | }{ 39 | // TODO: Add test cases. 40 | {" 1", d1, w1}, 41 | {" 2", d2, w2}, 42 | {" 2", d3, w3}, 43 | } 44 | for _, tt := range tests { 45 | t.Run(tt.name, func(t *testing.T) { 46 | doc := &tt.fields 47 | if got := doc.Cover(); got != tt.want { 48 | t.Errorf("TDocument.Cover() = '%s',\nwant '%s'", got, tt.want) 49 | } 50 | }) 51 | } 52 | } // TestTDocument_Cover() 53 | 54 | func TestTDocument_coverAbs(t *testing.T) { 55 | SetCalibreLibraryPath("/var/opt/Calibre/") 56 | d1 := TDocument{ 57 | ID: 7628, 58 | path: "Spiegel/Der Spiegel (2019-06-01) 23_2019 (7628)", 59 | } 60 | w1 := d1.path + "/cover.jpg" 61 | 62 | d2 := TDocument{ 63 | ID: 6730, 64 | path: "John Scalzi/Zoe's Tale (6730)", 65 | } 66 | w2 := d2.path + "/cover.jpg" 67 | w3 := filepath.Join(dbCalibreLibraryPath, w1) 68 | w4 := filepath.Join(dbCalibreLibraryPath, w2) 69 | d5 := TDocument{ 70 | ID: 4793, 71 | path: "Gail Carriger/Soulless [1] (4793)", 72 | } 73 | w5 := d5.path + "/cover.jpg" 74 | type args struct { 75 | aRelative bool 76 | } 77 | w6 := filepath.Join(dbCalibreLibraryPath, w5) 78 | tests := []struct { 79 | name string 80 | fields TDocument 81 | args args 82 | want string 83 | wantErr bool 84 | }{ 85 | // TODO: Add test cases. 86 | {" 1", d1, args{true}, w1, false}, 87 | {" 2", d2, args{true}, w2, false}, 88 | {" 3", d1, args{false}, w3, false}, 89 | {" 4", d2, args{false}, w4, false}, 90 | {" 5", d5, args{true}, w5, false}, 91 | {" 6", d5, args{false}, w6, false}, 92 | } 93 | for _, tt := range tests { 94 | t.Run(tt.name, func(t *testing.T) { 95 | doc := &tt.fields 96 | got, err := doc.CoverAbs(tt.args.aRelative) 97 | if (err != nil) != tt.wantErr { 98 | t.Errorf("TDocument.coverAbs() error = %v, wantErr %v", err, tt.wantErr) 99 | return 100 | } 101 | if got != tt.want { 102 | t.Errorf("TDocument.coverAbs() = '%s',\nwant '%s'", got, tt.want) 103 | } 104 | }) 105 | } 106 | } // TestTDocument_coverAbs() 107 | 108 | func TestTDocument_Filename(t *testing.T) { 109 | SetCalibreLibraryPath("/var/opt/Calibre/") 110 | d1 := TDocument{ 111 | formats: &tFormatList{ 112 | TEntity{ 113 | Name: "AZW3", 114 | }, 115 | TEntity{ 116 | Name: "EPUB", 117 | }, 118 | TEntity{ 119 | Name: "PDF", 120 | }, 121 | }, 122 | path: "John Scalzi/Zoe's Tale (6730)", 123 | } 124 | w1 := filepath.Join(d1.path, "Zoe's Tale - John Scalzi.azw3") 125 | type args struct { 126 | aFormat string 127 | } 128 | tests := []struct { 129 | name string 130 | fields TDocument 131 | args args 132 | want string 133 | }{ 134 | // TODO: Add test cases. 135 | {" 1", d1, args{"azw3"}, w1}, 136 | } 137 | for _, tt := range tests { 138 | t.Run(tt.name, func(t *testing.T) { 139 | doc := &tt.fields 140 | if got := doc.Filename(tt.args.aFormat); got != tt.want { 141 | t.Errorf("TDocument.Filename() = '%s',\nwant '%s'", got, tt.want) 142 | } 143 | }) 144 | } 145 | } // TestTDocument_Filename 146 | 147 | func TestTDocument_filenames(t *testing.T) { 148 | SetCalibreLibraryPath("/var/opt/Calibre/") 149 | d1 := TDocument{ 150 | formats: &tFormatList{ 151 | TEntity{ 152 | Name: "PDF", 153 | }, 154 | }, 155 | path: "Spiegel/Der Spiegel (2019-06-01) 23_2019 (7628)", 156 | } 157 | w1 := &tPathMap{ 158 | "PDF": "/var/opt/Calibre/Spiegel/Der Spiegel (2019-06-01) 23_2019 (7628)/Der Spiegel (2019-06-01) 23_2019 - Spiegel.pdf", 159 | } 160 | d2 := TDocument{ 161 | formats: &tFormatList{ 162 | TEntity{ 163 | Name: "AZW3", 164 | }, 165 | TEntity{ 166 | Name: "EPUB", 167 | }, 168 | TEntity{ 169 | Name: "PDF", 170 | }, 171 | }, 172 | path: "John Scalzi/Zoe's Tale (6730)", 173 | } 174 | w2 := &tPathMap{ 175 | "AZW3": "/var/opt/Calibre/John Scalzi/Zoe's Tale (6730)/Zoe's Tale - John Scalzi.azw3", 176 | "EPUB": "/var/opt/Calibre/John Scalzi/Zoe's Tale (6730)/Zoe's Tale - John Scalzi.epub", 177 | "PDF": "/var/opt/Calibre/John Scalzi/Zoe's Tale (6730)/Zoe's Tale - John Scalzi.pdf", 178 | } 179 | tests := []struct { 180 | name string 181 | fields TDocument 182 | want *tPathMap 183 | }{ 184 | // TODO: Add test cases. 185 | {" 1", d1, w1}, 186 | {" 2", d2, w2}, 187 | } 188 | for _, tt := range tests { 189 | t.Run(tt.name, func(t *testing.T) { 190 | doc := &tt.fields 191 | if got := doc.filenames(); !reflect.DeepEqual(got, tt.want) { 192 | t.Errorf("TDocument.Filenames() = %v,\nwant %v", got, tt.want) 193 | } 194 | }) 195 | } 196 | } // TestTDocument_filenames() 197 | 198 | func TestTDocument_Files(t *testing.T) { 199 | SetCalibreLibraryPath("/var/opt/Calibre/") 200 | d1 := TDocument{ 201 | ID: 1, 202 | formats: &tFormatList{ 203 | TEntity{ 204 | ID: 2, 205 | Name: "PDF", 206 | }, 207 | }, 208 | Title: "this is the document's title", 209 | } 210 | w1 := &TEntityList{ 211 | TEntity{ 212 | ID: 2, 213 | Name: "PDF", 214 | URL: "/file/1/PDF/this_is_the_document%27s_title.pdf", 215 | }, 216 | } 217 | d2 := TDocument{ 218 | authors: &tAuthorList{ 219 | TEntity{ 220 | ID: 3, 221 | Name: "Spiegel", 222 | }, 223 | }, 224 | ID: 7628, 225 | formats: &tFormatList{ 226 | TEntity{ 227 | ID: 2, 228 | Name: "PDF", 229 | }, 230 | }, 231 | Title: "Der Spiegel (2019-06-01) 23/2019", 232 | } 233 | w2 := &TEntityList{ 234 | TEntity{ 235 | ID: 2, 236 | Name: `PDF`, 237 | URL: `/file/7628/PDF/Spiegel_-_Der_Spiegel_%282019-06-01%29_23-2019.pdf`, 238 | }, 239 | } 240 | tests := []struct { 241 | name string 242 | fields TDocument 243 | want *TEntityList 244 | }{ 245 | // TODO: Add test cases. 246 | {" 1", d1, w1}, 247 | {" 2", d2, w2}, 248 | } 249 | for _, tt := range tests { 250 | t.Run(tt.name, func(t *testing.T) { 251 | doc := &tt.fields 252 | if got := doc.Files(); !reflect.DeepEqual(got, tt.want) { 253 | t.Errorf("TDocument.Files() = %v,\nwant %v", got, tt.want) 254 | } 255 | }) 256 | } 257 | } // TestTDocument_Files() 258 | -------------------------------------------------------------------------------- /db/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mwat56/kaliber/db 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/mattn/go-sqlite3 v1.14.19 7 | github.com/mwat56/apachelogger v1.6.3 8 | ) 9 | -------------------------------------------------------------------------------- /db/go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= 2 | github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 3 | github.com/mwat56/apachelogger v1.6.3 h1:OduPW/xJe2z3x1PFBZ6uPMBR20nMR6NXP8kfutCnn5A= 4 | github.com/mwat56/apachelogger v1.6.3/go.mod h1:ANhhXo3mFyU0KxT41RHlrlo1qWd+EI9pT/nnXCqPJK8= 5 | -------------------------------------------------------------------------------- /db/metadata.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2020 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package db 8 | 9 | //lint:file-ignore ST1017 - I prefer Yoda conditions 10 | 11 | /* 12 | * This file provides functions to access Calibre`s metadata store. 13 | */ 14 | 15 | import ( 16 | "encoding/json" 17 | "errors" 18 | "fmt" 19 | "os" 20 | "regexp" 21 | "sort" 22 | "strings" 23 | "sync" 24 | 25 | "github.com/mwat56/apachelogger" 26 | ) 27 | 28 | const ( 29 | // Name of the JSON section holding the names of book fields. 30 | mdBookDisplayFields = `book_display_fields` 31 | 32 | // Name of the JSON section holding e.g. user-defined field definitions. 33 | mdFieldMetadata = `field_metadata` 34 | 35 | // Name of the JSON section holding the virtual library names to hide.. 36 | mdHiddenVirtualLibraries = `virt_libs_hidden` 37 | 38 | // Name of the JSON section holding the virtual library definitions. 39 | mdVirtualLibraries = `virtual_libraries` 40 | ) 41 | 42 | type ( 43 | // tBookDisplayFieldsList is the `book_display_fields` metadata list. 44 | tBookDisplayFieldsList map[string]bool 45 | 46 | tInterfaceList map[string]interface{} 47 | 48 | // TVirtLibList is the `virtual_libraries` JSON metadata section 49 | // indexed by virt.library name. 50 | TVirtLibList map[string]string 51 | ) 52 | 53 | var ( 54 | // cache of "book_display_fields" list 55 | mdBookDisplayFieldsList tBookDisplayFieldsList 56 | mdBookDisplayFieldsListMtx = new(sync.RWMutex) 57 | 58 | // cache of "field_metadata" list 59 | mdFieldsMetadataList *tInterfaceList // map[string]interface{} 60 | mdFieldsMetadataListMtx = new(sync.RWMutex) 61 | 62 | // list of virtual libraries to hide 63 | mdHiddenVirtLibs *tInterfaceList // map[string]interface{} 64 | mdHiddenVirtLibsMtx = new(sync.RWMutex) 65 | 66 | // cache of all DB metadata preferences 67 | mdMetadataDbPrefs *tInterfaceList // map[string]interface{} 68 | mdMetadataDbPrefsMtx = new(sync.RWMutex) 69 | 70 | // virtual libraries list 71 | mdVirtLibList TVirtLibList 72 | mdVirtLibListMtx = new(sync.RWMutex) 73 | 74 | // raw virtual libraries list 75 | mdVirtLibsRaw *tInterfaceList // map[string]interface{} 76 | mdVirtLibsRawMtx = new(sync.RWMutex) 77 | ) 78 | 79 | // `mdGetFieldData()` returns a list of field definitions for `aField`. 80 | func mdGetFieldData(aField string) (rList tInterfaceList /* map[string]interface{} */, rErr error) { 81 | if 0 == len(aField) { 82 | return 83 | } 84 | if rErr = mdReadFieldMetadata(); nil != rErr { 85 | msg := fmt.Sprintf("mdReadFieldMetadata(): %v", rErr) 86 | rErr = errors.New(msg) 87 | return 88 | } 89 | mdFieldsMetadataListMtx.RLock() 90 | defer mdFieldsMetadataListMtx.RUnlock() 91 | 92 | fmd := *mdFieldsMetadataList 93 | fd, ok := fmd[aField] 94 | if !ok { 95 | return nil, errors.New("no such JSON section: " + aField) 96 | } 97 | lst := fd.(map[string]interface{}) 98 | rList = tInterfaceList(lst) 99 | 100 | return 101 | } // mdGetFieldData() 102 | 103 | // `mdReadBookDisplayFields()` 104 | func mdReadBookDisplayFields() error { 105 | if err := mdReadMetadataFile(); nil != err { 106 | msg := fmt.Sprintf("mdReadMetadataFile(): %v", err) 107 | return errors.New(msg) 108 | } 109 | 110 | section, ok := mdGetMetadataDbPref(mdBookDisplayFields) 111 | if !ok { 112 | return errors.New("no such JSON section: " + mdBookDisplayFields) 113 | } 114 | 115 | mdBookDisplayFieldsListMtx.Lock() 116 | defer mdBookDisplayFieldsListMtx.Unlock() 117 | 118 | if nil != mdBookDisplayFieldsList { 119 | return nil // field metadata already read 120 | } 121 | 122 | data := section.([]interface{}) 123 | mdBookDisplayFieldsList = make(tBookDisplayFieldsList, len(data)) 124 | for _, raw := range data { 125 | entry := raw.([]interface{}) 126 | field := entry[0].(string) 127 | display := entry[1].(bool) 128 | mdBookDisplayFieldsList[field] = display 129 | } 130 | 131 | return nil 132 | } // mdReadBookDisplayFields() 133 | 134 | // `mdReadFieldMetadata()` 135 | func mdReadFieldMetadata() error { 136 | if err := mdReadMetadataFile(); nil != err { 137 | msg := fmt.Sprintf("mdReadMetadataFile(): %v", err) 138 | return errors.New(msg) 139 | } 140 | 141 | section, ok := mdGetMetadataDbPref(mdFieldMetadata) 142 | if !ok { 143 | return errors.New("no such JSON section: " + mdFieldMetadata) 144 | } 145 | 146 | mdFieldsMetadataListMtx.Lock() 147 | defer mdFieldsMetadataListMtx.Unlock() 148 | 149 | if nil != mdFieldsMetadataList { 150 | return nil // field metadata already read 151 | } 152 | 153 | fmd := section.(map[string]interface{}) 154 | msi := tInterfaceList(fmd) 155 | mdFieldsMetadataList = &msi 156 | 157 | return nil 158 | } // mdReadFieldMetadata() 159 | 160 | // `mdReadHiddenVirtualLibraries()` reads the list ob hidden libraries to hide. 161 | func mdReadHiddenVirtualLibraries() error { 162 | if err := mdReadMetadataFile(); nil != err { 163 | msg := fmt.Sprintf("mdReadMetadataFile(): %v", err) 164 | apachelogger.Err("mdReadHiddenVirtualLibraries", msg) 165 | return errors.New(msg) 166 | } 167 | 168 | section, ok := mdGetMetadataDbPref(mdHiddenVirtualLibraries) 169 | if !ok { 170 | msg := "no such JSON section: " + mdHiddenVirtualLibraries 171 | apachelogger.Err("mdReadHiddenVirtualLibraries", msg) 172 | return errors.New(msg) 173 | } 174 | 175 | mdHiddenVirtLibsMtx.Lock() 176 | defer mdHiddenVirtLibsMtx.Unlock() 177 | 178 | if nil != mdHiddenVirtLibs { 179 | return nil 180 | } 181 | 182 | hvl := section.([]interface{}) 183 | if 0 == len(hvl) { 184 | return nil 185 | } 186 | result := make(tInterfaceList /* map[string]interface{} */, len(hvl)) 187 | for _, val := range hvl { 188 | lib := val.(string) 189 | result[lib] = struct{}{} 190 | } 191 | mdHiddenVirtLibs = &result 192 | 193 | return nil 194 | } // mdReadHiddenVirtualLibraries() 195 | 196 | // `mdGetMetadataDbPref()` returns the preferences section indexed by `aKey`. 197 | func mdGetMetadataDbPref(aKey string) (rSection interface{}, rOK bool) { 198 | mdMetadataDbPrefsMtx.RLock() 199 | defer mdMetadataDbPrefsMtx.RUnlock() 200 | 201 | rSection, rOK = (*mdMetadataDbPrefs)[aKey] 202 | 203 | return 204 | } // mdGetMetadataDbPref() 205 | 206 | // `mdReadMetadataFile()` returns a map of the JSON data read. 207 | func mdReadMetadataFile() error { 208 | mdMetadataDbPrefsMtx.Lock() 209 | defer mdMetadataDbPrefsMtx.Unlock() 210 | 211 | if nil != mdMetadataDbPrefs { 212 | return nil // metadata already read 213 | } 214 | 215 | fName := CalibrePreferencesFile() 216 | srcFile, err := os.OpenFile(fName, os.O_RDONLY, 0) 217 | if nil != err { 218 | msg := fmt.Sprintf("os.OpenFile(%s): %v", fName, err) 219 | return errors.New(msg) 220 | } 221 | defer srcFile.Close() 222 | 223 | var jsData tInterfaceList // map[string]interface{} 224 | dec := json.NewDecoder(srcFile) 225 | if err := dec.Decode(&jsData); err != nil { 226 | msg := fmt.Sprintf("json.NewDecoder.Decode(): %v", err) 227 | return errors.New(msg) 228 | } 229 | 230 | // remove unneeded list entries: 231 | delete(jsData, `column_icon_rules`) 232 | delete(jsData, `cover_grid_icon_rules`) 233 | delete(jsData, `gui_view_history`) 234 | delete(jsData, `namespaced:CountPagesPlugin:settings`) 235 | delete(jsData, `namespaced:FindDuplicatesPlugin:settings`) 236 | delete(jsData, `news_to_be_synced`) 237 | delete(jsData, `saved_searches`) 238 | delete(jsData, `update_all_last_mod_dates_on_start`) 239 | delete(jsData, `user_categories`) 240 | mdMetadataDbPrefs = &jsData 241 | 242 | return nil 243 | } // mdReadMetadataFile() 244 | 245 | // `mdReadVirtualLibraries()` reads the raw virt.library definitions. 246 | func mdReadVirtualLibraries() error { 247 | if err := mdReadMetadataFile(); nil != err { 248 | msg := fmt.Sprintf("mdReadMetadataFile(): %v", err) 249 | apachelogger.Err("mdReadVirtualLibraries()", msg) 250 | return errors.New(msg) 251 | } 252 | 253 | section, ok := mdGetMetadataDbPref(mdVirtualLibraries) 254 | if !ok { 255 | msg := "no such JSON section: " + mdVirtualLibraries 256 | apachelogger.Err("mdReadVirtualLibraries()", msg) 257 | return errors.New(msg) 258 | } 259 | 260 | mdVirtLibsRawMtx.Lock() 261 | defer mdVirtLibsRawMtx.Unlock() 262 | 263 | if nil != mdVirtLibsRaw { 264 | return nil 265 | } 266 | 267 | vlr := section.(map[string]interface{}) 268 | msi := tInterfaceList(vlr) 269 | mdVirtLibsRaw = &msi 270 | 271 | return nil 272 | } // mdReadVirtualLibraries() 273 | 274 | // `mdVirtLibDefinitions()` returns a map of virtual library definitions. 275 | func mdVirtLibDefinitions() (*TVirtLibList, error) { 276 | if err := mdReadVirtualLibraries(); nil != err { 277 | msg := fmt.Sprintf("mdReadVirtualLibraries(): %v", err) 278 | apachelogger.Err("mdVirtualLibDefinitions()", msg) 279 | return nil, errors.New(msg) 280 | } 281 | if err := mdReadHiddenVirtualLibraries(); nil != err { 282 | msg := fmt.Sprintf("mdReadHiddenVirtualLibraries(): %v", err) 283 | apachelogger.Err("mdVirtualLibDefinitions()", msg) 284 | return nil, errors.New(msg) 285 | } 286 | 287 | mdVirtLibsRawMtx.RLock() 288 | defer mdVirtLibsRawMtx.RUnlock() 289 | 290 | m := *mdVirtLibsRaw 291 | result := make(TVirtLibList, len(m)) 292 | for key, value := range m { 293 | //FIXME MUTEX 294 | if nil != mdHiddenVirtLibs { 295 | if _, ok := (*mdHiddenVirtLibs)[key]; ok { 296 | continue 297 | } 298 | } 299 | if definition, ok := value.(string); ok { 300 | result[key] = definition 301 | } else { 302 | msg := fmt.Sprintf("json.value.(string): wrong type %v", value) 303 | apachelogger.Err("mdVirtualLibDefinitions", msg) 304 | } 305 | } 306 | 307 | return &result, nil 308 | } // mdVirtualLibDefinitions() 309 | 310 | /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ 311 | 312 | // BookFieldVisible returns whether `aFieldname` should be visible or not. 313 | // 314 | // If `aFieldname` can't be found the function returns `true` and an error, 315 | // otherwise the (boolean) `visible` value and `nil`. 316 | // 317 | // `aFieldname` The name of the field/column to check. 318 | func BookFieldVisible(aFieldname string) (bool, error) { 319 | if err := mdReadBookDisplayFields(); nil != err { 320 | msg := fmt.Sprintf("mdReadBookDisplayFields(): %v", err) 321 | apachelogger.Err("md.BookFieldVisible()", msg) 322 | return true, errors.New(msg) 323 | } 324 | mdBookDisplayFieldsListMtx.RLock() 325 | defer mdBookDisplayFieldsListMtx.RUnlock() 326 | 327 | if result, ok := mdBookDisplayFieldsList[aFieldname]; ok { 328 | return result, nil 329 | } 330 | 331 | msg := "field name doesn't exist: " + aFieldname 332 | apachelogger.Err("md.BookFieldVisible()", msg) 333 | 334 | return true, errors.New(msg) 335 | } // BookFieldVisible() 336 | 337 | // MetaFieldValue returns the value of `aField` of `aSection`. 338 | // 339 | // `aSection` Name of the field's metadata section. 340 | // `aField` Name of the data field within `aSection`. 341 | func MetaFieldValue(aSection, aField string) (interface{}, error) { 342 | if (0 == len(aSection)) || (0 == len(aField)) { 343 | msg := fmt.Sprintf(`md.MetaFieldValue(): empty arguments ("%s". "%s")`, aSection, aField) 344 | apachelogger.Err("md.MetaFieldValue", msg) 345 | return nil, errors.New(msg) 346 | } 347 | 348 | fmd, err := mdGetFieldData(aSection) 349 | if nil != err { 350 | msg := fmt.Sprintf("mdGetFieldData(): %v", err) 351 | apachelogger.Err("md.MetaFieldValue", msg) 352 | return nil, errors.New(msg) 353 | } 354 | 355 | result, ok := fmd[aField] 356 | if !ok { 357 | msg := fmt.Sprintf("no such JSON section: %s[%s]", aSection, aField) 358 | apachelogger.Err("md.MetaFieldValue", msg) 359 | return nil, errors.New(msg) 360 | } 361 | 362 | return result, nil 363 | } // MetaFieldValue() 364 | 365 | // VirtLibOptions returns the SELECT/OPTIONs of the virtual libraries. 366 | // 367 | // `aSelected` Name of the currently selected library. 368 | func VirtLibOptions(aSelected string) string { 369 | _, err := VirtualLibraryList() 370 | if nil != err { 371 | msg := fmt.Sprintf("md.VirtualLibraryList(): %v", err) 372 | apachelogger.Err("md.VirtLibOptions", msg) 373 | return "" 374 | } 375 | mdVirtLibListMtx.RLock() 376 | defer mdVirtLibListMtx.RUnlock() 377 | 378 | list := make([]string, 0, len(mdVirtLibList)+1) 379 | if (0 == len(aSelected)) || ("-" == aSelected) { 380 | list = append(list, ``) 381 | aSelected = "" 382 | } else { 383 | list = append(list, ``) 384 | } 385 | for key := range mdVirtLibList { 386 | option := `` 392 | list = append(list, option) 393 | } 394 | sort.Slice(list, func(i, j int) bool { 395 | return strings.ToLower(list[i]) < strings.ToLower(list[j]) 396 | }) 397 | 398 | return strings.Join(list, "\n") 399 | } // VirtLibOptions() 400 | 401 | var ( 402 | // RegEx to find `.*` in a virt.lib. definition 403 | mdDotStarRE = regexp.MustCompile(`(\.?\*)`) 404 | ) 405 | 406 | // VirtualLibraryList returns a list of virtual library definitions 407 | // and SQL code to access them. 408 | func VirtualLibraryList() (TVirtLibList, error) { 409 | mdVirtLibListMtx.Lock() 410 | defer mdVirtLibListMtx.Unlock() 411 | 412 | if nil != mdVirtLibList { 413 | return mdVirtLibList, nil 414 | } 415 | 416 | jsList, err := mdVirtLibDefinitions() 417 | if nil != err { 418 | msg := fmt.Sprintf("mdVirtLibDefinitions(): %v", err) 419 | apachelogger.Err("md.VirtualLibraryList()", msg) 420 | return nil, err 421 | } 422 | 423 | mdVirtLibList = make(TVirtLibList, len(*jsList)) 424 | for key, value := range *jsList { 425 | 426 | //TODO check for libraries to hide 427 | 428 | mdVirtLibList[key] = mdDotStarRE.ReplaceAllLiteralString(value, "%") 429 | } 430 | 431 | return mdVirtLibList, nil 432 | } // VirtualLibraryList() 433 | 434 | /* _EoF_ */ 435 | -------------------------------------------------------------------------------- /db/metadata_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2024 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package db 8 | 9 | //lint:file-ignore ST1017 - I prefer Yoda conditions 10 | 11 | import ( 12 | "reflect" 13 | "testing" 14 | ) 15 | 16 | func Test_BookFieldVisible(t *testing.T) { 17 | SetCalibreLibraryPath("/var/opt/Calibre") 18 | type args struct { 19 | aFieldname string 20 | } 21 | tests := []struct { 22 | name string 23 | args args 24 | want bool 25 | wantErr bool 26 | }{ 27 | // TODO: Add test cases. 28 | {" 1", args{"title"}, false, false}, 29 | {" 2", args{"sort"}, true, false}, 30 | {" 3", args{"n.a."}, true, true}, 31 | } 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | got, err := BookFieldVisible(tt.args.aFieldname) 35 | if (err != nil) != tt.wantErr { 36 | t.Errorf("BookFieldVisible() error = '%v', wantErr '%v'", err, tt.wantErr) 37 | return 38 | } 39 | if got != tt.want { 40 | t.Errorf("BookFieldVisible() = '%v,' want '%v'", got, tt.want) 41 | } 42 | }) 43 | } 44 | } // Test_BookFieldVisible() 45 | 46 | func Test_mdGetFieldData(t *testing.T) { 47 | SetCalibreLibraryPath("/var/opt/Calibre") 48 | var w1 tInterfaceList // map[string]interface{} 49 | type args struct { 50 | aKey string 51 | } 52 | tests := []struct { 53 | name string 54 | args args 55 | want tInterfaceList // map[string]interface{} 56 | wantErr bool 57 | }{ 58 | // TODO: Add test cases. 59 | {" 1", args{"size"}, w1, false}, 60 | {" 2", args{"#genre"}, w1, false}, 61 | } 62 | for _, tt := range tests { 63 | t.Run(tt.name, func(t *testing.T) { 64 | got, err := mdGetFieldData(tt.args.aKey) 65 | if (err != nil) != tt.wantErr { 66 | t.Errorf("mdGetFieldData() error = '%v', wantErr '%v'", err, tt.wantErr) 67 | return 68 | } 69 | // if !reflect.DeepEqual(got, tt.want) { 70 | // t.Errorf("mdGetFieldData() = '%v', want '%v'", got, tt.want) 71 | // } 72 | if 0 == len(got) { 73 | t.Errorf("mdGetFieldData() = '%v', want '%v'", len(got), "> 0") 74 | } 75 | }) 76 | } 77 | } // Test_mdGetFieldData() 78 | 79 | func Test_mdReadBookDisplayFields(t *testing.T) { 80 | SetCalibreLibraryPath("/var/opt/Calibre") 81 | tests := []struct { 82 | name string 83 | wantErr bool 84 | }{ 85 | // TODO: Add test cases. 86 | {" 1", false}, 87 | } 88 | for _, tt := range tests { 89 | t.Run(tt.name, func(t *testing.T) { 90 | if err := mdReadBookDisplayFields(); (err != nil) != tt.wantErr { 91 | t.Errorf("mdReadBookDisplayFields() error = '%v', wantErr '%v'", err, tt.wantErr) 92 | } 93 | if nil == mdBookDisplayFieldsList { 94 | t.Errorf("mdReadBookDisplayFields() error = '%v', want '%s'", nil, "!nil") 95 | } 96 | }) 97 | } 98 | } // Test_mdReadBookDisplayFields() 99 | 100 | func Test_mdReadFieldMetadata(t *testing.T) { 101 | SetCalibreLibraryPath("/var/opt/Calibre") 102 | tests := []struct { 103 | name string 104 | wantErr bool 105 | }{ 106 | // TODO: Add test cases. 107 | {" 1", false}, 108 | } 109 | for _, tt := range tests { 110 | t.Run(tt.name, func(t *testing.T) { 111 | if err := mdReadFieldMetadata(); (err != nil) != tt.wantErr { 112 | t.Errorf("mdReadFieldMetadata() error = '%v', wantErr '%v'", err, tt.wantErr) 113 | } 114 | if 0 == len(*mdFieldsMetadataList) { 115 | t.Errorf("GetVirtLibList() = '%v', want '%v'", len(*mdFieldsMetadataList), "> 0") 116 | } 117 | }) 118 | } 119 | } // Test_mdReadFieldMetadata() 120 | 121 | func Test_mdReadHiddenVirtualLibraries(t *testing.T) { 122 | SetCalibreLibraryPath("/var/opt/Calibre") 123 | tests := []struct { 124 | name string 125 | wantErr bool 126 | }{ 127 | // TODO: Add test cases. 128 | {" 1", false}, 129 | } 130 | for _, tt := range tests { 131 | t.Run(tt.name, func(t *testing.T) { 132 | if err := mdReadHiddenVirtualLibraries(); (err != nil) != tt.wantErr { 133 | t.Errorf("mdReadHiddenVirtualLibraries() error = '%v', wantErr '%v'", err, tt.wantErr) 134 | } 135 | }) 136 | } 137 | } // Test_mdReadHiddenVirtualLibraries() 138 | 139 | func Test_mdReadMetadataFile(t *testing.T) { 140 | SetCalibreLibraryPath("/var/opt/Calibre") 141 | var v1 TVirtLibList 142 | tests := []struct { 143 | name string 144 | want *TVirtLibList 145 | wantErr bool 146 | }{ 147 | // TODO: Add test cases. 148 | {" 1", &v1, false}, 149 | } 150 | for _, tt := range tests { 151 | t.Run(tt.name, func(t *testing.T) { 152 | err := mdReadMetadataFile() 153 | if (err != nil) != tt.wantErr { 154 | t.Errorf("mdReadMetadataFile() error = '%v', wantErr '%v'", err, tt.wantErr) 155 | return 156 | } 157 | if 0 == len(*mdMetadataDbPrefs) { 158 | t.Errorf("mdReadMetadataFile() = '%v', want '%v'", len(*mdMetadataDbPrefs), "> 0") 159 | } 160 | }) 161 | } 162 | } // Test_mdReadMetadataFile() 163 | 164 | func Test_mdReadVirtualLibraries(t *testing.T) { 165 | SetCalibreLibraryPath("/var/opt/Calibre") 166 | tests := []struct { 167 | name string 168 | wantErr bool 169 | }{ 170 | // TODO: Add test cases. 171 | {" 1", false}, 172 | } 173 | for _, tt := range tests { 174 | t.Run(tt.name, func(t *testing.T) { 175 | if err := mdReadVirtualLibraries(); (err != nil) != tt.wantErr { 176 | t.Errorf("mdReadVirtualLibraries() error = '%v', wantErr '%v'", err, tt.wantErr) 177 | } 178 | if 0 == len(*mdVirtLibsRaw) { 179 | t.Errorf("mdReadVirtualLibraries() = '%v', want '%v'", len(*mdVirtLibsRaw), "> 0") 180 | } 181 | }) 182 | } 183 | } // Test_mdReadVirtualLibraries() 184 | 185 | func Test_mdVirtualLibDefinitions(t *testing.T) { 186 | SetCalibreLibraryPath("/var/opt/Calibre") 187 | tests := []struct { 188 | name string 189 | want *TVirtLibList 190 | wantErr bool 191 | }{ 192 | // TODO: Add test cases. 193 | {" 1", nil, false}, 194 | } 195 | for _, tt := range tests { 196 | t.Run(tt.name, func(t *testing.T) { 197 | got, err := mdVirtLibDefinitions() 198 | if (err != nil) != tt.wantErr { 199 | t.Errorf("mdVirtualLibDefinitions() error = '%v', wantErr '%v'", err, tt.wantErr) 200 | return 201 | } 202 | if 0 == len(*got) { 203 | t.Errorf("mdVirtualLibDefinitions() = '%v', want '%v'", len(*got), "> 0") 204 | } 205 | }) 206 | } 207 | } // Test_mdVirtualLibDefinitions() 208 | 209 | func Test_MetaFieldValue(t *testing.T) { 210 | SetCalibreLibraryPath("/var/opt/Calibre") 211 | type args struct { 212 | aField string 213 | aKey string 214 | } 215 | tests := []struct { 216 | name string 217 | args args 218 | want interface{} 219 | wantErr bool 220 | }{ 221 | // TODO: Add test cases. 222 | {" 1", args{"authors", "is_category"}, true, false}, 223 | {" 2", args{"authors", "table"}, "authors", false}, 224 | {" 3", args{"#genre", "is_category"}, true, false}, 225 | {" 4", args{"#genre", "is_custom"}, true, false}, 226 | {" 5", args{"#genre", "table"}, "custom_column_1", false}, 227 | } 228 | for _, tt := range tests { 229 | t.Run(tt.name, func(t *testing.T) { 230 | got, err := MetaFieldValue(tt.args.aField, tt.args.aKey) 231 | if (err != nil) != tt.wantErr { 232 | t.Errorf("GetMetaFieldValue() error = '%v', wantErr '%v'", err, tt.wantErr) 233 | return 234 | } 235 | if !reflect.DeepEqual(got, tt.want) { 236 | t.Errorf("GetMetaFieldValue() = '%v', want '%v'", got, tt.want) 237 | } 238 | }) 239 | } 240 | } // Test_MetaFieldValue() 241 | 242 | func Test_VirtualLibraryList(t *testing.T) { 243 | SetCalibreLibraryPath("/var/opt/Calibre") 244 | wl1 := map[string]string{} 245 | tests := []struct { 246 | name string 247 | want map[string]string 248 | wantErr bool 249 | }{ 250 | // TODO: Add test cases. 251 | {" 1", wl1, false}, 252 | } 253 | for _, tt := range tests { 254 | t.Run(tt.name, func(t *testing.T) { 255 | got, err := VirtualLibraryList() 256 | if (err != nil) != tt.wantErr { 257 | t.Errorf("VirtualLibraryList() error = '%v',\nwantErr '%v'", err, tt.wantErr) 258 | return 259 | } 260 | if 0 == len(got) { 261 | t.Errorf("VirtualLibraryList() = '%v', want '%v'", len(got), "> 0") 262 | } 263 | }) 264 | } 265 | } // Test_VirtualLibraryList() 266 | 267 | func Test_VirtLibOptions(t *testing.T) { 268 | SetCalibreLibraryPath("/var/opt/Calibre") 269 | type args struct { 270 | aSelected string 271 | } 272 | tests := []struct { 273 | name string 274 | args args 275 | }{ 276 | // TODO: Add test cases. 277 | {" 1", args{""}}, 278 | {" 2", args{"Warentest"}}, 279 | } 280 | for _, tt := range tests { 281 | t.Run(tt.name, func(t *testing.T) { 282 | if got := VirtLibOptions(tt.args.aSelected); 0 == len(got) { 283 | t.Errorf("GetVirtLibOptions() = '%v',\nwant '%v'", got, "> 0") 284 | } 285 | }) 286 | } 287 | } // Test_VirtLibOptions() 288 | -------------------------------------------------------------------------------- /db/pool.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package db 8 | 9 | //lint:file-ignore ST1017 - I prefer Yoda conditions 10 | 11 | /* 12 | * This file implements a simple connection pool to recycle 13 | * used SQLite connections. 14 | */ 15 | 16 | import ( 17 | "context" 18 | "database/sql" 19 | "errors" 20 | "fmt" 21 | "path/filepath" 22 | "sync" 23 | "time" 24 | ) 25 | 26 | type ( 27 | // A List of database connections. 28 | tDBlist []*sql.DB 29 | 30 | // tDBpool The list of database connections. 31 | tDBpool struct { 32 | pList tDBlist // The actual list of available connections 33 | pMtx *sync.Mutex // A guard against concurrent write accesses 34 | } 35 | ) 36 | 37 | var ( 38 | // The list of database connections. 39 | pConnPool *tDBpool 40 | 41 | // Guard for repetitive calls to `NewPool()`. 42 | pInitPoolOnce sync.Once 43 | ) 44 | 45 | // `goMonitorPool()` checks the size of the connection pool. 46 | func goMonitorPool() { 47 | var pLen int 48 | chkInterval := time.Minute << 2 // four minutes 49 | chkTimer := time.NewTimer(chkInterval) 50 | defer chkTimer.Stop() 51 | 52 | //lint:ignore S1000 - We won't use `range` here 53 | for { 54 | select { 55 | case <-chkTimer.C: 56 | pConnPool.pMtx.Lock() 57 | pLen = len(pConnPool.pList) 58 | pConnPool.pMtx.Unlock() 59 | 60 | if 63 < pLen { 61 | pConnPool.clear() 62 | } 63 | chkTimer.Reset(chkInterval) 64 | } 65 | } 66 | } // goMonitorPool() 67 | 68 | // `newPool()` returns a list of database connections. 69 | // 70 | // To retrieve or store a certain connection use the return value's 71 | // `get()` and `put()` methods respectively. 72 | func newPool() *tDBpool { 73 | pInitPoolOnce.Do(func() { 74 | pConnPool = &tDBpool{ 75 | pList: make(tDBlist, 0, 127), 76 | pMtx: new(sync.Mutex), 77 | } 78 | go goMonitorPool() 79 | }) 80 | 81 | return pConnPool 82 | } // newPool() 83 | 84 | /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ 85 | 86 | // `clear()` empties the list. 87 | // 88 | // All connections are closed. 89 | func (p *tDBpool) clear() *tDBpool { 90 | if nil == p { 91 | return p 92 | } 93 | p.pMtx.Lock() 94 | defer p.pMtx.Unlock() 95 | 96 | for idx, conn := range p.pList { 97 | if nil != conn { 98 | _ = conn.Close() 99 | } 100 | p.pList[idx] = nil // clear reference 101 | } 102 | p.pList = p.pList[:0] // empty the list 103 | 104 | return p 105 | } // clear() 106 | 107 | // `get()` selects a single database connection from the list, removes it 108 | // from the Pool, and returns it to the caller. 109 | // 110 | // Callers should not assume any relation between values passed to `Put()` 111 | // and the values returned by `get()`. 112 | // 113 | // `aContext` The current request's context. 114 | func (p *tDBpool) get(aContext context.Context) (rConn *sql.DB, rErr error) { 115 | if nil == p { 116 | rErr = errors.New(`'tDBpool' object uninitialised`) 117 | return 118 | } 119 | p.pMtx.Lock() 120 | defer p.pMtx.Unlock() 121 | 122 | // There are three cases to consider: 123 | // 124 | // (1) the list is empty, 125 | // (2) the list has one entry, 126 | // (3) the list has more than one entry. 127 | // 128 | // We unroll these cases here to handle each most efficiently. 129 | 130 | sLen := len(p.pList) 131 | if 0 == sLen { // case (1) 132 | 133 | //XXX Are there custom functions to inject? 134 | 135 | // `cache=shared` is essential to avoid running out of file 136 | // handles since each query seems to hold its own file handle. 137 | // `loc=auto` gets time.Time with current locale. 138 | // `mode=ro` is self-explanatory since we don't change the DB 139 | // in any way. 140 | dsn := `file:` + 141 | filepath.Join(dbCalibreCachePath, dbCalibreDatabaseFilename) + 142 | `?cache=shared&case_sensitive_like=1&immutable=0&loc=auto&mode=ro&query_only=1` 143 | 144 | select { 145 | case <-aContext.Done(): 146 | rErr = aContext.Err() 147 | 148 | default: 149 | if rConn, rErr = sql.Open(`sqlite3`, dsn); nil == rErr { 150 | // rConn.Exec("PRAGMA xxx=yyy") 151 | go goSQLtrace(`-- opened DB connection`, time.Now()) //REMOVE 152 | rErr = rConn.PingContext(aContext) 153 | } 154 | } 155 | 156 | return 157 | } 158 | 159 | select { 160 | case <-aContext.Done(): 161 | rErr = aContext.Err() 162 | 163 | default: 164 | rConn = p.pList[0] 165 | p.pList[0] = nil // remove reference 166 | 167 | if 1 == sLen { // case (2) 168 | p.pList = p.pList[:0] // empty the list 169 | } else { // case (3) 170 | p.pList = p.pList[1:] // remove first item from list 171 | } 172 | go goSQLtrace(`-- reusing DB connection`, time.Now()) //REMOVE 173 | } 174 | 175 | return 176 | } // get() 177 | 178 | // `put()` adds `aConnection` to the list. 179 | // 180 | // `aConnection` The database connection to add to the pool. 181 | func (p *tDBpool) put(aConnection *sql.DB) *tDBpool { 182 | if nil == p { 183 | return p 184 | } 185 | p.pMtx.Lock() 186 | defer p.pMtx.Unlock() 187 | 188 | if nil != aConnection { 189 | p.pList = append(p.pList, aConnection) 190 | 191 | go goSQLtrace(fmt.Sprintf( 192 | "-- recycling DB connection %d", len(p.pList)), 193 | time.Now()) //FIXME REMOVE 194 | } 195 | 196 | return p 197 | } // put() 198 | 199 | /* _EoF_ */ 200 | -------------------------------------------------------------------------------- /db/pool_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package db 8 | 9 | import ( 10 | "context" 11 | "crypto/md5" 12 | "database/sql" 13 | "fmt" 14 | "os" 15 | "path/filepath" 16 | "reflect" 17 | "sync" 18 | "testing" 19 | ) 20 | 21 | func prepDBforTesting(aContext context.Context) { 22 | libPath := `/var/opt/Calibre` 23 | s := fmt.Sprintf("%x", md5.Sum([]byte(libPath))) // #nosec G401 24 | ucd, _ := os.UserCacheDir() 25 | SetCalibreCachePath(filepath.Join(ucd, "kaliber", s)) 26 | SetCalibreLibraryPath(libPath) 27 | SetSQLtraceFile("./SQLtrace.sql") 28 | _, _ = OpenDatabase(aContext) 29 | } // prepDBforTesting() 30 | 31 | func Test_tDBpool_clear(t *testing.T) { 32 | ctx := context.Background() 33 | prepDBforTesting(ctx) 34 | 35 | type fields struct { 36 | pList tDBlist 37 | pMtx *sync.Mutex 38 | } 39 | tests := []struct { 40 | name string 41 | fields *tDBpool 42 | want *tDBpool 43 | }{ 44 | // TODO: Add test cases. 45 | {" 0", nil, nil}, 46 | {" 1", pConnPool, pConnPool}, 47 | {" 2", pConnPool, pConnPool}, 48 | } 49 | for _, tt := range tests { 50 | t.Run(tt.name, func(t *testing.T) { 51 | p := tt.fields 52 | if got := p.clear(); !reflect.DeepEqual(got, tt.want) { 53 | t.Errorf("tDBpool.clear() = %v, want %v", got, tt.want) 54 | } 55 | }) 56 | } 57 | } // Test_tDBpool_clear() 58 | 59 | func Test_tDBpool_get(t *testing.T) { 60 | ctx := context.Background() 61 | prepDBforTesting(ctx) 62 | 63 | type args struct { 64 | aContext context.Context 65 | } 66 | tests := []struct { 67 | name string 68 | fields *tDBpool 69 | args args 70 | wantRnil bool 71 | wantErr bool 72 | }{ 73 | // TODO: Add test cases. 74 | {" 0", nil, args{ctx}, true, true}, 75 | {" 1", pConnPool, args{ctx}, false, false}, 76 | {" 2", pConnPool, args{ctx}, false, false}, 77 | } 78 | for _, tt := range tests { 79 | t.Run(tt.name, func(t *testing.T) { 80 | p := tt.fields 81 | gotRConn, err := p.get(tt.args.aContext) 82 | if (err != nil) != tt.wantErr { 83 | t.Errorf("tDBpool.get() error = %v, wantErr %v", 84 | err, tt.wantErr) 85 | return 86 | } 87 | if nil != gotRConn { 88 | if tt.wantRnil { 89 | t.Errorf("tDBpool.get() = %v, want NIL", gotRConn) 90 | } 91 | } else { 92 | if !tt.wantRnil { 93 | t.Errorf("tDBpool.get() = %v, want !NIL", nil) 94 | } 95 | } 96 | }) 97 | } 98 | } // Test_tDBpool_get() 99 | 100 | func Test_tDBpool_put(t *testing.T) { 101 | ctx := context.Background() 102 | prepDBforTesting(ctx) 103 | 104 | var conn1 *sql.DB 105 | conn2, _ := pConnPool.get(ctx) 106 | 107 | type args struct { 108 | aConnection *sql.DB 109 | } 110 | tests := []struct { 111 | name string 112 | fields *tDBpool 113 | args args 114 | want *tDBpool 115 | }{ 116 | // TODO: Add test cases. 117 | {" 0", nil, args{conn1}, nil}, 118 | {" 1", pConnPool, args{conn1}, pConnPool}, 119 | {" 2", pConnPool, args{conn2}, pConnPool}, 120 | } 121 | for _, tt := range tests { 122 | t.Run(tt.name, func(t *testing.T) { 123 | p := tt.fields 124 | if got := p.put(tt.args.aConnection); !reflect.DeepEqual(got, tt.want) { 125 | t.Errorf("tDBpool.put() = %v, want %v", got, tt.want) 126 | } 127 | }) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /db/queryoptions.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2019, 2020 M.Watermann, 10247 Berlin, Germany 3 | All rights reserved 4 | EMail : 5 | */ 6 | 7 | package db 8 | 9 | //lint:file-ignore ST1017 - I prefer Yoda conditions 10 | 11 | import ( 12 | "fmt" 13 | "net/http" 14 | "strconv" 15 | "strings" 16 | ) 17 | 18 | type ( 19 | // TSortType is used for the sorting options. 20 | TSortType uint8 21 | ) 22 | 23 | // Constants defining the ORDER_BY clause 24 | const ( 25 | qoSortByAcquisition = TSortType(iota) 26 | qoSortByAuthor 27 | qoSortByLanguage 28 | qoSortByPublisher 29 | qoSortByRating 30 | qoSortBySeries 31 | qoSortBySize 32 | qoSortByTags 33 | qoSortByTime 34 | qoSortByTitle 35 | ) 36 | 37 | // Definition of the GUI language to use 38 | const ( 39 | QoLangGerman = uint8(0) 40 | QoLangEnglish = uint8(1) 41 | ) 42 | 43 | // Definition of the layout type 44 | const ( 45 | QoLayoutList = uint8(0) 46 | QoLayoutGrid = uint8(1) 47 | ) 48 | 49 | // Definition of the CSS theme to use 50 | const ( 51 | QoThemeLight = uint8(0) 52 | QoThemeDark = uint8(1) 53 | ) 54 | 55 | type ( 56 | // TQueryOptions hold properties configuring a query. 57 | // 58 | // This type is used by the HTTP pagehandler when receiving 59 | // a page request. 60 | TQueryOptions struct { 61 | ID TID // an entity ID to lookup 62 | Descending bool // sort direction 63 | Entity string // query for a certain entity (authors, publisher, series, tags) 64 | GuiLang uint8 // GUI language 65 | Layout uint8 // either `qoLayoutList` or `qoLayoutGrid` 66 | LimitLength uint // number of documents per page 67 | LimitStart uint // starting number 68 | Matching string // text to lookup in all documents 69 | QueryCount uint // number of DB records matching the query options 70 | SortBy TSortType // display order of documents (`qoSortByXXX`) 71 | Theme uint8 // CSS presentation theme 72 | VirtLib string // virtual libraries 73 | } 74 | ) 75 | 76 | // Pattern used by `String()` and `Scan()`: 77 | const ( 78 | qoStringPattern = `|%d|%t|%q|%d|%d|%d|%d|%q|%d|%d|%d|%q|` 79 | // | | | | | | | | | | | + Theme 80 | // | | | | | | | | | | + Theme 81 | // | | | | | | | | | + SortBy 82 | // | | | | | | | | + QueryCount 83 | // | | | | | | | + Matching 84 | // | | | | | | + LimitStart 85 | // | | | | | + LimitLength 86 | // | | | | + Layout 87 | // | | | + GUI lang 88 | // | | + Entity 89 | // | + Descending 90 | // + ID 91 | ) 92 | 93 | // `clone()` copies the current object's properties to a new instance. 94 | // 95 | // NOTE: This method is merely a debugging aid. 96 | func (qo *TQueryOptions) clone() *TQueryOptions { 97 | result := TQueryOptions{ 98 | ID: qo.ID, 99 | Descending: qo.Descending, 100 | Entity: qo.Entity, 101 | GuiLang: qo.GuiLang, 102 | Layout: qo.Layout, 103 | LimitLength: qo.LimitLength, 104 | LimitStart: qo.LimitStart, 105 | Matching: qo.Matching, 106 | QueryCount: qo.QueryCount, 107 | SortBy: qo.SortBy, 108 | Theme: qo.Theme, 109 | VirtLib: qo.VirtLib, 110 | } 111 | 112 | return &result 113 | } // clone() 114 | 115 | // DecLimit decrements the LIMIT-start value. 116 | func (qo *TQueryOptions) DecLimit() *TQueryOptions { 117 | if 0 < qo.LimitStart { 118 | if qo.LimitStart <= qo.LimitLength { 119 | qo.LimitStart = 0 120 | } else { 121 | qo.LimitStart -= qo.LimitLength 122 | } 123 | } 124 | 125 | return qo 126 | } // DecLimit() 127 | 128 | // IncLimit increments the LIMIT values. 129 | func (qo *TQueryOptions) IncLimit() *TQueryOptions { 130 | qo.LimitStart += qo.LimitLength 131 | 132 | return qo 133 | } // IncLimit() 134 | 135 | // Scan returns the options read from `aString`. 136 | // 137 | // `aString` The value string to scan. 138 | func (qo *TQueryOptions) Scan(aString string) *TQueryOptions { 139 | var m, v string 140 | _, _ = fmt.Sscanf(aString, qoStringPattern, 141 | &qo.ID, &qo.Descending, &qo.Entity, &qo.GuiLang, &qo.Layout, 142 | &qo.LimitLength, &qo.LimitStart, &m, &qo.QueryCount, 143 | &qo.SortBy, &qo.Theme, &v) 144 | qo.Matching = strings.TrimSpace(m) 145 | if "-" == v { 146 | qo.VirtLib = "" 147 | } else { 148 | qo.VirtLib = strings.TrimSpace(v) 149 | } 150 | 151 | return qo 152 | } // Scan() 153 | 154 | // SelectLanguageOptions returns a list of two SELECT/OPTIONs 155 | // for the language choice. 156 | func (qo *TQueryOptions) SelectLanguageOptions() *TStringMap { 157 | result := make(TStringMap, 2) 158 | switch qo.GuiLang { 159 | case QoLangEnglish: 160 | result["de"] = ``, qoSelectedLookup[limit == qo.LimitLength], limit, limit) 204 | } 205 | 206 | return strings.Join(sList, `\n`) 207 | } // SelectLimitOptions() 208 | 209 | // SelectOrderOptions returns a list of SELECT/OPTIONs 210 | // for the order choice. 211 | func (qo *TQueryOptions) SelectOrderOptions() *TStringMap { 212 | result := make(TStringMap, 2) 213 | if qo.Descending { 214 | result["ascending"] = `