├── .gitignore ├── .golangci.yml ├── Dockerfile ├── LICENSE ├── README.md ├── build-binaries.sh ├── docker-compose.yml ├── docker └── entrypoint.sh ├── documents ├── favicon.svg ├── ghost.svg └── screenshot.xcf ├── go.mod ├── go.sum ├── gobkm.service ├── handlers └── handlers.go ├── main.go ├── models ├── idatastore.go └── sqlitedatastore.go ├── screenshot.png ├── static ├── index.html └── wasm │ ├── main.wasm │ └── wasm_exec.js ├── tags └── types └── bookmark.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | # Custom locations 27 | bindata.go 28 | bkm.db 29 | gobkm 30 | build 31 | bkm.db* 32 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # options for analysis running 2 | run: 3 | # timeout for analysis, e.g. 30s, 5m, default is 1m 4 | timeout: 1m 5 | 6 | # exit code when at least one issue was found, default is 1 7 | issues-exit-code: 0 8 | 9 | # include test files or not, default is true 10 | tests: false 11 | 12 | # which dirs to skip: issues from them won't be reported; 13 | # can use regexp here: generated.*, regexp is applied on full path; 14 | # default value is empty list, but default dirs are skipped independently 15 | # from this option's value (see skip-dirs-use-default). 16 | # "/" will be replaced by current OS file path separator to properly work 17 | # on Windows. 18 | skip-dirs: 19 | - wasm 20 | - static 21 | - node_modules 22 | - documents 23 | - docker 24 | - bind-* 25 | 26 | # default is true. Enables skipping of directories: 27 | # vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ 28 | skip-dirs-use-default: true 29 | 30 | # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules": 31 | # If invoked with -mod=readonly, the go command is disallowed from the implicit 32 | # automatic updating of go.mod described above. Instead, it fails when any changes 33 | # to go.mod are needed. This setting is most useful to check that go.mod does 34 | # not need updates, such as in a continuous integration and testing system. 35 | # If invoked with -mod=vendor, the go command assumes that the vendor 36 | # directory holds the correct copies of dependencies and ignores 37 | # the dependency descriptions in go.mod. 38 | modules-download-mode: mod 39 | 40 | # Allow multiple parallel golangci-lint instances running. 41 | # If false (default) - golangci-lint acquires file lock on start. 42 | allow-parallel-runners: true 43 | 44 | 45 | # output configuration options 46 | output: 47 | # colored-line-number|line-number|json|tab|checkstyle|code-climate|junit-xml|github-actions 48 | # default is "colored-line-number" 49 | format: colored-line-number 50 | 51 | # print lines of code with issue, default is true 52 | print-issued-lines: true 53 | 54 | # print linter name in the end of issue text, default is true 55 | print-linter-name: true 56 | 57 | # make issues output unique by line, default is true 58 | uniq-by-line: true 59 | 60 | # add a prefix to the output file references; default is no prefix 61 | path-prefix: "" 62 | 63 | # sorts results by: filepath, line and column 64 | sort-results: false 65 | 66 | 67 | # all available settings of specific linters 68 | linters-settings: 69 | gofmt: 70 | # simplify code: gofmt with `-s` option, true by default 71 | simplify: true 72 | staticcheck: 73 | # Select the Go version to target. The default is '1.13'. 74 | go: "1.16" 75 | # https://staticcheck.io/docs/options#checks 76 | checks: ["all"] 77 | stylecheck: 78 | # Select the Go version to target. The default is '1.13'. 79 | go: "1.18" 80 | # https://staticcheck.io/docs/options#checks 81 | checks: ["all"] 82 | errcheck: 83 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 84 | # default is false: such cases aren't reported by default. 85 | check-blank: true 86 | 87 | linters: 88 | enable: 89 | # go install github.com/kisielk/errcheck@latest 90 | # go install github.com/gordonklaus/ineffassign@latest 91 | # go install honnef.co/go/tools/cmd/staticcheck@latest 92 | # go install gitlab.com/opennota/check/cmd/varcheck@latest 93 | # go install github.com/go-critic/go-critic/cmd/gocritic@latest 94 | - errcheck 95 | - staticcheck 96 | - stylecheck 97 | - ineffassign 98 | - varcheck 99 | - gofmt 100 | - gocritic 101 | - wsl 102 | fast: false 103 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Version: 0.0.1 2 | FROM golang:1.18 3 | LABEL author="Thomas Bellembois" 4 | 5 | # copying sources 6 | WORKDIR /go/src/github.com/tbellembois/gobkm/ 7 | COPY . . 8 | 9 | # installing dependencies 10 | RUN go get -v ./... 11 | 12 | # compiling 13 | RUN go install . 14 | 15 | # installing GoBkm 16 | RUN mkdir /var/www-data \ 17 | && cp /go/bin/gobkm /var/www-data/ \ 18 | && chown -R www-data /var/www-data \ 19 | && chmod +x /var/www-data/gobkm 20 | 21 | # cleanup sources 22 | RUN rm -Rf /go/src/* 23 | 24 | # copying entrypoint 25 | COPY docker/entrypoint.sh / 26 | RUN chmod +x /entrypoint.sh 27 | 28 | # creating volume directory 29 | RUN mkdir /data 30 | 31 | USER www-data 32 | WORKDIR /var/www-data 33 | ENTRYPOINT [ "/entrypoint.sh" ] 34 | VOLUME ["/data"] 35 | EXPOSE 8081 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoBkm 2 | 3 | GoBkm is an *ultra minimalist single user online bookmark manager* inspired by written in [Go](https://golang.org/) and GopherJS. 4 | It is designed to run on a remote server (I run it on a [RaspberryPi](https://www.raspberrypi.org/)) and accessed remotely. 5 | 6 | The purpose of this project was to study the Go programming language (OOP, databases, HTML templates, learning curve). 7 | 8 | ![screenshot](screenshot.png) 9 | 10 | ## Installation 11 | 12 | Use the Docker image with the `docker-compose.yml` file in the sources. 13 | 14 | or 15 | 16 | Download and uncompress the latest release from . 17 | 18 | or 19 | 20 | ```bash 21 | $ go get -u github.com/tbellembois/gobkm 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```bash 27 | ./gobkm # run GoBkm on localhost:8080 28 | ``` 29 | 30 | You can change the listening port with: 31 | ```bash 32 | ./gobkm -port [port_number] 33 | ``` 34 | 35 | Using an HTTP proxy (Apache/Nginx), specify its URL with: 36 | ```bash 37 | ./gobkm -port [port_number] -proxy [proxy_url] 38 | ``` 39 | 40 | Log to file 41 | ```bash 42 | ./gobkm -logfile /var/log/gobkm.log 43 | ``` 44 | 45 | Debug mode (by default only errors are shown): 46 | ```bash 47 | ./gobkm -debug 48 | ``` 49 | 50 | Specify sqlite database file path 51 | ```bash 52 | ./gobkm -db /var/gobkm/gobkm.db 53 | ``` 54 | 55 | ## GUI 56 | 57 | - drag and drop an URL from your Web browser address bar into a folder OR 58 | - use the "new bookmark" icon in a folder menu 59 | 60 | ### Stars 61 | 62 | You can "star" your favorite bookmarks to keep them on the top of the window. 63 | 64 | ### Tags 65 | 66 | You can tag bookmarks. This may be redondant with folders but it may help if you have bookmarks with the same topic in different folders. 67 | 68 | ## Bookmarklets 69 | 70 | Click on the little "earth" icon at the bottom of the application and drag and drop the bookmarklet in your bookmark bar. 71 | Searches in the search field are performed by bookmark names and tags. 72 | 73 | ## Nginx proxy (optional) 74 | 75 | ### GoBkm installation 76 | 77 | You can use Nginx in front of GoBkm to use authentication and HTTPS. 78 | 79 | - create a `gobkm` user and group, and a home for the app 80 | 81 | ```bash 82 | groupadd --system gobkm 83 | useradd --system gobkm --gid gobkm 84 | mkdir /usr/local/gobkm 85 | ``` 86 | 87 | - drop the bkm binary into the `/usr/local/gobkm` directory 88 | 89 | - setup permissions 90 | 91 | ```bash 92 | chown -R gobkm:gobkm /usr/local/gobkm 93 | cd /usr/local/gobkm 94 | ``` 95 | 96 | - launch GoBkm 97 | 98 | ```bash 99 | cd /usr/local/gobkm 100 | su - gobkm -c "/usr/local/gobkm/gobkm -proxy http://proxy_url" & 101 | ``` 102 | 103 | ### Nginx configuration 104 | 105 | - setup a GoBkm server configuration file such as `/etc/nginx/servers-available/gobkm.conf` 106 | 107 | ```bash 108 | server { 109 | 110 | listen 80; 111 | # change proxy_url 112 | server_name proxy_url; 113 | 114 | root /usr/local/gobkm; 115 | charset utf-8; 116 | 117 | gzip on; 118 | gzip_disable "msie6"; 119 | 120 | gzip_comp_level 6; 121 | gzip_min_length 1100; 122 | gzip_buffers 16 8k; 123 | gzip_proxied any; 124 | gzip_types 125 | text/plain 126 | text/css 127 | text/js 128 | text/xml 129 | text/javascript 130 | application/javascript 131 | application/x-javascript 132 | application/json 133 | application/xml 134 | application/rss+xml 135 | image/svg+xml; 136 | 137 | # uncomment and change to enable HTTPS 138 | #ssl on; 139 | #ssl_certificate /etc/nginx/ssl2/my-gobkm.crt; 140 | #ssl_certificate_key /etc/nginx/ssl2/my-gobkm.key; 141 | 142 | # uncomment to enable authentication 143 | # details at: http://nginx.org/en/docs/http/ngx_http_auth_basic_module.html 144 | #auth_basic "GoBkm"; 145 | #auth_basic_user_file /usr/local/gobkm/gobkm.htpasswd; 146 | 147 | location / { 148 | 149 | # preflight OPTIONS requests response 150 | if ($request_method = 'OPTIONS') { 151 | add_header 'Access-Control-Allow-Credentials' 'true'; 152 | add_header 'Access-Control-Allow-Origin' '*'; 153 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; 154 | # 155 | # Custom headers and headers various browsers *should* be OK with but aren't 156 | # 157 | add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; 158 | # 159 | # Tell client that this pre-flight info is valid for 20 days 160 | # 161 | add_header 'Access-Control-Max-Age' 1728000; 162 | add_header 'Content-Type' 'text/plain charset=UTF-8'; 163 | add_header 'Content-Length' 0; 164 | return 204; 165 | } 166 | 167 | # change the port if needed 168 | proxy_set_header Upgrade $http_upgrade; 169 | proxy_set_header Connection 'upgrade'; 170 | proxy_pass http://127.0.0.1:8080; 171 | } 172 | 173 | } 174 | ``` 175 | 176 | - enable the new site 177 | 178 | ```bash 179 | $ ln -s /etc/nginx/servers-available/gobkm.conf /etc/nginx/servers-enabled/ 180 | $ systemctl restart nginx 181 | ``` 182 | 183 | ### SSL self-signed certificate generation (optional) 184 | 185 | I strongly recommend [Let's Encrypt](https://letsencrypt.org/) for the certificates generation. 186 | 187 | The following method is just here for archive purposes. 188 | 189 | ```bash 190 | # generate a root CA key 191 | openssl genrsa -out rootCA.key 2048 192 | # the a root CA 193 | openssl req -x509 -new -nodes -key rootCA.key -days 3650 -out rootCA.crt 194 | # generate a server key 195 | openssl genrsa -out my-gobkm.key 2048 196 | # then a CSR (certificate signing request) 197 | openssl req -new -key my-gobkm.key -out my-gobkm.csr 198 | # and finally auto signing the server certificate with the root CA 199 | openssl x509 -req -in my-gobkm.csr -CA rootCA.crt -CAkey rootCA.key -CAcreateserial -out my-gobkm.crt -days 3650 200 | ``` 201 | To avoid security exceptions just import your `rootCA.crt` into your browser. 202 | 203 | ## systemd script (optional) 204 | 205 | If you want to start GoBkm at boot you can install the provided systemd `gobkm.service` script. 206 | 207 | ```bash 208 | # example for Arch Linux 209 | cd /etc/systemd/system 210 | wget https://raw.githubusercontent.com/tbellembois/gobkm/master/gobkm.service 211 | vim gobkm.service 212 | # change the ExecStart line and other parameters according to your configuration 213 | systemctl enable gobkm.service 214 | ``` 215 | 216 | ## Thanks 217 | 218 | Thanks to [Sébastien Binet](https://github.com/sbinet) for the tutorial and help on Go. 219 | Thanks to [Dmitri Shuralyov](https://github.com/shurcooL) for the help on GopherJS. 220 | 221 | ## Known limitations 222 | 223 | - no user management 224 | - no authentication (relies on the HTTP proxy) 225 | - folders and bookmarks are sorted by title (currently not configurable) 226 | 227 | ## Notes 228 | 229 | Cross compiled for the RaspberryPi under Arch Linux with: 230 | ```bash 231 | # requires the package arm-linux-gnueabihf-gcc 232 | env GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=1 CC=/usr/bin/arm-linux-gnueabihf-gcc go build . 233 | ``` 234 | 235 | Javascript generation (DEPRECATED): 236 | ```bash 237 | gopherjs build static/js/gjs-main.go -o static/js/gjs-main.js 238 | ``` 239 | 240 | ## Credits 241 | 242 | - sites favicon retrieved from [Google](http://www.google.com) 243 | - folders, bookmarks, rename and delete icons from the [FontAwesome](https://fontawesome.github.io/Font-Awesome/) library 244 | - GoBKM SVG favicon build with [Inkscape](http://www.inkscape-fr.org/) from and 245 | - favicon PNG generated from 246 | 247 | ## References 248 | 249 | - 250 | - 251 | - 252 | - 253 | - 254 | - 255 | - 256 | - 257 | - 258 | - 259 | -------------------------------------------------------------------------------- /build-binaries.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Developper script to build binaries 4 | # 5 | set -o nounset # Treat unset variables as an error 6 | 7 | OUTPUT_DIR="./build" 8 | 9 | BINARY_ARMV7_NAME="gobkm-ARMv7" 10 | BINARY_X86_NAME="gobkm-AMD64" 11 | 12 | PACKAGE_ARCHIVE_NAME="gobkm.zip" 13 | STATIC_RESOURCES_ARCHIVE_NAME="static.zip" 14 | 15 | BUILD_ARMV7_CMD="env GOOS=linux GOARCH=arm CC=arm-linux-gnueabi-gcc GOARM=7 CGO_ENABLED=1 ENABLE_CGO=1 go build -o $OUTPUT_DIR/$BINARY_ARMV7_NAME ." 16 | BUILD_X86_CMD="go build -o $OUTPUT_DIR/$BINARY_X86_NAME ." 17 | 18 | RICE_ARMV7_CMD="rice append --exec $OUTPUT_DIR/$BINARY_ARMV7_NAME" 19 | RICE_X86_CMD="rice append --exec $OUTPUT_DIR/$BINARY_X86_NAME" 20 | 21 | echo "-cleaning $OUTPUT_DIR" 22 | rm -Rf $OUTPUT_DIR/* 23 | 24 | echo "-building $BINARY_X86_NAME" 25 | $BUILD_X86_CMD 26 | 27 | echo "-building binaries zip" 28 | zip -r $OUTPUT_DIR/$PACKAGE_ARCHIVE_NAME $OUTPUT_DIR 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | gobkm: 4 | container_name: gobkm 5 | image: tbellembois/gobkm 6 | restart: unless-stopped 7 | ports: 8 | - 80:8081 9 | environment: 10 | - GOBKM_PROXYURL=http://localhost 11 | # - GOBKM_HISTORY=3 12 | # - GOBKM_USERNAME=me 13 | # - GOBKM_DEBUG=true 14 | # - GOBKM_LOGFILE=/var/log/chimitheque.log 15 | volumes: 16 | - /data/gobkm:/data 17 | networks: 18 | - chimitheque 19 | networks: 20 | chimitheque: -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | proxy="" 3 | logfile="" 4 | debug="" 5 | history=3 6 | username="" 7 | 8 | if [ ! -z "$GOBKM_PROXYURL" ] 9 | then 10 | proxy="-proxy $GOBKM_PROXYURL" 11 | fi 12 | if [ ! -z "$GOBKM_HISTORY" ] 13 | then 14 | history="-history $GOBKM_HISTORY" 15 | fi 16 | if [ ! -z "$GOBKM_USERNAME" ] 17 | then 18 | username="-username $GOBKM_USERNAME" 19 | fi 20 | if [ ! -z "$GOBKM_DEBUG" ] 21 | then 22 | debug="-debug" 23 | fi 24 | if [ ! -z "$GOBKM_LOGFILE" ] 25 | then 26 | logfile="-logfile $GOBKM_LOGFILE" 27 | fi 28 | 29 | echo "proxy: $GOBKM_PROXYURL" 30 | echo "logfile: $GOBKM_LOGFILE" 31 | echo "debug: $GOBKM_DEBUG" 32 | echo "history: $GOBKM_HISTORY" 33 | echo "username: $GOBKM_USERNAME" 34 | 35 | /var/www-data/gobkm -db /data/bkm.db \ 36 | $debug \ 37 | $proxy \ 38 | $logfile \ 39 | $history \ 40 | $username -------------------------------------------------------------------------------- /documents/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml -------------------------------------------------------------------------------- /documents/ghost.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 42 | 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 54 | 58 | 60 | 72 | 75 | move 87 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /documents/screenshot.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbellembois/gobkm/7f373d50cb801a247ea25e6ce03a528089ff5261/documents/screenshot.xcf -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tbellembois/gobkm 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/justinas/alice v1.2.0 7 | github.com/mattn/go-sqlite3 v1.14.12 8 | github.com/rs/cors v1.8.2 9 | github.com/sirupsen/logrus v1.8.1 10 | golang.org/x/net v0.0.0-20220403103023-749bd193bc2b 11 | ) 12 | 13 | require golang.org/x/sys v0.0.0-20220403020550-483a9cbc67c0 // indirect 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 5 | github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= 6 | github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= 7 | github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= 8 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 9 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 10 | github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 11 | github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= 12 | github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= 13 | github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= 14 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= 15 | github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA= 16 | github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 17 | github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0= 18 | github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= 19 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 20 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so= 24 | github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM= 25 | github.com/rs/cors v1.8.2 h1:KCooALfAYGs415Cwu5ABvv9n9509fSiG5SQJn/AQo4U= 26 | github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= 27 | github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= 28 | github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 29 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 30 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 31 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 32 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 33 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 34 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 35 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 36 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= 37 | golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 38 | golang.org/x/net v0.0.0-20220401154927-543a649e0bdd h1:zYlwaUHTmxuf6H7hwO2dgwqozQmH7zf4x+/qql4oVWc= 39 | golang.org/x/net v0.0.0-20220401154927-543a649e0bdd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 40 | golang.org/x/net v0.0.0-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0= 41 | golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 42 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 45 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 46 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= 47 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f h1:rlezHXNlxYWvBCzNses9Dlc7nGFaNMJeqLolcmQSSZY= 49 | golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/sys v0.0.0-20220403020550-483a9cbc67c0 h1:PgUUmg0gNMIPY2WafhL/oLyQGw+kdTNPlVWOjltpp3w= 51 | golang.org/x/sys v0.0.0-20220403020550-483a9cbc67c0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 53 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 54 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 55 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 56 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= 57 | gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= 58 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 59 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 60 | -------------------------------------------------------------------------------- /gobkm.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=GoBkm 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/usr/local/gobkm/gobkm -proxy https://my-gobkm.foo.org 7 | WorkingDirectory=/usr/local/gobkm 8 | User=gobkm 9 | Group=gobkm 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /handlers/handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "net/http" 11 | "net/url" 12 | "strconv" 13 | "text/template" 14 | "time" 15 | 16 | "golang.org/x/net/html" 17 | 18 | "github.com/tbellembois/gobkm/models" 19 | "github.com/tbellembois/gobkm/types" 20 | 21 | log "github.com/sirupsen/logrus" 22 | ) 23 | 24 | const faviconRequestBaseURL = "http://www.google.com/s2/favicons?domain_url=" 25 | 26 | // Env is a structure used to pass objects throughout the application. 27 | type Env struct { 28 | DB models.Datastore 29 | GoBkmProxyURL string // the application URL 30 | GoBkmProxyHost string // the application Host 31 | GoBkmHistorySize int // the folder history size 32 | GoBkmUsername string // the dfault login username 33 | TplMainData string // main template data 34 | TplAddBookmarkData string // add bookmark template data 35 | TplTestData string // test template data 36 | CSSMainData []byte // main css data 37 | CSSAwesoneFontsData []byte // awesome fonts css data 38 | JsData []byte // js data 39 | } 40 | 41 | // staticDataStruct is used to pass static data to the Main template. 42 | type staticDataStruct struct { 43 | Bkms []*types.Bookmark 44 | CSSMainData string 45 | CSSAwesoneFontsData string 46 | JsData string 47 | GoBkmProxyURL string 48 | GoBkmProxyHost string 49 | GoBkmHistorySize int 50 | GoBkmUsername string 51 | NewBookmarkURL string 52 | NewBookmarkTitle string 53 | } 54 | 55 | // exportBookmarksStruct is used to build the bookmarks and folders tree in the export operation. 56 | type exportBookmarksStruct struct { 57 | Fld *types.Folder 58 | Bkms []*types.Bookmark 59 | Sub []*exportBookmarksStruct 60 | } 61 | 62 | // failHTTP send an HTTP error (httpStatus) with the given errorMessage. 63 | func failHTTP(w http.ResponseWriter, functionName string, errorMessage string, httpStatus int) { 64 | 65 | log.WithFields(log.Fields{ 66 | "functionName": functionName, 67 | "errorMessage": errorMessage, 68 | }).Error("failHTTP") 69 | w.WriteHeader(httpStatus) 70 | 71 | // JS console log 72 | fmt.Fprint(w, errorMessage) 73 | 74 | } 75 | 76 | // insertIndent the "depth" number of tabs to the given io.Writer. 77 | func insertIndent(wr io.Writer, depth int) { 78 | 79 | for i := 0; i < depth; i++ { 80 | if _, err := wr.Write([]byte("\t")); err != nil { 81 | // Just logging the error. 82 | log.WithFields(log.Fields{ 83 | "err": err, 84 | }).Error("insertIdent") 85 | return 86 | } 87 | } 88 | 89 | } 90 | 91 | // updateBookmarkFavicon retrieves and updates the favicon for the given bookmark. 92 | func (env *Env) updateBookmarkFavicon(bkm *types.Bookmark) { 93 | 94 | if u, err := url.Parse(bkm.URL); err == nil { 95 | 96 | // Building the favicon request URL. 97 | bkmDomain := u.Scheme + "://" + u.Host 98 | faviconRequestURL := faviconRequestBaseURL + bkmDomain 99 | log.WithFields(log.Fields{ 100 | "bkmDomain": bkmDomain, 101 | "faviconRequestUrl": faviconRequestURL, 102 | }).Debug("UpdateBookmarkFavicon") 103 | 104 | // Getting the favicon. 105 | if response, err := http.Get(faviconRequestURL); err == nil { 106 | defer func() { 107 | if err := response.Body.Close(); err != nil { 108 | log.WithFields(log.Fields{ 109 | "err": err, 110 | }).Error("UpdateBookmarkFavicon:error closing response Body") 111 | } 112 | }() 113 | 114 | // Getting the favicon image type. 115 | contentType := response.Header.Get("Content-Type") 116 | log.WithFields(log.Fields{ 117 | "response.ContentLength": response.ContentLength, 118 | "contentType": contentType, 119 | }).Debug("UpdateBookmarkFavicon") 120 | 121 | // Converting the image into a base64 string. 122 | image, _ := ioutil.ReadAll(response.Body) 123 | bkm.Favicon = "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(image) 124 | log.WithFields(log.Fields{ 125 | "bkm": bkm, 126 | }).Debug("UpdateBookmarkFavicon") 127 | 128 | // Updating the bookmark into the DB. 129 | env.DB.UpdateBookmark(bkm) 130 | if err = env.DB.FlushErrors(); err != nil { 131 | log.WithFields(log.Fields{ 132 | "err": err, 133 | }).Error("UpdateBookmarkFavicon") 134 | } 135 | } 136 | } 137 | 138 | } 139 | 140 | // SearchBookmarkHandler returns the bookmarks matching the search. 141 | func (env *Env) SearchBookmarkHandler(w http.ResponseWriter, r *http.Request) { 142 | 143 | var ( 144 | err error 145 | ) 146 | 147 | // GET parameters retrieval. 148 | search := r.URL.Query()["search"] 149 | log.WithFields(log.Fields{ 150 | "search": search, 151 | }).Debug("SearchBookmarkHandler:Query parameter") 152 | 153 | // Parameters check. 154 | if len(search) == 0 { 155 | failHTTP(w, "SearchBookmarkHandler", "search empty", http.StatusBadRequest) 156 | return 157 | } 158 | 159 | // Searching the bookmarks. 160 | bkms := env.DB.SearchBookmarks(search[0]) 161 | 162 | // Adding them into a map. 163 | var bookmarksMap []*types.Bookmark 164 | bookmarksMap = append(bookmarksMap, bkms...) 165 | 166 | w.Header().Set("Content-Type", "application/json") 167 | if err = json.NewEncoder(w).Encode(bookmarksMap); err != nil { 168 | failHTTP(w, "SearchBookmarkHandler", err.Error(), http.StatusInternalServerError) 169 | } 170 | 171 | } 172 | 173 | // AddBookmarkHandler handles the bookmarks creation with drag and drop. 174 | func (env *Env) AddBookmarkHandler(w http.ResponseWriter, r *http.Request) { 175 | 176 | var ( 177 | err error 178 | b types.Bookmark 179 | ) 180 | 181 | if err := r.ParseForm(); err != nil { 182 | failHTTP(w, "AddBookmarkHandler", "form parsing error", http.StatusBadRequest) 183 | return 184 | } 185 | 186 | decoder := json.NewDecoder(r.Body) 187 | if err = decoder.Decode(&b); err != nil { 188 | failHTTP(w, "AddBookmarkHandler", "form decoding error", http.StatusBadRequest) 189 | } 190 | log.WithFields(log.Fields{ 191 | "b": b, 192 | }).Debug("AddBookmarkHandler:Query parameter") 193 | 194 | // Getting the destination folder. 195 | dstFld := env.DB.GetFolder(b.Folder.Id) 196 | // Creating a new Bookmark. 197 | newBookmark := types.Bookmark{Title: b.Title, URL: b.URL, Folder: dstFld, Tags: b.Tags} 198 | // Saving the bookmark into the DB, getting its id. 199 | bookmarkID := env.DB.SaveBookmark(&newBookmark) 200 | // Datastore error check 201 | if err = env.DB.FlushErrors(); err != nil { 202 | failHTTP(w, "AddBookmarkHandler", err.Error(), http.StatusInternalServerError) 203 | return 204 | } 205 | 206 | // Updating the bookmark favicon. 207 | newBookmark.Id = int(bookmarkID) 208 | go env.updateBookmarkFavicon(&newBookmark) 209 | 210 | w.Header().Set("Content-Type", "application/json") 211 | if err = json.NewEncoder(w).Encode(newBookmark); err != nil { 212 | failHTTP(w, "AddBookmarkHandler", err.Error(), http.StatusInternalServerError) 213 | } 214 | 215 | } 216 | 217 | // AddFolderHandler handles the folders creation. 218 | func (env *Env) AddFolderHandler(w http.ResponseWriter, r *http.Request) { 219 | 220 | var ( 221 | err error 222 | f types.Folder 223 | ) 224 | 225 | if err := r.ParseForm(); err != nil { 226 | failHTTP(w, "AddFolderHandler", "form parsing error", http.StatusBadRequest) 227 | return 228 | } 229 | 230 | decoder := json.NewDecoder(r.Body) 231 | if err = decoder.Decode(&f); err != nil { 232 | failHTTP(w, "AddFolderHandler", "form decoding error", http.StatusBadRequest) 233 | } 234 | log.WithFields(log.Fields{ 235 | "f": f, 236 | }).Debug("AddFolderHandler:Query parameter") 237 | 238 | // Leaving on empty folder name. 239 | if f.Title == "" { 240 | return 241 | } 242 | 243 | // Getting the root folder. 244 | parentFolder := env.DB.GetFolder(f.Parent.Id) 245 | // Creating a new Folder. 246 | newFolder := types.Folder{Title: f.Title, Parent: parentFolder} 247 | // Saving the folder into the DB, getting its id. 248 | newFolder.Id = int(env.DB.SaveFolder(&newFolder)) 249 | // Datastore error check. 250 | if err = env.DB.FlushErrors(); err != nil { 251 | failHTTP(w, "AddFolderHandler", err.Error(), http.StatusInternalServerError) 252 | return 253 | } 254 | 255 | w.Header().Set("Content-Type", "application/json") 256 | //if err = json.NewEncoder(w).Encode(types.Folder{Id: int(folderID), Title: folderName[0], Parent: parentFolder}); err != nil { 257 | if err = json.NewEncoder(w).Encode(newFolder); err != nil { 258 | failHTTP(w, "AddFolderHandler", err.Error(), http.StatusInternalServerError) 259 | } 260 | 261 | } 262 | 263 | // DeleteFolderHandler handles the folders deletion. 264 | func (env *Env) DeleteFolderHandler(w http.ResponseWriter, r *http.Request) { 265 | 266 | var ( 267 | err error 268 | folderID int 269 | ) 270 | // GET parameters retrieval. 271 | folderIDParam := r.URL.Query()["id"] 272 | log.WithFields(log.Fields{ 273 | "folderIdParam": folderIDParam, 274 | }).Debug("DeleteFolderHandler:Query parameter") 275 | 276 | // Parameters check. 277 | if len(folderIDParam) == 0 { 278 | failHTTP(w, "DeleteFolderHandler", "folderIdParam empty", http.StatusBadRequest) 279 | return 280 | } 281 | // folderId int convertion. 282 | if folderID, err = strconv.Atoi(folderIDParam[0]); err != nil { 283 | failHTTP(w, "DeleteFolderHandler", "folderId Atoi conversion", http.StatusInternalServerError) 284 | return 285 | } 286 | 287 | // Getting the folder. 288 | fld := env.DB.GetFolder(folderID) 289 | // Deleting it. 290 | env.DB.DeleteFolder(fld) 291 | // Datastore error check 292 | if err = env.DB.FlushErrors(); err != nil { 293 | failHTTP(w, "DeleteFolderHandler", err.Error(), http.StatusInternalServerError) 294 | return 295 | } 296 | 297 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 298 | w.WriteHeader(http.StatusOK) 299 | // Returning an empty JSON to trigger de done() ajax function. 300 | if err = json.NewEncoder(w).Encode(types.Folder{}); err != nil { 301 | failHTTP(w, "DeleteFolderHandler", err.Error(), http.StatusInternalServerError) 302 | } 303 | 304 | } 305 | 306 | // DeleteBookmarkHandler handles the bookmarks deletion. 307 | func (env *Env) DeleteBookmarkHandler(w http.ResponseWriter, r *http.Request) { 308 | 309 | var ( 310 | err error 311 | bookmarkID int 312 | ) 313 | // GET parameters retrieval. 314 | bookmarkIDParam := r.URL.Query()["id"] 315 | log.WithFields(log.Fields{ 316 | "bookmarkIdParam": bookmarkIDParam, 317 | }).Debug("DeleteBookmarkHandler:Query parameter") 318 | 319 | // Parameters check. 320 | if len(bookmarkIDParam) == 0 { 321 | failHTTP(w, "DeleteBookmarkHandler", "bookmarkIdParam empty", http.StatusBadRequest) 322 | return 323 | } 324 | // bookmarkId int convertion. 325 | if bookmarkID, err = strconv.Atoi(bookmarkIDParam[0]); err != nil { 326 | failHTTP(w, "DeleteBookmarkHandler", "bookmarkId Atoi conversion", http.StatusInternalServerError) 327 | return 328 | } 329 | // the id in the view in negative, reverting 330 | bookmarkID = -bookmarkID 331 | 332 | // Getting the bookmark. 333 | bkm := env.DB.GetBookmark(bookmarkID) 334 | // Deleting it. 335 | env.DB.DeleteBookmark(bkm) 336 | // Datastore error check. 337 | if err = env.DB.FlushErrors(); err != nil { 338 | failHTTP(w, "DeleteBookmarkHandler", err.Error(), http.StatusInternalServerError) 339 | return 340 | } 341 | 342 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 343 | w.WriteHeader(http.StatusOK) 344 | // Returning an empty JSON to trigger de done() ajax function. 345 | if err = json.NewEncoder(w).Encode(types.Bookmark{}); err != nil { 346 | failHTTP(w, "DeleteBookmarkHandler", err.Error(), http.StatusInternalServerError) 347 | } 348 | 349 | } 350 | 351 | // UpdateFolderHandler handles the folder rename. 352 | func (env *Env) UpdateFolderHandler(w http.ResponseWriter, r *http.Request) { 353 | 354 | var ( 355 | err error 356 | f types.Folder 357 | ) 358 | 359 | if err := r.ParseForm(); err != nil { 360 | failHTTP(w, "UpdateFolderHandler", "form parsing error", http.StatusBadRequest) 361 | return 362 | } 363 | 364 | decoder := json.NewDecoder(r.Body) 365 | if err = decoder.Decode(&f); err != nil { 366 | failHTTP(w, "UpdateFolderHandler", "form decoding error", http.StatusBadRequest) 367 | } 368 | log.WithFields(log.Fields{ 369 | "f": f, 370 | }).Debug("UpdateFolderHandler:Query parameter") 371 | 372 | // Leaving on empty folder name. 373 | if f.Title == "" { 374 | return 375 | } 376 | 377 | // Getting the folder. 378 | fld := env.DB.GetFolder(f.Id) 379 | 380 | // And its parent if it exist. 381 | if f.Parent != nil && f.Parent.Id != 0 { 382 | // this is a move 383 | // we will update only the parent folder 384 | dstFld := env.DB.GetFolder(f.Parent.Id) 385 | log.WithFields(log.Fields{ 386 | "f": f, 387 | "dstFld": dstFld, 388 | }).Debug("UpdateFolderHandler: retrieved Folder instances") 389 | 390 | // Updating the source folder parent. 391 | f.Parent = dstFld 392 | } else { 393 | // this is an update 394 | // we will update the folder fields 395 | // Updating it. 396 | fld.Title = f.Title 397 | } 398 | 399 | // Updating the folder into the DB. 400 | env.DB.UpdateFolder(fld) 401 | 402 | // Datastore error check. 403 | if err = env.DB.FlushErrors(); err != nil { 404 | failHTTP(w, "UpdateFolderHandler", err.Error(), http.StatusInternalServerError) 405 | return 406 | } 407 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 408 | w.WriteHeader(http.StatusOK) 409 | if err = json.NewEncoder(w).Encode(fld); err != nil { 410 | failHTTP(w, "UpdateFolderHandler", err.Error(), http.StatusInternalServerError) 411 | } 412 | 413 | } 414 | 415 | // UpdateBookmarkHandler handles the bookmarks rename. 416 | func (env *Env) UpdateBookmarkHandler(w http.ResponseWriter, r *http.Request) { 417 | 418 | var ( 419 | err error 420 | b types.Bookmark 421 | bookmarkID int 422 | bookmarkTags []*types.Tag 423 | ) 424 | 425 | if err := r.ParseForm(); err != nil { 426 | failHTTP(w, "UpdateBookmarkHandler", "form parsing error", http.StatusBadRequest) 427 | return 428 | } 429 | 430 | decoder := json.NewDecoder(r.Body) 431 | if err = decoder.Decode(&b); err != nil { 432 | failHTTP(w, "UpdateBookmarkHandler", "form decoding error", http.StatusBadRequest) 433 | } 434 | log.WithFields(log.Fields{ 435 | "b": b, 436 | }).Debug("UpdateBookmarkHandler:Query parameter") 437 | 438 | // the id in the view in negative, reverting 439 | bookmarkID = -b.Id 440 | 441 | // Getting the bookmark. 442 | bkm := env.DB.GetBookmark(bookmarkID) 443 | 444 | // Getting the destination folder if it exists. 445 | if b.Folder != nil && b.Folder.Id != 0 { 446 | // this is a move 447 | // we will update only the parent folder 448 | 449 | dstFld := env.DB.GetFolder(b.Folder.Id) 450 | log.WithFields(log.Fields{ 451 | "srcBkm": bkm, 452 | "dstFld": dstFld, 453 | }).Debug("UpdateBookmarkHandler: retrieved Folder instances") 454 | 455 | // Updating the folder parent. 456 | bkm.Folder = dstFld 457 | } else { 458 | // this is an update 459 | // we will update the bookmark fields 460 | 461 | // Getting the tags. 462 | for _, t := range b.Tags { 463 | if t.Id == -1 { 464 | // the tag is a new one with name t 465 | // adding it into the db 466 | t.Id = int(env.DB.SaveTag(&types.Tag{Name: string(t.Name)})) 467 | } 468 | bookmarkTags = append(bookmarkTags, env.DB.GetTag(t.Id)) 469 | } 470 | log.WithFields(log.Fields{ 471 | "bookmarkTags": bookmarkTags, 472 | }).Debug("UpdateBookmarkHandler") 473 | 474 | // Updating it. 475 | bkm.Title = b.Title 476 | bkm.URL = b.URL 477 | bkm.Tags = bookmarkTags 478 | } 479 | 480 | // Updating the folder into the DB. 481 | env.DB.UpdateBookmark(bkm) 482 | // Datastore error check. 483 | if err = env.DB.FlushErrors(); err != nil { 484 | failHTTP(w, "UpdateBookmarkHandler", err.Error(), http.StatusInternalServerError) 485 | return 486 | } 487 | 488 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 489 | w.WriteHeader(http.StatusOK) 490 | if err = json.NewEncoder(w).Encode(bkm); err != nil { 491 | failHTTP(w, "UpdateBookmarkHandler", err.Error(), http.StatusInternalServerError) 492 | } 493 | 494 | } 495 | 496 | // StarBookmarkHandler handles the bookmark starring/unstarring. 497 | func (env *Env) StarBookmarkHandler(w http.ResponseWriter, r *http.Request) { 498 | 499 | var ( 500 | bookmarkID int 501 | err error 502 | star = true 503 | ) 504 | 505 | // GET parameters retrieval. 506 | bookmarkIDParam := r.URL.Query()["id"] 507 | starParam := r.URL.Query()["star"] 508 | log.WithFields(log.Fields{ 509 | "bookmarkId": bookmarkID, 510 | "starParam": starParam, 511 | }).Debug("StarBookmarkHandler:Query parameter") 512 | 513 | // Parameters check. 514 | if len(bookmarkIDParam) == 0 { 515 | failHTTP(w, "StarBookmarkHandler", "bookmarkId empty", http.StatusBadRequest) 516 | return 517 | } 518 | // star parameter retrieval. 519 | if len(starParam) == 0 || starParam[0] != "true" { 520 | star = false 521 | } 522 | log.WithFields(log.Fields{ 523 | "star": star, 524 | }).Debug("StarBookmarkHandler") 525 | // bookmarkId int convertion. 526 | if bookmarkID, err = strconv.Atoi(bookmarkIDParam[0]); err != nil { 527 | failHTTP(w, "StarBookmarkHandler", "bookmarkId Atoi conversion", http.StatusInternalServerError) 528 | return 529 | } 530 | // the id in the view in negative, reverting 531 | bookmarkID = -bookmarkID 532 | 533 | // Getting the bookmark. 534 | bkm := env.DB.GetBookmark(bookmarkID) 535 | // Renaming it. 536 | bkm.Starred = star 537 | // Updating the folder into the DB. 538 | env.DB.UpdateBookmark(bkm) 539 | // Datastore error check. 540 | if err = env.DB.FlushErrors(); err != nil { 541 | failHTTP(w, "StarBookmarkHandler", err.Error(), http.StatusInternalServerError) 542 | return 543 | } 544 | 545 | // Building the result struct. 546 | resultBookmarkStruct := types.Bookmark{Id: bookmarkID, Title: bkm.Title, URL: bkm.URL, Favicon: bkm.Favicon, Starred: bkm.Starred} 547 | log.WithFields(log.Fields{ 548 | "resultBookmarkStruct": resultBookmarkStruct, 549 | }).Debug("StarBookmarkHandler") 550 | 551 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 552 | w.WriteHeader(http.StatusOK) 553 | if err = json.NewEncoder(w).Encode(resultBookmarkStruct); err != nil { 554 | failHTTP(w, "StarBookmarkHandler", err.Error(), http.StatusInternalServerError) 555 | } 556 | 557 | } 558 | 559 | // getChildren recursively get subfolders and bookmarks of the folder f 560 | func (env *Env) getChildren(f *types.Folder) types.Folder { 561 | 562 | log.WithFields(log.Fields{"f.Id": f.Id}).Debug("getChildren") 563 | 564 | f.Folders = env.DB.GetFolderSubfolders(f.Id) 565 | if f.Folders != nil && len(f.Folders) > 0 { 566 | for _, fld := range f.Folders { 567 | log.WithFields(log.Fields{"fld": fld}).Debug("getChildren") 568 | env.getChildren(fld) 569 | } 570 | } 571 | 572 | f.Bookmarks = env.DB.GetFolderBookmarks(f.Id) 573 | 574 | return *f 575 | 576 | } 577 | 578 | // GetTreeHandler return the entire folder and bookmark tree 579 | func (env *Env) GetTreeHandler(w http.ResponseWriter, r *http.Request) { 580 | 581 | var ( 582 | err error 583 | ) 584 | 585 | // Adding root folder. 586 | rootNode := &types.Folder{Id: 0, Title: "/"} 587 | 588 | // Getting the root folder children folders and bookmarks. 589 | rootNode.Folders = env.DB.GetFolderSubfolders(1) 590 | rootNode.Bookmarks = env.DB.GetFolderBookmarks(1) 591 | 592 | // Datastore error check. 593 | if err = env.DB.FlushErrors(); err != nil { 594 | failHTTP(w, "GetBranchNodesHandler", err.Error(), http.StatusInternalServerError) 595 | return 596 | } 597 | 598 | // Recursively getting the subfolders and bookmarks. 599 | for _, fld := range rootNode.Folders { 600 | env.getChildren(fld) 601 | } 602 | 603 | w.Header().Set("Content-Type", "application/json") 604 | if err = json.NewEncoder(w).Encode(rootNode); err != nil { 605 | failHTTP(w, "GetBranchNodesHandler", err.Error(), http.StatusInternalServerError) 606 | } 607 | 608 | } 609 | 610 | // GetTagsHandler retrieves the tags. 611 | func (env *Env) GetTagsHandler(w http.ResponseWriter, r *http.Request) { 612 | 613 | var ( 614 | err error 615 | ) 616 | 617 | // Getting the tags. 618 | tags := env.DB.GetTags() 619 | // Datastore error check. 620 | if err = env.DB.FlushErrors(); err != nil { 621 | failHTTP(w, "GetTagsHandler", err.Error(), http.StatusInternalServerError) 622 | return 623 | } 624 | 625 | w.Header().Set("Content-Type", "application/json") 626 | if err = json.NewEncoder(w).Encode(tags); err != nil { 627 | failHTTP(w, "GetTagsHandler", err.Error(), http.StatusInternalServerError) 628 | } 629 | 630 | } 631 | 632 | // GetStarsHandler retrieves the starred bookmarks. 633 | func (env *Env) GetStarsHandler(w http.ResponseWriter, r *http.Request) { 634 | 635 | var ( 636 | err error 637 | ) 638 | 639 | // Getting the stars. 640 | stars := env.DB.GetStars() 641 | // Datastore error check. 642 | if err = env.DB.FlushErrors(); err != nil { 643 | failHTTP(w, "GetStarsHandler", err.Error(), http.StatusInternalServerError) 644 | return 645 | } 646 | 647 | w.Header().Set("Content-Type", "application/json") 648 | if err = json.NewEncoder(w).Encode(stars); err != nil { 649 | failHTTP(w, "GetStarsHandler", err.Error(), http.StatusInternalServerError) 650 | } 651 | 652 | } 653 | 654 | // GetFolderChildrenHandler retrieves the subfolders and bookmarks of the given folder. 655 | func (env *Env) GetFolderChildrenHandler(w http.ResponseWriter, r *http.Request) { 656 | 657 | var ( 658 | key int 659 | err error 660 | f *types.Folder 661 | ) 662 | 663 | // GET parameters retrieval. 664 | folderIdParam := r.URL.Query().Get("id") 665 | log.WithFields(log.Fields{ 666 | "keyParam": folderIdParam, 667 | }).Debug("GetFolderChildrenHandler:Query parameter") 668 | 669 | // Returning the root folder if not parameters are passed. 670 | if len(folderIdParam) == 0 { 671 | folderIdParam = "1" 672 | } 673 | // key int convertion. 674 | if key, err = strconv.Atoi(folderIdParam); err != nil { 675 | failHTTP(w, "GetFolderChildrenHandler", "key Atoi conversion", http.StatusInternalServerError) 676 | return 677 | } 678 | 679 | // Getting this folder 680 | f = env.DB.GetFolder(key) 681 | // Datastore error check. 682 | if err = env.DB.FlushErrors(); err != nil { 683 | failHTTP(w, "GetFolderChildrenHandler", err.Error(), http.StatusInternalServerError) 684 | return 685 | } 686 | 687 | // Getting the folder children folders. 688 | f.Folders = env.DB.GetFolderSubfolders(key) 689 | // Datastore error check. 690 | if err = env.DB.FlushErrors(); err != nil { 691 | failHTTP(w, "GetFolderChildrenHandler", err.Error(), http.StatusInternalServerError) 692 | return 693 | } 694 | 695 | // Getting the folder bookmarks. 696 | f.Bookmarks = env.DB.GetFolderBookmarks(key) 697 | // Datastore error check. 698 | if err = env.DB.FlushErrors(); err != nil { 699 | failHTTP(w, "GetFolderChildrenHandler", err.Error(), http.StatusInternalServerError) 700 | return 701 | } 702 | 703 | w.Header().Set("Content-Type", "application/json") 704 | if err = json.NewEncoder(w).Encode(f); err != nil { 705 | failHTTP(w, "GetFolderChildrenHandler", err.Error(), http.StatusInternalServerError) 706 | } 707 | 708 | } 709 | 710 | // MainHandler handles the main application page. 711 | func (env *Env) MainHandler(w http.ResponseWriter, r *http.Request) { 712 | 713 | var ( 714 | folderAndBookmark = new(staticDataStruct) 715 | err error 716 | ) 717 | 718 | // Getting the starred bookmarks. 719 | starredBookmarks := env.DB.GetStars() 720 | 721 | // Getting the static data. 722 | folderAndBookmark.JsData = string(env.JsData) 723 | folderAndBookmark.GoBkmProxyURL = env.GoBkmProxyURL 724 | folderAndBookmark.GoBkmProxyHost = env.GoBkmProxyHost 725 | folderAndBookmark.GoBkmHistorySize = env.GoBkmHistorySize 726 | folderAndBookmark.GoBkmUsername = env.GoBkmUsername 727 | folderAndBookmark.Bkms = starredBookmarks 728 | 729 | // Building the HTML template. 730 | htmlTpl := template.New("main") 731 | if htmlTpl, err = htmlTpl.Parse(env.TplMainData); err != nil { 732 | failHTTP(w, "MainHandler", err.Error(), http.StatusInternalServerError) 733 | // TODO: should we exit the program ? 734 | } 735 | 736 | if err = htmlTpl.Execute(w, folderAndBookmark); err != nil { 737 | failHTTP(w, "MainHandler", err.Error(), http.StatusInternalServerError) 738 | } 739 | 740 | } 741 | 742 | // ImportHandler handles the import requests. 743 | func (env *Env) ImportHandler(w http.ResponseWriter, r *http.Request) { 744 | 745 | // Getting the import file. 746 | file, err := ioutil.ReadAll(r.Body) 747 | if err != nil { 748 | failHTTP(w, "ImportHandler", err.Error(), http.StatusInternalServerError) 749 | return 750 | } 751 | // Parsing the HTML. 752 | doc, err := html.Parse(bytes.NewReader(file)) 753 | if err != nil { 754 | failHTTP(w, "ImportHandler", err.Error(), http.StatusBadRequest) 755 | return 756 | } 757 | 758 | // Building a new import folder name. 759 | currentDate := time.Now().Local() 760 | importFolderName := "import-" + currentDate.Format("2006-01-02") 761 | // Creating and saving a new folder. 762 | importFolder := types.Folder{Title: importFolderName} 763 | id := env.DB.SaveFolder(&importFolder) 764 | importFolder.Id = int(id) 765 | 766 | // Function to recursively parse the n node. 767 | var f func(n *html.Node, parentFolder *types.Folder) 768 | f = func(n *html.Node, parentFolder *types.Folder) { 769 | // Keeping the parent folder before calling f recursively. 770 | parentFolderBackup := *parentFolder 771 | 772 | // Parsing the n children. 773 | for c := n.FirstChild; c != nil; c = c.NextSibling { 774 | // Got a dt tag. 775 | if c.Type == html.ElementNode && c.Data == "dt" { 776 | dtTag := c.FirstChild 777 | switch dtTag.Data { 778 | case "h3": 779 | // Got a

tag. 780 | // Building the new folder. 781 | h3Value := dtTag.FirstChild.Data 782 | newFolder := types.Folder{Title: h3Value, Parent: parentFolder} 783 | // Saving it into the DB. 784 | id := env.DB.SaveFolder(&newFolder) 785 | newFolder.Id = int(id) 786 | // Updating the parent folder for next recursion. 787 | parentFolder = &newFolder 788 | case "a": 789 | // Got a
tag. 790 | var h3Value string 791 | var h3Href string 792 | var h3Icon string 793 | 794 | // Parsing the link attributes for href and icon. 795 | for _, attr := range dtTag.Attr { 796 | key := attr.Key 797 | val := attr.Val 798 | if key == "href" { 799 | h3Href = val 800 | } 801 | if key == "icon" { 802 | h3Icon = val 803 | } 804 | } 805 | // Looking for a link title. 806 | if dtTag.FirstChild != nil { 807 | h3Value = dtTag.FirstChild.Data 808 | } else { 809 | h3Value = h3Href 810 | } 811 | 812 | // Creating the new Bookmark. 813 | newBookmark := types.Bookmark{Title: h3Value, URL: h3Href, Favicon: h3Icon, Folder: parentFolder} 814 | log.WithFields(log.Fields{ 815 | "newBookmark": newBookmark, 816 | }).Debug("ImportHandler:Saving bookmark") 817 | // And saving it. 818 | env.DB.SaveBookmark(&newBookmark) 819 | } 820 | } 821 | 822 | // Calling recursively f for each child of n. 823 | f(c, parentFolder) 824 | 825 | // Restoring the parent folder. 826 | parentFolder = &parentFolderBackup 827 | } 828 | } 829 | 830 | // Importing the folders and bookmarks. 831 | f(doc, &importFolder) 832 | // Database errors check. 833 | if err = env.DB.FlushErrors(); err != nil { 834 | failHTTP(w, "ImportHandler", err.Error(), http.StatusInternalServerError) 835 | return 836 | } 837 | 838 | // Returning "ok" to inform the AJAX caller that everyting was fine. 839 | if _, err = w.Write([]byte("ok")); err != nil { 840 | // Just logging the error. 841 | log.WithFields(log.Fields{ 842 | "err": err, 843 | }).Error("ImportHandler") 844 | } 845 | 846 | } 847 | 848 | // ExportHandler handles the export requests. 849 | func (env *Env) ExportHandler(w http.ResponseWriter, r *http.Request) { 850 | 851 | // Getting the root folder. 852 | rootFolder := env.DB.GetFolder(1) 853 | // HTML header and footer definition. 854 | header := ` 855 | 858 | 859 | GoBkm 860 |

GoBkm

861 |

` + "\n" 862 | footer := "

\n" 863 | 864 | // Writing the header meta informations. 865 | w.Header().Set("Content-Disposition", "attachment; filename=gobkm.html") 866 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 867 | // Writing the HTML header 868 | if _, err := w.Write([]byte(header)); err != nil { 869 | // Just logging the error. 870 | log.WithFields(log.Fields{ 871 | "err": err, 872 | }).Error("ExportHandler") 873 | } 874 | // Exporting the bookmarks. 875 | env.exportTree(w, &exportBookmarksStruct{Fld: rootFolder}, 0) 876 | // Writing the HTML footer. 877 | if _, err := w.Write([]byte(footer)); err != nil { 878 | // Just logging the error. 879 | log.WithFields(log.Fields{ 880 | "err": err, 881 | }).Error("ExportHandler") 882 | } 883 | 884 | } 885 | 886 | // exportTree recursively exports in HTML the given bookmark struct. 887 | func (env *Env) exportTree(wr io.Writer, eb *exportBookmarksStruct, depth int) *exportBookmarksStruct { 888 | 889 | // Depth is just for cosmetics indent purposes. 890 | depth++ 891 | log.WithFields(log.Fields{ 892 | "*eb": *eb, 893 | }).Debug("ExportTree") 894 | 895 | // Writing the folder title. 896 | insertIndent(wr, depth) 897 | _, _ = wr.Write([]byte("

" + eb.Fld.Title + "

\n")) 898 | insertIndent(wr, depth) 899 | _, _ = wr.Write([]byte("

\n")) 900 | 901 | // For each children folder recursively building the bookmars tree. 902 | for _, child := range env.DB.GetFolderSubfolders(eb.Fld.Id) { 903 | eb.Sub = append(eb.Sub, env.exportTree(wr, &exportBookmarksStruct{Fld: child}, depth)) 904 | } 905 | 906 | // Getting the folder bookmarks. 907 | eb.Bkms = env.DB.GetFolderBookmarks(eb.Fld.Id) 908 | // Writing them. 909 | for _, bkm := range eb.Bkms { 910 | insertIndent(wr, depth) 911 | _, _ = wr.Write([]byte("

" + bkm.Title + "\n")) 912 | } 913 | insertIndent(wr, depth) 914 | _, _ = wr.Write([]byte("

\n")) 915 | 916 | return eb 917 | 918 | } 919 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "flag" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | 10 | log "github.com/sirupsen/logrus" 11 | "github.com/tbellembois/gobkm/handlers" 12 | "github.com/tbellembois/gobkm/models" 13 | 14 | "github.com/justinas/alice" 15 | "github.com/rs/cors" 16 | ) 17 | 18 | var ( 19 | datastore *models.SQLiteDataStore 20 | err error 21 | logf *os.File 22 | 23 | //go:embed static/wasm/* 24 | embedWasmBox embed.FS 25 | 26 | //go:embed static/index.html 27 | embedIndex string 28 | ) 29 | 30 | func main() { 31 | 32 | // Getting the program parameters. 33 | listenPort := flag.String("port", "8081", "the port to listen") 34 | proxyURL := flag.String("proxy", "http://localhost:"+*listenPort, "the proxy full URL if used") 35 | historySize := flag.Int("history", 3, "the folder history size") 36 | username := flag.String("username", "", "the default login username") 37 | dbPath := flag.String("db", "bkm.db", "the full sqlite db path") 38 | logfile := flag.String("logfile", "", "log to the given file") 39 | debug := flag.Bool("debug", false, "debug (verbose log), default is error") 40 | flag.Parse() 41 | 42 | // Logging to file if logfile parameter specified. 43 | if *logfile != "" { 44 | if logf, err = os.OpenFile(*logfile, os.O_WRONLY|os.O_CREATE, 0755); err != nil { 45 | log.Panic(err) 46 | } else { 47 | log.SetOutput(logf) 48 | } 49 | } 50 | // Setting the log level. 51 | if *debug { 52 | log.SetLevel(log.DebugLevel) 53 | } else { 54 | log.SetLevel(log.ErrorLevel) 55 | } 56 | log.WithFields(log.Fields{ 57 | "listenPort": *listenPort, 58 | "proxyURL": *proxyURL, 59 | "historySize": *historySize, 60 | "username": *username, 61 | "logfile": *logfile, 62 | "debug": *debug, 63 | }).Debug("main:flags") 64 | 65 | // Database initialization. 66 | if datastore, err = models.NewDBstore(*dbPath); err != nil { 67 | log.Panic(err) 68 | } 69 | // Database creation. 70 | datastore.CreateDatabase() 71 | datastore.PopulateDatabase() 72 | // Error check. 73 | if datastore.FlushErrors() != nil { 74 | log.Panic(err) 75 | } 76 | 77 | // Host from URL. 78 | u, err := url.Parse(*proxyURL) 79 | if err != nil { 80 | log.Fatal(err) 81 | } 82 | log.Debug(u) 83 | 84 | // Environment creation. 85 | env := handlers.Env{ 86 | DB: datastore, 87 | GoBkmProxyURL: *proxyURL, 88 | GoBkmProxyHost: u.Host, 89 | GoBkmHistorySize: *historySize, 90 | GoBkmUsername: *username, 91 | } 92 | 93 | env.TplMainData = embedIndex 94 | 95 | // CORS handler. 96 | c := cors.New(cors.Options{ 97 | Debug: true, 98 | AllowedOrigins: []string{"http://localhost:8081", *proxyURL}, 99 | AllowCredentials: true, 100 | AllowedMethods: []string{http.MethodGet, http.MethodPost, http.MethodOptions}, 101 | AllowedHeaders: []string{"Authorization", "DNT", "User-Agent", "X-Requested-With", "If-Modified-Since", "Cache-Control", "Content-Type", "Range"}, 102 | }) 103 | 104 | mux := http.NewServeMux() 105 | 106 | // Handlers initialization. 107 | mux.Handle("/wasm/", http.StripPrefix("/wasm/", http.FileServer(http.FS(embedWasmBox)))) 108 | 109 | mux.HandleFunc("/addBookmark/", env.AddBookmarkHandler) 110 | mux.HandleFunc("/addFolder/", env.AddFolderHandler) 111 | mux.HandleFunc("/deleteBookmark/", env.DeleteBookmarkHandler) 112 | mux.HandleFunc("/deleteFolder/", env.DeleteFolderHandler) 113 | mux.HandleFunc("/getTags/", env.GetTagsHandler) 114 | mux.HandleFunc("/getStars/", env.GetStarsHandler) 115 | mux.HandleFunc("/getFolderChildren/", env.GetFolderChildrenHandler) 116 | mux.HandleFunc("/getTree/", env.GetTreeHandler) 117 | mux.HandleFunc("/import/", env.ImportHandler) 118 | mux.HandleFunc("/export/", env.ExportHandler) 119 | mux.HandleFunc("/updateFolder/", env.UpdateFolderHandler) 120 | mux.HandleFunc("/updateBookmark/", env.UpdateBookmarkHandler) 121 | mux.HandleFunc("/searchBookmarks/", env.SearchBookmarkHandler) 122 | mux.HandleFunc("/starBookmark/", env.StarBookmarkHandler) 123 | mux.HandleFunc("/", env.MainHandler) 124 | 125 | chain := alice.New(c.Handler).Then(mux) 126 | 127 | if err = http.ListenAndServe(":"+*listenPort, chain); err != nil { 128 | log.Fatal(err) 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /models/idatastore.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/tbellembois/gobkm/types" 5 | ) 6 | 7 | // Datastore is a folders and bookmarks storage interface. 8 | type Datastore interface { 9 | FlushErrors() error 10 | 11 | SearchBookmarks(string) []*types.Bookmark 12 | GetBookmark(int) *types.Bookmark 13 | GetBookmarkTags(int) []*types.Tag 14 | GetFolderBookmarks(int) types.Bookmarks 15 | SaveBookmark(*types.Bookmark) int64 16 | UpdateBookmark(*types.Bookmark) 17 | DeleteBookmark(*types.Bookmark) 18 | 19 | GetFolder(int) *types.Folder 20 | GetFolderSubfolders(int) []*types.Folder 21 | SaveFolder(*types.Folder) int64 22 | UpdateFolder(*types.Folder) 23 | DeleteFolder(*types.Folder) 24 | 25 | GetTags() []*types.Tag 26 | GetStars() []*types.Bookmark 27 | GetTag(int) *types.Tag 28 | SaveTag(*types.Tag) int64 29 | } 30 | -------------------------------------------------------------------------------- /models/sqlitedatastore.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | 6 | _ "github.com/mattn/go-sqlite3" // register sqlite3 driver 7 | log "github.com/sirupsen/logrus" 8 | "github.com/tbellembois/gobkm/types" 9 | ) 10 | 11 | const ( 12 | dbdriver = "sqlite3" 13 | ) 14 | 15 | // SQLiteDataStore implements the Datastore interface 16 | // to store the folders and bookmarks in SQLite3. 17 | type SQLiteDataStore struct { 18 | *sql.DB 19 | err error 20 | } 21 | 22 | // NewDBstore returns a database connection to the given dataSourceName 23 | // ie. a path to the sqlite database file. 24 | func NewDBstore(dataSourceName string) (*SQLiteDataStore, error) { 25 | 26 | log.WithFields(log.Fields{ 27 | "dataSourceName": dataSourceName, 28 | }).Debug("NewDBstore:params") 29 | 30 | var ( 31 | db *sql.DB 32 | err error 33 | ) 34 | 35 | if db, err = sql.Open(dbdriver, dataSourceName); err != nil { 36 | log.WithFields(log.Fields{ 37 | "dataSourceName": dataSourceName, 38 | }).Error("NewDBstore:error opening the database") 39 | return nil, err 40 | } 41 | 42 | return &SQLiteDataStore{db, nil}, nil 43 | 44 | } 45 | 46 | // FlushErrors returns the last DB errors and flushes it. 47 | func (db *SQLiteDataStore) FlushErrors() error { 48 | 49 | // Saving the last thrown error. 50 | lastError := db.err 51 | // Resetting the error. 52 | db.err = nil 53 | // Returning the last error. 54 | return lastError 55 | 56 | } 57 | 58 | // CreateDatabase creates the database tables. 59 | func (db *SQLiteDataStore) CreateDatabase() { 60 | 61 | log.Info("Creating database") 62 | 63 | // Activate the foreign keys feature. 64 | if _, db.err = db.Exec("PRAGMA foreign_keys = ON"); db.err != nil { 65 | log.Error("CreateDatabase: error executing the PRAGMA request:" + db.err.Error()) 66 | panic(db.err) 67 | } 68 | 69 | // Tables creation if needed. 70 | if _, db.err = db.Exec(`CREATE TABLE IF NOT EXISTS folder ( id integer PRIMARY KEY, title string NOT NULL, parentFolderId integer, nbChildrenFolders integer, 71 | FOREIGN KEY (parentFolderId) references folder(id) 72 | ON DELETE CASCADE)`); db.err != nil { 73 | log.Error("CreateDatabase: error executing the CREATE TABLE request for table bookmark") 74 | panic(db.err) 75 | } 76 | if _, db.err = db.Exec("CREATE TABLE IF NOT EXISTS tag ( id integer PRIMARY KEY, name string NOT NULL)"); db.err != nil { 77 | log.Error("CreateDatabase: error executing the CREATE TABLE request for table bookmark") 78 | panic(db.err) 79 | } 80 | if _, db.err = db.Exec(`CREATE TABLE IF NOT EXISTS bookmarktag ( id integer PRIMARY KEY, 81 | bookmarkId integer, 82 | tagId integer, 83 | FOREIGN KEY (bookmarkId) references bookmark(id), 84 | FOREIGN KEY (tagId) references tag(id))`); db.err != nil { 85 | log.Error("CreateDatabase: error executing the CREATE TABLE request for table bookmarktag") 86 | panic(db.err) 87 | } 88 | if _, db.err = db.Exec(`CREATE TABLE IF NOT EXISTS bookmark ( id integer PRIMARY KEY, title string NOT NULL, url string NOT NULL, favicon string, starred integer, folderId integer, 89 | FOREIGN KEY (folderId) references folder(id) 90 | ON DELETE CASCADE)`); db.err != nil { 91 | log.Error("CreateDatabase: error executing the CREATE TABLE request for table bookmark") 92 | panic(db.err) 93 | } 94 | 95 | // Looking for folders. 96 | var count int 97 | if db.err = db.QueryRow("SELECT COUNT(*) as count FROM folder").Scan(&count); db.err != nil { 98 | log.Error("CreateDatabase: error executing the SELECT COUNT(*) request for table folder") 99 | panic(db.err) 100 | } 101 | // Inserting the / folder if not present. 102 | if count > 0 { 103 | log.Info("CreateDatabase: folder table not empty, leaving") 104 | return 105 | } 106 | if _, db.err = db.Exec("INSERT INTO folder(id, title) values(\"1\", \"/\")"); db.err != nil { 107 | log.Error("CreateDatabase: error inserting the root folder") 108 | panic(db.err) 109 | } 110 | 111 | } 112 | 113 | // PopulateDatabase populate the database with sample folders and bookmarks. 114 | func (db *SQLiteDataStore) PopulateDatabase() { 115 | 116 | log.Info("Populating database") 117 | 118 | // Leaving on past errors. 119 | if db.err != nil { 120 | panic(db.err) 121 | } 122 | 123 | var ( 124 | folders []*types.Folder 125 | bookmarks []*types.Bookmark 126 | count int 127 | ) 128 | 129 | // Leaving if database is already populated. 130 | if db.err = db.QueryRow("SELECT COUNT(*) as count FROM folder").Scan(&count); db.err != nil || count > 1 { 131 | log.Info("Database not empty, leaving") 132 | return 133 | } 134 | 135 | // Getting the root folder. 136 | folderRoot := db.GetFolder(1) 137 | // Creating new sample folders. 138 | folder1 := types.Folder{Id: 1, Title: "IT", Parent: folderRoot} 139 | folder2 := types.Folder{Id: 2, Title: "Development", Parent: &folder1} 140 | // Creating new sample tags. 141 | tag1 := []*types.Tag{{Id: 1, Name: "mytag1"}} 142 | tag2 := []*types.Tag{{Id: 1, Name: "mytag1"}, {Id: 2, Name: "mytag2"}} 143 | // Creating new sample bookmarks. 144 | bookmark1 := types.Bookmark{Id: 1, Title: "GoLang", Tags: tag1, Starred: true, URL: "https://golang.org/", Favicon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAb9JREFUOI3tkj9oU1EUh797c3lgjA4xL61FX0yhMQqmW5QgFim4+GcyQ3Hp1MlBqFIyOGUobRScnYoQikNA0Ao6WJS2UIdiK7SUVGtfIZg0iMSA+Iy5Dg9fGnyLu2e6nHPu9zv3/K7QWuMXjfqebjQbOM5PIuEjHI6Ywq9P/TlUdm09+3KeNxtlAHbLWzTrNeTBQxjhHuLHohrgwqkBRi5dpO+4JQDEh80NfePOXaIDJ3FigximBUAyk+5SOvFphR/tNovvyzg769TKmxQLecS5a9d1dOQ2zp7N6bjF1PAZlJKMv1hFpVxIa+0t96+cBWD82TLr2zaGaVGbvYcEqLx+gmFajKZiqANBeo/2MZcb89RHUzEAeiNh5nJjGKZF9VUJAFks5FGVrc7IuuW7VH518slMGlHdpljII/sTSW+7j5ohEIrP9S9cnnxIaShOaSjOzNoOBNz81ceLHqg/kRRqv0ggGGLCdm3t+fqRmZtZ15HKEhN2Go1ABUO06VjfBdDSLQS0IFNd4fytSQAWHuR4B8gW7lWJP8B7rtA8zU7zfH4V8f0brew0ou37j/wBHigx2D2d/LvHJ/Vv8R8AvwHjjZMncK4ImgAAAABJRU5ErkJggg==", Folder: &folder2} 145 | bookmark2 := types.Bookmark{Id: 2, Title: "GoBkm Github", Tags: tag2, Starred: false, URL: "https://github.com/tbellembois/gobkm", Favicon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAilJREFUOI2FUUFrE0EYfTuzNgaxtmlmd9ON3WiS0gQ3Fs+BPXmVCBb03EMPUhBELzmEQGjv3nsJSiRLLvkDnrxJo5XUnCRirWiaGmIhBLP5PNSpSbDxg4GZ4X3ve+99wESVs1lz1baLZihUNzTtxNC0EzMUqq/adrGczZqTeEVeKJdDynVL31ut+0QERVHGgPJPE+Ll3traAyWf/0tAuRwiOzt7vX7fxj+ax4gA+H2+98319ZSSz4MBQMp1S71+376gqh8cx3mkcP55MBiAM9bmjLU77TY451/j8fhjn6q+7fX7dsp1S2eedSFIF4Li0agrpxDRmHxZy7FYSeLL2azJtqrVbQmg4fCS9DVqY8KST5JuVavbihkK1QeDQRIAvrVa55uXarpdGNEoAYCqqvvM8zwLALqdznBU6nmlzM6CM/YTADzPsxgADgBX5ufZtPTPFBBhSHT5z5MxlfMmAPw4OgIR3fkfwe10Oi2Vcs4/sblAYBcA5hYW4OP8+TQbRIR3jUYFON2U8Pt32cNM5ikAxCxr87ppvtE1jcK6/rpWq92TjY7j3L22tPQqGAgQY0wDTje1ubHxBABgJ5NlPRgkIsKyZVXDi4v7dHw8NlnuXh47mSxLIhARLNNs/PI8cdU0M7FI5MuLSuXjaKiGppG0d3Fmpt48OLgxFvohEZKJRMXQNNKFoMksDCHIEIISKyvu4bR1P8vlwrfi8WJvAnTTtouFQsGaxP8GGFLoTDqN+p0AAAAASUVORK5CYII=", Folder: &folder2} 146 | 147 | folders = append(folders, &folder1, &folder2) 148 | bookmarks = append(bookmarks, &bookmark1, &bookmark2) 149 | 150 | // DB save. 151 | for _, fld := range folders { 152 | db.SaveFolder(fld) 153 | } 154 | for _, bkm := range bookmarks { 155 | db.SaveBookmark(bkm) 156 | } 157 | 158 | } 159 | 160 | // GetBookmark returns the full tags list 161 | func (db *SQLiteDataStore) GetTags() []*types.Tag { 162 | 163 | // Leaving silently on past errors... 164 | if db.err != nil { 165 | return nil 166 | } 167 | 168 | // Querying the tags. 169 | var ( 170 | rows *sql.Rows 171 | tags []*types.Tag 172 | ) 173 | rows, db.err = db.Query("SELECT * FROM tag") 174 | defer func() { 175 | if db.err = rows.Close(); db.err != nil { 176 | log.WithFields(log.Fields{ 177 | "err": db.err, 178 | }).Error("GetTags:error closing rows") 179 | } 180 | }() 181 | 182 | switch { 183 | case db.err == sql.ErrNoRows: 184 | log.Debug("GetTags:no bookmarks") 185 | db.err = nil 186 | return nil 187 | case db.err != nil: 188 | log.WithFields(log.Fields{ 189 | "err": db.err, 190 | }).Error("GetTags:SELECT query error") 191 | return nil 192 | default: 193 | for rows.Next() { 194 | // Building a new Tag instance with each row. 195 | tag := new(types.Tag) 196 | db.err = rows.Scan(&tag.Id, &tag.Name) 197 | if db.err != nil { 198 | log.WithFields(log.Fields{ 199 | "err": db.err, 200 | }).Error("GetTags:error scanning the query result row") 201 | return nil 202 | } 203 | tags = append(tags, tag) 204 | } 205 | if db.err = rows.Err(); db.err != nil { 206 | log.WithFields(log.Fields{ 207 | "err": db.err, 208 | }).Error("GetTags:error looping rows") 209 | return nil 210 | } 211 | } 212 | return tags 213 | 214 | } 215 | 216 | // GetTag returns a Tag instance with the given id. 217 | func (db *SQLiteDataStore) GetTag(id int) *types.Tag { 218 | 219 | log.WithFields(log.Fields{ 220 | "id": id, 221 | }).Debug("GetTag") 222 | 223 | // Leaving silently on past errors... 224 | if db.err != nil { 225 | return nil 226 | } 227 | 228 | // Querying the Tag. 229 | tag := new(types.Tag) 230 | db.err = db.QueryRow("SELECT * FROM tag WHERE id=?", id).Scan(&tag.Id, &tag.Name) 231 | switch { 232 | case db.err == sql.ErrNoRows: 233 | log.WithFields(log.Fields{ 234 | "id": id, 235 | }).Debug("GetTag:no tag with that ID") 236 | db.err = nil 237 | return nil 238 | case db.err != nil: 239 | log.WithFields(log.Fields{ 240 | "err": db.err, 241 | }).Error("GetTag:SELECT query error") 242 | return nil 243 | default: 244 | log.WithFields(log.Fields{ 245 | "Id": tag.Id, 246 | "Name": tag.Name, 247 | }).Debug("GetTag:tag found") 248 | } 249 | return tag 250 | 251 | } 252 | 253 | // GetBookmark returns a Bookmark instance with the given id. 254 | func (db *SQLiteDataStore) GetBookmark(id int) *types.Bookmark { 255 | 256 | log.WithFields(log.Fields{ 257 | "id": id, 258 | }).Debug("GetBookmark") 259 | 260 | // Leaving silently on past errors... 261 | if db.err != nil { 262 | return nil 263 | } 264 | 265 | var ( 266 | folderID sql.NullInt64 267 | starred sql.NullInt64 268 | ) 269 | 270 | // Querying the bookmark. 271 | bkm := new(types.Bookmark) 272 | db.err = db.QueryRow("SELECT id, title, url, favicon, starred, folderId FROM bookmark WHERE id=?", id).Scan(&bkm.Id, &bkm.Title, &bkm.URL, &bkm.Favicon, &starred, &folderID) 273 | switch { 274 | case db.err == sql.ErrNoRows: 275 | log.WithFields(log.Fields{ 276 | "id": id, 277 | }).Debug("GetBookmark:no bookmark with that ID") 278 | db.err = nil 279 | return nil 280 | case db.err != nil: 281 | log.WithFields(log.Fields{ 282 | "err": db.err, 283 | }).Error("GetBookmark:SELECT query error") 284 | return nil 285 | default: 286 | log.WithFields(log.Fields{ 287 | "Id": bkm.Id, 288 | "Title": bkm.Title, 289 | "folderId": folderID, 290 | "Favicon": bkm.Favicon, 291 | }).Debug("GetBookmark:bookmark found") 292 | // Starred bookmark ? 293 | if int(starred.Int64) != 0 { 294 | bkm.Starred = true 295 | } 296 | // Retrieving the parent folder if it is not the root (/). 297 | if folderID.Int64 != 0 { 298 | bkm.Folder = db.GetFolder(int(folderID.Int64)) 299 | if db.err != nil { 300 | log.WithFields(log.Fields{ 301 | "err": db.err, 302 | }).Error("GetBookmark:parent Folder retrieving error") 303 | return nil 304 | } 305 | } 306 | } 307 | return bkm 308 | 309 | } 310 | 311 | // GetFolder returns a Folder instance with the given id. 312 | func (db *SQLiteDataStore) GetFolder(id int) *types.Folder { 313 | 314 | log.WithFields(log.Fields{ 315 | "id": id, 316 | }).Debug("GetFolder") 317 | 318 | // Leaving silently on past errors... 319 | if db.err != nil || id == 0 { 320 | return nil 321 | } 322 | 323 | // Querying the folder. 324 | var parentFldID sql.NullInt64 325 | fld := new(types.Folder) 326 | db.err = db.QueryRow("SELECT id, title, parentFolderId FROM folder WHERE id=?", id).Scan(&fld.Id, &fld.Title, &parentFldID) 327 | switch { 328 | case db.err == sql.ErrNoRows: 329 | log.WithFields(log.Fields{ 330 | "id": id, 331 | }).Debug("GetFolder:no folder with that ID") 332 | db.err = nil 333 | return nil 334 | case db.err != nil: 335 | log.WithFields(log.Fields{ 336 | "err": db.err, 337 | }).Error("GetFolder:SELECT query error") 338 | return nil 339 | default: 340 | log.WithFields(log.Fields{ 341 | "Id": fld.Id, 342 | "Title": fld.Title, 343 | "parentFldId": parentFldID, 344 | }).Debug("GetFolder:folder found") 345 | // recursively retrieving the parents 346 | if parentFldID.Int64 != 0 { 347 | fld.Parent = db.GetFolder(int(parentFldID.Int64)) 348 | } 349 | } 350 | 351 | // Recursively getting the parents 352 | if parentFldID.Valid { 353 | fld.Parent = db.GetFolder(int(parentFldID.Int64)) 354 | } 355 | 356 | return fld 357 | 358 | } 359 | 360 | // GetStars returns the starred bookmarks. 361 | func (db *SQLiteDataStore) GetStars() []*types.Bookmark { 362 | 363 | // Leaving silently on past errors... 364 | if db.err != nil { 365 | return nil 366 | } 367 | 368 | // Querying the bookmarks. 369 | var ( 370 | rows *sql.Rows 371 | bkms []*types.Bookmark 372 | ) 373 | rows, db.err = db.Query("SELECT id, title, url, favicon, starred, folderId FROM bookmark WHERE starred ORDER BY title") 374 | defer func() { 375 | if db.err = rows.Close(); db.err != nil { 376 | log.WithFields(log.Fields{ 377 | "err": db.err, 378 | }).Error("GetStarredBookmarks:error closing rows") 379 | } 380 | }() 381 | 382 | switch { 383 | case db.err == sql.ErrNoRows: 384 | log.Debug("GetStarredBookmarks:no bookmarks") 385 | db.err = nil 386 | return nil 387 | case db.err != nil: 388 | log.WithFields(log.Fields{ 389 | "err": db.err, 390 | }).Error("GetStarredBookmarks:SELECT query error") 391 | return nil 392 | default: 393 | for rows.Next() { 394 | // Building a new Bookmark instance with each row. 395 | bkm := new(types.Bookmark) 396 | var fldID sql.NullInt64 397 | db.err = rows.Scan(&bkm.Id, &bkm.Title, &bkm.URL, &bkm.Favicon, &bkm.Starred, &fldID) 398 | if db.err != nil { 399 | log.WithFields(log.Fields{ 400 | "err": db.err, 401 | }).Error("GetStarredBookmarks:error scanning the query result row") 402 | return nil 403 | } 404 | // Retrieving the bookmark folder. 405 | bkm.Folder = db.GetFolder(int(fldID.Int64)) 406 | bkms = append(bkms, bkm) 407 | } 408 | if db.err = rows.Err(); db.err != nil { 409 | log.WithFields(log.Fields{ 410 | "err": db.err, 411 | }).Error("GetStarredBookmarks:error looping rows") 412 | return nil 413 | } 414 | return bkms 415 | } 416 | 417 | } 418 | 419 | // SearchBookmarks returns the bookmarks with the title containing the given string. 420 | func (db *SQLiteDataStore) SearchBookmarks(s string) []*types.Bookmark { 421 | 422 | log.WithFields(log.Fields{ 423 | "s": s, 424 | }).Debug("SearchBookmarks") 425 | 426 | // Leaving silently on past errors... 427 | if db.err != nil { 428 | return nil 429 | } 430 | var ( 431 | rows *sql.Rows 432 | bkms []*types.Bookmark 433 | ) 434 | 435 | // Querying the bookmarks. 436 | rows, db.err = db.Query(`SELECT bookmark.id, bookmark.title, bookmark.url, bookmark.favicon, bookmark.starred, bookmark.folderId 437 | FROM bookmark 438 | LEFT JOIN bookmarktag ON bookmarktag.bookmarkId = bookmark.Id 439 | LEFT JOIN tag ON bookmarktag.tagId = tag.Id 440 | WHERE bookmark.title LIKE ? OR 441 | tag.name LIKE ? 442 | GROUP BY bookmark.id 443 | ORDER BY bookmark.title`, "%"+s+"%", "%"+s+"%") 444 | defer func() { 445 | if db.err = rows.Close(); db.err != nil { 446 | log.WithFields(log.Fields{ 447 | "err": db.err, 448 | }).Error("SearchBookmarks:error closing rows") 449 | } 450 | }() 451 | switch { 452 | case db.err == sql.ErrNoRows: 453 | log.Debug("SearchBookmarks:no bookmarks") 454 | db.err = nil 455 | return nil 456 | case db.err != nil: 457 | log.WithFields(log.Fields{ 458 | "err": db.err, 459 | }).Error("SearchBookmarks:SELECT query error") 460 | return nil 461 | default: 462 | for rows.Next() { 463 | // Building a new Bookmark instance with each row. 464 | bkm := new(types.Bookmark) 465 | var parentFldID sql.NullInt64 466 | var starred sql.NullInt64 467 | db.err = rows.Scan(&bkm.Id, &bkm.Title, &bkm.URL, &bkm.Favicon, &starred, &parentFldID) 468 | 469 | // Getting the folder 470 | bkm.Folder = db.GetFolder(int(parentFldID.Int64)) 471 | 472 | // Starred bookmark ? 473 | if int(starred.Int64) != 0 { 474 | bkm.Starred = true 475 | } 476 | if db.err != nil { 477 | log.WithFields(log.Fields{ 478 | "err": db.err, 479 | }).Error("SearchBookmarks:error scanning the query result row") 480 | return nil 481 | } 482 | log.WithFields(log.Fields{ 483 | "bkm": bkm, 484 | }).Debug("SearchBookmarks:bookmark found") 485 | bkms = append(bkms, bkm) 486 | } 487 | if db.err = rows.Err(); db.err != nil { 488 | log.WithFields(log.Fields{ 489 | "err": db.err, 490 | }).Error("SearchBookmarks:error looping rows") 491 | return nil 492 | } 493 | return bkms 494 | } 495 | 496 | } 497 | 498 | // GetFolderBookmarks returns the bookmarks of the given folder id. 499 | func (db *SQLiteDataStore) GetFolderBookmarks(id int) types.Bookmarks { 500 | 501 | log.WithFields(log.Fields{ 502 | "id": id, 503 | }).Debug("GetFolderBookmarks") 504 | 505 | // Leaving silently on past errors... 506 | if db.err != nil { 507 | return nil 508 | } 509 | var ( 510 | rows *sql.Rows 511 | bkms types.Bookmarks 512 | ) 513 | 514 | // Querying the bookmarks. 515 | rows, db.err = db.Query("SELECT id, title, url, favicon, starred, folderId FROM bookmark WHERE folderId is ? ORDER BY title", id) 516 | defer func() { 517 | if db.err = rows.Close(); db.err != nil { 518 | log.WithFields(log.Fields{ 519 | "err": db.err, 520 | }).Error("GetFolderBookmarks:error closing rows") 521 | } 522 | }() 523 | 524 | switch { 525 | case db.err == sql.ErrNoRows: 526 | log.Debug("GetFolderBookmarks:no bookmarks") 527 | db.err = nil 528 | return nil 529 | case db.err != nil: 530 | log.WithFields(log.Fields{ 531 | "err": db.err, 532 | }).Error("GetFolderBookmarks:SELECT query error") 533 | return nil 534 | default: 535 | for rows.Next() { 536 | // Building a new Bookmark instance with each row. 537 | bkm := new(types.Bookmark) 538 | var parentFldID sql.NullInt64 539 | var starred sql.NullInt64 540 | db.err = rows.Scan(&bkm.Id, &bkm.Title, &bkm.URL, &bkm.Favicon, &starred, &parentFldID) 541 | // Starred bookmark ? 542 | if int(starred.Int64) != 0 { 543 | bkm.Starred = true 544 | } 545 | if db.err != nil { 546 | log.WithFields(log.Fields{ 547 | "err": db.err, 548 | }).Error("GetFolderBookmarks:error scanning the query result row") 549 | return nil 550 | } 551 | 552 | // Getting the bookmark tags 553 | bkm.Tags = db.GetBookmarkTags(bkm.Id) 554 | 555 | bkm.Folder = &types.Folder{Id: int(parentFldID.Int64)} 556 | bkms = append(bkms, bkm) 557 | log.WithFields(log.Fields{ 558 | "bkm": bkm, 559 | }).Debug("GetFolderBookmarks:bookmark found") 560 | } 561 | if db.err = rows.Err(); db.err != nil { 562 | log.WithFields(log.Fields{ 563 | "err": db.err, 564 | }).Error("GetFolderBookmarks:error looping rows") 565 | return nil 566 | } 567 | 568 | return bkms 569 | } 570 | 571 | } 572 | 573 | // GetBookmarkTags returns the tags of the bookmark 574 | func (db *SQLiteDataStore) GetBookmarkTags(id int) []*types.Tag { 575 | 576 | log.WithFields(log.Fields{ 577 | "id": id, 578 | }).Debug("GetBookmarkTags") 579 | 580 | // Leaving silently on past errors... 581 | if db.err != nil { 582 | return nil 583 | } 584 | 585 | var ( 586 | row *sql.Row 587 | rows *sql.Rows 588 | tagids []int 589 | tags []*types.Tag 590 | ) 591 | // Querying the tags ids. 592 | rows, db.err = db.Query("SELECT tagId FROM bookmarktag WHERE bookmarkId is ?", id) 593 | defer func() { 594 | if db.err = rows.Close(); db.err != nil { 595 | log.WithFields(log.Fields{ 596 | "err": db.err, 597 | }).Error("GetBookmarkTags:error closing rows") 598 | } 599 | }() 600 | switch { 601 | case db.err == sql.ErrNoRows: 602 | log.Debug("GetBookmarkTags:no tags") 603 | db.err = nil 604 | return nil 605 | case db.err != nil: 606 | log.WithFields(log.Fields{ 607 | "err": db.err, 608 | }).Error("GetBookmarkTags:SELECT query error") 609 | return nil 610 | default: 611 | for rows.Next() { 612 | var tagid int 613 | db.err = rows.Scan(&tagid) 614 | if db.err != nil { 615 | log.WithFields(log.Fields{ 616 | "err": db.err, 617 | }).Error("GetBookmarkTags:error scanning the query result row - tagid") 618 | return nil 619 | } 620 | tagids = append(tagids, tagid) 621 | } 622 | if db.err = rows.Err(); db.err != nil { 623 | log.WithFields(log.Fields{ 624 | "err": db.err, 625 | }).Error("GetBookmarkTags:error looping rows") 626 | return nil 627 | } 628 | } 629 | log.WithFields(log.Fields{"tagids": tagids}).Debug("GetBookmarkTags") 630 | 631 | // Querying the tags. 632 | for _, tid := range tagids { 633 | row = db.QueryRow("SELECT id, name FROM tag WHERE id is ?", tid) 634 | defer func() { 635 | if db.err = rows.Close(); db.err != nil { 636 | log.WithFields(log.Fields{ 637 | "err": db.err, 638 | }).Error("GetBookmarkTags:error closing rows") 639 | } 640 | }() 641 | var tag types.Tag 642 | db.err = row.Scan(&tag.Id, &tag.Name) 643 | if db.err != nil { 644 | log.WithFields(log.Fields{ 645 | "err": db.err, 646 | }).Error("GetBookmarkTags:error scanning the query result row - tag") 647 | return nil 648 | } 649 | log.WithFields(log.Fields{"tag": tag}).Debug("GetBookmarkTags") 650 | tags = append(tags, &tag) 651 | } 652 | 653 | return tags 654 | 655 | } 656 | 657 | // GetFolderSubfolders returns the children folders as an array of *Folder 658 | func (db *SQLiteDataStore) GetFolderSubfolders(id int) []*types.Folder { 659 | 660 | log.WithFields(log.Fields{ 661 | "id": id, 662 | }).Debug("GetChildrenFolders") 663 | 664 | // Leaving silently on past errors... 665 | if db.err != nil { 666 | return nil 667 | } 668 | 669 | var ( 670 | rows *sql.Rows 671 | flds []*types.Folder 672 | ) 673 | // Querying the folders. 674 | rows, db.err = db.Query("SELECT id, title, parentFolderId, nbChildrenFolders FROM folder WHERE parentFolderId is ? ORDER BY title", id) 675 | defer func() { 676 | if db.err = rows.Close(); db.err != nil { 677 | log.WithFields(log.Fields{ 678 | "err": db.err, 679 | }).Error("GetFolderSubfolders:error closing rows") 680 | } 681 | }() 682 | switch { 683 | case db.err == sql.ErrNoRows: 684 | log.Debug("GetChildrenFolders:no folders") 685 | db.err = nil 686 | return nil 687 | case db.err != nil: 688 | log.WithFields(log.Fields{ 689 | "err": db.err, 690 | }).Error("GetChildrenFolders:SELECT query error") 691 | return nil 692 | default: 693 | for rows.Next() { 694 | // Building a new Folder instance with each row. 695 | fld := new(types.Folder) 696 | var parentFldID sql.NullInt64 697 | db.err = rows.Scan(&fld.Id, &fld.Title, &parentFldID, &fld.NbChildrenFolders) 698 | if db.err != nil { 699 | log.WithFields(log.Fields{ 700 | "err": db.err, 701 | }).Error("GetChildrenFolders:error scanning the query result row") 702 | return nil 703 | } 704 | fld.Parent = &types.Folder{Id: int(parentFldID.Int64)} 705 | flds = append(flds, fld) 706 | } 707 | if db.err = rows.Err(); db.err != nil { 708 | log.WithFields(log.Fields{ 709 | "err": db.err, 710 | }).Error("GetChildrenFolders:error looping rows") 711 | return nil 712 | } 713 | return flds 714 | } 715 | 716 | } 717 | 718 | // SaveFolder saves the given new Folder into the db and returns the folder id. 719 | // Called only on folder creation or rename 720 | // so only the Title has to be set. 721 | func (db *SQLiteDataStore) SaveFolder(f *types.Folder) int64 { 722 | 723 | log.WithFields(log.Fields{ 724 | "f": f, 725 | }).Debug("SaveFolder") 726 | 727 | // Leaving silently on past errors... 728 | if db.err != nil { 729 | return 0 730 | } 731 | var stmt *sql.Stmt 732 | 733 | // Preparing the query. 734 | // id will be auto incremented 735 | if stmt, db.err = db.Prepare("INSERT INTO folder(title, parentFolderId, nbChildrenFolders) values(?,?,?)"); db.err != nil { 736 | log.WithFields(log.Fields{ 737 | "err": db.err, 738 | }).Error("SaveFolder:SELECT request prepare error") 739 | return 0 740 | } 741 | defer func() { 742 | if db.err = stmt.Close(); db.err != nil { 743 | log.WithFields(log.Fields{ 744 | "err": db.err, 745 | }).Error("SaveFolder:error closing stmt") 746 | } 747 | }() 748 | 749 | // Executing the query. 750 | var res sql.Result 751 | if f.Parent != nil { 752 | res, db.err = stmt.Exec(f.Title, f.Parent.Id, f.NbChildrenFolders) 753 | } else { 754 | res, db.err = stmt.Exec(f.Title, 1, f.NbChildrenFolders) 755 | } 756 | id, _ := res.LastInsertId() // we should check the error here too... 757 | if db.err != nil { 758 | log.WithFields(log.Fields{ 759 | "err": db.err, 760 | }).Error("SaveFolder:INSERT query error") 761 | return 0 762 | } 763 | return id 764 | 765 | } 766 | 767 | // UpdateBookmark updates the given bookmark. 768 | func (db *SQLiteDataStore) UpdateBookmark(b *types.Bookmark) { 769 | 770 | log.WithFields(log.Fields{ 771 | "b": b, 772 | }).Debug("UpdateBookmark") 773 | 774 | // Leaving silently on past errors... 775 | if db.err != nil { 776 | return 777 | } 778 | 779 | var ( 780 | stmt *sql.Stmt 781 | tx *sql.Tx 782 | ) 783 | 784 | // Beginning a new transaction. 785 | // TODO: is a transaction needed here? 786 | tx, db.err = db.Begin() 787 | if db.err != nil { 788 | log.WithFields(log.Fields{ 789 | "err": db.err, 790 | }).Error("Update bookmark:transaction begin failed") 791 | return 792 | } 793 | 794 | // Preparing the update request. 795 | stmt, db.err = tx.Prepare("UPDATE bookmark SET title=?, url=?, folderId=?, starred=?, favicon=? WHERE id=?") 796 | if db.err != nil { 797 | log.WithFields(log.Fields{ 798 | "err": db.err, 799 | }).Error("Update bookmark:UPDATE request prepare error") 800 | return 801 | } 802 | defer func() { 803 | if db.err = stmt.Close(); db.err != nil { 804 | log.WithFields(log.Fields{ 805 | "err": db.err, 806 | }).Error("UpdateBookmark:error closing stmt") 807 | } 808 | }() 809 | 810 | // Executing the query. 811 | if b.Folder != nil { 812 | _, db.err = stmt.Exec(b.Title, b.URL, b.Folder.Id, b.Starred, b.Favicon, b.Id) 813 | } else { 814 | _, db.err = stmt.Exec(b.Title, b.URL, 1, b.Starred, b.Favicon, b.Id) 815 | } 816 | // Rolling back on errors, or commit. 817 | if db.err != nil { 818 | log.WithFields(log.Fields{ 819 | "err": db.err, 820 | }).Error("UpdateBookmark: UPDATE bookmark error") 821 | if db.err = tx.Rollback(); db.err != nil { 822 | // Just logging the error. 823 | log.WithFields(log.Fields{ 824 | "err": db.err, 825 | }).Error("UpdateBookmark: UPDATE query transaction rollback error") 826 | return 827 | } 828 | return 829 | } 830 | if db.err = tx.Commit(); db.err != nil { 831 | // Just logging the error. 832 | log.WithFields(log.Fields{ 833 | "err": db.err, 834 | }).Error("UpdateBookmark: UPDATE bookmark transaction commit error") 835 | } 836 | 837 | // 838 | // Tags 839 | // 840 | // lazily deleting current tags 841 | _, db.err = db.Exec("DELETE from bookmarktag WHERE bookmarkId IS ?", b.Id) 842 | if db.err != nil { 843 | log.WithFields(log.Fields{ 844 | "err": db.err, 845 | }).Error("UpdateBookmark: DELETE bookmarktag query error") 846 | return 847 | } 848 | // inserting new tags 849 | for _, t := range b.Tags { 850 | log.WithFields(log.Fields{"t": t}).Debug("UpdateBookmark") 851 | // new tag id 852 | var ntid int 853 | // getting new tag from db 854 | nt := db.GetTag(t.Id) 855 | if nt == nil { 856 | // inserting the new tag into the db if it does not exist 857 | ntid = int(db.SaveTag(t)) 858 | } else { 859 | ntid = nt.Id 860 | } 861 | 862 | // linking the new tag to the bookmark 863 | log.WithFields(log.Fields{"b.Id": b.Id, "ntid": ntid}).Debug("UpdateBookmark") 864 | _, db.err = db.Exec("INSERT INTO bookmarktag(bookmarkId, tagId) values(?,?)", b.Id, ntid) 865 | if db.err != nil { 866 | log.WithFields(log.Fields{ 867 | "err": db.err, 868 | }).Error("UpdateBookmark: INSERT bookmarktag query error") 869 | return 870 | } 871 | } 872 | // cleaning orphan tags 873 | _, db.err = db.Exec("DELETE FROM tag WHERE tag.id NOT IN (SELECT tagId FROM bookmarktag)") 874 | if db.err != nil { 875 | log.WithFields(log.Fields{ 876 | "err": db.err, 877 | }).Error("UpdateBookmark: DELETE tag query error") 878 | return 879 | } 880 | 881 | } 882 | 883 | // SaveTag saves the new given Tag into the db 884 | func (db *SQLiteDataStore) SaveTag(t *types.Tag) int64 { 885 | 886 | log.WithFields(log.Fields{ 887 | "t": t, 888 | }).Debug("SaveTag") 889 | 890 | // Leaving silently on past errors... 891 | if db.err != nil { 892 | return 0 893 | } 894 | 895 | // Preparing the query. 896 | var stmt *sql.Stmt 897 | stmt, db.err = db.Prepare("INSERT INTO tag(name) values(?)") 898 | if db.err != nil { 899 | log.WithFields(log.Fields{ 900 | "err": db.err, 901 | }).Error("SaveTag:INSERT request prepare error") 902 | return 0 903 | } 904 | defer func() { 905 | if db.err = stmt.Close(); db.err != nil { 906 | log.WithFields(log.Fields{ 907 | "err": db.err, 908 | }).Error("SaveTag:error closing stmt") 909 | } 910 | }() 911 | 912 | // Executing the query. 913 | var res sql.Result 914 | res, db.err = stmt.Exec(t.Name) 915 | if db.err != nil { 916 | log.WithFields(log.Fields{ 917 | "err": db.err, 918 | }).Error("SaveTag:INSERT query error") 919 | return 0 920 | } 921 | 922 | id, _ := res.LastInsertId() 923 | return id 924 | 925 | } 926 | 927 | // SaveBookmark saves the new given Bookmark into the db 928 | func (db *SQLiteDataStore) SaveBookmark(b *types.Bookmark) int64 { 929 | 930 | log.WithFields(log.Fields{ 931 | "b": b, 932 | }).Debug("SaveBookmark") 933 | 934 | // Leaving silently on past errors... 935 | if db.err != nil { 936 | return 0 937 | } 938 | 939 | // 940 | // Bookmark 941 | // 942 | // Preparing the query. 943 | var stmt *sql.Stmt 944 | stmt, db.err = db.Prepare("INSERT INTO bookmark(title, url, folderId, favicon) values(?,?,?,?)") 945 | if db.err != nil { 946 | log.WithFields(log.Fields{ 947 | "err": db.err, 948 | }).Error("SaveBookmark:INSERT request prepare error") 949 | return 0 950 | } 951 | defer func() { 952 | if db.err = stmt.Close(); db.err != nil { 953 | log.WithFields(log.Fields{ 954 | "err": db.err, 955 | }).Error("SaveBookmark:error closing stmt") 956 | } 957 | }() 958 | 959 | // Executing the query. 960 | var res sql.Result 961 | if b.Folder != nil { 962 | res, db.err = stmt.Exec(b.Title, b.URL, b.Folder.Id, b.Favicon) 963 | } else { 964 | res, db.err = stmt.Exec(b.Title, b.URL, 1, b.Favicon) 965 | } 966 | if db.err != nil { 967 | log.WithFields(log.Fields{ 968 | "err": db.err, 969 | }).Error("SaveBookmark:INSERT query error") 970 | return 0 971 | } 972 | id, _ := res.LastInsertId() 973 | 974 | // 975 | // Tags 976 | // 977 | // inserting new tags 978 | for _, t := range b.Tags { 979 | log.WithFields(log.Fields{"t": t}).Debug("SaveBookmark") 980 | // new tag id 981 | var ntid int 982 | // getting new tag from db 983 | nt := db.GetTag(t.Id) 984 | if nt == nil { 985 | // inserting the new tag into the db if it does not exist 986 | ntid = int(db.SaveTag(t)) 987 | } else { 988 | ntid = nt.Id 989 | } 990 | 991 | // linking the new tag to the bookmark 992 | log.WithFields(log.Fields{"b.Id": b.Id, "ntid": ntid}).Debug("SaveBookmark") 993 | if _, db.err = db.Exec("INSERT INTO bookmarktag(bookmarkId, tagId) values(?,?)", b.Id, ntid); db.err != nil { 994 | return 0 995 | } 996 | } 997 | 998 | return id 999 | 1000 | } 1001 | 1002 | // DeleteBookmark delete the given Bookmark from the db 1003 | func (db *SQLiteDataStore) DeleteBookmark(b *types.Bookmark) { 1004 | 1005 | log.WithFields(log.Fields{ 1006 | "b": b, 1007 | }).Debug("DeleteBookmark") 1008 | 1009 | // Leaving silently on past errors... 1010 | if db.err != nil { 1011 | return 1012 | } 1013 | 1014 | // Executing the query. 1015 | _, db.err = db.Exec("DELETE from bookmark WHERE id=?", b.Id) 1016 | if db.err != nil { 1017 | log.WithFields(log.Fields{ 1018 | "err": db.err, 1019 | }).Error("DeleteBookmark:DELETE query error") 1020 | return 1021 | } 1022 | 1023 | } 1024 | 1025 | // UpdateFolder updates the given folder. 1026 | func (db *SQLiteDataStore) UpdateFolder(f *types.Folder) { 1027 | 1028 | log.WithFields(log.Fields{ 1029 | "f": f, 1030 | }).Debug("UpdateFolder") 1031 | 1032 | // Leaving silently on past errors... 1033 | if db.err != nil { 1034 | return 1035 | } 1036 | 1037 | var oldParentFolderID sql.NullInt64 1038 | // Retrieving the parentFolderId of the folder to be updated. 1039 | if db.err = db.QueryRow("SELECT parentFolderId from folder WHERE id=?", f.Id).Scan(&oldParentFolderID); db.err != nil { 1040 | log.WithFields(log.Fields{ 1041 | "err": db.err, 1042 | }).Error("UpdateFolder:SELECT query error") 1043 | return 1044 | } 1045 | log.WithFields(log.Fields{ 1046 | "oldParentFolderId": oldParentFolderID, 1047 | "f.Parent": f.Parent, 1048 | }).Debug("UpdateFolder") 1049 | 1050 | // Preparing the update request for the folder. 1051 | var stmt *sql.Stmt 1052 | stmt, db.err = db.Prepare("UPDATE folder SET title=?, parentFolderId=?, nbChildrenFolders=(SELECT count(*) from folder WHERE parentFolderId=?) WHERE id=?") 1053 | if db.err != nil { 1054 | log.WithFields(log.Fields{ 1055 | "err": db.err, 1056 | }).Error("UpdateFolder:UPDATE request prepare error") 1057 | return 1058 | } 1059 | defer func() { 1060 | if db.err = stmt.Close(); db.err != nil { 1061 | log.WithFields(log.Fields{ 1062 | "err": db.err, 1063 | }).Error("UpdateFolder:error closing stmt") 1064 | } 1065 | }() 1066 | 1067 | // Executing the query. 1068 | if f.Parent != nil { 1069 | _, db.err = stmt.Exec(f.Title, f.Parent.Id, f.Id, f.Id) 1070 | } else { 1071 | _, db.err = stmt.Exec(f.Title, 1, f.Id, f.Id) 1072 | } 1073 | if db.err != nil { 1074 | log.WithFields(log.Fields{ 1075 | "err": db.err, 1076 | }).Error("UpdateFolder:UPDATE query error") 1077 | return 1078 | } 1079 | 1080 | // Preparing the update request for the old and new parent folders (to update the nbChildrenFolders). 1081 | stmt, db.err = db.Prepare("UPDATE folder SET nbChildrenFolders=(SELECT count(*) from folder WHERE parentFolderId=?) WHERE id=?") 1082 | if db.err != nil { 1083 | log.WithFields(log.Fields{ 1084 | "err": db.err, 1085 | }).Error("UpdateFolder:UPDATE old parent request prepare error") 1086 | return 1087 | } 1088 | defer func() { 1089 | if db.err = stmt.Close(); db.err != nil { 1090 | log.WithFields(log.Fields{ 1091 | "err": db.err, 1092 | }).Error("UpdateFolder:error closing stmt") 1093 | } 1094 | }() 1095 | 1096 | // Executing the query for the old parent. 1097 | if _, db.err = stmt.Exec(oldParentFolderID, oldParentFolderID); db.err != nil { 1098 | log.WithFields(log.Fields{ 1099 | "err": db.err, 1100 | }).Error("UpdateFolder:UPDATE old parent request error") 1101 | return 1102 | } 1103 | // And the new. 1104 | if f.Parent != nil { 1105 | if _, db.err = stmt.Exec(f.Parent.Id, f.Parent.Id); db.err != nil { 1106 | log.WithFields(log.Fields{ 1107 | "err": db.err, 1108 | }).Error("UpdateFolder:UPDATE new parent request error") 1109 | return 1110 | } 1111 | } 1112 | 1113 | } 1114 | 1115 | // DeleteFolder delete the given Folder from the db. 1116 | func (db *SQLiteDataStore) DeleteFolder(f *types.Folder) { 1117 | 1118 | log.WithFields(log.Fields{ 1119 | "f": f, 1120 | }).Debug("DeleteFolder") 1121 | 1122 | // Leaving silently on past errors... 1123 | if db.err != nil { 1124 | return 1125 | } 1126 | 1127 | // Executing the query. 1128 | _, db.err = db.Exec("DELETE from folder WHERE id=?", f.Id) 1129 | if db.err != nil { 1130 | log.WithFields(log.Fields{ 1131 | "err": db.err, 1132 | }).Error("DeleteFolder:DELETE query error") 1133 | return 1134 | } 1135 | 1136 | } 1137 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbellembois/gobkm/7f373d50cb801a247ea25e6ce03a528089ff5261/screenshot.png -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 29 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /static/wasm/main.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tbellembois/gobkm/7f373d50cb801a247ea25e6ce03a528089ff5261/static/wasm/main.wasm -------------------------------------------------------------------------------- /static/wasm/wasm_exec.js: -------------------------------------------------------------------------------- 1 | // Copyright 2018 The Go Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | "use strict"; 6 | 7 | (() => { 8 | const enosys = () => { 9 | const err = new Error("not implemented"); 10 | err.code = "ENOSYS"; 11 | return err; 12 | }; 13 | 14 | if (!globalThis.fs) { 15 | let outputBuf = ""; 16 | globalThis.fs = { 17 | constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused 18 | writeSync(fd, buf) { 19 | outputBuf += decoder.decode(buf); 20 | const nl = outputBuf.lastIndexOf("\n"); 21 | if (nl != -1) { 22 | console.log(outputBuf.substr(0, nl)); 23 | outputBuf = outputBuf.substr(nl + 1); 24 | } 25 | return buf.length; 26 | }, 27 | write(fd, buf, offset, length, position, callback) { 28 | if (offset !== 0 || length !== buf.length || position !== null) { 29 | callback(enosys()); 30 | return; 31 | } 32 | const n = this.writeSync(fd, buf); 33 | callback(null, n); 34 | }, 35 | chmod(path, mode, callback) { callback(enosys()); }, 36 | chown(path, uid, gid, callback) { callback(enosys()); }, 37 | close(fd, callback) { callback(enosys()); }, 38 | fchmod(fd, mode, callback) { callback(enosys()); }, 39 | fchown(fd, uid, gid, callback) { callback(enosys()); }, 40 | fstat(fd, callback) { callback(enosys()); }, 41 | fsync(fd, callback) { callback(null); }, 42 | ftruncate(fd, length, callback) { callback(enosys()); }, 43 | lchown(path, uid, gid, callback) { callback(enosys()); }, 44 | link(path, link, callback) { callback(enosys()); }, 45 | lstat(path, callback) { callback(enosys()); }, 46 | mkdir(path, perm, callback) { callback(enosys()); }, 47 | open(path, flags, mode, callback) { callback(enosys()); }, 48 | read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, 49 | readdir(path, callback) { callback(enosys()); }, 50 | readlink(path, callback) { callback(enosys()); }, 51 | rename(from, to, callback) { callback(enosys()); }, 52 | rmdir(path, callback) { callback(enosys()); }, 53 | stat(path, callback) { callback(enosys()); }, 54 | symlink(path, link, callback) { callback(enosys()); }, 55 | truncate(path, length, callback) { callback(enosys()); }, 56 | unlink(path, callback) { callback(enosys()); }, 57 | utimes(path, atime, mtime, callback) { callback(enosys()); }, 58 | }; 59 | } 60 | 61 | if (!globalThis.process) { 62 | globalThis.process = { 63 | getuid() { return -1; }, 64 | getgid() { return -1; }, 65 | geteuid() { return -1; }, 66 | getegid() { return -1; }, 67 | getgroups() { throw enosys(); }, 68 | pid: -1, 69 | ppid: -1, 70 | umask() { throw enosys(); }, 71 | cwd() { throw enosys(); }, 72 | chdir() { throw enosys(); }, 73 | } 74 | } 75 | 76 | if (!globalThis.crypto) { 77 | throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); 78 | } 79 | 80 | if (!globalThis.performance) { 81 | throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); 82 | } 83 | 84 | if (!globalThis.TextEncoder) { 85 | throw new Error("globalThis.TextEncoder is not available, polyfill required"); 86 | } 87 | 88 | if (!globalThis.TextDecoder) { 89 | throw new Error("globalThis.TextDecoder is not available, polyfill required"); 90 | } 91 | 92 | const encoder = new TextEncoder("utf-8"); 93 | const decoder = new TextDecoder("utf-8"); 94 | 95 | globalThis.Go = class { 96 | constructor() { 97 | this.argv = ["js"]; 98 | this.env = {}; 99 | this.exit = (code) => { 100 | if (code !== 0) { 101 | console.warn("exit code:", code); 102 | } 103 | }; 104 | this._exitPromise = new Promise((resolve) => { 105 | this._resolveExitPromise = resolve; 106 | }); 107 | this._pendingEvent = null; 108 | this._scheduledTimeouts = new Map(); 109 | this._nextCallbackTimeoutID = 1; 110 | 111 | const setInt64 = (addr, v) => { 112 | this.mem.setUint32(addr + 0, v, true); 113 | this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); 114 | } 115 | 116 | const getInt64 = (addr) => { 117 | const low = this.mem.getUint32(addr + 0, true); 118 | const high = this.mem.getInt32(addr + 4, true); 119 | return low + high * 4294967296; 120 | } 121 | 122 | const loadValue = (addr) => { 123 | const f = this.mem.getFloat64(addr, true); 124 | if (f === 0) { 125 | return undefined; 126 | } 127 | if (!isNaN(f)) { 128 | return f; 129 | } 130 | 131 | const id = this.mem.getUint32(addr, true); 132 | return this._values[id]; 133 | } 134 | 135 | const storeValue = (addr, v) => { 136 | const nanHead = 0x7FF80000; 137 | 138 | if (typeof v === "number" && v !== 0) { 139 | if (isNaN(v)) { 140 | this.mem.setUint32(addr + 4, nanHead, true); 141 | this.mem.setUint32(addr, 0, true); 142 | return; 143 | } 144 | this.mem.setFloat64(addr, v, true); 145 | return; 146 | } 147 | 148 | if (v === undefined) { 149 | this.mem.setFloat64(addr, 0, true); 150 | return; 151 | } 152 | 153 | let id = this._ids.get(v); 154 | if (id === undefined) { 155 | id = this._idPool.pop(); 156 | if (id === undefined) { 157 | id = this._values.length; 158 | } 159 | this._values[id] = v; 160 | this._goRefCounts[id] = 0; 161 | this._ids.set(v, id); 162 | } 163 | this._goRefCounts[id]++; 164 | let typeFlag = 0; 165 | switch (typeof v) { 166 | case "object": 167 | if (v !== null) { 168 | typeFlag = 1; 169 | } 170 | break; 171 | case "string": 172 | typeFlag = 2; 173 | break; 174 | case "symbol": 175 | typeFlag = 3; 176 | break; 177 | case "function": 178 | typeFlag = 4; 179 | break; 180 | } 181 | this.mem.setUint32(addr + 4, nanHead | typeFlag, true); 182 | this.mem.setUint32(addr, id, true); 183 | } 184 | 185 | const loadSlice = (addr) => { 186 | const array = getInt64(addr + 0); 187 | const len = getInt64(addr + 8); 188 | return new Uint8Array(this._inst.exports.mem.buffer, array, len); 189 | } 190 | 191 | const loadSliceOfValues = (addr) => { 192 | const array = getInt64(addr + 0); 193 | const len = getInt64(addr + 8); 194 | const a = new Array(len); 195 | for (let i = 0; i < len; i++) { 196 | a[i] = loadValue(array + i * 8); 197 | } 198 | return a; 199 | } 200 | 201 | const loadString = (addr) => { 202 | const saddr = getInt64(addr + 0); 203 | const len = getInt64(addr + 8); 204 | return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); 205 | } 206 | 207 | const timeOrigin = Date.now() - performance.now(); 208 | this.importObject = { 209 | go: { 210 | // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) 211 | // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported 212 | // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). 213 | // This changes the SP, thus we have to update the SP used by the imported function. 214 | 215 | // func wasmExit(code int32) 216 | "runtime.wasmExit": (sp) => { 217 | sp >>>= 0; 218 | const code = this.mem.getInt32(sp + 8, true); 219 | this.exited = true; 220 | delete this._inst; 221 | delete this._values; 222 | delete this._goRefCounts; 223 | delete this._ids; 224 | delete this._idPool; 225 | this.exit(code); 226 | }, 227 | 228 | // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) 229 | "runtime.wasmWrite": (sp) => { 230 | sp >>>= 0; 231 | const fd = getInt64(sp + 8); 232 | const p = getInt64(sp + 16); 233 | const n = this.mem.getInt32(sp + 24, true); 234 | fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); 235 | }, 236 | 237 | // func resetMemoryDataView() 238 | "runtime.resetMemoryDataView": (sp) => { 239 | sp >>>= 0; 240 | this.mem = new DataView(this._inst.exports.mem.buffer); 241 | }, 242 | 243 | // func nanotime1() int64 244 | "runtime.nanotime1": (sp) => { 245 | sp >>>= 0; 246 | setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); 247 | }, 248 | 249 | // func walltime() (sec int64, nsec int32) 250 | "runtime.walltime": (sp) => { 251 | sp >>>= 0; 252 | const msec = (new Date).getTime(); 253 | setInt64(sp + 8, msec / 1000); 254 | this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); 255 | }, 256 | 257 | // func scheduleTimeoutEvent(delay int64) int32 258 | "runtime.scheduleTimeoutEvent": (sp) => { 259 | sp >>>= 0; 260 | const id = this._nextCallbackTimeoutID; 261 | this._nextCallbackTimeoutID++; 262 | this._scheduledTimeouts.set(id, setTimeout( 263 | () => { 264 | this._resume(); 265 | while (this._scheduledTimeouts.has(id)) { 266 | // for some reason Go failed to register the timeout event, log and try again 267 | // (temporary workaround for https://github.com/golang/go/issues/28975) 268 | console.warn("scheduleTimeoutEvent: missed timeout event"); 269 | this._resume(); 270 | } 271 | }, 272 | getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early 273 | )); 274 | this.mem.setInt32(sp + 16, id, true); 275 | }, 276 | 277 | // func clearTimeoutEvent(id int32) 278 | "runtime.clearTimeoutEvent": (sp) => { 279 | sp >>>= 0; 280 | const id = this.mem.getInt32(sp + 8, true); 281 | clearTimeout(this._scheduledTimeouts.get(id)); 282 | this._scheduledTimeouts.delete(id); 283 | }, 284 | 285 | // func getRandomData(r []byte) 286 | "runtime.getRandomData": (sp) => { 287 | sp >>>= 0; 288 | crypto.getRandomValues(loadSlice(sp + 8)); 289 | }, 290 | 291 | // func finalizeRef(v ref) 292 | "syscall/js.finalizeRef": (sp) => { 293 | sp >>>= 0; 294 | const id = this.mem.getUint32(sp + 8, true); 295 | this._goRefCounts[id]--; 296 | if (this._goRefCounts[id] === 0) { 297 | const v = this._values[id]; 298 | this._values[id] = null; 299 | this._ids.delete(v); 300 | this._idPool.push(id); 301 | } 302 | }, 303 | 304 | // func stringVal(value string) ref 305 | "syscall/js.stringVal": (sp) => { 306 | sp >>>= 0; 307 | storeValue(sp + 24, loadString(sp + 8)); 308 | }, 309 | 310 | // func valueGet(v ref, p string) ref 311 | "syscall/js.valueGet": (sp) => { 312 | sp >>>= 0; 313 | const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); 314 | sp = this._inst.exports.getsp() >>> 0; // see comment above 315 | storeValue(sp + 32, result); 316 | }, 317 | 318 | // func valueSet(v ref, p string, x ref) 319 | "syscall/js.valueSet": (sp) => { 320 | sp >>>= 0; 321 | Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); 322 | }, 323 | 324 | // func valueDelete(v ref, p string) 325 | "syscall/js.valueDelete": (sp) => { 326 | sp >>>= 0; 327 | Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); 328 | }, 329 | 330 | // func valueIndex(v ref, i int) ref 331 | "syscall/js.valueIndex": (sp) => { 332 | sp >>>= 0; 333 | storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); 334 | }, 335 | 336 | // valueSetIndex(v ref, i int, x ref) 337 | "syscall/js.valueSetIndex": (sp) => { 338 | sp >>>= 0; 339 | Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); 340 | }, 341 | 342 | // func valueCall(v ref, m string, args []ref) (ref, bool) 343 | "syscall/js.valueCall": (sp) => { 344 | sp >>>= 0; 345 | try { 346 | const v = loadValue(sp + 8); 347 | const m = Reflect.get(v, loadString(sp + 16)); 348 | const args = loadSliceOfValues(sp + 32); 349 | const result = Reflect.apply(m, v, args); 350 | sp = this._inst.exports.getsp() >>> 0; // see comment above 351 | storeValue(sp + 56, result); 352 | this.mem.setUint8(sp + 64, 1); 353 | } catch (err) { 354 | sp = this._inst.exports.getsp() >>> 0; // see comment above 355 | storeValue(sp + 56, err); 356 | this.mem.setUint8(sp + 64, 0); 357 | } 358 | }, 359 | 360 | // func valueInvoke(v ref, args []ref) (ref, bool) 361 | "syscall/js.valueInvoke": (sp) => { 362 | sp >>>= 0; 363 | try { 364 | const v = loadValue(sp + 8); 365 | const args = loadSliceOfValues(sp + 16); 366 | const result = Reflect.apply(v, undefined, args); 367 | sp = this._inst.exports.getsp() >>> 0; // see comment above 368 | storeValue(sp + 40, result); 369 | this.mem.setUint8(sp + 48, 1); 370 | } catch (err) { 371 | sp = this._inst.exports.getsp() >>> 0; // see comment above 372 | storeValue(sp + 40, err); 373 | this.mem.setUint8(sp + 48, 0); 374 | } 375 | }, 376 | 377 | // func valueNew(v ref, args []ref) (ref, bool) 378 | "syscall/js.valueNew": (sp) => { 379 | sp >>>= 0; 380 | try { 381 | const v = loadValue(sp + 8); 382 | const args = loadSliceOfValues(sp + 16); 383 | const result = Reflect.construct(v, args); 384 | sp = this._inst.exports.getsp() >>> 0; // see comment above 385 | storeValue(sp + 40, result); 386 | this.mem.setUint8(sp + 48, 1); 387 | } catch (err) { 388 | sp = this._inst.exports.getsp() >>> 0; // see comment above 389 | storeValue(sp + 40, err); 390 | this.mem.setUint8(sp + 48, 0); 391 | } 392 | }, 393 | 394 | // func valueLength(v ref) int 395 | "syscall/js.valueLength": (sp) => { 396 | sp >>>= 0; 397 | setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); 398 | }, 399 | 400 | // valuePrepareString(v ref) (ref, int) 401 | "syscall/js.valuePrepareString": (sp) => { 402 | sp >>>= 0; 403 | const str = encoder.encode(String(loadValue(sp + 8))); 404 | storeValue(sp + 16, str); 405 | setInt64(sp + 24, str.length); 406 | }, 407 | 408 | // valueLoadString(v ref, b []byte) 409 | "syscall/js.valueLoadString": (sp) => { 410 | sp >>>= 0; 411 | const str = loadValue(sp + 8); 412 | loadSlice(sp + 16).set(str); 413 | }, 414 | 415 | // func valueInstanceOf(v ref, t ref) bool 416 | "syscall/js.valueInstanceOf": (sp) => { 417 | sp >>>= 0; 418 | this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); 419 | }, 420 | 421 | // func copyBytesToGo(dst []byte, src ref) (int, bool) 422 | "syscall/js.copyBytesToGo": (sp) => { 423 | sp >>>= 0; 424 | const dst = loadSlice(sp + 8); 425 | const src = loadValue(sp + 32); 426 | if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { 427 | this.mem.setUint8(sp + 48, 0); 428 | return; 429 | } 430 | const toCopy = src.subarray(0, dst.length); 431 | dst.set(toCopy); 432 | setInt64(sp + 40, toCopy.length); 433 | this.mem.setUint8(sp + 48, 1); 434 | }, 435 | 436 | // func copyBytesToJS(dst ref, src []byte) (int, bool) 437 | "syscall/js.copyBytesToJS": (sp) => { 438 | sp >>>= 0; 439 | const dst = loadValue(sp + 8); 440 | const src = loadSlice(sp + 16); 441 | if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { 442 | this.mem.setUint8(sp + 48, 0); 443 | return; 444 | } 445 | const toCopy = src.subarray(0, dst.length); 446 | dst.set(toCopy); 447 | setInt64(sp + 40, toCopy.length); 448 | this.mem.setUint8(sp + 48, 1); 449 | }, 450 | 451 | "debug": (value) => { 452 | console.log(value); 453 | }, 454 | } 455 | }; 456 | } 457 | 458 | async run(instance) { 459 | if (!(instance instanceof WebAssembly.Instance)) { 460 | throw new Error("Go.run: WebAssembly.Instance expected"); 461 | } 462 | this._inst = instance; 463 | this.mem = new DataView(this._inst.exports.mem.buffer); 464 | this._values = [ // JS values that Go currently has references to, indexed by reference id 465 | NaN, 466 | 0, 467 | null, 468 | true, 469 | false, 470 | globalThis, 471 | this, 472 | ]; 473 | this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id 474 | this._ids = new Map([ // mapping from JS values to reference ids 475 | [0, 1], 476 | [null, 2], 477 | [true, 3], 478 | [false, 4], 479 | [globalThis, 5], 480 | [this, 6], 481 | ]); 482 | this._idPool = []; // unused ids that have been garbage collected 483 | this.exited = false; // whether the Go program has exited 484 | 485 | // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. 486 | let offset = 4096; 487 | 488 | const strPtr = (str) => { 489 | const ptr = offset; 490 | const bytes = encoder.encode(str + "\0"); 491 | new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); 492 | offset += bytes.length; 493 | if (offset % 8 !== 0) { 494 | offset += 8 - (offset % 8); 495 | } 496 | return ptr; 497 | }; 498 | 499 | const argc = this.argv.length; 500 | 501 | const argvPtrs = []; 502 | this.argv.forEach((arg) => { 503 | argvPtrs.push(strPtr(arg)); 504 | }); 505 | argvPtrs.push(0); 506 | 507 | const keys = Object.keys(this.env).sort(); 508 | keys.forEach((key) => { 509 | argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); 510 | }); 511 | argvPtrs.push(0); 512 | 513 | const argv = offset; 514 | argvPtrs.forEach((ptr) => { 515 | this.mem.setUint32(offset, ptr, true); 516 | this.mem.setUint32(offset + 4, 0, true); 517 | offset += 8; 518 | }); 519 | 520 | // The linker guarantees global data starts from at least wasmMinDataAddr. 521 | // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. 522 | const wasmMinDataAddr = 4096 + 8192; 523 | if (offset >= wasmMinDataAddr) { 524 | throw new Error("total length of command line and environment variables exceeds limit"); 525 | } 526 | 527 | this._inst.exports.run(argc, argv); 528 | if (this.exited) { 529 | this._resolveExitPromise(); 530 | } 531 | await this._exitPromise; 532 | } 533 | 534 | _resume() { 535 | if (this.exited) { 536 | throw new Error("Go program has already exited"); 537 | } 538 | this._inst.exports.resume(); 539 | if (this.exited) { 540 | this._resolveExitPromise(); 541 | } 542 | } 543 | 544 | _makeFuncWrapper(id) { 545 | const go = this; 546 | return function () { 547 | const event = { id: id, this: this, args: arguments }; 548 | go._pendingEvent = event; 549 | go._resume(); 550 | return event.result; 551 | }; 552 | } 553 | } 554 | })(); 555 | -------------------------------------------------------------------------------- /types/bookmark.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | ) 7 | 8 | // Folder containing the bookmarks 9 | type Folder struct { 10 | Id int `json:"id"` 11 | Title string `json:"title"` 12 | Parent *Folder `json:"parent"` 13 | Folders []*Folder `json:"folders"` 14 | Bookmarks []*Bookmark `json:"bookmarks"` 15 | NbChildrenFolders int `json:"nbchildrenfolders"` 16 | } 17 | 18 | // Bookmark 19 | type Bookmark struct { 20 | Id int `json:"id"` 21 | Title string `json:"title"` 22 | URL string `json:"url"` 23 | Favicon string `json:"favicon"` // base64 encoded image 24 | Starred bool `json:"starred"` 25 | Folder *Folder `json:"folder"` // reference to the folder to help 26 | Tags []*Tag `json:"tags"` 27 | } 28 | 29 | // Tag represents a bookmark tag 30 | type Tag struct { 31 | Id int `json:"id"` 32 | Name string `json:"name"` 33 | } 34 | 35 | // Bookmarks implements the sort interface 36 | type Bookmarks []*Bookmark 37 | 38 | func (b Bookmarks) Len() int { 39 | return len(b) 40 | } 41 | 42 | func (b Bookmarks) Swap(i, j int) { 43 | b[i], b[j] = b[j], b[i] 44 | } 45 | 46 | func (b Bookmarks) Less(i, j int) bool { 47 | url1 := b[i].Title 48 | url2 := b[j].Title 49 | title1 := url1[strings.Index(url1, "//")+2:] 50 | title2 := url2[strings.Index(url2, "//")+2:] 51 | return title1 < title2 52 | } 53 | 54 | func (bk *Bookmark) String() string { 55 | var out []byte 56 | var err error 57 | 58 | if out, err = json.Marshal(bk); err != nil { 59 | return "" 60 | } 61 | return string(out) 62 | } 63 | 64 | // PathString returns the bookmark full path as a string 65 | func (bk *Bookmark) PathString() string { 66 | var ( 67 | p *Folder 68 | r string 69 | ) 70 | for p = bk.Folder; p != nil; p = p.Parent { 71 | r += "/" + p.Title 72 | } 73 | return r 74 | } 75 | 76 | func (fd *Folder) String() string { 77 | var out []byte 78 | var err error 79 | 80 | if out, err = json.Marshal(fd); err != nil { 81 | return "" 82 | } 83 | return string(out) 84 | } 85 | 86 | // IsRootFolder returns true if the given Folder has no parent 87 | func (fd *Folder) IsRootFolder() bool { 88 | return fd.Parent == nil 89 | } 90 | 91 | // HasChildrenFolders returns true if the given Folder has children 92 | func (fd *Folder) HasChildrenFolders() bool { 93 | return fd.NbChildrenFolders > 0 94 | } 95 | --------------------------------------------------------------------------------