├── screenshots ├── screenshot1.png └── screenshot2.png ├── activate ├── src └── gobookmark │ ├── migrations │ ├── 0001_append_tags.down.sql │ └── 0001_append_tags.up.sql │ ├── public │ ├── js │ │ ├── main.js │ │ └── bootstrap-tagsinput.js │ └── css │ │ ├── bootstrap-tagsinput.css │ │ └── screen.css │ ├── utils_test.go │ ├── templates │ ├── includes │ │ └── paginate.html │ ├── login.html │ ├── edit.html │ ├── index.html │ └── layout.html │ ├── utils.go │ ├── assetfs.go │ ├── gobookmark.go │ ├── views.go │ ├── main_test.go │ ├── glide.yaml │ ├── glide.lock │ └── models.go ├── Dockerfile ├── .gitignore ├── docker-compose.yml ├── contrib ├── docker-compose.yml ├── README.md └── bm.example.com.nginx.conf ├── Makefile └── README.md /screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harobed/gobookmark/HEAD/screenshots/screenshot1.png -------------------------------------------------------------------------------- /screenshots/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harobed/gobookmark/HEAD/screenshots/screenshot2.png -------------------------------------------------------------------------------- /activate: -------------------------------------------------------------------------------- 1 | export GOPATH=`pwd` 2 | export GOBIN=`pwd`/bin 3 | export PATH=`pwd`/bin:$PATH 4 | export GO15VENDOREXPERIMENT=1 5 | -------------------------------------------------------------------------------- /src/gobookmark/migrations/0001_append_tags.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX links; 2 | DROP INDEX fk_links_tags; 3 | DROP TABLE rel_links_tags; 4 | DROP TABLE tags; 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:jessie 2 | RUN mkdir /data/ 3 | WORKDIR / 4 | ADD ./releases/linux_amd64/gobookmark /gobookmark 5 | ENV GOBOOKMARK_DATABASE=/data/gobookmark 6 | ENV GOBOOKMARK_HOST=0.0.0.0 7 | EXPOSE 8000 8 | CMD /gobookmark web 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /pkg/ 3 | vendor/ 4 | *.db 5 | bindata.go 6 | src/github.com/ 7 | src/gopkg.in/ 8 | src/9fans.net/ 9 | src/golang.org/ 10 | gobookmark.index/ 11 | gobookmark-test.index/ 12 | src/gobookmark/gobookmark 13 | imports/ 14 | .tags 15 | .tags1 16 | releases/ 17 | -------------------------------------------------------------------------------- /src/gobookmark/public/js/main.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | console.log("fooobar"); 3 | $('input[name="url"]').bind("propertychange change click keyup input paste", function() { 4 | $.get("/fetch-title/?url=" + $('input[name="url"]').val(), function(data) { 5 | if (data != '') { 6 | $('input[name="title"]').val(data); 7 | } 8 | }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | gobookmark: 4 | container_name: gobookmark 5 | build: . 6 | restart: always 7 | volumes: 8 | - gobookmark-db-volume:/data/ 9 | - ./imports:/imports/ 10 | ports: 11 | - "8000:8000" 12 | environment: 13 | - GOBOOKMARK_PASSWORD=password 14 | 15 | volumes: 16 | gobookmark-db-volume: 17 | driver: local 18 | -------------------------------------------------------------------------------- /contrib/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | gobookmark: 4 | container_name: gobookmark 5 | restart: always 6 | image: harobed/gobookmark 7 | volumes: 8 | - gobookmark-db-volume:/data/ 9 | - ./imports:/imports/ 10 | ports: 11 | - "8000:8000" 12 | environment: 13 | - GOBOOKMARK_PASSWORD=password 14 | 15 | volumes: 16 | gobookmark-db-volume: 17 | driver: local 18 | -------------------------------------------------------------------------------- /src/gobookmark/utils_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestExtractTags(t *testing.T) { 9 | assert.Equal(t, extractTags("[foo][bar] extra")[0], "foo") 10 | assert.Equal(t, extractTags("[foo][bar ] extra")[1], "bar") 11 | assert.Len(t, extractTags("extra"), 0) 12 | } 13 | 14 | func TestRemoveTags(t *testing.T) { 15 | assert.Equal(t, removeTags("[foo][bar] extra"), "extra") 16 | } 17 | -------------------------------------------------------------------------------- /contrib/README.md: -------------------------------------------------------------------------------- 1 | # How to use docker-compose on production 2 | 3 | ``` 4 | # docker-compose up -d 5 | # docker-compose logs 6 | ``` 7 | 8 | How to import your bookmark file to gobookmark : 9 | 10 | * place your bookmark file in imports folder, next : 11 | 12 | ``` 13 | # docker-compose stop gobookmark 14 | # docker-compose run --rm gobookmark ./gobookmark import --reset /imports/bookmarks_public_20160318_101550.html 15 | # docker-compose up -d gobookmark 16 | ``` 17 | -------------------------------------------------------------------------------- /src/gobookmark/migrations/0001_append_tags.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS links ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | title TEXT NOT NULL, 4 | url TEXT NOT NULL, 5 | createdate DATE DEFAULT (datetime('now','localtime')) 6 | ); 7 | 8 | CREATE TABLE IF NOT EXISTS tags ( 9 | id INTEGER PRIMARY KEY AUTOINCREMENT, 10 | title TEXT NOT NULL, 11 | slug TEXT NOT NULL 12 | ); 13 | CREATE INDEX fk_tags_slug ON tags (slug); 14 | 15 | CREATE TABLE IF NOT EXISTS rel_links_tags ( 16 | link_id INTEGER NOT NULL, 17 | tag_id INTEGER NOT NULL 18 | ); 19 | 20 | CREATE INDEX fk_links_tags ON rel_links_tags (link_id, tag_id); 21 | -------------------------------------------------------------------------------- /contrib/bm.example.com.nginx.conf: -------------------------------------------------------------------------------- 1 | upstream gobookmark { 2 | server localhost:8000; 3 | } 4 | 5 | server { 6 | listen 80; 7 | 8 | server_name bm.example.com; 9 | server_tokens off; # don't show the version number, a security best practice 10 | client_max_body_size 5000M; 11 | access_log /var/log/nginx/bm.example.com_access.log; 12 | error_log /var/log/nginx/bm.example.com_error.log; 13 | 14 | location / { 15 | proxy_pass http://gobookmark; 16 | proxy_set_header Host $http_host; # required for docker client's sake 17 | proxy_set_header X-Real-IP $remote_addr; # pass on real client's IP 18 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 19 | proxy_set_header X-Forwarded-Proto $scheme; 20 | proxy_read_timeout 900; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/gobookmark/templates/includes/paginate.html: -------------------------------------------------------------------------------- 1 | {{ define "paginate" }} 2 | 28 | {{ end }} 29 | -------------------------------------------------------------------------------- /src/gobookmark/templates/login.html: -------------------------------------------------------------------------------- 1 | {{ template "layout" . }} 2 | {{ define "content" }} 3 |
4 |
5 | {{ if .Error }} 6 | 10 | {{ end }} 11 |
12 |
13 | 14 |
15 | 23 |
24 |
25 |
26 |
27 | 28 |
29 |
30 |
31 |
32 |
33 | {{ end }} 34 | -------------------------------------------------------------------------------- /src/gobookmark/public/css/bootstrap-tagsinput.css: -------------------------------------------------------------------------------- 1 | .bootstrap-tagsinput { 2 | background-color: #fff; 3 | border: 1px solid #ccc; 4 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); 5 | display: inline-block; 6 | padding: 4px 6px; 7 | color: #555; 8 | vertical-align: middle; 9 | border-radius: 4px; 10 | max-width: 100%; 11 | line-height: 22px; 12 | cursor: text; 13 | } 14 | .bootstrap-tagsinput input { 15 | border: none; 16 | box-shadow: none; 17 | outline: none; 18 | background-color: transparent; 19 | padding: 0 6px; 20 | margin: 0; 21 | width: auto; 22 | max-width: inherit; 23 | } 24 | .bootstrap-tagsinput.form-control input::-moz-placeholder { 25 | color: #777; 26 | opacity: 1; 27 | } 28 | .bootstrap-tagsinput.form-control input:-ms-input-placeholder { 29 | color: #777; 30 | } 31 | .bootstrap-tagsinput.form-control input::-webkit-input-placeholder { 32 | color: #777; 33 | } 34 | .bootstrap-tagsinput input:focus { 35 | border: none; 36 | box-shadow: none; 37 | } 38 | .bootstrap-tagsinput .tag { 39 | margin-right: 2px; 40 | color: white; 41 | } 42 | .bootstrap-tagsinput .tag [data-role="remove"] { 43 | margin-left: 8px; 44 | cursor: pointer; 45 | } 46 | .bootstrap-tagsinput .tag [data-role="remove"]:after { 47 | content: "x"; 48 | padding: 0px 2px; 49 | } 50 | .bootstrap-tagsinput .tag [data-role="remove"]:hover { 51 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); 52 | } 53 | .bootstrap-tagsinput .tag [data-role="remove"]:hover:active { 54 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); 55 | } 56 | -------------------------------------------------------------------------------- /src/gobookmark/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "os/user" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | ) 13 | 14 | func absPath(path string) (string, error) { 15 | usr, _ := user.Current() 16 | dir := usr.HomeDir 17 | 18 | if path[:2] == "~/" { 19 | path = strings.Replace(path, "~", dir, 1) 20 | } 21 | 22 | return filepath.Abs(path) 23 | } 24 | 25 | func appendHttp(url string) string { 26 | if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { 27 | return "http://" + url 28 | } 29 | return url 30 | } 31 | 32 | func assetFS() http.FileSystem { 33 | for k := range _bintree.Children { 34 | return http.Dir(k) 35 | } 36 | panic("unreachable") 37 | } 38 | 39 | func extractPageTitle(url string) (string, error) { 40 | resp, err := http.Get(url) 41 | if err != nil { 42 | return "", err 43 | } 44 | body, err := ioutil.ReadAll(resp.Body) 45 | checkErr(err) 46 | 47 | re := regexp.MustCompile("(.*?)") 48 | result := re.FindStringSubmatch(string(body)) 49 | if len(result) == 2 { 50 | return result[1], nil 51 | } 52 | return "", errors.New( 53 | fmt.Sprintf("... not found in %s url", url), 54 | ) 55 | } 56 | 57 | func checkErr(err error) { 58 | if err != nil { 59 | panic(err) 60 | } 61 | } 62 | 63 | func extractTags(search string) (result []string) { 64 | re := regexp.MustCompile("\\[(.*?)\\]") 65 | 66 | for _, submatch := range re.FindAllStringSubmatch(search, -1) { 67 | if len(submatch) > 1 { 68 | result = append(result, strings.TrimSpace(submatch[1])) 69 | } 70 | } 71 | 72 | return result 73 | } 74 | 75 | func removeTags(search string) string { 76 | re := regexp.MustCompile("(\\[.*?\\])") 77 | return strings.TrimSpace(re.ReplaceAllString(search, "")) 78 | } 79 | -------------------------------------------------------------------------------- /src/gobookmark/templates/edit.html: -------------------------------------------------------------------------------- 1 | {{ template "layout" . }} 2 | {{ define "content" }} 3 |
4 |
5 |
6 |
7 | 8 |
9 | 17 |
18 |
19 |
20 | 21 |
22 | 29 |
30 |
31 |
32 | 33 |
34 | 42 |
43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 |
51 |
52 | {{ end }} 53 | -------------------------------------------------------------------------------- /src/gobookmark/templates/index.html: -------------------------------------------------------------------------------- 1 | {{ template "layout" . }} 2 | {{ define "content" }} 3 |
4 |
5 | {{ .TotalLinks }} links 6 |
7 |
8 | 9 | {{ if gt .Page.TotalPages 1 }} 10 |
11 | {{ template "paginate" .Page }} 12 |
13 | {{ end }} 14 | 15 |
16 |
17 | Links per page : 18 | 25 | 19 | 50 | 20 | 100 21 |
22 |
23 | 24 | 45 | 46 | {{ if gt .Page.TotalPages 1 }} 47 |
48 | {{ template "paginate" .Page }} 49 |
50 | {{ end }} 51 | 52 |
53 |
54 | {{ end }} 55 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export GOPATH := $(PWD) 2 | export GOBIN := $(PWD)/bin 3 | export PATH := $(PWD):$(PATH) 4 | export GO15VENDOREXPERIMENT=1 5 | 6 | BINARY=gobookmark 7 | 8 | VERSION=0.1.0 9 | BUILD_TIME=`date +%FT%T%z` 10 | 11 | build_bindata = bin/go-bindata $(1) -o src/gobookmark/bindata.go -prefix src/gobookmark/ src/gobookmark/public/... src/gobookmark/templates/... src/gobookmark/migrations/... 12 | build_gobookmark = go build -v -o bin/${BINARY} gobookmark 13 | 14 | .PHONY: all 15 | all: install serve 16 | 17 | .PHONY: install 18 | install: 19 | go get -u github.com/Masterminds/glide/... 20 | go get -u github.com/jteeuwen/go-bindata/... 21 | cd src/gobookmark/; $(PWD)/bin/glide install; $(PWD)/bin/glide rebuild 22 | $(call build_bindata,-debug) 23 | $(call build_gobookmark) 24 | 25 | .PHONY: build 26 | build: 27 | $(call build_gobookmark) 28 | 29 | .PHONY: serve 30 | serve: 31 | $(call build_gobookmark) 32 | bin/${BINARY} 33 | 34 | 35 | .PHONY: assets 36 | assets: 37 | $(call build_bindata,-debug) 38 | 39 | .PHONY: release 40 | release: 41 | mkdir -p releases/darwin_amd64/ 42 | mkdir -p releases/linux_amd64/ 43 | $(call build_bindata) 44 | go build -o releases/darwin_amd64/${BINARY} gobookmark 45 | docker run -it --rm -v $(PWD):/usr/src/myapp -w /usr/src/myapp golang:1.6 bash -c "export CGO_ENABLED=1; export GOPATH=/usr/src/myapp/; go build -ldflags '-s' -o releases/linux_amd64/${BINARY} gobookmark" 46 | $(call build_bindata,-debug) 47 | cd releases/darwin_amd64/; tar czf ../gobookmark_darwin_amd64.tar.gz gobookmark 48 | cd releases/linux_amd64/; tar czf ../gobookmark_linux_amd64.tar.gz gobookmark 49 | 50 | .PHONY: test 51 | test: 52 | go test gobookmark -v 53 | 54 | .PHONY: clean 55 | clean: 56 | rm -rf bin/ releases/ pkg/ src/gobookmark/vendor/ src/gopkg.in/ src/github.com/ 57 | 58 | .PHONY: build-docker 59 | build-docker: release 60 | docker build -t gobookmark . 61 | 62 | .PHONY: push-docker 63 | push-docker: build-docker 64 | docker tag gobookmark:latest docker.santa-maria.io/stephane/gobookmark 65 | docker push docker.santa-maria.io/stephane/gobookmark 66 | -------------------------------------------------------------------------------- /src/gobookmark/public/css/screen.css: -------------------------------------------------------------------------------- 1 | .links { 2 | list-style: none; 3 | padding: 0; 4 | margin-top: 20px; 5 | } 6 | 7 | .links .link-title { 8 | color: black; 9 | /*font-weight: bold;*/ 10 | font-size: 1.2em; 11 | } 12 | 13 | .links > LI { 14 | line-height: 1.5em; 15 | padding: 15px 15px; 16 | border-bottom: 1px solid #aaa; 17 | background: transparent linear-gradient(#F2F2F2, #FFF) repeat scroll 0% 0%; 18 | } 19 | 20 | .links > LI .line2 { 21 | font-size: 0.8em; 22 | } 23 | 24 | .bootstrap-tagsinput { 25 | width: 100%; 26 | } 27 | 28 | .tags { 29 | list-style: none; 30 | padding: 0; 31 | } 32 | 33 | .tags > LI { 34 | display: inline; 35 | } 36 | 37 | .tags > LI:after { 38 | content: "," 39 | } 40 | 41 | .tags > LI:last-child:after { 42 | content: "" 43 | } 44 | 45 | .navbar-default { 46 | background-color: #345; 47 | background-image: none; 48 | border: 0; 49 | border-bottom: 1px solid #234; 50 | border-radius: 0; 51 | } 52 | 53 | .navbar-default .navbar-brand { 54 | color: #ddd; 55 | text-shadow: 0 1px 1px #444; 56 | border-right: 1px solid #234; 57 | outline: 1px solid #456; 58 | } 59 | 60 | .navbar-login { 61 | color: #ddd; 62 | text-shadow: 0 1px 1px #444; 63 | border-left: 1px solid #234; 64 | outline: 1px solid #456; 65 | } 66 | 67 | .navbar-default .navbar-brand:hover { 68 | color: #fff; 69 | text-shadow: 0 1px 1px #444; 70 | } 71 | 72 | .navbar-default .navbar-nav > li > a { 73 | color: #ddd; 74 | text-shadow: 0 1px 1px #444; 75 | } 76 | 77 | .navbar-default .navbar-nav > li > a:hover { 78 | color: #fff; 79 | text-shadow: 0 1px 1px #444; 80 | } 81 | 82 | .items_per_page A.active { 83 | color: black; 84 | font-weight: bold; 85 | } 86 | 87 | .navbar-form { 88 | padding: 0; 89 | margin-left: 10px; 90 | margin-right: 10px; 91 | } 92 | 93 | FOOTER { 94 | border-top: 1px solid #d6d6d6; 95 | background-color: #eee; 96 | text-align: center; 97 | padding: 10px; 98 | font-size: small; 99 | } 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gobookmark 2 | 3 | Inspired by [Shaarli](https://github.com/sebsauvage/Shaarli) (Delicious clone), easier to install (only one standalone executable). 4 | 5 | Built with Go - GoBookmark runs without installation on multiple platforms. 6 | 7 | [![Docker repository](https://img.shields.io/docker/pulls/harobed/gobookmark.svg)](https://hub.docker.com/r/harobed/gobookmark/) 8 | [![Join the chat at https://gitter.im/harobed/gobookmark](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/harobed/gobookmark) 9 | 10 | 11 | ## Overview 12 | 13 | GoBookmark is a personnal web bookmark web service with : 14 | 15 | * tags support 16 | * plain text search engine 17 | * no dependencies, only based on filesystem (SQLite + [Bleve](http://www.blevesearch.com/)) 18 | 19 | ## Getting started 20 | 21 | 1. [Download the latest release](https://github.com/harobed/gobookmark/releases/) of GoBookmark for your platform 22 | 2. Configure GoBookmark, or use the default settings 23 | 24 | ``` 25 | $ ./gobookmark web 26 | [negroni] listening on localhost:8000 27 | ``` 28 | 29 | Default password is ```password```. 30 | 31 | More info : 32 | 33 | ``` 34 | $ bin/gobookmark --help 35 | NAME: 36 | gobookmark - A personnal bookmark service 37 | 38 | USAGE: 39 | gobookmark [global options] command [command options] [arguments...] 40 | 41 | VERSION: 42 | 0.1.0 43 | 44 | COMMANDS: 45 | web Start Gobookmark web server 46 | import Import bookmark HTML file 47 | reindex Execute plain text search indexation 48 | help, h Shows a list of commands or help for specific command 49 | 50 | GLOBAL OPTIONS: 51 | --data, -d "gobookmark" Database filename [$GOBOOKMARK_DATABASE] 52 | --help, -h show help 53 | --version, -v print the version 54 | ``` 55 | 56 | 57 | ## Screenshots 58 | 59 | *** 60 | 61 | ![screenshot1](screenshots/screenshot1.png) 62 | 63 | *** 64 | 65 | ![screenshot2](screenshots/screenshot2.png) 66 | 67 | *** 68 | 69 | ## Contributions 70 | 71 | ### Build 72 | 73 | On OSX : 74 | 75 | $ brew install go 76 | $ go --version 77 | go version go1.6 darwin/amd64 78 | 79 | Download source : 80 | 81 | $ git clone git@gitlab.stephane-klein.info:stephane-klein/gobookmark.git 82 | 83 | Build and execute : 84 | 85 | $ cd gobookmark 86 | $ make install 87 | $ make serve 88 | [negroni] listening on :8080 89 | 90 | 91 | ### Test 92 | 93 | $ make test 94 | 95 | 96 | ### Build Docker image 97 | 98 | $ docker build -t gobookmark . 99 | 100 | Next push image : 101 | 102 | $ docker tag gobookmark:latest harobed/gobookmark 103 | $ docker push harobed/gobookmark 104 | 105 | 106 | ## Why I did this project ? 107 | 108 | It's my second Golang project, my first Golang web application, it's also a training project. 109 | 110 | What I've learned in this project : 111 | 112 | * how to use [go-bindata](https://github.com/jteeuwen/go-bindata) to include all assets in a standalone binary 113 | * how to use [migrate](https://github.com/mattes/migrate/), a database migration tool, it's very simple to apply, very KISS 114 | * how to use [bleve](github.com/blevesearch/bleve), a modern text indexing library, it's less powerful than [ElasticSearch](https://github.com/elastic/elasticsearch) but 115 | Bleve is embeded in *gobookmark* standalone executable 116 | * how to use [glide](https://github.com/Masterminds/glide) to handle dependencies (after played with [govendor](https://github.com/kardianos/govendor), [godeps](https://github.com/tools/godep)…) 117 | * how to use the [httptreemux](https://github.com/dimfeld/httptreemux) High-speed, flexible tree-based HTTP router 118 | * how to use [gorilla/context](https://github.com/gorilla/context) a golang registry for global request variables 119 | * how to use default golang template engine and database api 120 | -------------------------------------------------------------------------------- /src/gobookmark/templates/layout.html: -------------------------------------------------------------------------------- 1 | {{ define "layout" }} 2 | 3 | 4 | 5 | 6 | 7 | GoBookmark 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 67 |
68 |
69 |
70 | {{ template "content" . }} 71 |
72 |
73 | 78 | 79 | 80 | {{ end }} 81 | -------------------------------------------------------------------------------- /src/gobookmark/assetfs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "os" 10 | "path" 11 | "path/filepath" 12 | "time" 13 | ) 14 | 15 | var ( 16 | defaultFileTimestamp = time.Now() 17 | ) 18 | 19 | // FakeFile implements os.FileInfo interface for a given path and size 20 | type FakeFile struct { 21 | // Path is the path of this file 22 | Path string 23 | // Dir marks of the path is a directory 24 | Dir bool 25 | // Len is the length of the fake file, zero if it is a directory 26 | Len int64 27 | // Timestamp is the ModTime of this file 28 | Timestamp time.Time 29 | } 30 | 31 | func (f *FakeFile) Name() string { 32 | _, name := filepath.Split(f.Path) 33 | return name 34 | } 35 | 36 | func (f *FakeFile) Mode() os.FileMode { 37 | mode := os.FileMode(0644) 38 | if f.Dir { 39 | return mode | os.ModeDir 40 | } 41 | return mode 42 | } 43 | 44 | func (f *FakeFile) ModTime() time.Time { 45 | return f.Timestamp 46 | } 47 | 48 | func (f *FakeFile) Size() int64 { 49 | return f.Len 50 | } 51 | 52 | func (f *FakeFile) IsDir() bool { 53 | return f.Mode().IsDir() 54 | } 55 | 56 | func (f *FakeFile) Sys() interface{} { 57 | return nil 58 | } 59 | 60 | // AssetFile implements http.File interface for a no-directory file with content 61 | type AssetFile struct { 62 | *bytes.Reader 63 | io.Closer 64 | FakeFile 65 | } 66 | 67 | func NewAssetFile(name string, content []byte, timestamp time.Time) *AssetFile { 68 | if timestamp.IsZero() { 69 | timestamp = defaultFileTimestamp 70 | } 71 | return &AssetFile{ 72 | bytes.NewReader(content), 73 | ioutil.NopCloser(nil), 74 | FakeFile{name, false, int64(len(content)), timestamp}} 75 | } 76 | 77 | func (f *AssetFile) Readdir(count int) ([]os.FileInfo, error) { 78 | return nil, errors.New("not a directory") 79 | } 80 | 81 | func (f *AssetFile) Size() int64 { 82 | return f.FakeFile.Size() 83 | } 84 | 85 | func (f *AssetFile) Stat() (os.FileInfo, error) { 86 | return f, nil 87 | } 88 | 89 | // AssetDirectory implements http.File interface for a directory 90 | type AssetDirectory struct { 91 | AssetFile 92 | ChildrenRead int 93 | Children []os.FileInfo 94 | } 95 | 96 | func NewAssetDirectory(name string, children []string, fs *AssetFS) *AssetDirectory { 97 | fileinfos := make([]os.FileInfo, 0, len(children)) 98 | for _, child := range children { 99 | _, err := fs.AssetDir(filepath.Join(name, child)) 100 | fileinfos = append(fileinfos, &FakeFile{child, err == nil, 0, time.Time{}}) 101 | } 102 | return &AssetDirectory{ 103 | AssetFile{ 104 | bytes.NewReader(nil), 105 | ioutil.NopCloser(nil), 106 | FakeFile{name, true, 0, time.Time{}}, 107 | }, 108 | 0, 109 | fileinfos} 110 | } 111 | 112 | func (f *AssetDirectory) Readdir(count int) ([]os.FileInfo, error) { 113 | if count <= 0 { 114 | return f.Children, nil 115 | } 116 | if f.ChildrenRead+count > len(f.Children) { 117 | count = len(f.Children) - f.ChildrenRead 118 | } 119 | rv := f.Children[f.ChildrenRead : f.ChildrenRead+count] 120 | f.ChildrenRead += count 121 | return rv, nil 122 | } 123 | 124 | func (f *AssetDirectory) Stat() (os.FileInfo, error) { 125 | return f, nil 126 | } 127 | 128 | // AssetFS implements http.FileSystem, allowing 129 | // embedded files to be served from net/http package. 130 | type AssetFS struct { 131 | // Asset should return content of file in path if exists 132 | Asset func(path string) ([]byte, error) 133 | // AssetDir should return list of files in the path 134 | AssetDir func(path string) ([]string, error) 135 | // AssetInfo should return the info of file in path if exists 136 | AssetInfo func(path string) (os.FileInfo, error) 137 | // Prefix would be prepended to http requests 138 | Prefix string 139 | } 140 | 141 | func (fs *AssetFS) Open(name string) (http.File, error) { 142 | name = path.Join(fs.Prefix, name) 143 | if len(name) > 0 && name[0] == '/' { 144 | name = name[1:] 145 | } 146 | if b, err := fs.Asset(name); err == nil { 147 | timestamp := defaultFileTimestamp 148 | if info, err := fs.AssetInfo(name); err == nil { 149 | timestamp = info.ModTime() 150 | } 151 | return NewAssetFile(name, b, timestamp), nil 152 | } 153 | if children, err := fs.AssetDir(name); err == nil { 154 | return NewAssetDirectory(name, children, fs), nil 155 | } else { 156 | return nil, err 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/gobookmark/gobookmark.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/PuerkitoBio/goquery" 6 | "github.com/cheggaaa/pb" 7 | "github.com/codegangsta/cli" 8 | "github.com/codegangsta/negroni" 9 | "github.com/dimfeld/httptreemux" 10 | "github.com/goincremental/negroni-sessions" 11 | "github.com/goincremental/negroni-sessions/cookiestore" 12 | "github.com/gorilla/context" 13 | "log" 14 | "net/http" 15 | "os" 16 | "path" 17 | "strconv" 18 | "strings" 19 | "time" 20 | ) 21 | 22 | var ( 23 | Password string 24 | ) 25 | 26 | func stringFlag(name, value, usage string, envvar string) cli.StringFlag { 27 | return cli.StringFlag{ 28 | Name: name, 29 | Value: value, 30 | Usage: usage, 31 | EnvVar: envvar, 32 | } 33 | } 34 | 35 | func openDatabases(filename string) { 36 | cwd, _ := os.Getwd() 37 | filename = path.Join(cwd, filename) 38 | 39 | db_filename := fmt.Sprintf("%s.db", filename) 40 | log.Printf("Use %s SqlLite database", db_filename) 41 | DB = openDatabase(db_filename) 42 | 43 | index_filename := fmt.Sprintf("%s.index", filename) 44 | log.Printf("Use %s Bleve database", index_filename) 45 | INDEX = openBleve(index_filename) 46 | } 47 | 48 | func resetDatabases(filename string) { 49 | cwd, _ := os.Getwd() 50 | filename = path.Join(cwd, filename) 51 | 52 | db_filename := fmt.Sprintf("%s.db", filename) 53 | log.Printf("Reset %s SqlLite database", db_filename) 54 | os.Remove(db_filename) 55 | 56 | index_filename := fmt.Sprintf("%s.index", filename) 57 | log.Printf("Reset %s Bleve database", index_filename) 58 | os.RemoveAll(index_filename) 59 | } 60 | 61 | const default_items_by_page = 25 62 | 63 | func init() { 64 | Password = "password" 65 | } 66 | 67 | func GlobalVariableMiddleware(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) { 68 | context.Set(r, "login", false) 69 | context.Set(r, "index_page", false) 70 | next(rw, r) 71 | } 72 | 73 | func initApp() *negroni.Negroni { 74 | router := httptreemux.New() 75 | router.GET("/", Index) 76 | router.GET("/add/", Edit) 77 | router.GET("/fetch-title/", FetchTitle) 78 | router.POST("/add/", Save) 79 | router.GET("/:id/delete/", Delete) 80 | router.GET("/:id/edit/", Edit) 81 | router.POST("/:id/edit/", Save) 82 | router.GET("/login/", LoginForm) 83 | router.POST("/login/", Login) 84 | router.GET("/logout/", Logout) 85 | 86 | n := negroni.Classic() 87 | 88 | store := cookiestore.New([]byte("secret123")) 89 | n.Use(sessions.Sessions("my_session", store)) 90 | n.Use(negroni.HandlerFunc(GlobalVariableMiddleware)) 91 | n.Use(negroni.NewStatic( 92 | &AssetFS{ 93 | Asset: Asset, 94 | AssetDir: AssetDir, 95 | AssetInfo: AssetInfo, 96 | Prefix: "public", 97 | }, 98 | )) 99 | 100 | n.UseHandler(router) 101 | return n 102 | } 103 | 104 | func importFile(filename string) { 105 | filename, err := absPath(filename) 106 | checkErr(err) 107 | f, err := os.Open(filename) 108 | checkErr(err) 109 | 110 | doc, err := goquery.NewDocumentFromReader(f) 111 | checkErr(err) 112 | items := doc.Find("DT > A") 113 | bar := pb.StartNew(len(items.Nodes)) 114 | items.Each(func(i int, s *goquery.Selection) { 115 | bar.Increment() 116 | add_date_str, _ := s.Attr("add_date") 117 | add_date_int, err := strconv.ParseInt(add_date_str, 10, 64) 118 | checkErr(err) 119 | 120 | stmt, err := DB.Prepare("INSERT INTO links (title, url, createdate) VALUES(?, ?, ?)") 121 | checkErr(err) 122 | 123 | href, _ := s.Attr("href") 124 | 125 | bm := new(BookmarkItem) 126 | bm.Title = s.Text() 127 | bm.Url = href 128 | bm.CreateDate = time.Unix(add_date_int, 0) 129 | 130 | res, err := stmt.Exec( 131 | bm.Title, 132 | bm.Url, 133 | bm.CreateDate, 134 | ) 135 | checkErr(err) 136 | 137 | bm.Id, err = res.LastInsertId() 138 | tags, _ := s.Attr("tags") 139 | updateLinksTags(bm.Id, strings.Split(tags, ",")) 140 | 141 | // Index 142 | 143 | bm.Tags = getLinksTags(bm.Id) 144 | indexBookmarkItem(bm) 145 | }) 146 | bar.FinishPrint("The End!") 147 | } 148 | 149 | func main() { 150 | app := cli.NewApp() 151 | app.Name = "gobookmark" 152 | app.Version = "0.1.0" 153 | app.Usage = "A personnal bookmark service" 154 | app.Flags = []cli.Flag{ 155 | stringFlag("data, d", "gobookmark", "Database filename", "GOBOOKMARK_DATABASE"), 156 | } 157 | app.Commands = []cli.Command{ 158 | { 159 | Name: "web", 160 | Usage: "Start Gobookmark web server", 161 | Description: `Gobookmark web server is the only thing you need to run, 162 | and it takes care of all the other things for you`, 163 | Flags: []cli.Flag{ 164 | stringFlag("port, p", "8000", "Web server port", "GOBOOKMARK_PORT"), 165 | stringFlag("host", "localhost", "Web server host", "GOBOOKMARK_HOST"), 166 | stringFlag("password", "password", "Set login password", "GOBOOKMARK_PASSWORD"), 167 | }, 168 | Action: func(c *cli.Context) { 169 | Password = c.String("password") 170 | openDatabases(c.Parent().String("data")) 171 | n := initApp() 172 | n.Run(fmt.Sprintf("%s:%s", c.String("host"), c.String("port"))) 173 | }, 174 | }, 175 | { 176 | Name: "import", 177 | Usage: "Import bookmark HTML file", 178 | Flags: []cli.Flag{ 179 | cli.BoolFlag{ 180 | Name: "reset, r", 181 | Usage: "Reset database before importation", 182 | }, 183 | }, 184 | ArgsUsage: "", 185 | Action: func(c *cli.Context) { 186 | if len(c.Args()) == 0 { 187 | log.Print("Error : missing") 188 | } else { 189 | if c.Bool("reset") { 190 | resetDatabases(c.Parent().String("data")) 191 | } 192 | openDatabases(c.Parent().String("data")) 193 | importFile(c.Args()[0]) 194 | } 195 | }, 196 | }, 197 | { 198 | Name: "reindex", 199 | Usage: "Execute plain text search indexation", 200 | Action: func(c *cli.Context) { 201 | openDatabases(c.Parent().String("data")) 202 | log.Print("Reindex database with Bleve") 203 | indexAllBookmark() 204 | }, 205 | }, 206 | } 207 | app.Run(os.Args) 208 | } 209 | -------------------------------------------------------------------------------- /src/gobookmark/views.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/Unknwon/paginater" 5 | "github.com/arschles/go-bindata-html-template" 6 | "github.com/goincremental/negroni-sessions" 7 | "github.com/gorilla/context" 8 | "net/http" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | func getTemplate(r *http.Request, template_name string) *template.Template { 14 | funcMap := template.FuncMap{ 15 | "paginate_url": func(page int) string { 16 | values := r.URL.Query() 17 | values.Del("page") 18 | values.Add("page", strconv.Itoa(page)) 19 | r.URL.RawQuery = values.Encode() 20 | return r.URL.String() 21 | }, 22 | "per_page_url": func(per_page int) string { 23 | values := r.URL.Query() 24 | values.Del("items_by_page") 25 | values.Add("items_by_page", strconv.Itoa(per_page)) 26 | r.URL.RawQuery = values.Encode() 27 | return r.URL.String() 28 | }, 29 | "getContextBool": func(key string) bool { 30 | return context.Get(r, key).(bool) 31 | }, 32 | } 33 | t, err := template.New("mytmpl", Asset).Funcs(funcMap).ParseFiles( 34 | template_name, 35 | "templates/layout.html", 36 | "templates/includes/paginate.html", 37 | ) 38 | checkErr(err) 39 | return t 40 | } 41 | 42 | func Index(w http.ResponseWriter, r *http.Request, _ map[string]string) { 43 | session := sessions.GetSession(r) 44 | t := getTemplate(r, "templates/index.html") 45 | 46 | page, err := strconv.Atoi(r.URL.Query().Get("page")) 47 | if err != nil { 48 | page = 1 49 | } 50 | 51 | items_by_page, err := strconv.Atoi(r.URL.Query().Get("items_by_page")) 52 | if err != nil { 53 | items_by_page = default_items_by_page 54 | } 55 | 56 | total_links := countLinks("") 57 | var result_total int 58 | 59 | var bms []*BookmarkItem 60 | search := r.URL.Query().Get("search") 61 | if search != "" { 62 | result_total, bms = searchBookmark(search, page, items_by_page) 63 | } else { 64 | bms = queryBookmark(page, items_by_page, r.URL.Query().Get("tags")) 65 | result_total = countLinks(r.URL.Query().Get("tags")) 66 | } 67 | 68 | data := struct { 69 | Bms []*BookmarkItem 70 | Page *paginater.Paginater 71 | TotalLinks int 72 | ItemsByPage int 73 | Search string 74 | }{ 75 | Bms: bms, 76 | Page: paginater.New(result_total, items_by_page, page, 9), 77 | TotalLinks: total_links, 78 | ItemsByPage: items_by_page, 79 | Search: search, 80 | } 81 | 82 | context.Set(r, "index_page", true) 83 | if session.Get("login") != nil { 84 | context.Set(r, "login", true) 85 | } 86 | 87 | err = t.Execute(w, data) 88 | } 89 | 90 | func Edit(w http.ResponseWriter, r *http.Request, params map[string]string) { 91 | session := sessions.GetSession(r) 92 | t := getTemplate(r, "templates/edit.html") 93 | 94 | var bookmark_item BookmarkItem 95 | 96 | if _, ok := params["id"]; ok { 97 | id, err := strconv.ParseInt(params["id"], 10, 64) 98 | checkErr(err) 99 | 100 | bookmark_item = *getBookmark(id) 101 | } else { 102 | url := appendHttp(r.URL.Query().Get("url")) 103 | title, err := extractPageTitle(url) 104 | if err != nil { 105 | title = "" 106 | } 107 | bookmark_item = BookmarkItem{ 108 | 0, 109 | url, 110 | title, 111 | time.Time{}, 112 | nil, 113 | } 114 | } 115 | 116 | data := struct { 117 | Item BookmarkItem 118 | }{ 119 | Item: bookmark_item, 120 | } 121 | if session.Get("login") != nil { 122 | context.Set(r, "login", true) 123 | } 124 | 125 | err := t.Execute(w, data) 126 | checkErr(err) 127 | } 128 | 129 | func Save(w http.ResponseWriter, r *http.Request, params map[string]string) { 130 | session := sessions.GetSession(r) 131 | if session.Get("login") == nil { 132 | http.Redirect(w, r, "../../", 303) 133 | return 134 | } 135 | 136 | var link_id int64 137 | var err error 138 | if _, ok := params["id"]; ok { 139 | link_id, err = strconv.ParseInt(params["id"], 10, 64) 140 | checkErr(err) 141 | 142 | updateLink( 143 | link_id, 144 | r.FormValue("title"), 145 | appendHttp(r.FormValue("url")), 146 | r.FormValue("tags"), 147 | ) 148 | } else { 149 | link_id = insertLink( 150 | r.FormValue("title"), 151 | appendHttp(r.FormValue("url")), 152 | r.FormValue("tags"), 153 | ) 154 | } 155 | bookmark_item := getBookmark(link_id) 156 | indexBookmarkItem(bookmark_item) 157 | 158 | http.Redirect(w, r, "../../", 303) 159 | } 160 | 161 | func Delete(w http.ResponseWriter, r *http.Request, params map[string]string) { 162 | session := sessions.GetSession(r) 163 | if session.Get("login") == nil { 164 | http.Redirect(w, r, "../../", 303) 165 | return 166 | } 167 | 168 | stmt, err := DB.Prepare("DELETE FROM links WHERE id=?") 169 | checkErr(err) 170 | 171 | _, err = stmt.Exec(params["id"]) 172 | checkErr(err) 173 | 174 | http.Redirect(w, r, "../../", 303) 175 | } 176 | 177 | func LoginForm(w http.ResponseWriter, r *http.Request, _ map[string]string) { 178 | session := sessions.GetSession(r) 179 | t := getTemplate(r, "templates/login.html") 180 | 181 | data := struct { 182 | Error string 183 | }{ 184 | Error: "", 185 | } 186 | 187 | errors := session.Flashes("errors") 188 | if len(errors) > 0 { 189 | data.Error = errors[0].(string) 190 | } 191 | 192 | t.Execute(w, data) 193 | } 194 | 195 | func Login(w http.ResponseWriter, r *http.Request, _ map[string]string) { 196 | session := sessions.GetSession(r) 197 | if r.FormValue("password") == Password { 198 | session.Set("login", true) 199 | http.Redirect(w, r, "../", 303) 200 | } else { 201 | session.AddFlash("Password invalid", "errors") 202 | http.Redirect(w, r, ".", 303) 203 | } 204 | } 205 | 206 | func Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) { 207 | session := sessions.GetSession(r) 208 | session.Delete("login") 209 | http.Redirect(w, r, "../", 303) 210 | } 211 | 212 | func FetchTitle(w http.ResponseWriter, r *http.Request, _ map[string]string) { 213 | url := appendHttp(r.URL.Query().Get("url")) 214 | title, err := extractPageTitle(url) 215 | if err != nil { 216 | title = "" 217 | } 218 | w.Write([]byte(title)) 219 | } 220 | -------------------------------------------------------------------------------- /src/gobookmark/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "github.com/stretchr/testify/assert" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/cookiejar" 10 | "net/http/httptest" 11 | "net/url" 12 | "os" 13 | "testing" 14 | ) 15 | 16 | func openTestDatabase() *sql.DB { 17 | const test_database = "gobookmark-test.db" 18 | const test_bleve = "gobookmark-test.index" 19 | 20 | if _, err := os.Stat(test_database); err == nil { 21 | os.Remove(test_database) 22 | } 23 | 24 | if _, err := os.Stat(test_bleve); err == nil { 25 | os.RemoveAll(test_bleve) 26 | } 27 | 28 | INDEX = openBleve(test_bleve) 29 | 30 | return openDatabase(test_database) 31 | } 32 | 33 | func TestIndex(t *testing.T) { 34 | DB = openTestDatabase() 35 | defer DB.Close() 36 | app := initApp() 37 | server := httptest.NewServer(app) 38 | defer server.Close() 39 | 40 | cookieJar, _ := cookiejar.New(nil) 41 | client := &http.Client{ 42 | Jar: cookieJar, 43 | } 44 | 45 | resp, _ := client.Get(server.URL + "/") 46 | assert.Equal(t, resp.StatusCode, http.StatusOK) 47 | } 48 | 49 | func TestLogin(t *testing.T) { 50 | DB = openTestDatabase() 51 | defer DB.Close() 52 | app := initApp() 53 | server := httptest.NewServer(app) 54 | defer server.Close() 55 | 56 | cookieJar, _ := cookiejar.New(nil) 57 | client := &http.Client{ 58 | Jar: cookieJar, 59 | } 60 | 61 | resp, _ := client.Get(server.URL + "/login/") 62 | assert.Equal(t, resp.StatusCode, http.StatusOK) 63 | assertResponseBodyContains(t, resp, "Password") 64 | } 65 | 66 | func assertResponseBodyContains(t *testing.T, resp *http.Response, contains string) { 67 | bodyBytes, _ := ioutil.ReadAll(resp.Body) 68 | resp.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes)) 69 | assert.Contains(t, string(bodyBytes), contains) 70 | } 71 | 72 | func assertResponseBodyNotContains(t *testing.T, resp *http.Response, contains string) { 73 | body, _ := ioutil.ReadAll(resp.Body) 74 | 75 | assert.NotContains(t, string(body), contains) 76 | } 77 | 78 | func TestAddBookmark(t *testing.T) { 79 | DB = openTestDatabase() 80 | defer DB.Close() 81 | app := initApp() 82 | server := httptest.NewServer(app) 83 | defer server.Close() 84 | 85 | cookieJar, _ := cookiejar.New(nil) 86 | client := &http.Client{ 87 | Jar: cookieJar, 88 | } 89 | 90 | resp, _ := client.Get(server.URL + "/add/?url=http://cv.stephane-klein.info") 91 | assert.Equal(t, resp.StatusCode, http.StatusOK) 92 | assertResponseBodyContains(t, resp, "Développeur") 93 | } 94 | 95 | func TestSaveNewBookmark(t *testing.T) { 96 | DB = openTestDatabase() 97 | defer DB.Close() 98 | app := initApp() 99 | server := httptest.NewServer(app) 100 | defer server.Close() 101 | 102 | cookieJar, _ := cookiejar.New(nil) 103 | client := &http.Client{ 104 | Jar: cookieJar, 105 | } 106 | client.PostForm( 107 | server.URL+"/login/", 108 | url.Values{ 109 | "password": {"password"}, 110 | }, 111 | ) 112 | 113 | resp, _ := client.PostForm( 114 | server.URL+"/add/", 115 | url.Values{ 116 | "url": {"http://cv.stephane-klein.info"}, 117 | "title": {"Le CV de Stéphane Klein"}, 118 | "tags": {"tag1,tag2,tag3"}, 119 | }, 120 | ) 121 | 122 | assert.Equal(t, resp.StatusCode, http.StatusOK) 123 | assert.Equal(t, resp.Request.URL.Path, "/") 124 | 125 | assertResponseBodyContains(t, resp, "stephane") 126 | assertResponseBodyContains(t, resp, "tag2") 127 | } 128 | 129 | func TestDeleteBookmark(t *testing.T) { 130 | DB = openTestDatabase() 131 | defer DB.Close() 132 | app := initApp() 133 | server := httptest.NewServer(app) 134 | defer server.Close() 135 | 136 | cookieJar, _ := cookiejar.New(nil) 137 | client := &http.Client{ 138 | Jar: cookieJar, 139 | } 140 | client.PostForm( 141 | server.URL+"/login/", 142 | url.Values{ 143 | "password": {"password"}, 144 | }, 145 | ) 146 | 147 | insertLink("Le Curriculum vitae de Stéphane Klein", "http://cv.stephane-klein.info", "") 148 | 149 | resp, _ := client.Get(server.URL + "/1/delete/") 150 | 151 | assert.Equal(t, resp.StatusCode, http.StatusOK) 152 | assert.Equal(t, resp.Request.URL.Path, "/") 153 | 154 | assertResponseBodyNotContains(t, resp, "Curriculum") 155 | } 156 | 157 | func TestEditBookmark(t *testing.T) { 158 | DB = openTestDatabase() 159 | defer DB.Close() 160 | app := initApp() 161 | server := httptest.NewServer(app) 162 | defer server.Close() 163 | 164 | cookieJar, _ := cookiejar.New(nil) 165 | client := &http.Client{ 166 | Jar: cookieJar, 167 | } 168 | client.PostForm( 169 | server.URL+"/login/", 170 | url.Values{ 171 | "password": {"password"}, 172 | }, 173 | ) 174 | 175 | insertLink("Le CV de Stéphane Klein", "http://cv.stephane-klein.info", "") 176 | 177 | resp, _ := client.Get(server.URL + "/1/edit/") 178 | 179 | assert.Equal(t, resp.StatusCode, http.StatusOK) 180 | assertResponseBodyContains(t, resp, "stephane") 181 | 182 | resp, _ = client.PostForm( 183 | server.URL+"/1/edit/", 184 | url.Values{ 185 | "url": {"http://cv.noelie-deschamps.info"}, 186 | "title": {"Le CV de Noëlie Deschamps"}, 187 | }, 188 | ) 189 | 190 | assert.Equal(t, resp.StatusCode, http.StatusOK) 191 | assertResponseBodyContains(t, resp, "noelie") 192 | } 193 | 194 | func TestExtractPageTitle(t *testing.T) { 195 | title, _ := extractPageTitle("http://cv.stephane-klein.info") 196 | assert.Equal(t, title, "Curriculum vitæ de Stéphane Klein | CV | Développeur, Administrateur Système | 15 ans d'expérience") 197 | 198 | title, _ = extractPageTitle("https://golang.org/") 199 | assert.Equal(t, title, "The Go Programming Language") 200 | } 201 | 202 | func TestSearchByTags(t *testing.T) { 203 | DB = openTestDatabase() 204 | defer DB.Close() 205 | app := initApp() 206 | server := httptest.NewServer(app) 207 | defer server.Close() 208 | 209 | insertLink("AAAAAAAA", "http://example1.com", "python") 210 | insertLink("BBBBBBBB", "http://example2.com", "python") 211 | insertLink("CCCCCCCC", "http://example3.com", "python,golang") 212 | insertLink("DDDDDDDD", "http://example4.com", "python,golang") 213 | insertLink("EEEEEEEE", "http://example5.com", "golang") 214 | insertLink("FFFFFFFF", "http://example6.com", "golang") 215 | indexAllBookmark() 216 | 217 | total, _ := searchBookmark("[python]", 1, 10) 218 | assert.Equal(t, total, 4) 219 | 220 | total, _ = searchBookmark("[golang]", 1, 10) 221 | assert.Equal(t, total, 4) 222 | 223 | total, _ = searchBookmark("[golang][python]", 1, 10) 224 | assert.Equal(t, total, 2) 225 | 226 | total, bms := searchBookmark("[python] BBBBBBBB", 1, 10) 227 | assert.Equal(t, total, 4) 228 | assert.Equal(t, bms[0].Title, "BBBBBBBB") 229 | } 230 | -------------------------------------------------------------------------------- /src/gobookmark/glide.yaml: -------------------------------------------------------------------------------- 1 | package: gobookmark 2 | import: 3 | - package: github.com/andybalholm/cascadia 4 | version: 3ad29d1ad1c4f2023e355603324348cf1f4b2d48 5 | - package: github.com/arschles/go-bindata-html-template 6 | version: 4ae2add2b2f4e490b61ce4e7fdfa622824318375 7 | - package: github.com/bitly/go-simplejson 8 | version: aabad6e819789e569bd6aabf444c935aa9ba1e44 9 | - package: github.com/blevesearch/bleve 10 | version: 96577606c33b4be0a9993a7daf581028f84849ee 11 | subpackages: 12 | - analysis 13 | - analysis/analyzers/standard_analyzer 14 | - analysis/byte_array_converters/json 15 | - analysis/datetime_parsers/datetime_optional 16 | - document 17 | - index 18 | - index/firestorm 19 | - index/store 20 | - index/store/boltdb 21 | - index/store/gtreap 22 | - index/upside_down 23 | - numeric_util 24 | - registry 25 | - search 26 | - search/collectors 27 | - search/facets 28 | - search/highlight/highlighters/html 29 | - search/searchers 30 | - analysis/language/en 31 | - analysis/token_filters/lower_case_filter 32 | - analysis/tokenizers/unicode 33 | - analysis/datetime_parsers/flexible_go 34 | - search/highlight 35 | - search/highlight/fragment_formatters/html 36 | - search/highlight/fragmenters/simple 37 | - search/highlight/highlighters/simple 38 | - search/scorers 39 | - analysis/token_filters/porter 40 | - analysis/token_filters/stop_tokens_filter 41 | - package: github.com/blevesearch/go-porterstemmer 42 | version: 23a2c8e5cf1f380f27722c6d2ae8896431dc7d0e 43 | - package: github.com/blevesearch/segment 44 | version: db70c57796cc8c310613541dfade3dce627d09c7 45 | - package: github.com/boj/redistore 46 | version: 9c0e6bab4dd444285424f189b8fb0cb03f653242 47 | - package: github.com/boltdb/bolt 48 | version: c2745b3c62985affcf08d0522135f4747e9b81f3 49 | - package: github.com/cheggaaa/pb 50 | version: c089c0e183064d83038db7c2ae1b711fb2e747a4 51 | - package: github.com/codegangsta/cli 52 | version: f9cc3001e04f9783cb4ad08ca6791aa07134787c 53 | - package: github.com/codegangsta/negroni 54 | version: c7477ad8e330bef55bf1ebe300cf8aa67c492d1b 55 | - package: github.com/crowdmob/goamz 56 | version: 3a06871fe9fc0281ca90f3a7d97258d042ed64c0 57 | subpackages: 58 | - aws 59 | - dynamodb 60 | - dynamodb/dynamizer 61 | - ec2 62 | - elb 63 | - iam 64 | - s3 65 | - package: github.com/davecgh/go-spew 66 | version: 5215b55f46b2b919f50a1df0eaa5886afe4e3b3d 67 | subpackages: 68 | - spew 69 | - package: github.com/dchest/safefile 70 | version: 855e8d98f1852d48dde521e0522408d1fe7e836a 71 | - package: github.com/denizeren/dynamostore 72 | version: 69258d14eb58e5a5b894138d7f2f2609a5effc1f 73 | - package: github.com/dimfeld/httppath 74 | version: c8e499c3ef3c3e272ed8bdcc1ccf39f73c88debc 75 | - package: github.com/dimfeld/httptreemux 76 | version: f74b8868fbe8534a40358ce27b77818eeea2d6e0 77 | - package: github.com/extemporalgenome/slug 78 | version: 0320c85e32e0015b090f08a5d1324d5ab6094f07 79 | - package: github.com/feyeleanor/raw 80 | version: 724aedf6e1a5d8971aafec384b6bde3d5608fba4 81 | - package: github.com/feyeleanor/sets 82 | version: 6c54cb57ea406ff6354256a4847e37298194478f 83 | - package: github.com/feyeleanor/slices 84 | version: bb44bb2e4817fe71ba7082d351fd582e7d40e3ea 85 | - package: github.com/garyburd/redigo 86 | version: 836b6e58b3358112c8291565d01c35b8764070d7 87 | subpackages: 88 | - internal 89 | - redis 90 | - package: github.com/goincremental/dal 91 | version: 14fa60d0e842a8b7e8589b3fdb66c1d51762b11b 92 | - package: github.com/goincremental/negroni-sessions 93 | version: b07e2d18b97e3f627edaa67876c21fa23eba6a08 94 | subpackages: 95 | - cookiestore 96 | - package: github.com/golang/protobuf 97 | version: 5fc2294e655b78ed8a02082d37808d46c17d7e64 98 | subpackages: 99 | - proto 100 | - package: github.com/gorilla/context 101 | version: 1c83b3eabd45b6d76072b66b746c20815fb2872d 102 | - package: github.com/gorilla/securecookie 103 | version: e95799a481bbcc3d01c2ad5178524cb8bec9f370 104 | - package: github.com/gorilla/sessions 105 | version: 99b16d2edd22458c541b3640ce83689b08a05c20 106 | - package: github.com/jteeuwen/go-bindata 107 | version: a0ff2567cfb70903282db057e799fd826784d41d 108 | - package: github.com/mattn/go-sqlite3 109 | version: 5651a9d9d49ec25811d08220ee972080378d52ea 110 | - package: github.com/pmezard/go-difflib 111 | version: e8554b8641db39598be7f6342874b958f12ae1d4 112 | subpackages: 113 | - difflib 114 | - package: github.com/PuerkitoBio/goquery 115 | version: 417cce822c7b9a379df5824be95228d177c5698b 116 | - package: github.com/steveyen/gtreap 117 | version: 0abe01ef9be25c4aedc174758ec2d917314d6d70 118 | - package: github.com/stretchr/objx 119 | version: 1a9d0bb9f541897e62256577b352fdbc1fb4fd94 120 | - package: github.com/stretchr/testify 121 | version: e3a8ff8ce36581f87a15341206f205b1da467059 122 | subpackages: 123 | - assert 124 | - http 125 | - mock 126 | - require 127 | - package: github.com/Unknwon/paginater 128 | version: 7748a72e01415173a27d79866b984328e7b0c12b 129 | - package: github.com/willf/bitset 130 | version: bb0da3785c4fe9d26f6029c77c8fce2aa4d0b291 131 | - package: golang.org/x/crypto 132 | version: f18420efc3b4f8e9f3d51f6bd2476e92c46260e9 133 | subpackages: 134 | - blowfish 135 | - cast5 136 | - curve25519 137 | - nacl/secretbox 138 | - openpgp/armor 139 | - openpgp/elgamal 140 | - openpgp/errors 141 | - openpgp/packet 142 | - openpgp/s2k 143 | - pbkdf2 144 | - pkcs12/internal/rc2 145 | - poly1305 146 | - salsa20/salsa 147 | - ssh 148 | - ssh/terminal 149 | - package: golang.org/x/net 150 | version: ea6dba8c93880aa07d6ebed83c3c680cd9faa63a 151 | subpackages: 152 | - context 153 | - html 154 | - html/atom 155 | - http2 156 | - http2/hpack 157 | - internal/iana 158 | - internal/timeseries 159 | - ipv4 160 | - ipv6 161 | - webdav/internal/xml 162 | - dict 163 | - package: golang.org/x/text 164 | version: cf4986612c83df6c55578ba198316d1684a9a287 165 | subpackages: 166 | - collate 167 | - collate/colltab 168 | - encoding 169 | - encoding/charmap 170 | - encoding/htmlindex 171 | - encoding/internal 172 | - encoding/internal/identifier 173 | - encoding/japanese 174 | - encoding/korean 175 | - encoding/simplifiedchinese 176 | - encoding/traditionalchinese 177 | - encoding/unicode 178 | - internal 179 | - internal/colltab 180 | - internal/format 181 | - internal/gen 182 | - internal/tag 183 | - internal/utf8internal 184 | - language 185 | - runes 186 | - transform 187 | - unicode/cldr 188 | - unicode/norm 189 | - package: gopkg.in/check.v1 190 | version: 11d3bc7aa68e238947792f30573146a3231fc0f1 191 | - package: gopkg.in/mgo.v2 192 | version: e30de8ac9ae3b30df7065f766c71f88bba7d4e49 193 | subpackages: 194 | - bson 195 | - internal/scram 196 | - package: gopkg.in/tomb.v2 197 | version: 14b3d72120e8d10ea6e6b7f87f7175734b1faab8 198 | - package: github.com/mattes/migrate 199 | version: 33fa6fda9d5bd8c47c69ac4a69a771eb24ce9599 200 | repo: https://github.com/harobed/migrate.git 201 | vcs: git 202 | - package: github.com/ikawaha/kagome 203 | - package: github.com/syndtr/goleveldb 204 | - package: github.com/rcrowley/go-metrics 205 | - package: github.com/go-sql-driver/mysql 206 | - package: github.com/gocql/gocql 207 | - package: github.com/lib/pq 208 | -------------------------------------------------------------------------------- /src/gobookmark/glide.lock: -------------------------------------------------------------------------------- 1 | hash: c49a240434b485d1f176a990052c66e8f8922b55ed1bf234be8db2a242e9fdc7 2 | updated: 2016-03-29T22:59:59.869822375Z 3 | imports: 4 | - name: github.com/andybalholm/cascadia 5 | version: 3ad29d1ad1c4f2023e355603324348cf1f4b2d48 6 | - name: github.com/arschles/go-bindata-html-template 7 | version: 4ae2add2b2f4e490b61ce4e7fdfa622824318375 8 | - name: github.com/bitly/go-simplejson 9 | version: aabad6e819789e569bd6aabf444c935aa9ba1e44 10 | - name: github.com/blevesearch/bleve 11 | version: 96577606c33b4be0a9993a7daf581028f84849ee 12 | subpackages: 13 | - analysis 14 | - analysis/analyzers/standard_analyzer 15 | - analysis/byte_array_converters/json 16 | - analysis/datetime_parsers/datetime_optional 17 | - document 18 | - index 19 | - index/firestorm 20 | - index/store 21 | - index/store/boltdb 22 | - index/store/gtreap 23 | - index/upside_down 24 | - numeric_util 25 | - registry 26 | - search 27 | - search/collectors 28 | - search/facets 29 | - search/highlight/highlighters/html 30 | - search/searchers 31 | - analysis/language/en 32 | - analysis/token_filters/lower_case_filter 33 | - analysis/tokenizers/unicode 34 | - analysis/datetime_parsers/flexible_go 35 | - search/highlight 36 | - search/highlight/fragment_formatters/html 37 | - search/highlight/fragmenters/simple 38 | - search/highlight/highlighters/simple 39 | - search/scorers 40 | - analysis/token_filters/porter 41 | - analysis/token_filters/stop_tokens_filter 42 | - name: github.com/blevesearch/go-porterstemmer 43 | version: 23a2c8e5cf1f380f27722c6d2ae8896431dc7d0e 44 | - name: github.com/blevesearch/segment 45 | version: db70c57796cc8c310613541dfade3dce627d09c7 46 | - name: github.com/boj/redistore 47 | version: 9c0e6bab4dd444285424f189b8fb0cb03f653242 48 | - name: github.com/boltdb/bolt 49 | version: c2745b3c62985affcf08d0522135f4747e9b81f3 50 | - name: github.com/cheggaaa/pb 51 | version: c089c0e183064d83038db7c2ae1b711fb2e747a4 52 | - name: github.com/codegangsta/cli 53 | version: f9cc3001e04f9783cb4ad08ca6791aa07134787c 54 | - name: github.com/codegangsta/negroni 55 | version: c7477ad8e330bef55bf1ebe300cf8aa67c492d1b 56 | - name: github.com/crowdmob/goamz 57 | version: 3a06871fe9fc0281ca90f3a7d97258d042ed64c0 58 | subpackages: 59 | - aws 60 | - dynamodb 61 | - dynamodb/dynamizer 62 | - ec2 63 | - elb 64 | - iam 65 | - s3 66 | - name: github.com/davecgh/go-spew 67 | version: 5215b55f46b2b919f50a1df0eaa5886afe4e3b3d 68 | subpackages: 69 | - spew 70 | - name: github.com/dchest/safefile 71 | version: 855e8d98f1852d48dde521e0522408d1fe7e836a 72 | - name: github.com/denizeren/dynamostore 73 | version: 69258d14eb58e5a5b894138d7f2f2609a5effc1f 74 | - name: github.com/dimfeld/httppath 75 | version: c8e499c3ef3c3e272ed8bdcc1ccf39f73c88debc 76 | - name: github.com/dimfeld/httptreemux 77 | version: f74b8868fbe8534a40358ce27b77818eeea2d6e0 78 | - name: github.com/extemporalgenome/slug 79 | version: 0320c85e32e0015b090f08a5d1324d5ab6094f07 80 | - name: github.com/feyeleanor/raw 81 | version: 724aedf6e1a5d8971aafec384b6bde3d5608fba4 82 | - name: github.com/feyeleanor/sets 83 | version: 6c54cb57ea406ff6354256a4847e37298194478f 84 | - name: github.com/feyeleanor/slices 85 | version: bb44bb2e4817fe71ba7082d351fd582e7d40e3ea 86 | - name: github.com/garyburd/redigo 87 | version: 836b6e58b3358112c8291565d01c35b8764070d7 88 | subpackages: 89 | - internal 90 | - redis 91 | - name: github.com/go-sql-driver/mysql 92 | version: 1421caf44f6464fd2ee8de694c7508ee13f92964 93 | - name: github.com/gocql/gocql 94 | version: 277df5b5d96f3f58606a6c52418463370c132e2a 95 | - name: github.com/goincremental/dal 96 | version: 14fa60d0e842a8b7e8589b3fdb66c1d51762b11b 97 | - name: github.com/goincremental/negroni-sessions 98 | version: b07e2d18b97e3f627edaa67876c21fa23eba6a08 99 | subpackages: 100 | - cookiestore 101 | - name: github.com/golang/protobuf 102 | version: 5fc2294e655b78ed8a02082d37808d46c17d7e64 103 | subpackages: 104 | - proto 105 | - name: github.com/gorilla/context 106 | version: 1c83b3eabd45b6d76072b66b746c20815fb2872d 107 | - name: github.com/gorilla/securecookie 108 | version: e95799a481bbcc3d01c2ad5178524cb8bec9f370 109 | - name: github.com/gorilla/sessions 110 | version: 99b16d2edd22458c541b3640ce83689b08a05c20 111 | - name: github.com/ikawaha/kagome 112 | version: 053bff1aa00bb6d786a8bb9c49dc88ee930aa764 113 | - name: github.com/jteeuwen/go-bindata 114 | version: a0ff2567cfb70903282db057e799fd826784d41d 115 | - name: github.com/lib/pq 116 | version: 3cd0097429be7d611bb644ef85b42bfb102ceea4 117 | - name: github.com/mattes/migrate 118 | version: 33fa6fda9d5bd8c47c69ac4a69a771eb24ce9599 119 | repo: https://github.com/harobed/migrate.git 120 | vcs: git 121 | subpackages: 122 | - driver/sqlite3 123 | - file 124 | - migrate 125 | - driver 126 | - migrate/direction 127 | - pipe 128 | - name: github.com/mattn/go-sqlite3 129 | version: 5651a9d9d49ec25811d08220ee972080378d52ea 130 | - name: github.com/pmezard/go-difflib 131 | version: e8554b8641db39598be7f6342874b958f12ae1d4 132 | subpackages: 133 | - difflib 134 | - name: github.com/PuerkitoBio/goquery 135 | version: 417cce822c7b9a379df5824be95228d177c5698b 136 | - name: github.com/rcrowley/go-metrics 137 | version: eeba7bd0dd01ace6e690fa833b3f22aaec29af43 138 | - name: github.com/steveyen/gtreap 139 | version: 0abe01ef9be25c4aedc174758ec2d917314d6d70 140 | - name: github.com/stretchr/objx 141 | version: 1a9d0bb9f541897e62256577b352fdbc1fb4fd94 142 | - name: github.com/stretchr/testify 143 | version: e3a8ff8ce36581f87a15341206f205b1da467059 144 | subpackages: 145 | - assert 146 | - http 147 | - mock 148 | - require 149 | - name: github.com/syndtr/goleveldb 150 | version: 93fc893f2dadb96ffde441c7546cc67ea290a3a8 151 | - name: github.com/Unknwon/paginater 152 | version: 7748a72e01415173a27d79866b984328e7b0c12b 153 | - name: github.com/willf/bitset 154 | version: bb0da3785c4fe9d26f6029c77c8fce2aa4d0b291 155 | - name: golang.org/x/crypto 156 | version: f18420efc3b4f8e9f3d51f6bd2476e92c46260e9 157 | subpackages: 158 | - blowfish 159 | - cast5 160 | - curve25519 161 | - nacl/secretbox 162 | - openpgp/armor 163 | - openpgp/elgamal 164 | - openpgp/errors 165 | - openpgp/packet 166 | - openpgp/s2k 167 | - pbkdf2 168 | - pkcs12/internal/rc2 169 | - poly1305 170 | - salsa20/salsa 171 | - ssh 172 | - ssh/terminal 173 | - name: golang.org/x/net 174 | version: ea6dba8c93880aa07d6ebed83c3c680cd9faa63a 175 | subpackages: 176 | - context 177 | - html 178 | - html/atom 179 | - http2 180 | - http2/hpack 181 | - internal/iana 182 | - internal/timeseries 183 | - ipv4 184 | - ipv6 185 | - webdav/internal/xml 186 | - dict 187 | - name: golang.org/x/text 188 | version: cf4986612c83df6c55578ba198316d1684a9a287 189 | subpackages: 190 | - collate 191 | - collate/colltab 192 | - encoding 193 | - encoding/charmap 194 | - encoding/htmlindex 195 | - encoding/internal 196 | - encoding/internal/identifier 197 | - encoding/japanese 198 | - encoding/korean 199 | - encoding/simplifiedchinese 200 | - encoding/traditionalchinese 201 | - encoding/unicode 202 | - internal 203 | - internal/colltab 204 | - internal/format 205 | - internal/gen 206 | - internal/tag 207 | - internal/utf8internal 208 | - language 209 | - runes 210 | - transform 211 | - unicode/cldr 212 | - unicode/norm 213 | - name: gopkg.in/check.v1 214 | version: 11d3bc7aa68e238947792f30573146a3231fc0f1 215 | - name: gopkg.in/mgo.v2 216 | version: e30de8ac9ae3b30df7065f766c71f88bba7d4e49 217 | subpackages: 218 | - bson 219 | - internal/scram 220 | - name: gopkg.in/tomb.v2 221 | version: 14b3d72120e8d10ea6e6b7f87f7175734b1faab8 222 | devImports: [] 223 | -------------------------------------------------------------------------------- /src/gobookmark/models.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/blevesearch/bleve" 6 | "github.com/extemporalgenome/slug" 7 | _ "github.com/mattes/migrate/driver/sqlite3" 8 | "github.com/mattes/migrate/file" 9 | "github.com/mattes/migrate/migrate" 10 | _ "github.com/mattn/go-sqlite3" 11 | "log" 12 | "os" 13 | "strconv" 14 | "strings" 15 | "time" 16 | ) 17 | 18 | var ( 19 | DB *sql.DB 20 | INDEX bleve.Index 21 | ) 22 | 23 | type Tag struct { 24 | Id int64 25 | Title string 26 | Slug string 27 | } 28 | 29 | type BookmarkItem struct { 30 | Id int64 31 | Url string 32 | Title string 33 | CreateDate time.Time 34 | Tags []*Tag 35 | } 36 | 37 | func countLinks(tags string) int { 38 | var count int 39 | if tags != "" { 40 | err := DB.QueryRow( 41 | `SELECT 42 | COUNT(links.id) 43 | FROM 44 | links 45 | LEFT JOIN 46 | rel_links_tags 47 | ON 48 | rel_links_tags.link_id = links.id 49 | LEFT JOIN 50 | tags 51 | ON 52 | rel_links_tags.tag_id = tags.id 53 | WHERE 54 | tags.slug IN (?)`, tags).Scan(&count) 55 | checkErr(err) 56 | } else { 57 | err := DB.QueryRow("SELECT COUNT(id) FROM links").Scan(&count) 58 | checkErr(err) 59 | } 60 | return count 61 | } 62 | 63 | func openDatabase(filename string) *sql.DB { 64 | migrate.NonGraceful() 65 | migrate.UseStore(file.AssetStore{ 66 | Asset: Asset, 67 | AssetDir: AssetDir, 68 | }) 69 | errors, ok := migrate.UpSync("sqlite3://"+filename, "migrations") 70 | if !ok { 71 | log.Fatalf("%v", errors) 72 | } 73 | 74 | db, err := sql.Open("sqlite3", filename) 75 | checkErr(err) 76 | 77 | return db 78 | } 79 | 80 | func indexBookmarkItem(item *BookmarkItem) error { 81 | x := struct { 82 | Id int64 `json:"id"` 83 | Url string `json:"url"` 84 | Title string `json:"title"` 85 | Tags string `json:"tags"` 86 | }{ 87 | Id: item.Id, 88 | Url: item.Url, 89 | Title: item.Title, 90 | Tags: "", 91 | } 92 | for _, t := range item.Tags { 93 | x.Tags = x.Tags + " " + t.Slug 94 | } 95 | return INDEX.Index(strconv.FormatInt(item.Id, 10), x) 96 | } 97 | 98 | func indexAllBookmark() { 99 | rows, err := DB.Query("SELECT id, title, url, createdate FROM links") 100 | checkErr(err) 101 | defer rows.Close() 102 | 103 | for rows.Next() { 104 | bm := new(BookmarkItem) 105 | 106 | err := rows.Scan(&bm.Id, &bm.Title, &bm.Url, &bm.CreateDate) 107 | checkErr(err) 108 | 109 | bm.Tags = getLinksTags(bm.Id) 110 | 111 | indexBookmarkItem(bm) 112 | } 113 | } 114 | 115 | func openBleve(filename string) (index bleve.Index) { 116 | if _, err := os.Stat(filename); os.IsNotExist(err) { 117 | indexMapping := bleve.NewIndexMapping() 118 | linkMapping := bleve.NewDocumentMapping() 119 | 120 | linkTitleFieldMapping := bleve.NewTextFieldMapping() 121 | linkTitleFieldMapping.Analyzer = "en" 122 | linkMapping.AddFieldMappingsAt("title", linkTitleFieldMapping) 123 | 124 | linkUrlFieldMapping := bleve.NewTextFieldMapping() 125 | linkMapping.AddFieldMappingsAt("url", linkUrlFieldMapping) 126 | 127 | linkTagsFieldMapping := bleve.NewTextFieldMapping() 128 | linkMapping.AddFieldMappingsAt("tags", linkTagsFieldMapping) 129 | 130 | indexMapping.AddDocumentMapping("link", linkMapping) 131 | 132 | index, err = bleve.New(filename, indexMapping) 133 | checkErr(err) 134 | } else { 135 | index, err = bleve.Open(filename) 136 | checkErr(err) 137 | } 138 | 139 | return index 140 | } 141 | 142 | func getOrCreateTag(tag_name string) int64 { 143 | var id int64 144 | stmt, err := DB.Prepare("SELECT id FROM tags WHERE title=?") 145 | checkErr(err) 146 | 147 | rows, err := stmt.Query(tag_name) 148 | defer rows.Close() 149 | if rows.Next() { 150 | rows.Scan(&id) 151 | } else { 152 | stmt, err = DB.Prepare("INSERT INTO tags (title, slug) VALUES(?, ?)") 153 | checkErr(err) 154 | res, err := stmt.Exec(tag_name, slug.Slug(tag_name)) 155 | id, err = res.LastInsertId() 156 | checkErr(err) 157 | } 158 | return id 159 | } 160 | 161 | func insertLink(title string, url string, tags string) (id int64) { 162 | stmt, err := DB.Prepare("INSERT INTO links (title, url) VALUES(?, ?)") 163 | checkErr(err) 164 | 165 | res, err := stmt.Exec(title, url) 166 | checkErr(err) 167 | link_id, err := res.LastInsertId() 168 | checkErr(err) 169 | updateLinksTags(link_id, strings.Split(tags, ",")) 170 | return link_id 171 | } 172 | 173 | func updateLink(id int64, title string, url string, tags string) { 174 | stmt, err := DB.Prepare("UPDATE links SET title=?, url=? WHERE id=?") 175 | checkErr(err) 176 | 177 | _, err = stmt.Exec(title, url, id) 178 | 179 | updateLinksTags(id, strings.Split(tags, ",")) 180 | } 181 | 182 | func updateLinksTags(link_id int64, tag_name_list []string) { 183 | stmt, err := DB.Prepare("DELETE FROM rel_links_tags WHERE link_id=?") 184 | checkErr(err) 185 | _, err = stmt.Exec(link_id) 186 | checkErr(err) 187 | 188 | for _, tag_name := range tag_name_list { 189 | tag_id := getOrCreateTag(tag_name) 190 | stmt, err = DB.Prepare("INSERT INTO rel_links_tags (link_id, tag_id) VALUES(?, ?)") 191 | _, err = stmt.Exec(link_id, tag_id) 192 | checkErr(err) 193 | } 194 | } 195 | 196 | func getLinksTags(link_id int64) (result []*Tag) { 197 | stmt, err := DB.Prepare( 198 | `SELECT 199 | tags.id, 200 | tags.title, 201 | tags.slug 202 | FROM 203 | rel_links_tags 204 | LEFT JOIN 205 | tags 206 | ON 207 | rel_links_tags.tag_id = tags.id 208 | WHERE 209 | rel_links_tags.link_id=?`) 210 | checkErr(err) 211 | rows, err := stmt.Query(link_id) 212 | defer rows.Close() 213 | checkErr(err) 214 | for rows.Next() { 215 | tag := new(Tag) 216 | err := rows.Scan(&tag.Id, &tag.Title, &tag.Slug) 217 | checkErr(err) 218 | result = append(result, tag) 219 | } 220 | return result 221 | } 222 | 223 | func getBookmark(id int64) *BookmarkItem { 224 | bookmark_item := new(BookmarkItem) 225 | err := DB.QueryRow("SELECT id, title, url, createdate FROM links WHERE id=?", id).Scan( 226 | &bookmark_item.Id, 227 | &bookmark_item.Title, 228 | &bookmark_item.Url, 229 | &bookmark_item.CreateDate, 230 | ) 231 | checkErr(err) 232 | bookmark_item.Tags = getLinksTags(id) 233 | return bookmark_item 234 | } 235 | 236 | func queryBookmark(page int, items_by_page int, tags string) []*BookmarkItem { 237 | var rows *sql.Rows 238 | 239 | if tags != "" { 240 | stmt, err := DB.Prepare( 241 | `SELECT 242 | links.id, 243 | links.title, 244 | links.url, 245 | links.createdate 246 | FROM 247 | links 248 | LEFT JOIN 249 | rel_links_tags 250 | ON 251 | rel_links_tags.link_id = links.id 252 | LEFT JOIN 253 | tags 254 | ON 255 | rel_links_tags.tag_id = tags.id 256 | WHERE 257 | tags.slug IN (?) 258 | ORDER BY 259 | links.createdate DESC 260 | LIMIT ? OFFSET ?`) 261 | checkErr(err) 262 | rows, err = stmt.Query(tags, items_by_page, (page-1)*items_by_page) 263 | checkErr(err) 264 | } else { 265 | stmt, err := DB.Prepare( 266 | `SELECT 267 | id, 268 | title, 269 | url, 270 | createdate 271 | FROM 272 | links 273 | ORDER BY 274 | createdate DESC 275 | LIMIT ? OFFSET ?`) 276 | checkErr(err) 277 | rows, err = stmt.Query(items_by_page, (page-1)*items_by_page) 278 | checkErr(err) 279 | } 280 | defer rows.Close() 281 | 282 | bms := make([]*BookmarkItem, 0) 283 | for rows.Next() { 284 | bm := new(BookmarkItem) 285 | err := rows.Scan(&bm.Id, &bm.Title, &bm.Url, &bm.CreateDate) 286 | checkErr(err) 287 | 288 | bm.Tags = getLinksTags(bm.Id) 289 | 290 | bms = append(bms, bm) 291 | } 292 | return bms 293 | } 294 | 295 | func searchBookmark(search string, page int, items_by_page int) (total int, bms []*BookmarkItem) { 296 | tags := extractTags(search) 297 | search = removeTags(search) 298 | 299 | var tags_query_list []bleve.Query 300 | tags_query_list = nil 301 | 302 | if len(tags) > 0 { 303 | tags_query_list = make([]bleve.Query, 0) 304 | for _, tag := range tags { 305 | q := bleve.NewQueryStringQuery("tags:" + tag) 306 | tags_query_list = append(tags_query_list, q) 307 | } 308 | } 309 | 310 | var query bleve.Query 311 | 312 | if search != "" { 313 | query_list := make([]bleve.Query, 2) 314 | fuzzy_query := bleve.NewFuzzyQuery(search) 315 | fuzzy_query.FuzzinessVal = 1 316 | query_list[0] = fuzzy_query 317 | query_list[1] = bleve.NewRegexpQuery("[a-zA-Z0-9_]*" + search + "[a-zA-Z0-9_]*") 318 | query = bleve.NewBooleanQuery(tags_query_list, query_list, nil) 319 | } else { 320 | query = bleve.NewBooleanQuery(tags_query_list, nil, nil) 321 | } 322 | searchRequest := bleve.NewSearchRequestOptions(query, items_by_page, (page-1)*items_by_page, false) 323 | sr, err := INDEX.Search(searchRequest) 324 | checkErr(err) 325 | 326 | bms = make([]*BookmarkItem, 0) 327 | 328 | if sr.Total > 0 { 329 | if sr.Request.Size > 0 { 330 | for _, hit := range sr.Hits { 331 | id, err := strconv.ParseInt(hit.ID, 10, 64) 332 | checkErr(err) 333 | bms = append(bms, getBookmark(id)) 334 | } 335 | } 336 | } 337 | 338 | return int(sr.Total), bms 339 | } 340 | -------------------------------------------------------------------------------- /src/gobookmark/public/js/bootstrap-tagsinput.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | "use strict"; 3 | 4 | var defaultOptions = { 5 | tagClass: function(item) { 6 | return 'label label-info'; 7 | }, 8 | itemValue: function(item) { 9 | return item ? item.toString() : item; 10 | }, 11 | itemText: function(item) { 12 | return this.itemValue(item); 13 | }, 14 | itemTitle: function(item) { 15 | return null; 16 | }, 17 | freeInput: true, 18 | addOnBlur: true, 19 | maxTags: undefined, 20 | maxChars: undefined, 21 | confirmKeys: [13, 44], 22 | delimiter: ',', 23 | delimiterRegex: null, 24 | cancelConfirmKeysOnEmpty: false, 25 | onTagExists: function(item, $tag) { 26 | $tag.hide().fadeIn(); 27 | }, 28 | trimValue: false, 29 | allowDuplicates: false 30 | }; 31 | 32 | /** 33 | * Constructor function 34 | */ 35 | function TagsInput(element, options) { 36 | this.isInit = true; 37 | this.itemsArray = []; 38 | 39 | this.$element = $(element); 40 | this.$element.hide(); 41 | 42 | this.isSelect = (element.tagName === 'SELECT'); 43 | this.multiple = (this.isSelect && element.hasAttribute('multiple')); 44 | this.objectItems = options && options.itemValue; 45 | this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : ''; 46 | this.inputSize = Math.max(1, this.placeholderText.length); 47 | 48 | this.$container = $('
'); 49 | this.$input = $('').appendTo(this.$container); 50 | 51 | this.$element.before(this.$container); 52 | 53 | this.build(options); 54 | this.isInit = false; 55 | } 56 | 57 | TagsInput.prototype = { 58 | constructor: TagsInput, 59 | 60 | /** 61 | * Adds the given item as a new tag. Pass true to dontPushVal to prevent 62 | * updating the elements val() 63 | */ 64 | add: function(item, dontPushVal, options) { 65 | var self = this; 66 | 67 | if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags) 68 | return; 69 | 70 | // Ignore falsey values, except false 71 | if (item !== false && !item) 72 | return; 73 | 74 | // Trim value 75 | if (typeof item === "string" && self.options.trimValue) { 76 | item = $.trim(item); 77 | } 78 | 79 | // Throw an error when trying to add an object while the itemValue option was not set 80 | if (typeof item === "object" && !self.objectItems) 81 | throw("Can't add objects when itemValue option is not set"); 82 | 83 | // Ignore strings only containg whitespace 84 | if (item.toString().match(/^\s*$/)) 85 | return; 86 | 87 | // If SELECT but not multiple, remove current tag 88 | if (self.isSelect && !self.multiple && self.itemsArray.length > 0) 89 | self.remove(self.itemsArray[0]); 90 | 91 | if (typeof item === "string" && this.$element[0].tagName === 'INPUT') { 92 | var delimiter = (self.options.delimiterRegex) ? self.options.delimiterRegex : self.options.delimiter; 93 | var items = item.split(delimiter); 94 | if (items.length > 1) { 95 | for (var i = 0; i < items.length; i++) { 96 | this.add(items[i], true); 97 | } 98 | 99 | if (!dontPushVal) 100 | self.pushVal(); 101 | return; 102 | } 103 | } 104 | 105 | var itemValue = self.options.itemValue(item), 106 | itemText = self.options.itemText(item), 107 | tagClass = self.options.tagClass(item), 108 | itemTitle = self.options.itemTitle(item); 109 | 110 | // Ignore items allready added 111 | var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0]; 112 | if (existing && !self.options.allowDuplicates) { 113 | // Invoke onTagExists 114 | if (self.options.onTagExists) { 115 | var $existingTag = $(".tag", self.$container).filter(function() { return $(this).data("item") === existing; }); 116 | self.options.onTagExists(item, $existingTag); 117 | } 118 | return; 119 | } 120 | 121 | // if length greater than limit 122 | if (self.items().toString().length + item.length + 1 > self.options.maxInputLength) 123 | return; 124 | 125 | // raise beforeItemAdd arg 126 | var beforeItemAddEvent = $.Event('beforeItemAdd', { item: item, cancel: false, options: options}); 127 | self.$element.trigger(beforeItemAddEvent); 128 | if (beforeItemAddEvent.cancel) 129 | return; 130 | 131 | // register item in internal array and map 132 | self.itemsArray.push(item); 133 | 134 | // add a tag element 135 | 136 | var $tag = $('' + htmlEncode(itemText) + ''); 137 | $tag.data('item', item); 138 | self.findInputWrapper().before($tag); 139 | $tag.after(' '); 140 | 141 | // Check to see if the tag exists in its raw or uri-encoded form 142 | var optionExists = ( 143 | $('option[value="' + encodeURIComponent(itemValue) + '"]', self.$element).length || 144 | $('option[value="' + htmlEncode(itemValue) + '"]', self.$element).length 145 | ); 146 | 147 | // add