aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README7
-rw-r--r--TODO3
-rw-r--r--bommom.go2
-rw-r--r--core.go8
-rw-r--r--octopart.go129
-rw-r--r--octopart_test.go9
-rw-r--r--serve.go116
-rw-r--r--templates/bom_upload.html29
8 files changed, 284 insertions, 19 deletions
diff --git a/README b/README
index 7e62374..74072eb 100644
--- a/README
+++ b/README
@@ -10,13 +10,18 @@
BomMom: A web publishing/wiki system for electronics Bill of Materials (BOM)
-A work in progress as of April 2012. Written in golang.
+VAPORWARE ALERT! A work in progress as of April 2012.
+
+Written in golang.
### Instructions
Install golang compiler and run `go build` in this directory, then run the
`bommom` command to list available commands and options.
+Run ``./bommom -port 7777 serve`` to start a server on local port 7777; by
+default listens on all interfaces.
+
### Basic Features
- command line tools for managing part list files
diff --git a/TODO b/TODO
new file mode 100644
index 0000000..b590e58
--- /dev/null
+++ b/TODO
@@ -0,0 +1,3 @@
+- crude octopart integration (prices, availability)
+- basic view-only HTML5 skin, with charts etc
+- deployment scheme
diff --git a/bommom.go b/bommom.go
index 8fca701..2d14bf8 100644
--- a/bommom.go
+++ b/bommom.go
@@ -175,7 +175,7 @@ func initCmd() {
// dummy BomMeta already exists?
return
}
- b := makeTestBom()
+ _, b := makeTestBom()
b.Version = "v001"
bm = &BomMeta{Name: "gizmo",
Owner: "common",
diff --git a/core.go b/core.go
index b444ac6..31bbbf3 100644
--- a/core.go
+++ b/core.go
@@ -30,6 +30,7 @@ type LineItem struct {
Category string `json:"category"` // hierarchy as comma seperated list
Elements []string `json:"elements"`
Offers []Offer `json:"offers"`
+ AggregateInfo map[string]string `json:"miscinfo"`
}
func (li *LineItem) Id() string {
@@ -101,7 +102,7 @@ func (bm *BomMeta) Validate() error {
}
// ---------- testing
-func makeTestBom() *Bom {
+func makeTestBom() (*BomMeta, *Bom) {
op1 := OfferPrice{Currency: "usd", Price: 1.0, MinQty: 1}
op2 := OfferPrice{Currency: "usd", Price: 0.8, MinQty: 100}
o := Offer{Sku: "A123", Distributor: "Acme", Prices: []OfferPrice{op1, op2}}
@@ -114,5 +115,8 @@ func makeTestBom() *Bom {
//li.AddOffer(o)
b := NewBom("test01")
b.AddLineItem(&li)
- return b
+ b.AddLineItem(&li)
+ b.AddLineItem(&li)
+ bm := &BomMeta{Name: "Some Bom", Owner: "Some Owner", Description: "This is such a thing!", HeadVersion: b.Version, Homepage: "http://bommom.com", IsPublicView: true, IsPublicEdit: false}
+ return bm, b
}
diff --git a/octopart.go b/octopart.go
index a1f14df..367bc99 100644
--- a/octopart.go
+++ b/octopart.go
@@ -2,6 +2,11 @@ package main
import (
"net/http"
+ "net/url"
+ "encoding/json"
+ "bytes"
+ "log"
+ //"io/ioutil"
)
/*
@@ -15,34 +20,140 @@ type OctopartClient struct {
ApiKey string
RemoteHost string
client *http.Client
+ infoCache map[string]interface{}
}
func NewOctopartClient(apikey string) *OctopartClient {
oc := &OctopartClient{ApiKey: apikey,
- RemoteHost: "https://www.octopart.com"}
+ RemoteHost: "https://octopart.com"}
oc.client = &http.Client{}
+ oc.infoCache = make(map[string]interface{})
return oc
}
func openPricingSource() {
+ // TODO: pass through octopart API key here
pricingSource = NewOctopartClient("")
}
-func (*oc OctopartClient) apiCall(method string, params map[string]string) (map[string]interface, error) {
+func (oc *OctopartClient) apiCall(method string, params map[string]string) (map[string]interface{}, error) {
paramString := "?apikey=" + oc.ApiKey
+ // TODO: assert clean-ness of params
+ // TODO: use url.Values instead...
for key := range params {
- paramString += "&" + key + "=" + params[key]
+ paramString += "&" + url.QueryEscape(key) + "=" + url.QueryEscape(params[key])
}
- resp, err := oc.client.Get(oc.RemoteHost + "/api/v2/" + method)
-
- // resp as json, or interpret as error
- return
+ paramStringUnescaped, _ := url.QueryUnescape(paramString) // TODO: err
+ log.Println("Fetching: " + oc.RemoteHost + "/api/v2/" + method + paramStringUnescaped)
+ resp, err := oc.client.Get(oc.RemoteHost + "/api/v2/" + method + paramString)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != 200 {
+ return nil, Error("Octopart API call error: " + resp.Status)
+ }
+ result := make(map[string]interface{})
+ defer resp.Body.Close()
+ //body, err := ioutil.ReadAll(resp.Body)
+ //if err != nil {
+ // return nil, err
+ //}
+ //body = append(body, '\n')
+ //dec := json.NewDecoder(bytes.NewReader(body))
+ dec := json.NewDecoder(resp.Body)
+ if err = dec.Decode(&result); err != nil {
+ return nil, err
+ }
+ return result, nil
}
-func (*oc OctopartClient) GetMarketInfo(mpn, manufacturer string) (map[string]interface, error) {
+// this method doesn't check internal cache, but it does append to it
+func (oc *OctopartClient) bomApiCall(manufacturers, mpns []string) ([]map[string]interface{}, error) {
+ // TODO: check len(mpns) == len(manufacturers)
+ queryList := make([]map[string]string, len(mpns))
+ listItem := make(map[string]string)
+ for i, _ := range mpns {
+ listItem = make(map[string]string)
+ listItem["mpn_or_sku"] = mpns[i]
+ listItem["manufacturer"] = manufacturers[i]
+ listItem["limit"] = "1"
+ listItem["reference"] = manufacturers[i] + "|" + mpns[i]
+ queryList[i] = listItem
+ }
+ linesBuffer := new(bytes.Buffer)
+ enc := json.NewEncoder(linesBuffer)
+ if err := enc.Encode(queryList); err != nil {
+ return nil, err
+ }
+
+ response, err := oc.apiCall("bom/match", map[string]string{"lines": linesBuffer.String()})
+ if err != nil {
+ return nil, err
+ }
+ // TODO: just grabbing first result for now; user can make better specification later
+ ret := make([]map[string]interface{}, len(mpns))
+ for i, rawresult := range response["results"].([]interface{}) {
+ result := rawresult.(map[string]interface{})
+ hits := int(result["hits"].(float64))
+ reference := result["reference"].(string)
+ if hits == 0 {
+ ret[i] = nil
+ oc.infoCache[reference] = nil
+ } else {
+ ret[i] = result["items"].([]interface{})[0].(map[string]interface{})
+ oc.infoCache[reference] = ret[i]
+ }
+ }
+ return ret, nil
}
-func (*oc OctopartClient) GetPricing(method string, params map[string]string) (map[string]interface, error) {
+func (oc *OctopartClient) GetMarketInfo(manufacturers, mpns []string) ([]interface{}, error) {
+ if len(mpns) < 1 {
+ return nil, Error("no mpns strings passed in")
+ }
+ if len(mpns) != len(manufacturers) {
+ return nil, Error("number of mpns doesn't match number of manufacturers")
+ }
+ if len(mpns) > 100 {
+ return nil, Error("can't handle more than 100 queries at a time (yet)")
+ }
+ mpnToQuery := make([]string, 0)
+ manufacturersToQuery := make([]string, 0)
+ queryHash := ""
+ // check for queryHashes in internal cache
+ for i, _ := range mpns {
+ queryHash = manufacturers[i] + "|" + mpns[i]
+ if _, hasKey := oc.infoCache[queryHash]; hasKey != true {
+ manufacturersToQuery = append(manufacturersToQuery, manufacturers[i])
+ mpnToQuery = append(mpnToQuery, mpns[i])
+ }
+ }
+ // if necessary, fetch missing queryHashes to internal cache
+ if len(mpnToQuery) > 0 {
+ if _, err := oc.bomApiCall(manufacturersToQuery, mpnToQuery); err != nil {
+ return nil, err
+ }
+ }
+ // construct list of return info
+ result := make([]interface{}, len(mpns))
+ for i, _ := range mpns {
+ queryHash = manufacturers[i] + "|" + mpns[i]
+ value, hasKey := oc.infoCache[queryHash]
+ if hasKey != true {
+ return nil, Error("key should be in cache, but isn't: " + queryHash)
+ }
+ result[i] = value
+ }
+ return result, nil
+}
+func (oc *OctopartClient) GetReducedPricing(mpn, manufacturer string) (interface{}, error) {
+ marketInfo, err := oc.GetMarketInfo([]string{mpn}, []string{manufacturer})
+ if err != nil {
+ return nil, err
+ }
+ // reduce marketInfo to pricing
+ return marketInfo[0], nil
}
+
diff --git a/octopart_test.go b/octopart_test.go
index 75953ee..2e0e295 100644
--- a/octopart_test.go
+++ b/octopart_test.go
@@ -42,4 +42,11 @@ func TestGetMarketInfo(t *testing.T) {
}
}
-//TODO? TestGetPricing
+func TestAttachInfo(t *testing.T) {
+ b := makeTestBom()
+ bm := &BomMeta{}
+ oc := NewOctopartClient("")
+ oc.AttachMarketInfo(oc)
+ DumpBomAsText(bm, b,
+}
+
diff --git a/serve.go b/serve.go
index b1d921d..df344ce 100644
--- a/serve.go
+++ b/serve.go
@@ -6,25 +6,31 @@ import (
"log"
"net/http"
"regexp"
+ "path/filepath"
+ "time"
)
var (
- tmplHome, tmplView, tmplUser, tmplBomView *template.Template
+ tmplHome, tmplView, tmplUser, tmplBomView, tmplBomUpload *template.Template
)
func baseHandler(w http.ResponseWriter, r *http.Request) {
var err error
log.Printf("serving %s\n", r.URL.Path)
- bomUrlPattern := regexp.MustCompile("^/([a-zA-Z][a-zA-Z0-9_]*)/([a-zA-Z][a-zA-Z0-9_]*)(/.*)$")
+ bomUrlPattern := regexp.MustCompile("^/([a-zA-Z][a-zA-Z0-9_]*)/([a-zA-Z][a-zA-Z0-9_]*)/$")
+ bomUploadUrlPattern := regexp.MustCompile("^/([a-zA-Z][a-zA-Z0-9_]*)/([a-zA-Z][a-zA-Z0-9_]*)/_upload/$")
userUrlPattern := regexp.MustCompile("^/([a-zA-Z][a-zA-Z0-9_]*)/$")
switch {
case r.URL.Path == "/":
err = homeController(w, r)
+ case bomUploadUrlPattern.MatchString(r.URL.Path):
+ match := bomUploadUrlPattern.FindStringSubmatch(r.URL.Path)
+ err = bomUploadController(w, r, match[1], match[2])
case bomUrlPattern.MatchString(r.URL.Path):
match := bomUrlPattern.FindStringSubmatch(r.URL.Path)
- err = bomController(w, r, match[1], match[2], match[3])
+ err = bomController(w, r, match[1], match[2])
case userUrlPattern.MatchString(r.URL.Path):
match := userUrlPattern.FindStringSubmatch(r.URL.Path)
err = userController(w, r, match[1], "")
@@ -74,7 +80,7 @@ func userController(w http.ResponseWriter, r *http.Request, user, extra string)
return
}
-func bomController(w http.ResponseWriter, r *http.Request, user, name, extra string) (err error) {
+func bomController(w http.ResponseWriter, r *http.Request, user, name string) (err error) {
if !isShortName(user) {
http.Error(w, "invalid username: "+user, 400)
return
@@ -86,12 +92,111 @@ func bomController(w http.ResponseWriter, r *http.Request, user, name, extra str
context := make(map[string]interface{})
context["BomMeta"], context["Bom"], err = bomstore.GetHead(ShortName(user), ShortName(name))
if err != nil {
- return
+ http.Error(w, "404 couldn't open bom: " + user + "/" + name, 404)
+ return nil
}
err = tmplBomView.Execute(w, context)
return
}
+func bomUploadController(w http.ResponseWriter, r *http.Request, user, name string) (err error) {
+
+ if !isShortName(user) {
+ http.Error(w, "invalid username: "+user, 400)
+ return
+ }
+ if !isShortName(name) {
+ http.Error(w, "invalid bom name: "+name, 400)
+ return
+ }
+ context := make(map[string]interface{})
+ context["user"] = ShortName(user)
+ context["name"] = ShortName(name)
+ context["BomMeta"], context["Bom"], err = bomstore.GetHead(ShortName(user), ShortName(name))
+
+ switch r.Method {
+ case "POST":
+
+
+ err := r.ParseMultipartForm(1024*1024*2)
+ if err != nil {
+ log.Println(err)
+ http.Error(w, err.Error(), 400)
+ return nil
+ }
+ file, fileheader, err := r.FormFile("bomfile")
+ if err != nil {
+ log.Println(err)
+ context["error"] = "bomfile was nil!"
+ err = tmplBomUpload.Execute(w, context)
+ return err
+ }
+ if file == nil {
+ log.Println("bomfile was nil")
+ context["error"] = "bomfile was nil!"
+ err = tmplBomUpload.Execute(w, context)
+ return err
+ }
+ versionStr := r.FormValue("version")
+ if len(versionStr) == 0 || isShortName(versionStr) == false {
+ context["error"] = "Version must be specified and a ShortName!"
+ context["version"] = versionStr
+ err = tmplBomUpload.Execute(w, context)
+ return err
+ }
+
+ //contentType := fileheader.Header["Content-Type"][0]
+ var b *Bom
+ var bm *BomMeta
+
+ switch filepath.Ext(fileheader.Filename) {
+ case ".json":
+ bm, b, err = LoadBomFromJSON(file)
+ if err != nil {
+ context["error"] = "Problem loading JSON file"
+ err = tmplBomUpload.Execute(w, context)
+ return err
+ }
+ case ".csv":
+ b, err = LoadBomFromCSV(file)
+ bm = &BomMeta{}
+ if err != nil {
+ context["error"] = "Problem loading XML file"
+ err = tmplBomUpload.Execute(w, context)
+ return err
+ }
+ case ".xml":
+ bm, b, err = LoadBomFromXML(file)
+ if err != nil {
+ context["error"] = "Problem loading XML file"
+ err = tmplBomUpload.Execute(w, context)
+ return err
+ }
+ default:
+ context["error"] = "Unknown file type: " + string(fileheader.Filename)
+ err = tmplBomUpload.Execute(w, context)
+ return err
+ }
+ bm.Owner = user
+ bm.Name = name
+ b.Progeny = "File uploaded from " + fileheader.Filename
+ b.Created = time.Now()
+ b.Version = string(versionStr)
+ if err := bomstore.Persist(bm, b, ShortName(versionStr)); err != nil {
+ context["error"] = "Problem saving to datastore: " + err.Error()
+ err = tmplBomUpload.Execute(w, context)
+ }
+ http.Redirect(w, r, "//" + user + "/" + name + "/", 302)
+ case "GET":
+ err = tmplBomUpload.Execute(w, context)
+ default:
+ http.Error(w, "bad method", 405)
+ return nil
+ }
+ return
+}
+
+
func serveCmd() {
var err error
@@ -100,6 +205,7 @@ func serveCmd() {
tmplHome = template.Must(template.ParseFiles(*templatePath+"/home.html", baseTmplPath))
tmplUser = template.Must(template.ParseFiles(*templatePath+"/user.html", baseTmplPath))
tmplBomView = template.Must(template.ParseFiles(*templatePath+"/bom_view.html", baseTmplPath))
+ tmplBomUpload = template.Must(template.ParseFiles(*templatePath+"/bom_upload.html", baseTmplPath))
if err != nil {
log.Fatal(err)
}
diff --git a/templates/bom_upload.html b/templates/bom_upload.html
new file mode 100644
index 0000000..1d03963
--- /dev/null
+++ b/templates/bom_upload.html
@@ -0,0 +1,29 @@
+{{ template "HEADER" }}
+{{ if .BomMeta }}
+
+<h1>{{ .BomMeta.Name }} is a bom.</h1>
+<b>Owner: </b>{{ .BomMeta.Owner }}<br>
+{{ if .BomMeta.Homepage }}<b>Homepage: </b>{{ .BomMeta.Homepage }}<br>{{ end }}
+{{ if .BomMeta.Description }}<b>Description: </b>{{ .BomMeta.Description }}<br>{{ end }}
+<b>Version: </b>{{ .BomMeta.HeadVersion }} (at head)<br>
+<b>Created: </b>{{ .Bom.Created }}<br>
+{{ if .Bom.Progeny}}<b>Source: </b>{{ .Bom.Progeny }}<br>{{ end }}
+<hr>
+
+{{ else }}
+
+<h1>{{ .name }} could be a bom.</h1>
+<b>Owner: </b>{{ .user }}<br>
+<hr>
+
+{{ end }}
+
+{{ if .error }}<h3 style="color:red;">{{ .error }}</h3>{{ end }}
+<form enctype="multipart/form-data" method="POST" target=".">
+<b>Version: </b><input name="version" type="text" value="{{ .version }}"></input><br>
+<b>Created: </b>now!<br>
+<input type="file" name="bomfile" accept="application/json,application/xml,text/csv"></input><br>
+<input type="submit" value="Upload"></input>
+</form>
+
+{{ template "FOOTER" }}