├── README.md ├── api ├── common.go ├── feed.go ├── result.go └── system.go ├── build.py ├── global ├── config.ini.go └── global.go ├── images ├── qreader_on_phone.jpg ├── screenshot_android_opera_01.png ├── screenshot_android_opera_01_original.png ├── screenshot_android_opera_01_small.png ├── screenshot_android_opera_02.png ├── screenshot_android_opera_02_original.png ├── screenshot_android_opera_02_small.png ├── screenshot_windows_chrome_01.png ├── screenshot_windows_chrome_01_small.png ├── screenshot_windows_chrome_02.png ├── screenshot_windows_chrome_02_small.png ├── screenshot_windows_chrome_03.png ├── screenshot_windows_chrome_03_small.png ├── screenshot_windows_chrome_04.png ├── screenshot_windows_chrome_04_small.png ├── screenshot_windows_chrome_05.png ├── screenshot_windows_chrome_05_small.png ├── screenshot_windows_chrome_06.png ├── screenshot_windows_chrome_06_small.png ├── screenshot_windows_chrome_07.png ├── screenshot_windows_chrome_07_small.png ├── screenshot_windows_chrome_08.png └── screenshot_windows_chrome_08_small.png ├── model ├── core.go ├── error.go ├── feed.go ├── fetchfeed.go ├── initdb.go ├── misc.go └── search.go ├── qreader.go ├── server └── server.go ├── sitedata ├── cert │ ├── cert.pem │ └── key.pem └── client │ ├── include │ ├── article.tpl.html │ ├── article_list.tpl.html │ ├── feed_info.tpl.html │ ├── feed_list.tpl.html │ ├── login.css │ ├── login.js │ ├── qreader.auth.js │ ├── qreader.js │ ├── settings.tpl.html │ ├── style.css │ ├── tags_list.tpl.html │ └── utils.js │ ├── index.html │ ├── libs │ ├── angular-1.3.15.min.js │ ├── angular-route-1.3.15.min.js │ ├── angular-sanitize-1.3.15.min.js │ ├── font-awesome-4.3.0 │ │ ├── css │ │ │ └── font-awesome.min.css │ │ └── fonts │ │ │ ├── FontAwesome.otf │ │ │ ├── fontawesome-webfont.eot │ │ │ ├── fontawesome-webfont.svg │ │ │ ├── fontawesome-webfont.ttf │ │ │ ├── fontawesome-webfont.woff │ │ │ └── fontawesome-webfont.woff2 │ ├── forge-0.6.21.min.js │ ├── jquery-2.1.3.min.js │ ├── mousetrap1.4.6-min.js │ └── normalize-3.0.2.css │ └── login.html └── utils └── utils.go /README.md: -------------------------------------------------------------------------------- 1 | QReader - a browser-server based feed reader 2 | ==================================================== 3 | 4 | ![QReader](images/qreader_on_phone.jpg) 5 | 6 | QReader 是一款使用 Go 和 JavaScript 编写的阅读器,支持订阅 RSS 2.0 和 Atom 1.0 格式的 feed。Github地址:。 7 | 8 | 为了运行 QReader ,你需要有一台 server,它可以是你放在局域网中的 PC。你需要在 server 上运行 QReader 服务端程序,然后使用手机、平板电脑或 server 上的浏览器访问 QReader。当然,如果你有兴趣,可以尝试把 QReader 编译到 Android、iOS 设备或路由器中。 9 | 10 | QReader 是一个单用户的阅读器,不适合多人共同使用。 11 | 12 | [直接下载 QReader 可执行程序](https://github.com/m3ng9i/qreader/releases)。 13 | 14 | 最新版本:v0.2.2 15 | 16 | ## 更新说明 17 | 18 | - 2015-08-18 v0.2.2 发布:修复若干bug 19 | - 2015-07-03 v0.2.1 发布:新增搜索功能;文章页增加其他文章链接 20 | - 2015-05-22 v0.2 发布 21 | 22 | ## 更新方式 23 | 24 | 1. 关闭 QReader; 25 | 2. 备份 `sitedata` 目录下的 `feed.db` 和 `config.ini` 文件; 26 | 3. 重新[下载](https://github.com/m3ng9i/qreader/releases) QReader 可执行程序和 `sitedata` 文件,将压缩包解压缩; 27 | 4. 将之前备份的 `feed.db` 和 `config.ini` 文件放回到更新后的 `sitedata` 目录下; 28 | 5. 重新启动 QReader。 29 | 30 | 注意: 31 | 32 | - 在更新程序及 `sitedata` 后不要进行初始化,否则你的历史订阅数据将会被清空。 33 | - 如果在下载界面没有找到适合你的操作系统的可执行程序,请自行编译程序。 34 | 35 | ## next 分支 36 | 37 | 最新的程序代码变更、bug 修复将在 next 分支出现,但此分支不提供二进制可执行文件下载,你需要自行将代码编译为可执行程序。 38 | 39 | ## 0. 功能 40 | 41 | QReader 包含如下功能: 42 | 43 | - 按照标签分类 feed 44 | - 为每个 feed 单独设置更新周期 45 | - 设置最大已读保留数,当某个 feed 的未读文章数量超过设定的值后,较早的未读文章会被自动标记为已读 46 | - 设置最大文章保留数,当某个 feed 的已读文章数量超过设定的值后,较早的已读文章会被自动删除 47 | - 可以按随机顺序显示抓取的文章条目 48 | - 设置每页显示的条目数量 49 | - 文章加星 50 | - 设置登录密码 51 | - 与 QReader 服务器通讯的数据可以开启 TLS 加密 52 | - 支持使用 Socks5 代理服务器抓取 feed 53 | - 文章搜索 54 | 55 | ## 1. 截图 56 | 57 | 下面是 PC 和手机上的 QReader 界面截图,点击可查看大图。 58 | 59 | Windows, Google Chrome: 60 | 61 | [![文章列表](images/screenshot_windows_chrome_01_small.png)](images/screenshot_windows_chrome_01.png) 62 | [![订阅管理](images/screenshot_windows_chrome_02_small.png)](images/screenshot_windows_chrome_02.png) 63 | [![feed 详情](images/screenshot_windows_chrome_03_small.png)](images/screenshot_windows_chrome_03.png) 64 | [![标签列表](images/screenshot_windows_chrome_04_small.png)](images/screenshot_windows_chrome_04.png) 65 | [![加星文章](images/screenshot_windows_chrome_05_small.png)](images/screenshot_windows_chrome_05.png) 66 | [![随机文章](images/screenshot_windows_chrome_06_small.png)](images/screenshot_windows_chrome_06.png) 67 | [![文章页面](images/screenshot_windows_chrome_07_small.png)](images/screenshot_windows_chrome_07.png) 68 | [![系统信息](images/screenshot_windows_chrome_08_small.png)](images/screenshot_windows_chrome_08.png) 69 | 70 | Android, Opera: 71 | 72 | [![文章列表](images/screenshot_android_opera_01_small.png)](images/screenshot_android_opera_01.png) 73 | [![文章页面](images/screenshot_android_opera_02_small.png)](images/screenshot_android_opera_02.png) 74 | 75 | ## 2. 安装与运行 76 | 77 | QReader 程序分为服务器端与客户端两部分。服务器端负责抓取 feed 并提供 http 服务。客户端需要通过浏览器访问,用来阅读与管理 feed。建议将 QReader 服务器安装在 PC 或 Mac 上,然后使用电脑、手机或平板上的浏览器访问 QReader 客户端。 78 | 79 | 除了[直接下载](https://github.com/m3ng9i/qreader/releases)已编译好的可执行程序外,你也可以根据下面的说明自行编译 QReader。 80 | 81 | ### 2.1 编译 82 | 83 | 在命令行界面中运行以下命令下载并编译程序: 84 | 85 | go get -d github.com/m3ng9i/qreader 86 | cd `echo $GOPATH | cut -d ":" -f 1 | cut -d ";" -f 1`/src/github.com/m3ng9i/qreader 87 | chmod u+x build.py 88 | ./build.py 89 | 90 | 上述命令可以在 Mac/Linux 的命令行界面或 Windows 的 Cygwin 下运行。其中 `./build.py` 负责具体的编译工作,需要用到 python3,如果你没有安装 python3,也可以使用 `go build` 命令代替,但生成的可执行程序中将不会包含程序版本信息。 91 | 92 | 编译完成后会在当前目录(QReader 源码目录)下生成一个可执行文件,Windows 下为 qreader.exe,Mac/Linux 下为 qreader。编译完成后,除了刚编译好的可执行程序以及 `sitedata` 目录外,其他文件均不再需要,可以删除。 93 | 94 | 如果你想图省事,也可以使用下面的命令完成源码下载和编译: 95 | 96 | go get github.com/m3ng9i/qreader 97 | 98 | 使用此命令生成的可执行程序将会在 $GOPATH/bin 目录下生成,同样不会包含程序版本信息。 99 | 100 | 由于 QReader 使用的 SQLite3 数据库驱动 (github.com/mattn/go-sqlite3) 基于 cgo,因此无法实现跨平台编译。 101 | 102 | 如果在 Windows 下编译 go-sqlite3 时遇到问题,建议使用 [TDM-GCC](http://tdm-gcc.tdragon.net) 进行编译。 103 | 104 | ### 2.2 sitedata 目录 105 | 106 | `sitedata` 目录保存着配置文件、客户端程序(JavaScript、HTML、CSS文件)、数据库文件和用来进行 TLS 加密的证书。QReader运行时需要读取 sitedata 目录下的文件。 107 | 108 | ### 2.3 配置文件 109 | 110 | `sitedata/config.ini` 是 QReader的配置文件,在对 QReader 初始化后会自动生成此文件。你也可以使用 QReader 的命令行参数 `-defini` 显示默认的配置文件内容。配置文件中各字段的说明如下: 111 | 112 | - ip:QReader 服务器绑定的IP,如果只允许本机访问,可以设置为 127.0.0.1;如果需要让局域网内的其他设备访问 QReader,请设置为局域网 IP;如果要允许来自任意 IP 的设备访问 QReader,请设置为 0.0.0.0(可能存在安全风险,因此不推荐这样做);如果设置为 auto,系统将自动设置绑定 IP,绑定的 IP 可能是局域网 IP 或公网 IP,或 127.0.0.1。 113 | 114 | - port:QReader 服务器绑定的端口号。 115 | 116 | - usetls:是否使用 TLS 加密 QReader 服务器端与客户端之间的通信。开启加密后,需要用 https 开头的 url 访问 QReader。 117 | 118 | - logfile:日志文件路径,如果为空,日志将输出到 stdout。 119 | 120 | - loglevel:日志级别,默认值为 INFO,可选值为 DEBUG、NOTICE、INFO、WARN、ERROR、FATAL。 121 | 122 | - permission:创建日志文件和 config.ini 时的权限,默认为 640。 123 | 124 | - password:登录至 QReader 的密码,如不需要密码,留空即可。 125 | 126 | - salt:在进行 hash 时使用的 salt,必须与 sitedata/client/include/qreader.auth.js 中的 QReader.salt 变量值保持一致。一般无需修改,使用默认值即可。 127 | 128 | - debug:是否开启 debug,开启后将会输出更多的日志。 129 | 130 | - proxy:Socks5 代理服务器 IP 和端口,例如 127.0.0.1:8080。如果没有请留空。 131 | 132 | - proxy_username:Socks5 代理服务器用户名,如果没有请留空。 133 | 134 | - proxy_password:Socks5 代理服务器密码,如果没有请留空。 135 | 136 | - use_proxy:使用代理服务器的规则。always:总是使用代理服务器获取 feed。try:在获取 feed 失败后,尝试使用代理服务器再次获取 feed。never:不使用代理服务器获取 feed。 137 | 138 | 注意:修改了配置文件后,需要重新启动 QReader 才能生效。 139 | 140 | ### 2.4 初始化 141 | 142 | 你可以将 QReader 可执行程序和 sitedata 目录放到任意目录下。如果 sitedata 目录与 QReader 可执行程序不在同一个父目录下,在运行时,需要用 `-s` 参数指定 sitedata 目录的位置。 143 | 144 | 首次运行前需要初始化,用以生成数据库文件和配置文件。进入 QReader 可执行程序所在目录,然后执行: 145 | 146 | ./qreader -init 147 | 148 | 或 149 | 150 | ./qreader -s -init 151 | 152 | 然后输入 `y` 回车,会在 sitedata 目录下生成配置文件 config.ini 和 数据库文件 feed.db。如果多次进行初始化,新生成的 config.ini 和 feed.db 会覆盖已存在的文件。 153 | 154 | 你可以根据需要,修改配置文件中的部分配置。之后就可以运行了。 155 | 156 | ### 2.5 运行 157 | 158 | 完成初始化后,运行: 159 | 160 | ./qreader 161 | 162 | 或 163 | 164 | ./qreader -s 165 | 166 | 启动 QReader 服务器。默认会将日志输出到 stdout,你可以在日志中看到 QReader 的访问地址。如果你需要同时在系统默认浏览器中打开 QReader 页面,可以加上 `-open` 参数。 167 | 168 | 使用浏览器打开 QReader 网页,如果你没有在配置文件中设置密码,直接在登录界面点击“登录”即可。登录后,点击“订阅”,添加 feed。 169 | 170 | ### 2.6 命令行参数 171 | 172 | 完整的命令行参数说明: 173 | 174 | -s, -sitedata 指定 sitedata 路径,如果没有提供,使用当前目录下的 sitedata 目录 175 | -init 初始化 QReader 数据库和 config.ini 文件 176 | -initdb 初始化 QReader 数据库 177 | -current-token 显示当前的 api token 178 | -defini 显示默认的 config.ini 文件内容 179 | -open 运行 QReader 服务器的同时,使用系统默认浏览器打开 QReader 网页 180 | -h, -help 显示帮助 181 | -v, -version 显示版本信息 182 | 183 | ### 2.7 关闭 QReader 服务器 184 | 185 | 关闭 QReader 服务器有多种方法: 186 | 187 | - 通过 QReader 网页客户端关闭:点击“系统”→“关闭 QReader 服务器”按钮,确认后 QReader 服务器就被关闭了。 188 | - 打开之前运行 QReader 的命令行窗口,按 `ctrl+c` 结束 QReader 进程。 189 | - 使用命令行关闭:在 Mac/Linux 或 Windows 的 Cygwin 命令行窗口中,输入 `kill PID` 回车,其中 PID 指 QReader 的 Process ID。 190 | - 使用操作系统自带的进程管理工具关闭:例如 Windows 的“任务管理器”、Ubuntu 的“系统监视器”、Mac 的“活动监视器”。 191 | 192 | ### 2.8 浏览器支持 193 | 194 | QReader 使用网页作为客户端用户界面,建议使用的浏览器为 Google Chrome、Mozilla Firefox、Safari 或 Opera。在 IE 下可能无法正常使用。你也可以通过手机或平板电脑上的浏览器访问 QReader。 195 | 196 | QReader 支持如下的快捷键: 197 | 198 | 快捷键 | 说明 199 | ------------------------------- | ------------- 200 | 0 或 h | 前往首页 201 | r | 刷新页面 202 | t | 页面顶部 203 | b | 页面底部 204 | ctrl+left, command+left 或 p | 上一页 205 | ctrl+right, command+right 或 n | 下一页 206 | 207 | ## 3. 文章搜索 208 | 209 | 点击顶部菜单中的“搜索”,可以根据关键词、feed id、tag、已读状态、加星状态进行搜索,可以设置排序字段和排序方式,可以设置返回结果数量。 210 | 211 | ## 3.1 搜索语法 212 | 213 | 搜索指令语法:`[<条件>:]<值>[,<值>,...]` 214 | 215 | 搜索指令包括“条件”和“值”,两者之间使用英文冒号分隔,多个搜索指令之间使用空格分隔,例如:`tag:news starred:true` 会搜索所有标签为 “news”,且已加星的文章。 216 | 217 | “条件”项可以使用以下字段: 218 | 219 | 条件 | 说明 220 | ---------|---------------- 221 | fid | feed id,数字 222 | keyword | 关键词,表示从文章标题和文章内容中进行搜索 223 | title | 从文章标题中进行搜索 224 | content | 从文章内容中进行搜索 225 | read | 文章已读状态,any 表示任意文章,true 表示已读文章,false 表示未读文章,默认为 false。 226 | orderby | 排序字段,默认为id 227 | order | 排序方式,asc 表示正序,desc 表示逆序。默认为 desc。 228 | tag | feed 标签 229 | starred | 文章加星状态,any 表示任意文章,true 表示已加星文章,false 表示未加星文章,默认为 any。 230 | num | 每页显示的结果数量,数字,默认为系统配置中设置的“每页条目数量”。 231 | 232 | 条件字段可以省略,如果省略,表示输入的为 `keyword` 条件的值。 233 | 234 | `fid`、`keyword`、`title`、`content` 的值可以提供多个,相同的条件的多个值之间为“或”的关系,不同条件之间为“且”的关系。 235 | 236 | 当值中包含空格或符号时,需要用引号将值括起来,例如:`keyword:"value1 value2"` 237 | 238 | 多个值之间用英文逗号分隔:`keyword:value1,value2` 239 | 240 | ## 3.2 搜索举例 241 | 242 | 查找 feed id 为 15,标题中包含“编程”的未读文章: 243 | 244 | fid:15 title:编程 245 | 246 | 查找文章内容为“Go”或“JavaScript”的已加星文章,每页显示20个结果: 247 | 248 | content:Go,JavaScript starred:true num:20 249 | 250 | 查找标题或内容包含“linux”的文章,根据文章id和fid正序排序: 251 | 252 | linux order:asc orderby:id,fid 253 | 254 | ## 4. 技术规格 255 | 256 | - 开发语言:Go、JavaScript 257 | - 数据库:SQLite3 258 | 259 | 第三方模块(JavaScript/CSS): 260 | 261 | - WebApp:[AngularJS](https://angularjs.org) 262 | - JS library:[jQuery](http://jquery.com) 263 | - 摘要算法:[Forge](https://github.com/digitalbazaar/forge) 264 | - 快捷键:[Mousetrap](https://craig.is/killing/mice) 265 | - Icons:[Font Awesome](http://fontawesome.io) 266 | - Normalizes CSS:[normalize.css](https://github.com/necolas/normalize.css) 267 | 268 | 第三方模块(Go): 269 | 270 | - Web 服务:[Martini](https://github.com/go-martini/martini) 271 | - ORM:[Xorm](https://github.com/go-xorm/xorm) 272 | - SQLite3 驱动:[go-sqlite3](https://github.com/mattn/go-sqlite3) 273 | - HTML 过滤:[bluemonday](https://github.com/microcosm-cc/bluemonday) 274 | - 配置文件读取:[goconfig](https://github.com/Unknwon/goconfig) 275 | - 开启浏览器:[Webbrowser](https://github.com/toqueteos/webbrowser) 276 | 277 | 以下 Go 模块来自我写的 feedreader 和 go-utils 包: 278 | 279 | - RSS 和 Atom 解析:[feedreader](https://github.com/m3ng9i/feedreader) 280 | - Go 工具包:[go-utils](https://github.com/m3ng9i/go-utils) 281 | -------------------------------------------------------------------------------- /api/common.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "net/http" 4 | import "github.com/go-martini/martini" 5 | import httphelper "github.com/m3ng9i/go-utils/http" 6 | 7 | 8 | type ApiError struct { 9 | ErrCode uint `json:"errcode"` // 0 for no error 10 | ErrMsg string `json:"errmsg"` 11 | } 12 | 13 | 14 | func (this *ApiError) Error() string { 15 | return this.ErrMsg 16 | } 17 | 18 | /* 19 | 1xx authentication, query string error 20 | 2xx feed fetch or parse error 21 | 3xx database error 22 | 4xx system error, like io, filesystem, etc. 23 | */ 24 | 25 | var ErrTokenInvalid = ApiError{100, "Client token is invalid. Please make sure you have the permission to use QReader."} 26 | var ErrRequestNotAllowd = ApiError{101, "The request is not allowed."} 27 | var ErrBadRequest = ApiError{102, "Request query or post data not correct."} 28 | var ErrSearchSyntaxError = ApiError{103, "Search syntax not correct."} 29 | var ErrFetchError = ApiError{200, "Error occurs when fetching feed. Please check the internet connection and make sure the feed's url is valid."} 30 | var ErrParseError = ApiError{201, "Error occurs when parsing feed. Please check if the feed is valid."} 31 | var ErrQueryDB = ApiError{300, "Error occurs when querying the database."} 32 | var ErrAlreadySubscribed = ApiError{301, "Feed is already subscribed, cannot be subscribed again."} 33 | var ErrNoResultsFound = ApiError{302, "No results found."} 34 | var ErrNoDataChanged = ApiError{303, "No data changed."} 35 | var ErrFeedCannotBeDeleted = ApiError{304, "Feed has starred items, cannot be deleted."} 36 | var ErrSystemError = ApiError{400, "System error."} 37 | var ErrUnexpectedError = ApiError{999, "Unexpected error."} 38 | 39 | 40 | // Default api handler. 41 | func Default() martini.Handler { 42 | return func(w http.ResponseWriter, r *http.Request, rid httphelper.RequestId) { 43 | var result Result 44 | result.RequestId = rid 45 | result.Success = false 46 | result.Error = ErrRequestNotAllowd 47 | result.Response(w) 48 | } 49 | } 50 | 51 | 52 | // Indicate the QReader api is up and running. 53 | func Status() martini.Handler { 54 | return func(w http.ResponseWriter, r *http.Request, rid httphelper.RequestId) { 55 | var result Result 56 | result.RequestId = rid 57 | result.Success = true 58 | result.Response(w) 59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /api/result.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "net/http" 4 | import "fmt" 5 | import "encoding/json" 6 | import httphelper "github.com/m3ng9i/go-utils/http" 7 | import "github.com/m3ng9i/qreader/global" 8 | 9 | 10 | // Result will be converted to json string and write to http.ResponseWriter. 11 | type Result struct { 12 | RequestId httphelper.RequestId `json:"request_id"` 13 | Success bool `json:"success"` 14 | Error ApiError `json:"error"` // error to show to user 15 | Result interface{} `json:"result"` 16 | IntError error `json:"-"` // internal error, used to log 17 | } 18 | 19 | 20 | // Write json to ResponseWriter. 21 | func (this *Result) Response(w http.ResponseWriter, rid ...httphelper.RequestId) { 22 | 23 | const errMarshal = `{"success":false,"error":"Cannot marshal json data.","result":null}` 24 | 25 | if len(rid) > 0 { 26 | this.RequestId = rid[0] 27 | } 28 | 29 | b, err := json.Marshal(this) 30 | if err != nil { 31 | w.Header().Set("Content-Type", "application/json") 32 | w.WriteHeader(http.StatusInternalServerError) 33 | fmt.Fprintf(w, errMarshal) 34 | global.Logger.Errorf("[API] Cannot marshal json data: %v", this) 35 | return 36 | } 37 | 38 | w.Header().Set("Content-Type", "application/json") 39 | w.WriteHeader(http.StatusOK) // always return status 200 even if an error occurs 40 | w.Write(b) 41 | 42 | if this.IntError != nil { 43 | global.Logger.Errorf("[API Response] [#%s] [internal error: %s] %s", this.RequestId, this.IntError, string(b)) 44 | } else { 45 | global.Logger.Debugf("[API Response] [#%s] %s", this.RequestId, string(b)) 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /api/system.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "os" 4 | import "net/http" 5 | import "github.com/go-martini/martini" 6 | import httphelper "github.com/m3ng9i/go-utils/http" 7 | import "github.com/m3ng9i/qreader/global" 8 | import "github.com/m3ng9i/qreader/model" 9 | 10 | 11 | func Settings() martini.Handler { 12 | return func(w http.ResponseWriter, rid httphelper.RequestId) { 13 | var result Result 14 | 15 | var data = make(map[string]interface{}) 16 | data["ConfigFile"] = global.ConfigFile 17 | data["PathRoot"] = global.PathRoot 18 | data["PathDB"] = global.PathDB 19 | data["IP"] = global.IP 20 | data["Port"] = global.Port 21 | data["Usetls"] = global.Usetls 22 | data["UseProxy"] = global.UseProxy 23 | data["Debug"] = global.Debug 24 | 25 | if global.ProxyConfig != nil { 26 | data["ProxyAddr"] = global.ProxyConfig.Addr 27 | } 28 | 29 | var d = make(map[string]interface{}) 30 | d["SystemInfo"] = data 31 | 32 | feedNumber, err := model.GetFeedNumber() 33 | if err != nil { 34 | result.Error = ErrQueryDB 35 | result.IntError = err 36 | result.Response(w) 37 | return 38 | } 39 | 40 | articleNumber, err := model.GetArticleNumber() 41 | if err != nil { 42 | result.Error = ErrQueryDB 43 | result.IntError = err 44 | result.Response(w) 45 | return 46 | } 47 | 48 | dbsize, err := model.DBSize() 49 | if err != nil { 50 | result.Error = ErrSystemError 51 | result.IntError = err 52 | result.Response(w) 53 | return 54 | } 55 | 56 | var t struct { 57 | Feed int64 `json:"feed"` 58 | *model.ArticleNumber 59 | DBSize int64 `json:"dbsize"` 60 | } 61 | t.Feed = feedNumber 62 | t.ArticleNumber = articleNumber 63 | t.DBSize = dbsize 64 | 65 | d["Summary"] = t 66 | d["Version"] = global.Version 67 | 68 | result.Success = true 69 | result.Result = d 70 | result.Response(w, rid) 71 | } 72 | } 73 | 74 | 75 | func CloseServer() martini.Handler { 76 | return func(w http.ResponseWriter, params martini.Params, rid httphelper.RequestId) { 77 | var result Result 78 | result.RequestId = rid 79 | 80 | result.Success = true 81 | result.Result = "QReader server is going to shutdown" 82 | result.Response(w) 83 | 84 | global.Logger.Warnf("[API] [#%s] The server is shutdown manually.", result.RequestId) 85 | global.Logger.Wait() 86 | os.Exit(0) 87 | } 88 | } 89 | 90 | 91 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os, time, subprocess 4 | 5 | def runCmd(cmd): 6 | p = subprocess.Popen(cmd, shell = True, stdout = subprocess.PIPE, stderr = subprocess.PIPE) 7 | stdout = p.communicate()[0].decode('utf-8').strip() 8 | return stdout 9 | 10 | # Get last tag. 11 | def lastTag(): 12 | return runCmd('git describe --abbrev=0 --tags') 13 | 14 | # Get current branch name. 15 | def branch(): 16 | return runCmd('git rev-parse --abbrev-ref HEAD') 17 | 18 | # Get last git commit id. 19 | def lastCommitId(): 20 | return runCmd('git log --pretty=format:"%h" -1') 21 | 22 | # Assemble build command. 23 | def buildCmd(): 24 | buildFlag = [] 25 | 26 | version = lastTag() 27 | if version != "": 28 | buildFlag.append("-X main._version_ '{}'".format(version)) 29 | 30 | branchName = branch() 31 | if branchName != "": 32 | buildFlag.append("-X main._branch_ '{}'".format(branchName)) 33 | 34 | commitId = lastCommitId() 35 | if commitId != "": 36 | buildFlag.append("-X main._commitId_ '{}'".format(commitId)) 37 | 38 | # current time 39 | buildFlag.append("-X main._buildTime_ '{}'".format(time.strftime("%Y-%m-%d %H:%M %z"))) 40 | 41 | return 'go build -ldflags "{}"'.format(" ".join(buildFlag)) 42 | 43 | if subprocess.call(buildCmd(), shell = True) == 0: 44 | print("build finished.") 45 | -------------------------------------------------------------------------------- /global/config.ini.go: -------------------------------------------------------------------------------- 1 | package global 2 | 3 | import "strings" 4 | import "unicode" 5 | import "os" 6 | import "path/filepath" 7 | import "runtime" 8 | 9 | 10 | var defaultConfigIni = ` 11 | # Listen IP address of http server. Example: 127.0.0.1, 192.168.1.123, 0.0.0.0 or lan. 12 | # If is auto, QReader will get the sever's IP Automaticly. 13 | ip = auto 14 | 15 | # Listen port of http server 16 | port = 4664 17 | 18 | # Set usetls to true to use https, set to false to use http 19 | usetls = false 20 | 21 | # Path of logfile. If you want to output to stdout, leave it empty. 22 | logfile = 23 | 24 | # Log level: DEBUG, NOTICE, INFO, WARN, ERROR, FATAL 25 | loglevel = INFO 26 | 27 | # Permission of generated files 28 | permission = 640 29 | 30 | # Password 31 | password = 32 | 33 | # Used for hash 34 | salt = 34682084954d47239577b53caad5baf4 35 | 36 | # Debug mode 37 | debug = false 38 | 39 | # Socks5 proxy address. Example: 127.0.0.1:8080 40 | proxy = 41 | 42 | # Socks5 proxy username 43 | proxy_username = 44 | 45 | # Socks5 proxy password 46 | proxy_password = 47 | 48 | # always, try or never use proxy to fetch feed 49 | # always: use proxy to fetch feed for each connection 50 | # try: first use normal connection to fetch feed, if got error, try to use proxy to fetch 51 | # never: use normal connection to fetch feed. 52 | use_proxy = try 53 | ` 54 | 55 | func DefaultConfigIni() string { 56 | s := strings.TrimLeftFunc(defaultConfigIni, unicode.IsSpace) 57 | if runtime.GOOS == "windows" { 58 | s = strings.Replace(s, "\n", "\r\n", -1) 59 | } 60 | return s 61 | } 62 | 63 | 64 | func CreateConfigIni() error { 65 | file, err := os.OpenFile(filepath.Join(Sitedata, "config.ini"), 66 | os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 67 | Permission) 68 | defer file.Close() 69 | if err == nil { 70 | _, err = file.WriteString(DefaultConfigIni()) 71 | } 72 | return err 73 | } 74 | 75 | 76 | func IsConfigIniExist() bool { 77 | info, err := os.Stat(filepath.Join(Sitedata, "config.ini")) 78 | if err != nil || info.IsDir() { 79 | return false 80 | } 81 | return true 82 | } 83 | 84 | -------------------------------------------------------------------------------- /global/global.go: -------------------------------------------------------------------------------- 1 | // Global variables and configuration of QReader. 2 | package global 3 | 4 | import "fmt" 5 | import "os" 6 | import "sync" 7 | import "path/filepath" 8 | import "strconv" 9 | import "strings" 10 | import "net" 11 | import "github.com/Unknwon/goconfig" 12 | import "github.com/go-xorm/xorm" 13 | import "github.com/go-xorm/core" 14 | import _ "github.com/mattn/go-sqlite3" 15 | import "github.com/m3ng9i/go-utils/log" 16 | import h "github.com/m3ng9i/go-utils/http" 17 | 18 | 19 | type ProxyType string 20 | const PROXY_ALWAYS ProxyType = "always" 21 | const PROXY_TRY ProxyType = "try" 22 | const PROXY_NEVER ProxyType = "never" 23 | 24 | type VersionType struct { 25 | Version string // program version, from git tag 26 | Branch string // git branch 27 | CommitId string // git commit id 28 | BuildTime string // build time 29 | } 30 | 31 | var Sitedata string // Directory of sitedata 32 | 33 | var PathRoot string // Root directory of sitedata 34 | var PathClient string // Directory of javascript client 35 | var PathDB string // Path of database 36 | var PathCertPem string // Path of cert.pem 37 | var PathKeyPem string // Path of key.pem 38 | 39 | var ConfigFile string // path of config file 40 | 41 | var IP string // IP of http server 42 | var Port uint // Port of http server 43 | var Usetls bool // Set to true to use https, set to false to use http 44 | var Password string // Password to log into QReader 45 | var ProxyConfig *h.ProxyConfig // Proxy config. It's nil if no proxy is configured. 46 | var UseProxy ProxyType // if use proxy, always, try or never 47 | var Debug bool // If enable debug mode. 48 | var Salt string // Used for authentication 49 | var Permission os.FileMode = 0640 // Permission of generated files 50 | var Logger *log.Logger // Logger 51 | var Orm *xorm.Engine // Xorm database engine 52 | var NormalFetcher *h.Fetcher // Normal fetcher 53 | var Socks5Fetcher *h.Fetcher // Socks5 proxy fetcher 54 | var Version VersionType 55 | 56 | var Github string 57 | 58 | var loglevel log.LevelType 59 | var logfile string 60 | 61 | 62 | func createLogger(file string, l log.LevelType, p os.FileMode) (logger *log.Logger, err error) { 63 | var config log.Config 64 | config.Level = l 65 | config.TimeFormat = log.TF_DEFAULT 66 | config.Utc = false 67 | 68 | var f *os.File 69 | if file == "" { 70 | f = os.Stdout 71 | } else { 72 | f, err = log.OpenFile(file, p) 73 | if err != nil { 74 | return 75 | } 76 | } 77 | 78 | logger, err = log.New(f, config) 79 | return 80 | } 81 | 82 | 83 | // Read config file. 84 | func loadConfig(filename string) error { 85 | 86 | var err error 87 | 88 | ConfigFile, err = filepath.Abs(filename) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | c, err := goconfig.LoadConfigFile(filename) 94 | if err != nil { 95 | return fmt.Errorf("Cannot open config file: %s\n", filename) 96 | } 97 | 98 | IP = c.MustValue("", "ip") 99 | if IP == "auto" { 100 | IP, err = getIPAutomaticly() 101 | if err != nil { 102 | return err 103 | } 104 | if IP == "" { 105 | return fmt.Errorf("Cannot get IP address.\n") 106 | } 107 | } else if IP == "" { 108 | IP = "127.0.0.1" 109 | } 110 | 111 | Port = uint(c.MustInt("", "port")) 112 | if Port == 0 { 113 | return fmt.Errorf("Port cannot be 0.\n") 114 | } 115 | 116 | Usetls = c.MustBool("", "usetls", false) 117 | Password = c.MustValue("", "password") 118 | Debug = c.MustBool("", "debug", false) 119 | Salt = c.MustValue("", "salt") 120 | 121 | var proxy = new(h.ProxyConfig) 122 | proxy.Addr = c.MustValue("", "proxy") 123 | proxy.Username = c.MustValue("", "proxy_username") 124 | proxy.Password = c.MustValue("", "proxy_password") 125 | if proxy.Addr != "" { 126 | ProxyConfig = proxy 127 | } 128 | 129 | if ProxyConfig == nil { 130 | UseProxy = PROXY_NEVER 131 | } else { 132 | UseProxy = ProxyType(strings.ToLower(c.MustValue("", "use_proxy", "never"))) 133 | if UseProxy != PROXY_ALWAYS && UseProxy != PROXY_TRY { 134 | UseProxy = PROXY_NEVER 135 | } 136 | } 137 | 138 | value := c.MustValue("", "permission") 139 | p, err := strconv.ParseUint(value, 8, 0) 140 | if err != nil { 141 | return fmt.Errorf("Value of Permission is not legal.\n") 142 | } 143 | Permission = os.FileMode(p) 144 | 145 | value = c.MustValue("", "loglevel") 146 | var ok bool 147 | loglevel, ok = log.String2Level(value) 148 | if !ok { 149 | return fmt.Errorf("loglevel is not correct.\n") 150 | } 151 | logfile = c.MustValue("", "logfile") 152 | 153 | return nil 154 | } 155 | 156 | 157 | // Get first IPv4 address in system's network interface. 158 | // This may be a Lan IP or a public IP. 159 | func getIPAutomaticly() (a string, e error) { 160 | addr, e := net.InterfaceAddrs() 161 | if e != nil { 162 | return 163 | } 164 | for _, i := range addr { 165 | ip := net.ParseIP(strings.SplitN(i.String(), "/", 2)[0]) 166 | ipString := ip.String() 167 | if ip.To4() != nil && !ip.IsLoopback() && ipString != "0.0.0.0" { 168 | a = ipString 169 | goto END 170 | } 171 | } 172 | 173 | END: 174 | if a == "" { 175 | a = "127.0.0.1" 176 | } 177 | return 178 | } 179 | 180 | 181 | var once1, once2 sync.Once 182 | 183 | 184 | // Init step 1: set path and database 185 | func Init1() { 186 | once1.Do(func() { 187 | var err error 188 | PathRoot, err = filepath.Abs(Sitedata) 189 | if err != nil { 190 | fmt.Fprintf(os.Stderr, err.Error()) 191 | os.Exit(1) 192 | } 193 | 194 | PathClient = filepath.Join(PathRoot, "client") 195 | PathDB = filepath.Join(PathRoot, "feed.db") 196 | PathCertPem = filepath.Join(PathRoot, "cert", "cert.pem") 197 | PathKeyPem = filepath.Join(PathRoot, "cert", "key.pem") 198 | 199 | // set database 200 | 201 | Orm, err = xorm.NewEngine("sqlite3", PathDB) 202 | if err != nil { 203 | fmt.Fprintf(os.Stderr, err.Error()) 204 | os.Exit(1) 205 | } 206 | Orm.SetMapper(core.SameMapper{}) 207 | }) 208 | } 209 | 210 | 211 | // Init step 2: read config file and create logger 212 | func Init2() { 213 | once2.Do(func() { 214 | err := loadConfig(filepath.Join(Sitedata, "config.ini")) 215 | if err != nil { 216 | fmt.Fprintf(os.Stderr, err.Error()) 217 | os.Exit(1) 218 | } 219 | 220 | headers := make(map[string]string) 221 | headers["User-Agent"] = fmt.Sprintf("QReader %s (%s)", Version.Version, Github) 222 | 223 | NormalFetcher = h.NewFetcher(nil, headers) 224 | 225 | if UseProxy != PROXY_NEVER { 226 | socks5client, err := h.Socks5Client(*ProxyConfig) 227 | if err != nil { 228 | fmt.Fprintf(os.Stderr, err.Error()) 229 | os.Exit(1) 230 | } 231 | 232 | Socks5Fetcher = h.NewFetcher(socks5client, headers) 233 | } 234 | 235 | if Debug { 236 | Orm.ShowSQL = true 237 | } 238 | 239 | // create logger 240 | Logger, err = createLogger(logfile, loglevel, Permission) 241 | if err != nil { 242 | fmt.Fprintf(os.Stderr, err.Error()) 243 | os.Exit(1) 244 | } 245 | }) 246 | } 247 | -------------------------------------------------------------------------------- /images/qreader_on_phone.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/qreader_on_phone.jpg -------------------------------------------------------------------------------- /images/screenshot_android_opera_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_android_opera_01.png -------------------------------------------------------------------------------- /images/screenshot_android_opera_01_original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_android_opera_01_original.png -------------------------------------------------------------------------------- /images/screenshot_android_opera_01_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_android_opera_01_small.png -------------------------------------------------------------------------------- /images/screenshot_android_opera_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_android_opera_02.png -------------------------------------------------------------------------------- /images/screenshot_android_opera_02_original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_android_opera_02_original.png -------------------------------------------------------------------------------- /images/screenshot_android_opera_02_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_android_opera_02_small.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_01.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_01_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_01_small.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_02.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_02_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_02_small.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_03.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_03_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_03_small.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_04.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_04_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_04_small.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_05.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_05_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_05_small.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_06.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_06_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_06_small.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_07.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_07_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_07_small.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_08.png -------------------------------------------------------------------------------- /images/screenshot_windows_chrome_08_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/images/screenshot_windows_chrome_08_small.png -------------------------------------------------------------------------------- /model/core.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | 6 | /* Map to table "Feed" 7 | Alias, Filter and Note is pointer to string. When update use xorm, if it's value is nil, it means no need to up update this field. 8 | If it's pointer to empty string, it means update this field and set it to "". 9 | */ 10 | type Feed struct { 11 | Id int64 `json:"feed_id" xorm:"pk autoincr"` // primary key 12 | Name string `json:"feed_name" xorm:"notnull"` // name of feed 13 | Alias *string `json:"feed_alias" xorm:"notnull default ''"` // feed name's alias 14 | FeedUrl string `json:"feed_feed_url" xorm:"notnull unique"` // url of feed 15 | Url string `json:"feed_url" xorm:"notnull"` // url the feed point to 16 | Desc string `json:"feed_desc" xorm:"notnull default ''"` // feed description 17 | Type string `json:"feed_type" xorm:"notnull"` // feed type: rss or atom 18 | Interval *int `json:"feed_interval" xorm:"notnull default 0"` // refresh interval (minute), 0 for default interval. value below zero means not update. 19 | LastFetch time.Time `json:"feed_last_fetch" xorm:"notnull"` // last successful fetch time 20 | LastFailed time.Time `json:"feed_last_failed" xorm:"notnull"` // last failed time for fetching 21 | LastError string `json:"feed_last_error" xorm:"notnull default ''"` // last error for fetching 22 | MaxUnread *uint `json:"feed_max_unread" xorm:"notnull default 0"` // max number of unread items. 0 for keep all. 23 | MaxKeep *uint `json:"feed_max_keep" xorm:"notnull default 0"` // max number of items to keep. 0 for keep all, greater than 0 for keep n unread items. 24 | Filter *string `json:"feed_filter" xorm:"notnull default ''"` // filter. (not to use now) 25 | UseProxy int `json:"feed_use_proxy" xorm:"notnull default 0"` // whether to use proxy to fetch feed, 0: try, 1: always, 2: never 26 | Note *string `json:"feed_note" xorm:"notnull default ''"` // comments for this feed 27 | } 28 | 29 | 30 | // Map to table "Item" 31 | type Item struct { 32 | Id int64 `json:"item_id" xorm:"pk autoincr"` // primary key 33 | Fid int64 `json:"item_fid" xorm:"notnull unique(Fid_Guid)"` // Feed.Id 34 | Author string `json:"item_author" xorm:"notnull"` // author 35 | Url string `json:"item_url" xorm:"notnull unique"` // url of the item 36 | Guid string `json:"item_guid" xorm:"notnull unique(Fid_Guid)"` // guid of the item 37 | Title string `json:"item_title" xorm:"notnull"` // title 38 | Content string `json:"item_content" xorm:"notnull"` // content 39 | PubTime time.Time `json:"item_pub_time" xorm:"notnull"` // item pubtime 40 | FetchTime time.Time `json:"item_fetch_time" xorm:"notnull"` // item fetch time 41 | Starred bool `json:"item_starred" xorm:"notnull default 0"` // whether the item was starred 42 | Read bool `json:"item_read" xorm:"notnull default 0"` // whether the item has been read 43 | Hash string `json:"-" xorm:"notnull"` // md5sum of content 44 | } 45 | 46 | 47 | // Map to table "Tag" 48 | type Tag struct { 49 | Id int64 `xorm:"pk autoincr"` // primary key 50 | Name string `xorm:"notnull unique(Name_Fid)"` // tag name, case insensitive 51 | Fid int64 `xorm:"notnull unique(Name_Fid)"` // Feed.Id 52 | } 53 | 54 | 55 | -------------------------------------------------------------------------------- /model/error.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "errors" 4 | 5 | var ErrFeedNotFound = errors.New("Feed not found.") 6 | var ErrFeedHasNoItems = errors.New("Feed has no items.") 7 | var ErrFeedCannotBeDeleted = errors.New("Feed has starred items, cannot be deleted.") 8 | -------------------------------------------------------------------------------- /model/feed.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "fmt" 4 | import "strings" 5 | import dbsql "database/sql" 6 | import "github.com/go-xorm/xorm" 7 | import "github.com/m3ng9i/qreader/global" 8 | 9 | 10 | // Get number of subscribed feed. 11 | func GetFeedNumber() (int64, error) { 12 | return global.Orm.Count(&Feed{}) 13 | } 14 | 15 | 16 | type ArticleNumber struct { 17 | Feed int64 `json:"feed"` 18 | Amounts int64 `json:"amounts"` 19 | Read int64 `json:"read"` 20 | Unread int64 `json:"unread"` 21 | Starred int64 `json:"starred"` 22 | Grabbed int64 `json:"grabbed"` 23 | } 24 | 25 | 26 | /* 27 | Get number of articles which grabbed from the feed. 28 | 29 | Return value: 30 | 31 | number.Amounts Current number of articles saved in the database. 32 | number.Read Number of articles that is marked to read in the database. 33 | number.Unread Equals to result[0] - resut[1], means unread number of articles. 34 | number.Starred Number of starred articles. 35 | number.Grabbed Number of articles the QReader has grabbed. This is larger than result[0] because old articles will be deleted. 36 | */ 37 | func GetArticleNumber() (number *ArticleNumber, err error){ 38 | 39 | sql := `select (select count(*) from Item) amounts, 40 | (select count(*) from Item where Read=1) read, 41 | (select count(*) from Item where Read=0) unread, 42 | (select count(*) from Item where Starred=1) starred, 43 | (select Id from Item order by Id desc limit 1) grabbed` 44 | 45 | number = new(ArticleNumber) 46 | var grabbed dbsql.NullInt64 47 | 48 | err = global.Orm.DB().QueryRow(sql).Scan( 49 | &number.Amounts, 50 | &number.Read, 51 | &number.Unread, 52 | &number.Starred, 53 | &grabbed) 54 | 55 | number.Grabbed = grabbed.Int64 56 | return 57 | } 58 | 59 | 60 | // Get all data in table Feed. 61 | func GetFeedList() (feedlist []*Feed, err error) { 62 | 63 | feed := new(Feed) 64 | rows, err := global.Orm.Rows(feed) 65 | if err != nil { 66 | return 67 | } 68 | defer rows.Close() 69 | 70 | for rows.Next() { 71 | err = rows.Scan(feed) 72 | if err != nil { 73 | return 74 | } 75 | feedlist = append(feedlist, feed) 76 | } 77 | return 78 | } 79 | 80 | 81 | type FeedWithAmount struct { 82 | Feed `xorm:"extends"` 83 | Amount uint64 `json:"amount"` 84 | Unread uint64 `json:"unread"` 85 | Starred uint64 `json:"starred"` 86 | } 87 | 88 | 89 | // Get feed list with amount, unread and starred number. 90 | func GetFeedListWithAmount(session *xorm.Session) (feedlist []*FeedWithAmount, err error) { 91 | 92 | sql := `select Feed.*,t1.Amount,t2.Unread,t3.Starred from Feed left join 93 | (select Fid,count(*) as Amount from Item group by Fid) as t1 on Feed.Id = t1.Fid left join 94 | (select Fid,count(*) as Unread from Item where Read=0 group by Fid) as t2 on t1.Fid=t2.Fid left join 95 | (select Fid,count(*) as Starred from Item where Starred=1 group by Fid) as t3 on t1.Fid=t3.Fid` 96 | 97 | if session == nil { 98 | err = global.Orm.Sql(sql).Find(&feedlist) 99 | } else { 100 | err = session.Sql(sql).Find(&feedlist) 101 | } 102 | 103 | return 104 | } 105 | 106 | 107 | type TagsWithFeedList map[string][]*FeedWithAmount 108 | 109 | 110 | // Get tags with feed list. If getall is true, the function will get all data even if feed's unread item number is 0. 111 | // If getall is false, the feeds which unread item number is 0 will not returned. 112 | func GetTagsWithFeedList(getall ...bool) (feedlist TagsWithFeedList, err error) { 113 | 114 | session := global.Orm.NewSession() 115 | defer session.Close() 116 | 117 | err = session.Begin() 118 | if err != nil { 119 | return 120 | } 121 | 122 | tags, err := getTagWithFeedIds(session) 123 | if err != nil { 124 | session.Commit() 125 | return 126 | } 127 | 128 | list, err := GetFeedListWithAmount(session) 129 | if err != nil { 130 | session.Commit() 131 | return 132 | } 133 | 134 | all := false 135 | if len(getall) > 0 && getall[0] { 136 | all = true 137 | } 138 | 139 | feedlist = make(TagsWithFeedList) 140 | for k, v := range tags { 141 | for _, id := range v { 142 | for _, item := range list { 143 | if item.Id == id { 144 | if all == true || (all == false && item.Unread > 0) { 145 | feedlist[k] = append(feedlist[k], item) 146 | } else { 147 | break 148 | } 149 | } 150 | } 151 | } 152 | } 153 | 154 | session.Commit() 155 | return 156 | } 157 | 158 | 159 | type TagAndFeedId struct { 160 | FeedId int64 161 | Tag string 162 | } 163 | 164 | 165 | func getTagAndFeedIds(session *xorm.Session) (result []*TagAndFeedId, err error) { 166 | err = session.Sql("select Feed.Id as FeedId,Tag.Name as Tag from Feed left join Tag on Feed.Id=Tag.Fid").Find(&result) 167 | return 168 | } 169 | 170 | 171 | type TagWithFeedIds map[string][]int64 172 | 173 | 174 | /* Get tag with feed ids 175 | 176 | example: map[IT:[1,2,3], blog:[2,3,4]] 177 | 178 | if there tags: tag, Tag and TAG, they will be combined to TAG (uppercase form) 179 | */ 180 | func getTagWithFeedIds(session *xorm.Session) (result TagWithFeedIds, err error) { 181 | 182 | t, err := getTagAndFeedIds(session) 183 | if err != nil { 184 | return 185 | } 186 | 187 | r := make(TagWithFeedIds) 188 | result = make(TagWithFeedIds) 189 | tags := make(map[string]int) 190 | 191 | for _, i := range t { 192 | r[i.Tag] = append(r[i.Tag], i.FeedId) 193 | } 194 | 195 | for k, _ := range r { 196 | upperTag := strings.ToUpper(k) 197 | v, _ := tags[upperTag] 198 | tags[upperTag] = v + 1 199 | } 200 | 201 | for k, v := range r { 202 | upperTag := strings.ToUpper(k) 203 | num, _ := tags[upperTag] 204 | if num > 1 { 205 | _, ok := result[upperTag] 206 | if ok { 207 | result[upperTag] = append(result[upperTag], v...) 208 | } else { 209 | result[upperTag] = v 210 | } 211 | } else { 212 | result[k] = v 213 | } 214 | } 215 | 216 | return 217 | } 218 | 219 | 220 | type Article struct { 221 | Item `xorm:"extends"` 222 | Feed `xorm:"extends"` 223 | } 224 | 225 | 226 | type ArticleList struct { 227 | Articles []*Article `xorm:"extends"` 228 | Number int64 // amount of all articles 229 | } 230 | 231 | 232 | // Get unread random items. 233 | func GetRandomArticleList(limit int) (list ArticleList, err error) { 234 | 235 | session := global.Orm.NewSession() 236 | defer session.Close() 237 | 238 | err = session.Begin() 239 | if err != nil { 240 | return 241 | } 242 | 243 | list.Number, err = session.Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id").And("Item.Read=0").Count(&Article{}) 244 | if err != nil { 245 | session.Commit() 246 | return 247 | } 248 | 249 | // BUG: Omit("Item.Content") doesn't work, still select all columns, waiting xorm team to fix it. 250 | // opened an issue at: https://github.com/go-xorm/xorm/issues/222 251 | err = session.Omit("Item.Content").Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id").And("Item.Read=0").OrderBy("RANDOM()").Limit(limit).Find(&list.Articles) 252 | 253 | session.Commit() 254 | return 255 | } 256 | 257 | 258 | // Get unread article list, order by id desc. 259 | func GetArticleList(limit, offset int) (list ArticleList, err error) { 260 | 261 | session := global.Orm.NewSession() 262 | defer session.Close() 263 | 264 | err = session.Begin() 265 | if err != nil { 266 | return 267 | } 268 | 269 | list.Number, err = session.Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id").And("Item.Read=0").Count(&Article{}) 270 | if err != nil { 271 | session.Commit() 272 | return 273 | } 274 | 275 | // BUG: Omit("Item.Content") doesn't work, still select all columns, waiting xorm team to fix it. 276 | // opened an issue at: https://github.com/go-xorm/xorm/issues/222 277 | err = session.Omit("Item.Content").Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id").And("Item.Read=0").Desc("Id").Limit(limit, offset).Find(&list.Articles) 278 | 279 | session.Commit() 280 | return 281 | } 282 | 283 | 284 | // Get starred article lsit. 285 | func GetStarredArticleList(limit, offset int) (list ArticleList, err error) { 286 | session := global.Orm.NewSession() 287 | defer session.Close() 288 | 289 | err = session.Begin() 290 | if err != nil { 291 | return 292 | } 293 | 294 | list.Number, err = session.Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id").And("Item.Starred=1").Count(&Article{}) 295 | if err != nil { 296 | session.Commit() 297 | return 298 | } 299 | 300 | // BUG: Omit("Item.Content") doesn't work, still select all columns, waiting xorm team to fix it. 301 | // opened an issue at: https://github.com/go-xorm/xorm/issues/222 302 | err = session.Omit("Item.Content").Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id").And("Item.Starred=1").Desc("Id").Limit(limit, offset).Find(&list.Articles) 303 | 304 | session.Commit() 305 | return 306 | } 307 | 308 | 309 | // Get unread article list by fid, order by id desc. 310 | func GetArticleListByFid(fid int64, limit, offset int) (list ArticleList, err error) { 311 | 312 | session := global.Orm.NewSession() 313 | defer session.Close() 314 | 315 | err = session.Begin() 316 | if err != nil { 317 | return 318 | } 319 | 320 | list.Number, err = session.Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id"). 321 | And(fmt.Sprintf("Item.Fid=%d", fid)).And("Item.Read=0").Count(&Article{}) 322 | if err != nil { 323 | session.Commit() 324 | return 325 | } 326 | 327 | // BUG: Omit("Item.Content") doesn't work, still select all columns, waiting xorm team to fix it. 328 | // opened an issue at: https://github.com/go-xorm/xorm/issues/222 329 | err = session.Omit("Item.Content").Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id"). 330 | And(fmt.Sprintf("Item.Fid=%d", fid)).And("Item.Read=0").Desc("Id").Limit(limit, offset).Find(&list.Articles) 331 | 332 | session.Commit() 333 | return 334 | } 335 | 336 | 337 | // Get unread article list by tag name, order by id desc. 338 | func GetArticleListByTag(tag string, limit, offset int) (list ArticleList, err error) { 339 | session := global.Orm.NewSession() 340 | defer session.Close() 341 | 342 | err = session.Begin() 343 | if err != nil { 344 | return 345 | } 346 | 347 | fids, err := getFeedIdsByTag(session, tag) 348 | if err != nil { 349 | session.Commit() 350 | return 351 | } 352 | 353 | if len(fids) == 0 { 354 | session.Commit() 355 | return 356 | } 357 | 358 | list.Number, err = session.Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id"). 359 | In("Item.Fid", fids).And("Item.Read=0").Count(&Article{}) 360 | if err != nil { 361 | session.Commit() 362 | return 363 | } 364 | 365 | // BUG: Omit("Item.Content") doesn't work, still select all columns, waiting xorm team to fix it. 366 | // opened an issue at: https://github.com/go-xorm/xorm/issues/222 367 | err = session.Omit("Item.Content").Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id"). 368 | In("Item.Fid", fids).And("Item.Read=0").Desc("Id").Limit(limit, offset).Find(&list.Articles) 369 | 370 | //session.Table 371 | session.Commit() 372 | return 373 | } 374 | 375 | 376 | /* 377 | Get related articles by article id. 378 | 379 | Parameters: 380 | id if of an article 381 | n results to return. 382 | */ 383 | func GetRelatedArticles(id int64, n int) (list []*Article, err error) { 384 | 385 | if n <= 0 { 386 | err = fmt.Errorf("Parameter n cannot equal or lesser than zero.") 387 | return 388 | } 389 | 390 | session := global.Orm.NewSession() 391 | defer session.Close() 392 | 393 | err = session.Begin() 394 | if err != nil { 395 | return 396 | } 397 | 398 | var ok bool 399 | 400 | // 1. Get fid of the article. 401 | item := new(Item) 402 | ok, err = session.Cols("Fid").Where("Id = ?", id).Get(item) 403 | if err != nil { 404 | return 405 | } 406 | fid := item.Fid 407 | 408 | // 2. Get articles which Fid = fid 409 | if ok { 410 | // BUG: Omit("Item.Content") doesn't work, still select all columns, waiting xorm team to fix it. 411 | // opened an issue at: https://github.com/go-xorm/xorm/issues/222 412 | err = session.Omit("Item.Content").Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id"). 413 | And("Item.Fid = ?", fid).And("Item.Id != ?", id).And("Item.Read=0").Limit(n, 0).Find(&list) 414 | if err != nil { 415 | return 416 | } 417 | } 418 | 419 | n = n - len(list) 420 | 421 | // 3. Get random articles 422 | if n > 0 { 423 | 424 | var articles []*Article 425 | 426 | // BUG: Omit("Item.Content") doesn't work, still select all columns, waiting xorm team to fix it. 427 | // opened an issue at: https://github.com/go-xorm/xorm/issues/222 428 | err = session.Omit("Item.Content").Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id"). 429 | And("Item.Read=0").And("Feed.Id != ?", fid).OrderBy("RANDOM()").Limit(n).Find(&articles) 430 | if err != nil { 431 | return 432 | } 433 | 434 | list = append(list, articles...) 435 | } 436 | 437 | session.Commit() 438 | return 439 | } 440 | 441 | 442 | // Get feed ids by tag name. 443 | func getFeedIdsByTag(session *xorm.Session, tag string) (fids []int64, err error) { 444 | var t []*Tag 445 | err = session.Cols("Fid").Where("Name = ?", tag).Find(&t) 446 | if err != nil { 447 | return 448 | } 449 | for _, i := range t { 450 | fids = append(fids, i.Fid) 451 | } 452 | return 453 | } 454 | 455 | 456 | // Get feed ids by tags. 457 | func getFeedIdsByTags(session *xorm.Session, tags []string) (fids []int64, err error) { 458 | var t []*Tag 459 | err = session.Cols("Fid").In("Name", tags).Find(&t) 460 | if err != nil { 461 | return 462 | } 463 | for _, i := range t { 464 | fids = append(fids, i.Fid) 465 | } 466 | return 467 | } 468 | 469 | 470 | /* 471 | Get one article by Item.Id. 472 | 473 | If no article get, ok is false. 474 | */ 475 | func GetArticle(id int64) (article *Article, ok bool, err error) { 476 | var a Article 477 | ok, err = global.Orm.Table("Item").Join("INNER", "Feed", "Item.Fid=Feed.Id").And("Item.Id=?", id).Get(&a) 478 | article = &a 479 | return 480 | } 481 | 482 | 483 | /* 484 | Mark article read or unread. 485 | 486 | markread = true: mark read 487 | markread = false: mark unread 488 | */ 489 | func MarkArticleRead(id int64, markread bool) (ok bool, err error) { 490 | var read int 491 | if markread { 492 | read = 1 493 | } else { 494 | read = 0 495 | } 496 | 497 | result, err := global.Orm.Exec("update Item set read=? where id=?", read, id) 498 | if err != nil { 499 | return 500 | } 501 | 502 | affected, err := result.RowsAffected() 503 | if err != nil { 504 | return 505 | } 506 | 507 | if affected > 0 { 508 | ok = true 509 | } 510 | 511 | return 512 | } 513 | 514 | 515 | // Mark articles to read by article ids. 516 | func MarkArticlesRead(ids []int64) (affected int64, err error) { 517 | affected, err = global.Orm.In("id", ids).UseBool("Read").Update(&Item{Read:true}) 518 | return 519 | } 520 | 521 | 522 | // Get feed information by id. 523 | func GetFeed(id int64) (feed *Feed, ok bool, err error) { 524 | var f Feed 525 | ok, err = global.Orm.Id(id).Get(&f) 526 | feed = &f 527 | return 528 | } 529 | 530 | 531 | type FeedInfo struct { 532 | Feed `xorm:"extends"` 533 | Tags []string 534 | Amounts uint64 535 | Read uint64 536 | Unread uint64 537 | Starred uint64 538 | } 539 | 540 | 541 | // Get information of one feed by Feed.Id. 542 | // This function executes 3 queries, and combine the results together. 543 | // If no record found ,feedinfo will be nil. 544 | func GetFeedInfo(fid int64) (feedinfo *FeedInfo, err error) { 545 | 546 | session := global.Orm.NewSession() 547 | defer session.Close() 548 | 549 | err = session.Begin() 550 | if err != nil { 551 | return 552 | } 553 | 554 | var feed FeedInfo 555 | 556 | ok, err := session.Id(fid).Get(&feed.Feed) 557 | if err != nil || !ok { 558 | session.Commit() 559 | return 560 | } 561 | 562 | var tags []*Tag 563 | err = session.Where("Fid = ?", fid).Find(&tags) 564 | if err != nil { 565 | session.Commit() 566 | return 567 | } 568 | for _, i := range tags { 569 | feed.Tags = append(feed.Tags, i.Name) 570 | } 571 | 572 | sql := `select (select count(*) from Item where Fid = %d) amounts, 573 | (select count(*) from Item where Fid = %d and Read = 1) read, 574 | (select count(*) from Item where Fid = %d and Read = 0) unread, 575 | (select count(*) from Item where Fid = %d and Starred = 1) starred` 576 | sql = fmt.Sprintf(sql, fid, fid, fid, fid) 577 | err = session.DB().QueryRow(sql).Scan(&feed.Amounts, 578 | &feed.Read, 579 | &feed.Unread, 580 | &feed.Starred) 581 | if err != nil { 582 | session.Commit() 583 | return 584 | } 585 | 586 | session.Commit() 587 | 588 | feedinfo = &feed 589 | 590 | return 591 | } 592 | 593 | 594 | // Modify table Feed. 595 | func UpdateFeed(fid int64, feed *Feed) (ok bool, err error) { 596 | affected, err := global.Orm.Id(fid).Update(feed) 597 | if affected > 0 { 598 | ok = true 599 | } 600 | return 601 | } 602 | 603 | 604 | /* 605 | Remove same name tags. Remove each tag's leading and trailing white space. 606 | 607 | example: 608 | input []string{"aa ", " bb", "cc", "AA", "cC"} 609 | output []string{"aa", "bb", "cc"} 610 | */ 611 | func trimTags(tags []string) []string { 612 | var t []string 613 | 614 | LOOP: 615 | for _, i := range tags { 616 | tag := strings.TrimSpace(i) 617 | if tag == "" { 618 | continue 619 | } 620 | t1 := strings.ToLower(tag) 621 | for _, j := range t { 622 | t2 := strings.ToLower(j) 623 | if t1 == t2 { 624 | continue LOOP 625 | } 626 | } 627 | t = append(t, tag) 628 | } 629 | 630 | return t 631 | } 632 | 633 | 634 | // Update table Tag. 635 | func UpdateTags(fid int64, tags []string) (err error) { 636 | 637 | session := global.Orm.NewSession() 638 | defer session.Close() 639 | 640 | err = session.Begin() 641 | if err != nil { 642 | return 643 | } 644 | 645 | total, err := session.Where("Id = ?", fid).Count(&Feed{}) 646 | if err != nil { 647 | session.Commit() 648 | return 649 | } 650 | 651 | _, err = session.Where("Fid = ?", fid).Delete(&Tag{}) 652 | if err != nil { 653 | session.Rollback() 654 | return 655 | } 656 | 657 | // If a Feed is not exists (determin by Feed.Id), try to delete data in Tag Table, then return. 658 | if total == 0 { 659 | session.Commit() 660 | err = ErrFeedNotFound 661 | return 662 | } 663 | 664 | tags = trimTags(tags) 665 | 666 | for _, tag := range tags { 667 | _, err = session.Insert(&Tag{Name: tag, Fid: fid}) 668 | if err != nil { 669 | session.Rollback() 670 | return 671 | } 672 | } 673 | 674 | session.Commit() 675 | return 676 | } 677 | 678 | 679 | // Mark articles read by fid. 680 | func MarkArticlesReadByFid(fid int64) (affected int64, err error) { 681 | affected, err = global.Orm.Table("Item").Where("Fid = ?", fid).UseBool("Read").Update(&Item{Read: true}) 682 | return 683 | } 684 | 685 | 686 | // Mark articles read by tag. 687 | func MarkArticlesReadByTag(tag string) (affected int64, err error) { 688 | session := global.Orm.NewSession() 689 | defer session.Close() 690 | 691 | err = session.Begin() 692 | if err != nil { 693 | return 694 | } 695 | 696 | feedIds, err := getFeedIdsByTag(session, tag) 697 | if err != nil { 698 | session.Commit() 699 | } 700 | 701 | affected, err = session.Table("Item").In("Fid", feedIds).UseBool("Read").Update(&Item{Read: true}) 702 | 703 | session.Commit() 704 | return 705 | } 706 | 707 | 708 | // Mark articles starred. 709 | func MarkArticlesStarred(ids []int64, status bool) (affected int64, err error) { 710 | 711 | if len(ids) == 0 { 712 | return 713 | } 714 | 715 | affected, err = global.Orm.Table("Item").In("Id", ids).UseBool("Starred").Update(&Item{Starred: status}) 716 | return 717 | } 718 | 719 | 720 | // Delete a feed, including associated articles and tags. 721 | func DeleteFeed(fid int64) (err error) { 722 | 723 | session := global.Orm.NewSession() 724 | defer session.Close() 725 | 726 | err = session.Begin() 727 | if err != nil { 728 | return 729 | } 730 | 731 | number, err := session.Table("Item").Where("Fid = ?", fid).And("Starred=1").Count(&Item{}) 732 | if err != nil { 733 | session.Commit() 734 | return 735 | } 736 | 737 | if number > 0 { 738 | err = ErrFeedCannotBeDeleted 739 | session.Commit() 740 | return 741 | } 742 | 743 | _, err = session.Where("Fid = ?", fid).Delete(&Item{}) 744 | if err != nil { 745 | session.Rollback() 746 | return 747 | } 748 | 749 | _, err = session.Where("Fid = ?", fid).Delete(&Tag{}) 750 | if err != nil { 751 | session.Rollback() 752 | return 753 | } 754 | 755 | _, err = session.Where("Id = ?", fid).Delete(&Feed{}) 756 | if err != nil { 757 | session.Rollback() 758 | return 759 | } 760 | 761 | session.Commit() 762 | return 763 | } 764 | -------------------------------------------------------------------------------- /model/fetchfeed.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "crypto/md5" 4 | import "fmt" 5 | import "time" 6 | import "strings" 7 | import "sync" 8 | import "github.com/go-xorm/xorm" 9 | import "github.com/m3ng9i/feedreader" 10 | import h "github.com/m3ng9i/go-utils/http" 11 | import "github.com/m3ng9i/qreader/global" 12 | 13 | 14 | // Check if a feed url is already subscribed. 15 | func IsSubscribed(url string) (s bool, e error) { 16 | total, e := global.Orm.Where("Feedurl = ?", url).Count(&Feed{}) 17 | if e != nil { 18 | return 19 | } 20 | 21 | if total > 0 { 22 | s = true 23 | } 24 | 25 | return 26 | } 27 | 28 | 29 | /* 30 | Subscribe a feed. If a feed is already subscribed, a "UNIQUE constraint failed: Feed.Url" error will be return. 31 | 32 | return value: 33 | id id in table feed 34 | num amount of added items 35 | name feed name 36 | */ 37 | func Subscribe(feed *Feed, items []*Item) (id int64, num int64, name string, err error) { 38 | 39 | session := global.Orm.NewSession() 40 | defer session.Close() 41 | 42 | err = session.Begin() 43 | if err != nil { 44 | return 45 | } 46 | 47 | // Feed.Filter and Feed.Note cannot be null. 48 | empty := "" 49 | feed.Alias = &empty 50 | feed.Filter = &empty 51 | feed.Note = &empty 52 | 53 | var zeroInt int = 0 54 | var zeroUint uint = 0 55 | feed.Interval = &zeroInt 56 | feed.MaxUnread = &zeroUint 57 | feed.MaxKeep = &zeroUint 58 | 59 | name = feed.Name 60 | 61 | // insert data to table Feed 62 | _, err = session.Insert(feed) 63 | if err != nil { 64 | session.Rollback() 65 | return 66 | } 67 | 68 | // get Feed.Id 69 | f := new(Feed) 70 | _, err = session.Cols("Id").Where("Feedurl = ?", feed.FeedUrl).Get(f) 71 | if err != nil { 72 | session.Rollback() 73 | return 74 | } 75 | id = f.Id 76 | 77 | var affected int64 78 | // insert data to table Item 79 | for _, item := range items { 80 | item.Fid = f.Id 81 | // ignore error like "UNIQUE constraint failed" 82 | affected, _ = session.Insert(item) 83 | num += affected 84 | } 85 | 86 | err = session.Commit() 87 | if err != nil { 88 | session.Rollback() 89 | return 90 | } 91 | 92 | return 93 | } 94 | 95 | 96 | func fetchFeed(url string, fetcher *h.Fetcher) (feed *Feed, items []*Item, err error) { 97 | fd, err := feedreader.Fetch(url, fetcher) 98 | if err != nil { 99 | return 100 | } 101 | 102 | feed, items = assembleFeed(fd) 103 | return 104 | } 105 | 106 | 107 | // Fetch a feed normally or behind a proxy. 108 | func FetchFeed(url string) (feed *Feed, items []*Item, err error) { 109 | 110 | msgNormally := fmt.Sprintf("[FETCH] Fetch feed '%s' normally", url) 111 | msgProxy := fmt.Sprintf("[FETCH] Fetch feed '%s' behind proxy", url) 112 | 113 | if global.UseProxy == global.PROXY_ALWAYS { 114 | feed, items, err = fetchFeed(url, global.Socks5Fetcher) 115 | if err != nil { 116 | global.Logger.Errorf("%s: %s", msgProxy, err.Error()) 117 | } else { 118 | global.Logger.Infof(msgProxy) 119 | } 120 | return 121 | } 122 | 123 | feed, items, err = fetchFeed(url, global.NormalFetcher) 124 | if err == nil { 125 | global.Logger.Infof(msgNormally) 126 | return 127 | } 128 | if err != nil { 129 | global.Logger.Errorf("%s: %s", msgNormally, err.Error()) 130 | 131 | if global.UseProxy == global.PROXY_TRY { 132 | feed, items, err = fetchFeed(url, global.Socks5Fetcher) 133 | if err != nil { 134 | global.Logger.Errorf("%s: %s", msgProxy, err.Error()) 135 | } else { 136 | global.Logger.Infof(msgProxy) 137 | } 138 | } 139 | } 140 | 141 | return 142 | } 143 | 144 | func assembleFeed(fd *feedreader.Feed) (feed *Feed, items []*Item) { 145 | 146 | now := time.Now() 147 | 148 | feed = new(Feed) 149 | feed.Name = fd.Title 150 | feed.FeedUrl = fd.FeedLink 151 | feed.Url = fd.Link 152 | feed.Desc = fd.Description 153 | feed.Type = fd.Type 154 | feed.LastFetch = now 155 | 156 | for _, i := range fd.Items { 157 | var item = new(Item) 158 | 159 | if i.Author != nil { 160 | item.Author = i.Author.Name 161 | } 162 | 163 | item.Url = i.Link 164 | item.Guid = i.Guid 165 | item.Title = i.Title 166 | item.Content = i.Content 167 | item.FetchTime = now 168 | 169 | item.PubTime = i.PubDate 170 | if item.PubTime.IsZero() { 171 | item.PubTime = i.Updated 172 | } 173 | 174 | h := md5.New() 175 | fmt.Fprint(h, item.Content) 176 | item.Hash = fmt.Sprintf("%x", h.Sum(nil)) 177 | 178 | items = append(items, item) 179 | } 180 | 181 | return 182 | } 183 | 184 | 185 | type FeedRenewInfo struct { 186 | Id int64 187 | Feed *Feed 188 | Items []*Item 189 | FetchTime time.Time 190 | FetchError error 191 | } 192 | 193 | 194 | func fetchFeedAndItems(id int64) (info FeedRenewInfo, err error) { 195 | 196 | feed, ok, err := GetFeed(id) 197 | if err != nil { 198 | return 199 | } 200 | if !ok { 201 | err = ErrFeedNotFound 202 | return 203 | } 204 | 205 | info.Id = id 206 | info.Feed, info.Items, info.FetchError = FetchFeed(feed.FeedUrl) 207 | info.FetchTime = time.Now() 208 | 209 | if info.FetchError != nil { 210 | f := new(Feed) 211 | f.LastFailed = info.FetchTime 212 | f.LastError = info.FetchError.Error() 213 | _, e := global.Orm.Id(info.Id).Update(f) 214 | if e != nil { 215 | err = e 216 | } else { 217 | err = info.FetchError 218 | } 219 | return 220 | } 221 | 222 | // If feed has no items, record as an error. 223 | if len(info.Items) == 0 { 224 | info.FetchError = ErrFeedHasNoItems 225 | err = info.FetchError 226 | } 227 | 228 | return 229 | } 230 | 231 | 232 | func renewFeed(info FeedRenewInfo) (affected int64, err error) { 233 | 234 | session := global.Orm.NewSession() 235 | defer session.Close() 236 | 237 | err = session.Begin() 238 | if err != nil { 239 | return 240 | } 241 | 242 | _, err = session.Id(info.Id).Update(info.Feed) 243 | if err != nil { 244 | session.Rollback() 245 | return 246 | } 247 | 248 | for _, item := range info.Items { 249 | item.Fid = info.Id 250 | num, e := session.Insert(item) 251 | if e != nil { 252 | // Table Item has some unique indexes for preventing insert duplicate data. 253 | // So these errors should be ignored. 254 | if strings.HasPrefix(e.Error(), "UNIQUE constraint failed") { 255 | global.Logger.Noticef("[MODEL] insert item to table Item failed: %s, fid: %d, title: %s, url:%s, guid: %s", 256 | e.Error(), info.Id, item.Title, item.Url, item.Guid) 257 | continue 258 | } else { 259 | session.Rollback() 260 | err = e 261 | return 262 | } 263 | } 264 | affected += num 265 | } 266 | 267 | err = session.Commit() 268 | return 269 | } 270 | 271 | 272 | // Renew a feed: fetch new items of feed, and insert them into Item table. 273 | // If some information of remote feed has changed, e.g. feed name, description, they'll be synced to Feed table. 274 | // If returned error is not nil, it will be feedreader.FetchError, feedreader.ParseError or common error. 275 | func RenewFeed(id int64) (affected int64, err error) { 276 | feedInfo, err := fetchFeedAndItems(id) 277 | if err != nil { 278 | return 279 | } 280 | affected, err = renewFeed(feedInfo) 281 | return 282 | } 283 | 284 | 285 | // Get feed.Ids that need to update 286 | func GetFidsNeedToUpdate(interval uint) (fids []int64, err error) { 287 | 288 | if interval == 0 { 289 | err = fmt.Errorf("interval cannot be zero.") 290 | return 291 | } 292 | 293 | var feeds []*Feed 294 | 295 | err = global.Orm.Cols("Id", "Interval", "LastFetch", "LastFailed").Asc("LastFetch").Find(&feeds) 296 | if err != nil { 297 | return 298 | } 299 | 300 | now := time.Now() 301 | var t time.Time 302 | 303 | for _, feed := range feeds { 304 | 305 | if feed == nil { 306 | continue 307 | } 308 | 309 | if *feed.Interval < 0 { 310 | continue 311 | } 312 | 313 | if *feed.Interval == 0 { 314 | t = feed.LastFetch.Add(time.Duration(interval) * time.Minute) 315 | } else { 316 | t = feed.LastFetch.Add(time.Duration(*feed.Interval) * time.Minute) 317 | } 318 | 319 | if t.Before(now) { 320 | // if fetch failed, try again 1 hour later. 321 | t = feed.LastFailed.Add(time.Hour) 322 | if t.Before(now) { 323 | fids = append(fids, feed.Id) 324 | } 325 | } 326 | } 327 | 328 | return 329 | } 330 | 331 | 332 | // Mark old articles read. 333 | func MarkOldArticlesRead(session *xorm.Session) (affected int64, err error) { 334 | 335 | list, err := GetFeedListWithAmount(session) 336 | if err != nil { 337 | return 338 | } 339 | 340 | for _, item := range list { 341 | if int(*item.MaxUnread) > 0 && item.Unread > uint64(*item.MaxUnread) { 342 | n := int(item.Unread) - int(*item.MaxUnread) 343 | 344 | sql := "update Item set read=1 where id in (select id from Item where Fid = ? and Read = 0 order by Id asc limit ?)" 345 | result, e := session.Exec(sql, item.Id, n) 346 | if e != nil { 347 | err = e 348 | return 349 | } 350 | num, e := result.RowsAffected() 351 | if e != nil { 352 | err = e 353 | return 354 | } 355 | global.Logger.Infof("[TRIM DATA] in transaction: mark old articles read: feed id: %d, affected: %d", item.Id, num) 356 | affected += num 357 | } 358 | } 359 | 360 | return 361 | } 362 | 363 | 364 | // Delete old articles which read=1. 365 | func DeleteOldArticles(session *xorm.Session) (affected int64, err error) { 366 | 367 | list, err := GetFeedListWithAmount(session) 368 | if err != nil { 369 | return 370 | } 371 | 372 | for _, item := range list { 373 | if int(*item.MaxKeep) > 0 && (item.Amount - item.Unread > uint64(*item.MaxKeep)) { 374 | n := int(item.Amount - item.Unread - uint64(*item.MaxKeep)) 375 | 376 | sql := "delete from Item where id in (select id from Item where Fid = ? and Read = 1 and starred = 0 order by Id asc limit ?)" 377 | result, e := session.Exec(sql, item.Id, n) 378 | if e != nil { 379 | err = e 380 | return 381 | } 382 | num, e := result.RowsAffected() 383 | if e != nil { 384 | err = e 385 | return 386 | } 387 | global.Logger.Infof("[TRIM DATA] in transaction: delete old articles which is read: feed id: %d, affected: %d", item.Id, num) 388 | affected += num 389 | } 390 | } 391 | 392 | return 393 | } 394 | 395 | 396 | // Mark old articles read, then delete old articles which read=1. 397 | func TrimData() (markread, deleted int64, err error) { 398 | session := global.Orm.NewSession() 399 | defer session.Close() 400 | 401 | err = session.Begin() 402 | if err != nil { 403 | return 404 | } 405 | 406 | markread, err = MarkOldArticlesRead(session) 407 | if err != nil { 408 | session.Rollback() 409 | global.Logger.Errorf("[TRIM DATA] rollback: mark old articles read: %s", err.Error()) 410 | return 411 | } 412 | 413 | deleted, err = DeleteOldArticles(session) 414 | if err != nil { 415 | session.Rollback() 416 | global.Logger.Errorf("[TRIM DATA] rollback: mark delete old articles: %s", err.Error()) 417 | return 418 | } 419 | 420 | session.Commit() 421 | global.Logger.Infof("[TRIM DATA] commit: mark read: %d, delete: %d", markread, deleted) 422 | return 423 | } 424 | 425 | 426 | func AutoUpdateFeed(interval uint) { 427 | 428 | renewInfo := make(chan FeedRenewInfo, 30) 429 | 430 | // a goroutine for renewing feed 431 | go func(renew chan FeedRenewInfo) { 432 | for feed := range renew { 433 | affected, err := renewFeed(feed) 434 | if err != nil { 435 | global.Logger.Errorf("[SYSTEM] Auto update failed: fid:%d, %s", feed.Id, err.Error()) 436 | } else { 437 | global.Logger.Infof("[SYSTEM] Auto update success: fid:%d, add %d articles.", feed.Id, affected) 438 | } 439 | } 440 | }(renewInfo) 441 | 442 | // a goroutine for fetching feed 443 | go func(renew chan FeedRenewInfo) { 444 | for { 445 | fids, err := GetFidsNeedToUpdate(interval) 446 | if err != nil { 447 | global.Logger.Errorf("[SYSTEM] Auto update failed: %s", err.Error()) 448 | goto NEXT 449 | } 450 | 451 | if len(fids) > 0 { 452 | var wg sync.WaitGroup 453 | 454 | // fetch 5 feeds at one time at most 455 | maxFetch := make(chan bool, 5) 456 | 457 | for _, fid := range fids { 458 | wg.Add(1) 459 | go func(feedid int64) { 460 | maxFetch <- true 461 | 462 | feedInfo, err := fetchFeedAndItems(feedid) 463 | if err != nil { 464 | global.Logger.Errorf("[SYSTEM] Auto update failed: fid:%d, %s", feedid, err.Error()) 465 | } else { 466 | renew <- feedInfo 467 | } 468 | 469 | <- maxFetch 470 | wg.Done() 471 | }(fid) 472 | } 473 | wg.Wait() 474 | global.Logger.Info("[SYSTEM] Auto update: finish fetching.") 475 | } else { 476 | global.Logger.Info("[SYSTEM] Auto update: no feed need to update.") 477 | } 478 | 479 | // after fetching, wait a minute for database updating, then trim data. 480 | <- time.After(time.Minute) 481 | TrimData() 482 | 483 | // try again after few minutes 484 | NEXT: 485 | <- time.After(10 * time.Minute) 486 | } 487 | }(renewInfo) 488 | } 489 | -------------------------------------------------------------------------------- /model/initdb.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "bytes" 4 | import "github.com/m3ng9i/qreader/global" 5 | 6 | // SQL script for create tables. 7 | const createTablesSql = ` 8 | drop table if exists 'Feed'; 9 | drop table if exists 'Item'; 10 | drop table if exists 'Tag'; 11 | 12 | create table if not exists 'Feed' ( 13 | 'Id' integer not null primary key autoincrement, -- primary key 14 | 'Name' text not null, -- name of feed 15 | 'Alias' text not null default '', -- feed name's alias 16 | 'Feedurl' text not null, -- url of feed 17 | 'Url' text not null, -- url the feed point to 18 | 'Desc' text not null default '', -- feed description 19 | 'Type' text not null, -- feed type: rss or atom 20 | 'Interval' integer not null default 0, -- refresh interval (minute), 0 for default interval. value below zero means not update. 21 | 'LastFetch' datetime not null, -- last successful fetch time (timestamp) 22 | 'LastFailed' datetime not null, -- last failed time for fetching (timestamp) 23 | 'LastError' text not null default '', -- last error for fetching 24 | 'MaxUnread' integer not null default 0, -- max number of unread items. 0 for keep all. 25 | 'MaxKeep' integer not null default 0, -- max number of items to keep. 0 for keep all, greater than 0 for keep n unread items. 26 | 'Filter' text not null default '', -- filter. (not to use now) 27 | 'UseProxy' integer not null default 0, -- whether to use proxy to fetch feed. 0: try, 1: always, 2: never. 28 | 'Note' text not null default '' -- comments for this feed (not to use now) 29 | ); 30 | 31 | create table if not exists 'Item' ( 32 | 'Id' integer not null primary key autoincrement, -- primary key 33 | 'Fid' integer not null, -- Feed.id 34 | 'Author' text not null, -- author 35 | 'Url' text not null, -- url of the item 36 | 'Guid' text not null, -- guid of the item 37 | 'Title' text not null, -- title 38 | 'Content' text not null, -- content 39 | 'PubTime' datetime not null, -- item pubtime 40 | 'FetchTime' datetime not null, -- item fetch time 41 | 'Starred' integer not null default 0, -- whether the item was starred. 0:no, 1:yes. 42 | 'Read' integer not null default 0, -- whether the item has been read. 0:no, 1:yes 43 | 'Hash' text not null -- md5sum of content 44 | ); 45 | 46 | create table if not exists 'Tag' ( 47 | 'Id' integer not null primary key autoincrement, -- primary key 48 | 'Fid' integer not null, -- Feed.id 49 | 'Name' text collate nocase not null -- tag name, case insensitive 50 | ); 51 | ` 52 | 53 | 54 | // SQL script for create tables. 55 | const createIndexesSql = ` 56 | create unique index if not exists i_feed_url on Feed(Feedurl); 57 | 58 | create unique index if not exists i_item_url on Item(Url); 59 | 60 | create unique index if not exists i_item_combine_guid on Item(Fid, Guid); 61 | 62 | create unique index if not exists i_tag_combine_name_fid on Tag(Name, Fid); 63 | ` 64 | 65 | 66 | // Create tables for QReader, this will drop them first, you may lost data if the tables are already exists. 67 | func CreateTables() error { 68 | sql := "begin;" + createTablesSql + "commit;" 69 | _, err := global.Orm.Import(bytes.NewReader([]byte(sql))) 70 | return err 71 | } 72 | 73 | 74 | // Create indexes. 75 | func CreateIndexes() error { 76 | sql := "begin;" + createIndexesSql + "commit;" 77 | _, err := global.Orm.Import(bytes.NewReader([]byte(sql))) 78 | return err 79 | } 80 | 81 | 82 | // QReader database initialization 83 | func InitDB() error { 84 | sql := "begin;" + createTablesSql + createIndexesSql + "commit;" 85 | _, err := global.Orm.Import(bytes.NewReader([]byte(sql))) 86 | return err 87 | } 88 | 89 | -------------------------------------------------------------------------------- /model/misc.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "os" 4 | import "github.com/m3ng9i/qreader/global" 5 | 6 | 7 | // Get size of sqlite3 database file. 8 | func DBSize() (size int64, err error) { 9 | 10 | info, err := os.Stat(global.PathDB) 11 | if err != nil { 12 | return 13 | } 14 | 15 | size = info.Size() 16 | return 17 | } 18 | -------------------------------------------------------------------------------- /model/search.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "fmt" 4 | import "strings" 5 | import "strconv" 6 | import qp "github.com/m3ng9i/go-utils/query-parser" 7 | import "github.com/m3ng9i/go-utils/slice" 8 | import "github.com/m3ng9i/qreader/global" 9 | 10 | type SearchQuery struct { 11 | Fid *[]int64 12 | Title *[]string 13 | Content *[]string 14 | Read *bool // nil: unread 15 | Orderby *[]string 16 | Asc *bool 17 | Tag *[]string 18 | Starred *bool // nil: any 19 | Num *int // nil: default value 20 | } 21 | 22 | 23 | type SearchQueryError struct { 24 | qp.Node 25 | Msg string 26 | } 27 | 28 | 29 | func (e *SearchQueryError) Error() string { 30 | return e.Msg 31 | } 32 | 33 | 34 | // Determine if a string is a legal column used in sql orderby statement 35 | func legalOrderbyColumn(c string) bool { 36 | c = strings.ToLower(c) 37 | 38 | columns := []string { 39 | "id", 40 | "fid", 41 | "author", 42 | "url", 43 | "guid", 44 | "title", 45 | "content", 46 | "pubtime", 47 | "fetchtime", 48 | "starred", 49 | "read", 50 | } 51 | 52 | for _, col := range columns { 53 | if c == col { 54 | return true 55 | } 56 | } 57 | 58 | return false 59 | } 60 | 61 | // Get SearchQuery structure base on a search query. 62 | // err may be qp.InvalidCharError or SearchQueryError. 63 | // E.g. sq, err := Search("fid:22 title:'article title' orderby:title order:asc") 64 | func Search(q string) (sq SearchQuery, err error) { 65 | 66 | nodes, err := qp.Parse(q) 67 | if err != nil { 68 | return 69 | } 70 | 71 | t := true 72 | f := false 73 | 74 | readAny := false 75 | 76 | for _, node := range *nodes { 77 | 78 | if node.Negative { 79 | err = &SearchQueryError { 80 | Node: node, 81 | Msg: fmt.Sprintf("Incorrect use of '-' in key: %s", node.Key), 82 | } 83 | return 84 | } 85 | 86 | switch strings.ToLower(node.Key) { 87 | case "fid": 88 | var fids []int64 89 | for _, value := range node.Values { 90 | fid, e := strconv.ParseInt(value, 10, 64) 91 | if e != nil || fid <= 0 { 92 | err = &SearchQueryError { 93 | Node: node, 94 | Msg: fmt.Sprintf("Incorrect fid value: %s", value), 95 | } 96 | return 97 | } 98 | fids = append(fids, fid) 99 | } 100 | if len(fids) > 0 { 101 | if sq.Fid != nil { 102 | *sq.Fid = append(*sq.Fid, fids...) 103 | } else { 104 | sq.Fid = &fids 105 | } 106 | } 107 | 108 | case "": fallthrough 109 | case "keyword": 110 | v := node.Values 111 | 112 | if sq.Title != nil { 113 | *sq.Title = append(*sq.Title, node.Values...) 114 | } else { 115 | sq.Title = &v 116 | } 117 | 118 | if sq.Content != nil { 119 | *sq.Content = append(*sq.Content, node.Values...) 120 | } else { 121 | sq.Content = &v 122 | } 123 | 124 | case "title": 125 | if sq.Title != nil { 126 | *sq.Title = append(*sq.Title, node.Values...) 127 | } else { 128 | v := node.Values 129 | sq.Title = &v 130 | } 131 | 132 | case "content": 133 | if sq.Content != nil { 134 | *sq.Content = append(*sq.Content, node.Values...) 135 | } else { 136 | v := node.Values 137 | sq.Content = &v 138 | } 139 | 140 | case "read": 141 | if sq.Read != nil { 142 | err = &SearchQueryError { 143 | Node: node, 144 | Msg: "'read' has already been set.", 145 | } 146 | return 147 | } 148 | 149 | valueNum := len(node.Values) 150 | if valueNum > 1 { 151 | err = &SearchQueryError { 152 | Node: node, 153 | Msg: "'read' could only have one value.", 154 | } 155 | return 156 | } 157 | 158 | switch strings.ToLower(node.Values[0]) { 159 | case "true": fallthrough 160 | case "yes": 161 | sq.Read = &t 162 | 163 | case "false": fallthrough 164 | case "no": 165 | sq.Read = &f 166 | 167 | case "any": 168 | readAny = true 169 | 170 | default: 171 | err = &SearchQueryError { 172 | Node: node, 173 | Msg: fmt.Sprintf("Value of 'read' is not correct: %s", node.Values[0]), 174 | } 175 | return 176 | } 177 | 178 | case "orderby": 179 | if sq.Orderby != nil { 180 | *sq.Orderby = append(*sq.Orderby, node.Values...) 181 | } else { 182 | v := node.Values 183 | sq.Orderby = &v 184 | } 185 | 186 | case "order": 187 | if sq.Asc != nil { 188 | err = &SearchQueryError { 189 | Node: node, 190 | Msg: "'order' has already been set.", 191 | } 192 | return 193 | } 194 | 195 | valueNum := len(node.Values) 196 | if valueNum > 1 { 197 | err = &SearchQueryError { 198 | Node: node, 199 | Msg: "'order' could only have one value.", 200 | } 201 | return 202 | } 203 | 204 | switch strings.ToLower(node.Values[0]) { 205 | case "asc": 206 | sq.Asc = &t 207 | case "desc": 208 | sq.Asc = &f 209 | default: 210 | err = &SearchQueryError { 211 | Node: node, 212 | Msg: fmt.Sprintf("Value of 'order' is not correct: %s", node.Values[0]), 213 | } 214 | return 215 | } 216 | 217 | case "tag": 218 | if sq.Tag != nil { 219 | *sq.Tag = append(*sq.Tag, node.Values...) 220 | } else { 221 | v := node.Values 222 | sq.Tag = &v 223 | } 224 | 225 | case "starred": 226 | if sq.Starred != nil { 227 | err = &SearchQueryError { 228 | Node: node, 229 | Msg: "'starred' has already been set.", 230 | } 231 | return 232 | } 233 | 234 | valueNum := len(node.Values) 235 | if valueNum > 1 { 236 | err = &SearchQueryError { 237 | Node: node, 238 | Msg: "'starred' could only have one value.", 239 | } 240 | return 241 | } 242 | 243 | switch strings.ToLower(node.Values[0]) { 244 | case "true": fallthrough 245 | case "yes": 246 | sq.Starred = &t 247 | 248 | case "false": fallthrough 249 | case "no": 250 | sq.Starred = &f 251 | 252 | default: 253 | err = &SearchQueryError { 254 | Node: node, 255 | Msg: fmt.Sprintf("Value of 'starred' is not correct: %s", node.Values[0]), 256 | } 257 | return 258 | } 259 | 260 | case "num": 261 | if sq.Num != nil { 262 | err = &SearchQueryError { 263 | Node: node, 264 | Msg: "'num' has already been set.", 265 | } 266 | return 267 | } 268 | 269 | valueNum := len(node.Values) 270 | if valueNum > 1 { 271 | err = &SearchQueryError { 272 | Node: node, 273 | Msg: "'num' could only have one value.", 274 | } 275 | return 276 | } 277 | 278 | num, e := strconv.Atoi(node.Values[0]) 279 | if e != nil || num <= 0 { 280 | err = &SearchQueryError { 281 | Node: node, 282 | Msg: fmt.Sprintf("Incorrect num value: %s", node.Values[0]), 283 | } 284 | return 285 | } 286 | sq.Num = &num 287 | 288 | 289 | default: 290 | err = &SearchQueryError { 291 | Node: node, 292 | Msg: fmt.Sprintf("Do not support key: %s", node.Key), 293 | } 294 | return 295 | } 296 | } 297 | 298 | // set default values 299 | if sq.Orderby == nil || len(*sq.Orderby) == 0 { 300 | sq.Orderby = &[]string{"Id"} 301 | } 302 | if sq.Asc == nil { 303 | sq.Asc = &f // desc 304 | } 305 | if sq.Read == nil && readAny == false { 306 | sq.Read = &f // unread 307 | } 308 | 309 | // remove duplicate values 310 | 311 | if sq.Fid != nil { 312 | *sq.Fid = slice.Unique(*sq.Fid).([]int64) 313 | } 314 | 315 | if sq.Title != nil { 316 | *sq.Title = slice.Unique(*sq.Title).([]string) 317 | } 318 | 319 | if sq.Content != nil { 320 | *sq.Content = slice.Unique(*sq.Content).([]string) 321 | } 322 | 323 | if sq.Orderby != nil { 324 | *sq.Orderby = slice.Unique(*sq.Orderby).([]string) 325 | } 326 | 327 | if sq.Tag != nil { 328 | *sq.Tag = slice.Unique(*sq.Tag).([]string) 329 | } 330 | 331 | return 332 | } 333 | 334 | 335 | // Get article list of search query. 336 | func (this *SearchQuery) List(page int) (list ArticleList, err error) { 337 | 338 | session := global.Orm.NewSession() 339 | defer session.Close() 340 | 341 | err = session.Begin() 342 | if err != nil { 343 | return 344 | } 345 | 346 | var fids []int64 347 | if this.Tag != nil && len(*this.Tag) > 0 { 348 | fids, err = getFeedIdsByTags(session, *this.Tag) 349 | if err != nil { 350 | session.Rollback() 351 | return 352 | } 353 | } 354 | 355 | if this.Fid != nil { 356 | fids = append(fids, *this.Fid...) 357 | } 358 | 359 | var where []string 360 | if len(fids) > 0 { 361 | var s []string 362 | for _, fid := range fids { 363 | s = append(s, strconv.FormatInt(fid, 10)) 364 | } 365 | where = append(where, fmt.Sprintf("Item.Fid in (%s)", strings.Join(s, ","))) 366 | } 367 | 368 | var keywordSql []string // for combine sql of title and content 369 | 370 | // the code below escape the single quotation for used in sql. 371 | 372 | if this.Title != nil && len(*this.Title) > 0 { 373 | for _, title := range *this.Title { 374 | keywordSql = append(keywordSql, fmt.Sprintf("Item.Title like '%%%s%%'", 375 | strings.Replace(title, "'", "''", -1))) 376 | } 377 | } 378 | 379 | if this.Content != nil && len(*this.Content) > 0 { 380 | for _, content := range *this.Content { 381 | keywordSql = append(keywordSql, fmt.Sprintf("Item.Content like '%%%s%%'", 382 | strings.Replace(content, "'", "''", -1))) 383 | } 384 | } 385 | 386 | if len(keywordSql) > 0 { 387 | // where clause of title and content 388 | where = append(where, "(" + strings.Join(keywordSql, " or ") + ")") 389 | } 390 | 391 | if this.Read != nil { 392 | if *this.Read { 393 | where = append(where, "Item.Read=1") 394 | } else { 395 | where = append(where, "Item.Read=0") 396 | } 397 | } 398 | 399 | if this.Starred != nil { 400 | if *this.Starred { 401 | where = append(where, "Item.Starred=1") 402 | } else { 403 | where = append(where, "Item.Starred=0") 404 | } 405 | } 406 | 407 | whereSql := strings.Join(where, " and ") 408 | 409 | sql := "select count(*) from Item inner join Feed on Item.Fid=Feed.Id" 410 | if len(whereSql) > 0 { 411 | sql += " where " + whereSql 412 | } 413 | 414 | err = session.DB().QueryRow(sql).Scan(&list.Number) 415 | if err != nil { 416 | session.Rollback() 417 | return 418 | } 419 | 420 | // place Feed.* as the last column to fit list.Articles structure. 421 | sql = `select Item.Id, Item.Fid, Item.Author, Item.Url, Item.Guid, Item.Title, Item.PubTime, 422 | Item.FetchTime, Item.Starred, Item.Read, Item.Hash, Feed.* from Item 423 | inner join Feed on Item.Fid=Feed.Id` 424 | 425 | if len(whereSql) > 0 { 426 | sql += " where " + whereSql 427 | } 428 | 429 | if this.Asc != nil && this.Orderby != nil { 430 | var cols []string 431 | for _, e := range *this.Orderby { 432 | cols = append(cols, "`Item`.`" + e + "`") 433 | } 434 | 435 | s := fmt.Sprintf("order by %s", strings.Join(cols, ",")) 436 | 437 | if *this.Asc { 438 | s += " asc" 439 | } else { 440 | s += " desc" 441 | } 442 | 443 | sql += " " + s 444 | } 445 | 446 | if this.Num == nil { 447 | err = fmt.Errorf("'num' not provide for search query.") 448 | session.Rollback() 449 | return 450 | } 451 | 452 | sql = fmt.Sprintf("%s limit %d, %d", sql, (page - 1) * *this.Num, *this.Num) 453 | 454 | err = session.Sql(sql).Find(&list.Articles) 455 | 456 | session.Commit() 457 | 458 | return 459 | } 460 | 461 | -------------------------------------------------------------------------------- /qreader.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | import "flag" 5 | import "os" 6 | import "os/signal" 7 | import "fmt" 8 | import "strings" 9 | import "net/http" 10 | import "syscall" 11 | import "path/filepath" 12 | import "github.com/toqueteos/webbrowser" 13 | import "github.com/m3ng9i/qreader/global" 14 | import "github.com/m3ng9i/qreader/model" 15 | import "github.com/m3ng9i/qreader/utils" 16 | import "github.com/m3ng9i/qreader/server" 17 | 18 | 19 | var _version_ = "v0.2.2" // program version, from git tag 20 | var _branch_ = "unknown" // git branch 21 | var _commitId_ = "0000000" // git commit id 22 | var _buildTime_ = "0000-00-00 00:00" // build time 23 | 24 | var Version = fmt.Sprintf("Version: %s, Branch: %s, Build: %s, Build time: %s", 25 | _version_, _branch_, _commitId_, _buildTime_) 26 | 27 | var _github_ = "https://github.com/m3ng9i/qreader" 28 | 29 | func usage() { 30 | s := `QReader: a browser-server based feed reader 31 | 32 | Usage: 33 | qreader [option...] 34 | 35 | Options: 36 | -s, -sitedata Directory of sitedata. If not provided, use "sitedata" under current wokring directory. 37 | -init Initialize QReader database and config.ini. 38 | -initdb Initialize QReader database, will delete all the data and recreate tables. 39 | -current-token Show current api token. 40 | -defini Default content of config.ini. 41 | -open Open QReader web page on default browser. 42 | -h, -help Show this message. 43 | -v, -version Show version information. 44 | 45 | Github: 46 | <%s> 47 | 48 | Author: 49 | m3ng9i 50 | ` 51 | fmt.Printf(s, _github_) 52 | os.Exit(0) 53 | } 54 | 55 | 56 | func checkDBFile() error { 57 | p := global.PathDB 58 | init := "You can use -initdb parameter to initialize database." 59 | info, err := os.Stat(p) 60 | if err != nil { 61 | if os.IsNotExist(err) { 62 | return fmt.Errorf("Database: '%s' is not exist.\n%s\n", p, init) 63 | } 64 | return err 65 | } 66 | if info.IsDir() { 67 | return fmt.Errorf("'%s' is a directory, can not be used as database.\n", p) 68 | } 69 | if info.Size() == 0 { 70 | return fmt.Errorf("Size of database: '%s' is 0.\n%s\n", p, init) 71 | } 72 | return nil 73 | } 74 | 75 | 76 | func catchSignal() { 77 | signal_channel := make(chan os.Signal, 1) 78 | signal.Notify(signal_channel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) 79 | go func() { 80 | for value := range signal_channel { 81 | global.Logger.Warnf("Catch signal: %s, QReader server is going to shutdown", value.String()) 82 | global.Logger.Wait() 83 | os.Exit(0) 84 | } 85 | }() 86 | } 87 | 88 | 89 | func initDatabase() { 90 | err := model.InitDB() 91 | if err != nil { 92 | fmt.Fprintf(os.Stderr, "Error occurs when initializing database: %s\n", err.Error()) 93 | os.Exit(1) 94 | } else { 95 | fmt.Println("Database initialized.") 96 | } 97 | } 98 | 99 | 100 | func initConfigIni() { 101 | err := global.CreateConfigIni() 102 | if err != nil { 103 | fmt.Fprintln(os.Stderr, "Error occurs when initializing config.ini: %s\n", err.Error()) 104 | os.Exit(1) 105 | } else { 106 | fmt.Println("config.ini initialized.") 107 | } 108 | } 109 | 110 | 111 | func main() { 112 | 113 | global.Version = global.VersionType { 114 | Version: _version_, 115 | Branch: _branch_, 116 | CommitId: _commitId_, 117 | BuildTime: _buildTime_, 118 | } 119 | 120 | global.Github = _github_ 121 | 122 | var sitedata, input string 123 | var init, initdb, help, version, currentToken, defini, open bool 124 | flag.StringVar(&sitedata, "sitedata", "", "Directory of sitedata") 125 | flag.StringVar(&sitedata, "s", "", "Directory of sitedata") 126 | flag.BoolVar(&init, "init", false, "-init") 127 | flag.BoolVar(&initdb, "initdb", false, "-initdb") 128 | flag.BoolVar(&help, "h", false, "-h") 129 | flag.BoolVar(&help, "help", false, "-help") 130 | flag.BoolVar(&version, "v", false, "-v") 131 | flag.BoolVar(&version, "version", false, "-version") 132 | flag.BoolVar(¤tToken, "current-token", false, "-current-token") 133 | flag.BoolVar(&defini, "defini", false, "-defini") 134 | flag.BoolVar(&open, "open", false, "-open") 135 | flag.Usage = usage 136 | flag.Parse() 137 | 138 | if help { 139 | usage() 140 | } 141 | 142 | if version { 143 | fmt.Println(Version) 144 | os.Exit(0) 145 | } 146 | 147 | if defini { 148 | fmt.Println(global.DefaultConfigIni()) 149 | os.Exit(0) 150 | } 151 | 152 | // If sitedata is not provided, use default path. 153 | if sitedata == "" { 154 | sitedata = "sitedata" 155 | } 156 | global.Sitedata = sitedata 157 | 158 | // set path and database 159 | global.Init1() 160 | 161 | configIniExist := global.IsConfigIniExist() 162 | if configIniExist { 163 | // load config and create logger 164 | global.Init2() 165 | defer func() { 166 | global.Logger.Wait() 167 | }() 168 | } 169 | 170 | // initialize database and config.ini 171 | if init { 172 | fmt.Print("Are you sure to initialize QReader database and config.ini? This will delete all existing data and reset config.ini. ") 173 | fmt.Scanln(&input) 174 | if len(input) > 0 && strings.ToLower(string(input[0])) == "y" { 175 | initDatabase() 176 | initConfigIni() 177 | } else { 178 | fmt.Fprintln(os.Stderr, "Aborted to initialize database and config.ini.") 179 | } 180 | os.Exit(0) 181 | } 182 | 183 | // initialize database 184 | if initdb { 185 | fmt.Print("Are you sure to initialize QReader database? This will delete all existing data. ") 186 | fmt.Scanln(&input) 187 | if len(input) > 0 && strings.ToLower(string(input[0])) == "y" { 188 | initDatabase() 189 | } else { 190 | fmt.Fprintln(os.Stderr, "Aborted to initialize database.") 191 | } 192 | os.Exit(0) 193 | } 194 | 195 | if !configIniExist { 196 | fmt.Fprintf(os.Stderr, "%s is not exist or not a regular file.\n", filepath.Join(global.Sitedata, "config.ini")) 197 | os.Exit(1) 198 | } 199 | 200 | // check if database is correct 201 | err := checkDBFile() 202 | if err != nil { 203 | fmt.Fprintf(os.Stderr, err.Error()) 204 | os.Exit(1) 205 | } 206 | 207 | if currentToken { 208 | fmt.Println(utils.CurrentToken()) 209 | os.Exit(0) 210 | } 211 | 212 | addr := fmt.Sprintf("%s:%d", global.IP, global.Port) 213 | url := "" 214 | if global.Usetls { 215 | url = "https://" + addr 216 | } else { 217 | url = "http://" + addr 218 | } 219 | 220 | catchSignal() 221 | 222 | server.Init() 223 | 224 | global.Logger.Infof("QReader %s.", Version) 225 | global.Logger.Infof("QReader is running. Open %s in your browser to use.", url) 226 | 227 | // Auto update feed. Feed will be updated every 120 minutes (2 hours) default. 228 | model.AutoUpdateFeed(120) 229 | 230 | if open { 231 | go func() { 232 | <- time.After(500 * time.Millisecond) 233 | err = webbrowser.Open(url) 234 | if err != nil { 235 | global.Logger.Error(err) 236 | err = nil 237 | } 238 | }() 239 | } 240 | 241 | if global.Usetls { 242 | err = http.ListenAndServeTLS(addr, global.PathCertPem, global.PathKeyPem, server.Mux) 243 | } else { 244 | err = http.ListenAndServe(addr, server.Mux) 245 | } 246 | if err != nil { 247 | fmt.Fprintf(os.Stderr, err.Error()) 248 | os.Exit(1) 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import "fmt" 4 | import "time" 5 | import "net/http" 6 | import "strings" 7 | import "sync" 8 | import "github.com/go-martini/martini" 9 | import httphelper "github.com/m3ng9i/go-utils/http" 10 | import "github.com/m3ng9i/qreader/api" 11 | import "github.com/m3ng9i/qreader/global" 12 | import "github.com/m3ng9i/qreader/utils" 13 | 14 | 15 | // Generate a request id for each request. 16 | func requestId() martini.Handler { 17 | // requestIdFunc return a httphelper.RequestId type of value. 18 | requestIdFunc := httphelper.RequestIdGenerator(32) 19 | 20 | return func(c martini.Context) { 21 | c.Map(requestIdFunc()) 22 | c.Next() 23 | } 24 | } 25 | 26 | 27 | func recovery() martini.Handler { 28 | return func(w http.ResponseWriter, ctx martini.Context) { 29 | defer func() { 30 | if err := recover(); err != nil { 31 | w.WriteHeader(http.StatusInternalServerError) 32 | global.Logger.Errorf("PANIC: %s", err) 33 | } 34 | }() 35 | 36 | ctx.Next() 37 | } 38 | } 39 | 40 | 41 | // Create the router. 42 | func createRouter() martini.Router { 43 | router := martini.NewRouter() 44 | 45 | router.Get( "/api/feed/list", api.FeedList()) 46 | router.Get( "/api/feed/subscription", api.IsSubscribed()) 47 | router.Post( "/api/feed/subscription", api.Subscribe()) 48 | router.Post( "/api/feed/id/:id", api.Update()) 49 | router.Get( "/api/feed/id/:id", api.FeedInfo()) 50 | router.Put( "/api/feed/id/:id", api.UpdateFeedAndTags()) 51 | router.Delete( "/api/feed/id/:id", api.DeleteFeed()) 52 | router.Get( "/api/articles/random", api.RandomArticleList()) 53 | router.Get( "/api/articles/unread/:limit/:offset", api.ArticleList("unread")) 54 | router.Get( "/api/articles/fid/:fid/:limit/:offset", api.ArticleList("fid")) 55 | router.Get( "/api/articles/tag/:tag/:limit/:offset", api.ArticleList("tag")) 56 | router.Get( "/api/articles/starred/:limit/:offset", api.ArticleList("starred")) 57 | router.Get( "/api/articles/search/:deflimit", api.SearchList()) 58 | router.Put( "/api/articles/read", api.MarkArticlesRead()) 59 | router.Put( "/api/articles/starred", api.MarkArticlesStarred()) 60 | router.Get( "/api/article/content/:id", api.Article()) 61 | router.Put( "/api/article/read/:id", api.MarkReadStatus(true)) // mark read 62 | router.Put( "/api/article/unread/:id", api.MarkReadStatus(false)) // mark unread 63 | router.Get( "/api/tags/list", api.TagsList()) 64 | router.Get( "/api/system/settings", api.Settings()) 65 | router.Put( "/api/system/shutdown", api.CloseServer()) 66 | router.Get( "/api/", api.Status()) // do not need api token 67 | router.Get( "/api/checktoken", api.Status()) // check api token 68 | router.Any( "/api/**", api.Default()) 69 | 70 | return router 71 | } 72 | 73 | 74 | // Create the mux. 75 | func createMux(r martini.Router) *martini.Martini { 76 | mux := martini.New() 77 | mux.Use(recovery()) 78 | mux.Use(requestId()) // generate a request id for each request 79 | mux.Use(httpLog()) // log every request 80 | mux.Use(checkToken()) // check if token is valid if a request path is begin with /api/ 81 | 82 | // set global.PathClient to static file path, and if a path of an url start with /, that will be pointed to the static file path. 83 | mux.Use(martini.Static(global.PathClient, martini.StaticOptions{Prefix:"/", SkipLogging:true, IndexFile:"index.html"})) 84 | 85 | mux.Action(r.Handle) 86 | 87 | return mux 88 | } 89 | 90 | 91 | func httpLog() martini.Handler { 92 | 93 | return func(w http.ResponseWriter, r *http.Request, ctx martini.Context, rid httphelper.RequestId) { 94 | 95 | timer := time.Now() // start time of request 96 | 97 | ctx.Next() // execute the other handler 98 | 99 | rw := w.(martini.ResponseWriter) 100 | 101 | loginfo := fmt.Sprintf("[Access] [#%s] [status:%v] [ip:%s] [host:%s] [method:%s] [path:%s] [user-agent:%s] [ref:%s] [time:%.3fms]", 102 | string(rid), // request id 103 | rw.Status(), // http status code 104 | httphelper.GetIP(r), // client IP 105 | r.Host, 106 | r.Method, 107 | r.URL, 108 | r.Header["User-Agent"][0], 109 | r.Referer(), 110 | time.Since(timer).Seconds()*1000) // request time (milliseconds) 111 | 112 | global.Logger.Info(loginfo) 113 | } 114 | } 115 | 116 | 117 | // Get token in query string and check if it's valid, if not, response error. 118 | func checkToken() martini.Handler { 119 | return func(w http.ResponseWriter, r *http.Request, ctx martini.Context, rid httphelper.RequestId) { 120 | 121 | var apiPrefix = "/api/" 122 | 123 | if strings.HasPrefix(r.URL.Path, apiPrefix) && len(r.URL.Path) > len(apiPrefix) { 124 | token := r.Header.Get("X-QReader-Token") 125 | if !utils.ValidateToken(token) { 126 | var result api.Result 127 | result.RequestId = rid 128 | result.Success = false 129 | result.Error = api.ErrTokenInvalid 130 | result.IntError = fmt.Errorf("invalid token") 131 | result.Result = nil 132 | result.Response(w) 133 | return 134 | } 135 | } 136 | 137 | ctx.Next() 138 | } 139 | } 140 | 141 | 142 | var Router martini.Router 143 | 144 | var Mux *martini.Martini 145 | 146 | var once sync.Once 147 | 148 | // server's Init() should run after global's Init(). 149 | func Init() { 150 | once.Do(func() { 151 | Router = createRouter() 152 | Mux = createMux(Router) 153 | }) 154 | } 155 | -------------------------------------------------------------------------------- /sitedata/cert/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIC8DCCAdqgAwIBAgIQbZi0pjfJxJDLaajnI7Ex+DALBgkqhkiG9w0BAQswEjEQ 3 | MA4GA1UEChMHQWNtZSBDbzAeFw0xNTA1MTAwNjIzMjlaFw0xNjA1MDkwNjIzMjla 4 | MBIxEDAOBgNVBAoTB0FjbWUgQ28wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK 5 | AoIBAQDtZmoGFm4Ys/UhGhinmJo2tlISx9apgkhM6f+0kkPp7zcaTY27d1mR7zcT 6 | CqLh0IoF6lAo+z8HPKCE5dzh/9JjSWndaylMQtLId1v5mqTUTxQldfOIkV/p3sFh 7 | gvSSEZk8pe+Y2ZuR+DealzvXoHDecLpiy5zWcTddXYEiQPbc4bLRARZRsaaK4Hpq 8 | +vu49f5bYZYyPxiNOS+9QFp7rW9bGvFwKFCV9rqwkQz4nsJsgUekCmcljYUacmnu 9 | 76XQwxYNSCxm2vMPT7vuEjOPZYwFqQ7AIOfRvSATUO+AMUYZqEQXNl/potQlG3M7 10 | h50fSGlxpwXgq9jTG/h0vP5NY1XJAgMBAAGjRjBEMA4GA1UdDwEB/wQEAwIAoDAT 11 | BgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA8GA1UdEQQIMAaHBH8A 12 | AAEwCwYJKoZIhvcNAQELA4IBAQAt+0Wu0UXmNNJViXBhZZ1brlwvJfEQi40J9GAC 13 | aq9U0fi0yNG4sk1kG0Jp5kif+2vLPYxTGDKcyh2nWBN/HcPRc7QEEz1ZabrwRKib 14 | AfyUVRjwlJ46XjYLkQrYmfozxC+VLP9QWc92IkDyXQ6zqKe4vSdef1DxsVzgUcu9 15 | NFwgehTeJt0GZb28YRGqFXhwXLcyRyHeMVIkxUC77za+qEllQGwGn7WA4wl7f77T 16 | I5JsrOGgxXIcFJQRYlBpYMNorE9lFmZJdDrFyQD5zPWig6PksEQEo8XxWdV0+XVi 17 | ZQqh3ixyVqU1QDxp3/lUCfP438c3aITz+dMir41LFsSSWZzi 18 | -----END CERTIFICATE----- 19 | -------------------------------------------------------------------------------- /sitedata/cert/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEA7WZqBhZuGLP1IRoYp5iaNrZSEsfWqYJITOn/tJJD6e83Gk2N 3 | u3dZke83Ewqi4dCKBepQKPs/BzyghOXc4f/SY0lp3WspTELSyHdb+Zqk1E8UJXXz 4 | iJFf6d7BYYL0khGZPKXvmNmbkfg3mpc716Bw3nC6Ysuc1nE3XV2BIkD23OGy0QEW 5 | UbGmiuB6avr7uPX+W2GWMj8YjTkvvUBae61vWxrxcChQlfa6sJEM+J7CbIFHpApn 6 | JY2FGnJp7u+l0MMWDUgsZtrzD0+77hIzj2WMBakOwCDn0b0gE1DvgDFGGahEFzZf 7 | 6aLUJRtzO4edH0hpcacF4KvY0xv4dLz+TWNVyQIDAQABAoIBAQDLajSwsKl3m0MQ 8 | MQctG/IPrVtX4knKBusilGJY+/cbTLDfZdJq7lIeXGXJeBSm/wQ1G1fCNb4E2msE 9 | VN1V/Njt4CrI4ZiKUru/r55smphfnr65dn7M5xvTDd6PSiF7w36U2+4X/2VwxsoG 10 | OU26biwoPVlHbAYgLPRumL8cdaPRECB2pwXsZaRQ58MdLLMUxFejRKbnAsyDabfN 11 | lTO8fAG0bFiqX5UFag6nevc6CQ4iGrLJRqbHL0ayU1EvpjlwUBV0Kb6aD+idVXUK 12 | Abgxkm4PPnQEWRgAOpBrzIufjWH5ZtOADiijBNnj9dafBvNQtwUa35ayQOIoBkXY 13 | H0r4F6CtAoGBAP5AaNca1Zwpu82Y2gAwnqNoGaO4PTR6jGVksLev0nnJbwhT2tM3 14 | 6I0FCoo9xKuL5CZnNeUC9pA/5UotXqjXxThMwmOB4Qm1tn750FNx1r7D/QIDkw6q 15 | R81272t7wCd9RMTiL0jpOuqGxvfY85S9epl/bXWvIyDGAYKb0/ZxplEbAoGBAO8I 16 | Vrme+cK/uqaW1noL65VJYJ5huoCKGwop0c9msSGdQoGEdhWJNERWqjdzQ30Azeex 17 | HmpgFnizbXAfoF1ZFZ5iKdbTuS2KIaB8CbzCs9NYElyob4DTYdOF86RSFTv+UBTV 18 | F/j3oxhwNbK/dPQqTbAP9p+nNBQFY002e9nwtMbrAoGAIq7Onmk+smrO/6DWPChl 19 | u4y70qWTU9FTzZEKukP1xj9AMjaeJyn9Qx9o7Kq4ZV5T5Rk3NOJOmCbfNFs0CnxE 20 | nwV5jvFsrVJobrHNRVTGIofBv8CVEu8PlGuhBVyAPeLMur7QDHYkX1G7WpvxvlyK 21 | mN3VJLSbaiEYm6R+KaQfN/kCgYEAyXGOnQpkVIL+SyytfdeT55EaUv/rjC5XkV4j 22 | CpXxy3Fbvgki9w1VNg6PjwGdq7hEzvDOwDlQVtJn9WlB3cmY1YzG09xEoCNcKYK/ 23 | NgwkPoVnnBz7M2dxdzDZXu8qJBAz7wqTFGemVI8kQgrmBmusYydg4bWoxwKvaD/1 24 | antX7pcCgYEA7PJkca/l6DXDd2AX7k5vreuXJ45svmKhNayvdQNmp93Z7yeKGuFD 25 | AYgIraI3Xxb9Fj6z9OjZeUBReiDjVxAti6Z93JHohs2kH1E5kb0V4mJhJE73OJyL 26 | jbRAknUf6Bs4qbhP43wlgo5Fypq8zce701QiDlaZfnshNHYYBwiQEjw= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /sitedata/client/include/article.tpl.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | 5 | 6 | (未读) 7 |

8 |

9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | by {{data.article.item_author}} 17 | 18 | 19 | () 20 | 21 | 22 | () 23 | 24 |

25 |

原文地址:{{data.article.item_url}}

26 |
27 |
28 |
29 | 30 | 46 | 47 |
48 |

49 | 50 | 51 | 52 | 53 |

54 |
55 | -------------------------------------------------------------------------------- /sitedata/client/include/article_list.tpl.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | 5 | 6 |

7 | 8 |
    9 |
  • 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | () 22 | 23 | 24 | () 25 | 26 |
  • 27 |
28 | 29 |

30 | 31 | 32 | 33 | 34 |

35 | 36 |

37 | 38 | 上一页 39 | 上一页 40 | 41 | 第{{data.page}}页/共{{data.pagenum}}页/共{{data.Number}}条 42 | 43 | 下一页 44 | 下一页 45 | 46 |

47 |
48 | -------------------------------------------------------------------------------- /sitedata/client/include/feed_info.tpl.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |

5 |

保存:{{data.Amounts}}篇
已读:{{data.Read}}篇
未读:{{data.Unread}}篇
加星:{{data.Starred}}篇

6 |
7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 47 | 48 | 49 | 50 | 51 | 55 | 56 | 57 | 58 | 59 | 63 | 64 | 65 | 66 | 67 | 71 | 72 | 73 | 74 | 75 | 79 | 80 | 81 | 82 | 83 | 87 | 88 | 89 | 90 | 91 | 95 | 96 | 97 | 103 | 104 | 114 | 115 |
ID{{data.feed_id}}
网站地址{{data.feed_url}}
类型{{data.feed_type}}
上次获取时间{{data.feed_last_fetch | date:"yyyy-MM-dd HH:mm Z"}}
上次获取失败时间{{data.feed_last_failed | date:"yyyy-MM-dd HH:mm Z"}}
上次获取失败描述{{data.feed_last_error}}
feed地址 44 | 45 | 46 |
标签 52 | 53 | 54 |
更新周期(分钟) 60 | 61 | 62 |
最大已读保留数 68 | 69 | 70 |
最大未读保留数 76 | 77 | 78 |
别名 84 | 85 | 86 |
备注 92 | 93 | 94 |
116 | 117 |
118 | 119 | 120 |
121 | 122 |
123 | 124 |
125 | -------------------------------------------------------------------------------- /sitedata/client/include/feed_list.tpl.html: -------------------------------------------------------------------------------- 1 |
2 | feed地址: 3 | 4 | 5 |
6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 26 | 27 | 28 | 33 | 38 | 44 | 45 |
ID feed名称或别名 文章数 未读 加星 上次获取时间 上次失败时间 操作
{{feed.feed_id}} 22 | 23 | 24 | {{feed.amount}}{{feed.unread}}{{feed.starred}} 29 | 30 | {{feed.feed_last_fetch | date:"yyyy-MM-dd HH:mm"}} 31 | 32 | 34 | 35 | {{feed.feed_last_failed | date:"yyyy-MM-dd HH:mm"}} 36 | 37 | 39 | 40 | 41 | 42 | 43 |
46 | 47 |

由于你的屏幕宽度过小,上表中的部分字段已经被隐藏。

48 |
49 | -------------------------------------------------------------------------------- /sitedata/client/include/login.css: -------------------------------------------------------------------------------- 1 | #container { 2 | width: 95%; 3 | margin:10px auto; 4 | } 5 | 6 | #error { 7 | width:100%; 8 | left:0px; 9 | right:0px; 10 | display: none; 11 | cursor: pointer; 12 | } 13 | 14 | #error > div { 15 | margin: 0 auto; 16 | border: 1px rgb(249,145,145) solid; 17 | background-color: rgb(255,228,228); 18 | padding: 10px; 19 | border-radius: 4px; 20 | opacity: 0.9; 21 | } 22 | 23 | button { 24 | font-size:0.9em; 25 | } 26 | 27 | h1 { 28 | font-size: 2em; 29 | } 30 | -------------------------------------------------------------------------------- /sitedata/client/include/login.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | 3 | // check api token 4 | var checkToken = function(token) { 5 | var result = {}; 6 | result.ok = false; 7 | result.error = ""; 8 | 9 | $.ajax({ 10 | url: "/api/checktoken", 11 | headers: { "X-QReader-Token": token }, 12 | async: false, 13 | success: function(data) { 14 | if (data.success) { 15 | result.ok = true; 16 | } else { 17 | result.error = "密码错误,或系统时间与服务器时间时差较大。"; 18 | } 19 | }, 20 | error: function(data) { 21 | result.error = "错误:" + data; 22 | } 23 | }); 24 | 25 | return result; 26 | }; 27 | 28 | $("#container form").submit(function() { 29 | var password = $(this).find("input[name='password']")[0].value; 30 | var authToken = QReader.AuthToken(password); 31 | 32 | var result = checkToken(QReader.ApiToken(authToken)); 33 | if (result.ok == true) { 34 | localStorage.authToken = authToken; 35 | window.location.href = "/"; 36 | } else { 37 | $("#error div").text(result.error); 38 | $("#error").css("display", "block"); 39 | } 40 | 41 | return false; 42 | }); 43 | 44 | // check token after page load 45 | if (checkToken(QReader.ApiToken).ok == true) { 46 | window.location.href = "/"; 47 | } 48 | 49 | }); 50 | 51 | -------------------------------------------------------------------------------- /sitedata/client/include/qreader.auth.js: -------------------------------------------------------------------------------- 1 | var QReader = QReader || {}; 2 | 3 | 4 | QReader.salt = "34682084954d47239577b53caad5baf4"; 5 | 6 | 7 | QReader.Hash = function(str) { 8 | var h = forge.md.sha1.create(); 9 | h.update(str); 10 | return h.digest().toHex(); 11 | }; 12 | 13 | 14 | // Auth token will be saved in localStorage 15 | QReader.AuthToken = function(password) { 16 | return this.Hash(password + this.salt); 17 | }; 18 | 19 | 20 | // Auth token will be encoded to api token and send to api server. 21 | // If no auth token found, this function will return empty string. 22 | QReader.ApiToken = function(authToken) { 23 | 24 | var auth = ""; 25 | if (arguments.length > 0) { 26 | auth = authToken; 27 | } else { 28 | auth = localStorage.authToken || ""; 29 | } 30 | if (auth == "") { 31 | return ""; 32 | } 33 | 34 | var d = new Date(); 35 | var month = d.getUTCMonth() + 1; 36 | if (month < 10) { 37 | month = "0" + String(month); 38 | } else { 39 | month = String(month); 40 | } 41 | var current = String(d.getUTCFullYear()) + month; 42 | 43 | var ts = NAMESPACE.CurrentTimeSlot(); 44 | 45 | return this.Hash(this.Hash(current + auth) + ts + this.salt); 46 | }; 47 | 48 | -------------------------------------------------------------------------------- /sitedata/client/include/settings.tpl.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 |
系统状态
状态{{status}}
Api token{{token}}
版本信息
版本{{data.Version.Version}}
分支{{data.Version.Branch}}
版本号{{data.Version.CommitId}}
编译时间{{data.Version.BuildTime}}
数据统计
订阅feed{{data.Summary.feed}}个
保存文章{{data.Summary.amounts}}篇
未读{{data.Summary.unread}}篇
加星{{data.Summary.starred}}篇
总计抓取{{data.Summary.grabbed}}篇
数据库文件大小{{data.Summary.dbsize | ReadableFilesize}}
系统配置 (注*)
系统数据目录{{data.SystemInfo.PathRoot}}
服务器IP{{data.SystemInfo.IP}}
http服务端口{{data.SystemInfo.Port}}
使用tls加密
使用代理获取feed{{data.SystemInfo.UseProxy}}
Socks5代理服务器{{data.SystemInfo.ProxyAddr}}
是否开启debug
118 | 119 | 120 |

注*:如需要修改系统配置,请编辑配置文件,重启 QReader 后生效。

121 | 122 |

其他配置

123 |
124 |

125 | 每页条目数量: 126 | 127 | 128 |

129 |
130 |

131 | 132 | 133 |
134 | -------------------------------------------------------------------------------- /sitedata/client/include/style.css: -------------------------------------------------------------------------------- 1 | #container { 2 | width: 95%; 3 | margin:10px auto; 4 | } 5 | 6 | #container > header > nav { 7 | font-size: 0.9em; 8 | padding:10px 0; 9 | border-bottom: 1px #f0f0f0 solid; 10 | } 11 | 12 | #container > header > nav ul { 13 | padding: 0; 14 | margin: 0; 15 | } 16 | 17 | #container > header > nav li { 18 | display: inline; 19 | list-style-type: none; 20 | line-height:1.8em; 21 | } 22 | 23 | #container > header > nav span { 24 | padding-right: 0.3em; 25 | } 26 | 27 | #container > header > nav a { 28 | text-decoration: none; 29 | cursor: pointer; 30 | color: #336699; 31 | padding:12px 6px; 32 | white-space: nowrap; 33 | } 34 | 35 | #container > header > nav a:hover { 36 | color:white; 37 | background-color: #F5219A; 38 | } 39 | 40 | #container > header > h1 > a { 41 | text-decoration: none; 42 | } 43 | 44 | #container > header > h1 > a:hover { 45 | color: rgb(245, 33, 154); 46 | } 47 | 48 | #container article > header > h1 { 49 | font-size: 2em; 50 | } 51 | 52 | #container article > div.content { 53 | font-size: 1em; 54 | line-height: 1.7em; 55 | } 56 | 57 | #container article > div.content img { 58 | max-width: 95vw; 59 | } 60 | 61 | #container div.article_list ul { 62 | list-style: none; 63 | padding: 0px; 64 | } 65 | 66 | #container div.article_list li { 67 | font-size: 1em; 68 | line-height: 1.4em; 69 | } 70 | 71 | #related_articles { 72 | margin:1.5em 0; 73 | } 74 | 75 | #related_articles h1 { 76 | font-size: 1em; 77 | } 78 | 79 | #related_articles li { 80 | font-size: 0.9em; 81 | margin:0.6em 0; 82 | } 83 | 84 | #container > footer { 85 | text-align:center; 86 | padding:0.5em; 87 | margin-top:1em; 88 | } 89 | 90 | #container > footer a { 91 | color: #888888; 92 | font-size: 0.8em; 93 | text-decoration: none; 94 | } 95 | 96 | #container > footer a:hover { 97 | color: #F5219A; 98 | } 99 | 100 | #error { 101 | position: fixed; 102 | z-index:200; 103 | width:100%; 104 | left:0px; 105 | right:0px; 106 | display: none; 107 | cursor: pointer; 108 | } 109 | 110 | #error > div { 111 | width: calc(100% - 60px); 112 | margin: 0 auto; 113 | border: 1px rgb(249,145,145) solid; 114 | background-color: rgb(255,228,228); 115 | padding: 10px; 116 | border-radius: 4px; 117 | opacity: 0.9; 118 | } 119 | 120 | #view td, #view th { 121 | padding: 0.4em 0.5em; 122 | border:1px #dddddd solid; 123 | font-size: 0.9em; 124 | } 125 | #view tr:hover { 126 | background-color: #f8f8f8; 127 | } 128 | 129 | #view > div h1 { 130 | font-size:1.5em; 131 | line-height:1.5em; 132 | } 133 | 134 | #view a { 135 | text-decoration:none; 136 | } 137 | 138 | #view a:hover { 139 | color:rgb(245, 33, 154); 140 | } 141 | 142 | #subscription { 143 | margin:1em 0px; 144 | } 145 | 146 | #subscription div.result { 147 | margin:1em 0px; 148 | font-size: 0.9em; 149 | } 150 | 151 | #feedlist table { 152 | width: 100%; 153 | } 154 | 155 | #feedlist table th { 156 | cursor: pointer; 157 | } 158 | 159 | #feedlist table td:nth-child(1) { 160 | width: 2.3em; 161 | text-align: right; 162 | } 163 | 164 | #feedlist table td:nth-child(3) { 165 | width: 5em; 166 | text-align: right; 167 | } 168 | 169 | #feedlist table td:nth-child(4), #feedlist table td:nth-child(5) { 170 | width: 4em; 171 | text-align: right; 172 | } 173 | 174 | #feedlist table td:nth-child(6), #feedlist table td:nth-child(7) { 175 | width: 9em; 176 | text-align: center; 177 | } 178 | 179 | #feedlist table td:nth-child(8) { 180 | width: 10em; 181 | text-align: center; 182 | } 183 | 184 | #feedlist .notice { 185 | display: none; 186 | } 187 | 188 | @media screen and (max-width: 1000px) { 189 | /* hide last failed column */ 190 | #feedlist table tr > *:nth-child(7) { 191 | display: none; 192 | } 193 | 194 | #feedlist .notice { 195 | display: block; 196 | } 197 | } 198 | 199 | @media screen and (max-width: 850px) { 200 | /* hide starred column */ 201 | #feedlist table tr > *:nth-child(5) { 202 | display: none; 203 | } 204 | } 205 | 206 | @media screen and (max-width: 750px) { 207 | /* hide amount column */ 208 | #feedlist table tr > *:nth-child(3) { 209 | display: none; 210 | } 211 | } 212 | 213 | @media screen and (max-width: 750px) { 214 | /* hide lastfetch column */ 215 | #feedlist table tr > *:nth-child(6) { 216 | display: none; 217 | } 218 | 219 | #container > header > nav > ul > li:nth-child(1) { 220 | display: none; 221 | } 222 | 223 | #container > header > nav a { 224 | padding:5px 2px; 225 | } 226 | 227 | #container > header > nav li { 228 | line-height: 1.5em; 229 | padding:12px 6px; 230 | } 231 | 232 | } 233 | 234 | @media screen and (max-width: 550px) { 235 | /* hide id column */ 236 | #feedlist table tr > *:nth-child(1) { 237 | display: none; 238 | } 239 | } 240 | 241 | @media screen and (max-width: 500px) { 242 | /* hide unread column */ 243 | #feedlist table tr > *:nth-child(4) { 244 | display: none; 245 | } 246 | } 247 | 248 | #feedinfo { 249 | width: 100%; 250 | } 251 | 252 | #feedinfo > header p { 253 | line-height:1.5em; 254 | } 255 | 256 | #feedinfo > form { 257 | width: 100%; 258 | } 259 | 260 | #feedinfo > form table { 261 | margin: 0 auto 1em auto; 262 | width: 100%; 263 | } 264 | 265 | #feedinfo > form table th { 266 | text-align:left; 267 | } 268 | 269 | #feedinfo > form table th { 270 | width: 9em; 271 | } 272 | 273 | #feedinfo > form table td { 274 | width: calc(100% - 8em); 275 | } 276 | 277 | #feedinfo > form table input { 278 | border:none; 279 | background-color: transparent; 280 | width: calc(100% - 2em); 281 | margin:0; 282 | padding:0; 283 | } 284 | 285 | #feedinfo > form div.button { 286 | text-align: center; 287 | } 288 | 289 | #tagslist li { 290 | line-height:0.5em; 291 | } 292 | 293 | #gotop { 294 | position: fixed; 295 | right: 2%; 296 | bottom: 2%; 297 | z-index: 1000; 298 | border: 1px #bdbdbd solid; 299 | text-align: center; 300 | width: 30px; 301 | height: 30px; 302 | font-size: 22px; 303 | color: #222222; 304 | background-color: rgba(255,255,255,0.85); 305 | cursor: pointer; 306 | } 307 | 308 | #gotop:hover { 309 | background-color: rgba(15,15,15,0.9); 310 | color: #f0f0f0; 311 | border: 1px #f0f0f0 solid; 312 | } 313 | 314 | .ng-cloak { 315 | display: none !important; 316 | } 317 | 318 | button { 319 | font-size:0.9em; 320 | } 321 | 322 | .star { 323 | color:rgb(249,208,0); 324 | cursor: pointer; 325 | } 326 | 327 | .star-o { 328 | color:rgb(175,132,106); 329 | cursor: pointer; 330 | } 331 | 332 | h1 .star, h1 .star-o { 333 | font-size: 0.7em; 334 | } 335 | 336 | #loading { 337 | position: fixed; 338 | left:0; 339 | top:0; 340 | background-color: rgba(255,255,255,0.85); 341 | z-index: 2000; 342 | width:100%; 343 | height:100%; 344 | display: none; 345 | } 346 | #loading > div { 347 | font-size:1.5em; 348 | position: relative; 349 | top: 42%; 350 | text-align: center; 351 | } 352 | -------------------------------------------------------------------------------- /sitedata/client/include/tags_list.tpl.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
显示所有feed
4 | 5 |
6 |

无标签 ({{sum[key]}})

7 |

{{key}} ({{sum[key]}})

8 | 15 |
16 | 17 |
18 | -------------------------------------------------------------------------------- /sitedata/client/include/utils.js: -------------------------------------------------------------------------------- 1 | var NAMESPACE = NAMESPACE || {}; 2 | 3 | 4 | NAMESPACE.CurrentTimeSlot = function(size) { 5 | 6 | // If size if not available, set it to a default value. 7 | size = size || 5; 8 | 9 | var ErrSize = "Size of slot must between 1 and 30, and 60 could be divided exactly by it."; 10 | 11 | if (size < 1 || size > 30 || 60 % size != 0) { 12 | throw ErrSize; 13 | } 14 | 15 | // Convert a number to string, and pad it with leading zero, make sure the length of return value is 2. 16 | var padZero = function(num) { 17 | num = num.toString(); 18 | if (num.length < 2) { 19 | num = "0" + num; 20 | } 21 | return num; 22 | }; 23 | 24 | now = new Date(); 25 | 26 | var m = now.getMinutes(); 27 | if (now.getSeconds() > 0) { 28 | m++; 29 | } 30 | 31 | var slot = now.getUTCFullYear().toString(); 32 | slot += padZero(now.getUTCMonth() + 1); 33 | slot += padZero(now.getUTCDate()); 34 | slot += padZero(now.getUTCHours()); 35 | slot += padZero(Math.floor(m / size)); 36 | 37 | return slot; 38 | }; 39 | 40 | 41 | // Readable file size 42 | NAMESPACE.ReadableFilesize = function(bytes, precision) { 43 | if (bytes <= 0 || isNaN(parseFloat(bytes)) || !isFinite(bytes)) { 44 | return '-'; 45 | } 46 | if (typeof precision === 'undefined') { 47 | precision = 2; 48 | } 49 | var units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; 50 | var number = Math.floor(Math.log(bytes) / Math.log(1024)); 51 | return (bytes / Math.pow(1024, Math.floor(number))).toFixed(precision) + ' ' + units[number]; 52 | }; 53 | 54 | -------------------------------------------------------------------------------- /sitedata/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | QReader 18 | 19 | 20 | 21 | 22 |
23 |
24 | 25 | 加载中 26 |
27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 | 48 | 49 |

50 |
51 | 52 |
QReader 并不不支持你使用的浏览器,建议选择 Google Chrome、Mozilla Firefox、Safari 或 Opera。
53 | 54 |
55 | 56 | 59 | 60 |
61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /sitedata/client/libs/angular-route-1.3.15.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | AngularJS v1.3.15 3 | (c) 2010-2014 Google, Inc. http://angularjs.org 4 | License: MIT 5 | */ 6 | (function(q,d,C){'use strict';function v(r,k,h){return{restrict:"ECA",terminal:!0,priority:400,transclude:"element",link:function(a,f,b,c,y){function z(){l&&(h.cancel(l),l=null);m&&(m.$destroy(),m=null);n&&(l=h.leave(n),l.then(function(){l=null}),n=null)}function x(){var b=r.current&&r.current.locals;if(d.isDefined(b&&b.$template)){var b=a.$new(),c=r.current;n=y(b,function(b){h.enter(b,null,n||f).then(function(){!d.isDefined(t)||t&&!a.$eval(t)||k()});z()});m=c.scope=b;m.$emit("$viewContentLoaded"); 7 | m.$eval(w)}else z()}var m,n,l,t=b.autoscroll,w=b.onload||"";a.$on("$routeChangeSuccess",x);x()}}}function A(d,k,h){return{restrict:"ECA",priority:-400,link:function(a,f){var b=h.current,c=b.locals;f.html(c.$template);var y=d(f.contents());b.controller&&(c.$scope=a,c=k(b.controller,c),b.controllerAs&&(a[b.controllerAs]=c),f.data("$ngControllerController",c),f.children().data("$ngControllerController",c));y(a)}}}q=d.module("ngRoute",["ng"]).provider("$route",function(){function r(a,f){return d.extend(Object.create(a), 8 | f)}function k(a,d){var b=d.caseInsensitiveMatch,c={originalPath:a,regexp:a},h=c.keys=[];a=a.replace(/([().])/g,"\\$1").replace(/(\/)?:(\w+)([\?\*])?/g,function(a,d,b,c){a="?"===c?c:null;c="*"===c?c:null;h.push({name:b,optional:!!a});d=d||"";return""+(a?"":d)+"(?:"+(a?d:"")+(c&&"(.+?)"||"([^/]+)")+(a||"")+")"+(a||"")}).replace(/([\/$\*])/g,"\\$1");c.regexp=new RegExp("^"+a+"$",b?"i":"");return c}var h={};this.when=function(a,f){var b=d.copy(f);d.isUndefined(b.reloadOnSearch)&&(b.reloadOnSearch=!0); 9 | d.isUndefined(b.caseInsensitiveMatch)&&(b.caseInsensitiveMatch=this.caseInsensitiveMatch);h[a]=d.extend(b,a&&k(a,b));if(a){var c="/"==a[a.length-1]?a.substr(0,a.length-1):a+"/";h[c]=d.extend({redirectTo:a},k(c,b))}return this};this.caseInsensitiveMatch=!1;this.otherwise=function(a){"string"===typeof a&&(a={redirectTo:a});this.when(null,a);return this};this.$get=["$rootScope","$location","$routeParams","$q","$injector","$templateRequest","$sce",function(a,f,b,c,k,q,x){function m(b){var e=s.current; 10 | (v=(p=l())&&e&&p.$$route===e.$$route&&d.equals(p.pathParams,e.pathParams)&&!p.reloadOnSearch&&!w)||!e&&!p||a.$broadcast("$routeChangeStart",p,e).defaultPrevented&&b&&b.preventDefault()}function n(){var u=s.current,e=p;if(v)u.params=e.params,d.copy(u.params,b),a.$broadcast("$routeUpdate",u);else if(e||u)w=!1,(s.current=e)&&e.redirectTo&&(d.isString(e.redirectTo)?f.path(t(e.redirectTo,e.params)).search(e.params).replace():f.url(e.redirectTo(e.pathParams,f.path(),f.search())).replace()),c.when(e).then(function(){if(e){var a= 11 | d.extend({},e.resolve),b,g;d.forEach(a,function(b,e){a[e]=d.isString(b)?k.get(b):k.invoke(b,null,null,e)});d.isDefined(b=e.template)?d.isFunction(b)&&(b=b(e.params)):d.isDefined(g=e.templateUrl)&&(d.isFunction(g)&&(g=g(e.params)),g=x.getTrustedResourceUrl(g),d.isDefined(g)&&(e.loadedTemplateUrl=g,b=q(g)));d.isDefined(b)&&(a.$template=b);return c.all(a)}}).then(function(c){e==s.current&&(e&&(e.locals=c,d.copy(e.params,b)),a.$broadcast("$routeChangeSuccess",e,u))},function(b){e==s.current&&a.$broadcast("$routeChangeError", 12 | e,u,b)})}function l(){var a,b;d.forEach(h,function(c,h){var g;if(g=!b){var k=f.path();g=c.keys;var m={};if(c.regexp)if(k=c.regexp.exec(k)){for(var l=1,n=k.length;l=c;d--)e.end&&e.end(f[d]);f.length=c}}"string"!==typeof a&&(a=null===a||"undefined"===typeof a?"":""+a);var b,k,f=[],m=a,l;for(f.last=function(){return f[f.length-1]};a;){l="";k=!0;if(f.last()&&w[f.last()])a=a.replace(new RegExp("([\\W\\w]*)<\\s*\\/\\s*"+f.last()+"[^>]*>","i"),function(a,b){b=b.replace(H,"$1").replace(I,"$1");e.chars&&e.chars(q(b));return""}),c("",f.last());else{if(0===a.indexOf("\x3c!--"))b=a.indexOf("--",4),0<=b&&a.lastIndexOf("--\x3e",b)===b&&(e.comment&& 8 | e.comment(a.substring(4,b)),a=a.substring(b+3),k=!1);else if(x.test(a)){if(b=a.match(x))a=a.replace(b[0],""),k=!1}else if(J.test(a)){if(b=a.match(y))a=a.substring(b[0].length),b[0].replace(y,c),k=!1}else K.test(a)&&((b=a.match(z))?(b[4]&&(a=a.substring(b[0].length),b[0].replace(z,d)),k=!1):(l+="<",a=a.substring(1)));k&&(b=a.indexOf("<"),l+=0>b?a:a.substring(0,b),a=0>b?"":a.substring(b),e.chars&&e.chars(q(l)))}if(a==m)throw L("badparse",a);m=a}c()}function q(a){if(!a)return"";A.innerHTML=a.replace(//g,">")}function r(a,e){var d=!1,c=h.bind(a,a.push);return{start:function(a,k,f){a=h.lowercase(a);!d&&w[a]&&(d=a);d||!0!==C[a]||(c("<"),c(a),h.forEach(k,function(d,f){var k=h.lowercase(f),g="img"===a&&"src"===k||"background"=== 10 | k;!0!==O[k]||!0===D[k]&&!e(d,g)||(c(" "),c(f),c('="'),c(B(d)),c('"'))}),c(f?"/>":">"))},end:function(a){a=h.lowercase(a);d||!0!==C[a]||(c(""));a==d&&(d=!1)},chars:function(a){d||c(B(a))}}}var L=h.$$minErr("$sanitize"),z=/^<((?:[a-zA-Z])[\w:-]*)((?:\s+[\w:-]+(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)\s*(>?)/,y=/^<\/\s*([\w:-]+)[^>]*>/,G=/([\w:-]+)(?:\s*=\s*(?:(?:"((?:[^"])*)")|(?:'((?:[^'])*)')|([^>\s]+)))?/g,K=/^]*?)>/i, 11 | I=/"\u201d\u2019]/,d=/^mailto:/;return function(c,b){function k(a){a&&g.push(E(a))}function f(a,c){g.push("');k(c);g.push("")}if(!c)return c;for(var m,l=c,g=[],n,p;m=l.match(e);)n=m[0],m[2]||m[4]||(n=(m[3]?"http://":"mailto:")+n),p=m.index,k(l.substr(0,p)),f(n,m[0].replace(d,"")),l=l.substring(p+m[0].length);k(l);return a(g.join(""))}}])})(window,window.angular); 16 | //# sourceMappingURL=angular-sanitize.min.js.map 17 | -------------------------------------------------------------------------------- /sitedata/client/libs/font-awesome-4.3.0/css/font-awesome.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */@font-face{font-family:'FontAwesome';src:url('../fonts/fontawesome-webfont.eot?v=4.3.0');src:url('../fonts/fontawesome-webfont.eot?#iefix&v=4.3.0') format('embedded-opentype'),url('../fonts/fontawesome-webfont.woff2?v=4.3.0') format('woff2'),url('../fonts/fontawesome-webfont.woff?v=4.3.0') format('woff'),url('../fonts/fontawesome-webfont.ttf?v=4.3.0') format('truetype'),url('../fonts/fontawesome-webfont.svg?v=4.3.0#fontawesomeregular') format('svg');font-weight:normal;font-style:normal}.fa{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;transform:translate(0, 0)}.fa-lg{font-size:1.33333333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571429em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14285714em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14285714em;width:2.14285714em;top:.14285714em;text-align:center}.fa-li.fa-lg{left:-1.85714286em}.fa-border{padding:.2em .25em .15em;border:solid .08em #eee;border-radius:.1em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left{margin-right:.3em}.fa.pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=1);-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2);-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=3);-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);-webkit-transform:scale(-1, 1);-ms-transform:scale(-1, 1);transform:scale(-1, 1)}.fa-flip-vertical{filter:progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);-webkit-transform:scale(1, -1);-ms-transform:scale(1, -1);transform:scale(1, -1)}:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-flip-horizontal,:root .fa-flip-vertical{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:"\f000"}.fa-music:before{content:"\f001"}.fa-search:before{content:"\f002"}.fa-envelope-o:before{content:"\f003"}.fa-heart:before{content:"\f004"}.fa-star:before{content:"\f005"}.fa-star-o:before{content:"\f006"}.fa-user:before{content:"\f007"}.fa-film:before{content:"\f008"}.fa-th-large:before{content:"\f009"}.fa-th:before{content:"\f00a"}.fa-th-list:before{content:"\f00b"}.fa-check:before{content:"\f00c"}.fa-remove:before,.fa-close:before,.fa-times:before{content:"\f00d"}.fa-search-plus:before{content:"\f00e"}.fa-search-minus:before{content:"\f010"}.fa-power-off:before{content:"\f011"}.fa-signal:before{content:"\f012"}.fa-gear:before,.fa-cog:before{content:"\f013"}.fa-trash-o:before{content:"\f014"}.fa-home:before{content:"\f015"}.fa-file-o:before{content:"\f016"}.fa-clock-o:before{content:"\f017"}.fa-road:before{content:"\f018"}.fa-download:before{content:"\f019"}.fa-arrow-circle-o-down:before{content:"\f01a"}.fa-arrow-circle-o-up:before{content:"\f01b"}.fa-inbox:before{content:"\f01c"}.fa-play-circle-o:before{content:"\f01d"}.fa-rotate-right:before,.fa-repeat:before{content:"\f01e"}.fa-refresh:before{content:"\f021"}.fa-list-alt:before{content:"\f022"}.fa-lock:before{content:"\f023"}.fa-flag:before{content:"\f024"}.fa-headphones:before{content:"\f025"}.fa-volume-off:before{content:"\f026"}.fa-volume-down:before{content:"\f027"}.fa-volume-up:before{content:"\f028"}.fa-qrcode:before{content:"\f029"}.fa-barcode:before{content:"\f02a"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-book:before{content:"\f02d"}.fa-bookmark:before{content:"\f02e"}.fa-print:before{content:"\f02f"}.fa-camera:before{content:"\f030"}.fa-font:before{content:"\f031"}.fa-bold:before{content:"\f032"}.fa-italic:before{content:"\f033"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-align-left:before{content:"\f036"}.fa-align-center:before{content:"\f037"}.fa-align-right:before{content:"\f038"}.fa-align-justify:before{content:"\f039"}.fa-list:before{content:"\f03a"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-indent:before{content:"\f03c"}.fa-video-camera:before{content:"\f03d"}.fa-photo:before,.fa-image:before,.fa-picture-o:before{content:"\f03e"}.fa-pencil:before{content:"\f040"}.fa-map-marker:before{content:"\f041"}.fa-adjust:before{content:"\f042"}.fa-tint:before{content:"\f043"}.fa-edit:before,.fa-pencil-square-o:before{content:"\f044"}.fa-share-square-o:before{content:"\f045"}.fa-check-square-o:before{content:"\f046"}.fa-arrows:before{content:"\f047"}.fa-step-backward:before{content:"\f048"}.fa-fast-backward:before{content:"\f049"}.fa-backward:before{content:"\f04a"}.fa-play:before{content:"\f04b"}.fa-pause:before{content:"\f04c"}.fa-stop:before{content:"\f04d"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before{content:"\f050"}.fa-step-forward:before{content:"\f051"}.fa-eject:before{content:"\f052"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-plus-circle:before{content:"\f055"}.fa-minus-circle:before{content:"\f056"}.fa-times-circle:before{content:"\f057"}.fa-check-circle:before{content:"\f058"}.fa-question-circle:before{content:"\f059"}.fa-info-circle:before{content:"\f05a"}.fa-crosshairs:before{content:"\f05b"}.fa-times-circle-o:before{content:"\f05c"}.fa-check-circle-o:before{content:"\f05d"}.fa-ban:before{content:"\f05e"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-down:before{content:"\f063"}.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-expand:before{content:"\f065"}.fa-compress:before{content:"\f066"}.fa-plus:before{content:"\f067"}.fa-minus:before{content:"\f068"}.fa-asterisk:before{content:"\f069"}.fa-exclamation-circle:before{content:"\f06a"}.fa-gift:before{content:"\f06b"}.fa-leaf:before{content:"\f06c"}.fa-fire:before{content:"\f06d"}.fa-eye:before{content:"\f06e"}.fa-eye-slash:before{content:"\f070"}.fa-warning:before,.fa-exclamation-triangle:before{content:"\f071"}.fa-plane:before{content:"\f072"}.fa-calendar:before{content:"\f073"}.fa-random:before{content:"\f074"}.fa-comment:before{content:"\f075"}.fa-magnet:before{content:"\f076"}.fa-chevron-up:before{content:"\f077"}.fa-chevron-down:before{content:"\f078"}.fa-retweet:before{content:"\f079"}.fa-shopping-cart:before{content:"\f07a"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-arrows-v:before{content:"\f07d"}.fa-arrows-h:before{content:"\f07e"}.fa-bar-chart-o:before,.fa-bar-chart:before{content:"\f080"}.fa-twitter-square:before{content:"\f081"}.fa-facebook-square:before{content:"\f082"}.fa-camera-retro:before{content:"\f083"}.fa-key:before{content:"\f084"}.fa-gears:before,.fa-cogs:before{content:"\f085"}.fa-comments:before{content:"\f086"}.fa-thumbs-o-up:before{content:"\f087"}.fa-thumbs-o-down:before{content:"\f088"}.fa-star-half:before{content:"\f089"}.fa-heart-o:before{content:"\f08a"}.fa-sign-out:before{content:"\f08b"}.fa-linkedin-square:before{content:"\f08c"}.fa-thumb-tack:before{content:"\f08d"}.fa-external-link:before{content:"\f08e"}.fa-sign-in:before{content:"\f090"}.fa-trophy:before{content:"\f091"}.fa-github-square:before{content:"\f092"}.fa-upload:before{content:"\f093"}.fa-lemon-o:before{content:"\f094"}.fa-phone:before{content:"\f095"}.fa-square-o:before{content:"\f096"}.fa-bookmark-o:before{content:"\f097"}.fa-phone-square:before{content:"\f098"}.fa-twitter:before{content:"\f099"}.fa-facebook-f:before,.fa-facebook:before{content:"\f09a"}.fa-github:before{content:"\f09b"}.fa-unlock:before{content:"\f09c"}.fa-credit-card:before{content:"\f09d"}.fa-rss:before{content:"\f09e"}.fa-hdd-o:before{content:"\f0a0"}.fa-bullhorn:before{content:"\f0a1"}.fa-bell:before{content:"\f0f3"}.fa-certificate:before{content:"\f0a3"}.fa-hand-o-right:before{content:"\f0a4"}.fa-hand-o-left:before{content:"\f0a5"}.fa-hand-o-up:before{content:"\f0a6"}.fa-hand-o-down:before{content:"\f0a7"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-globe:before{content:"\f0ac"}.fa-wrench:before{content:"\f0ad"}.fa-tasks:before{content:"\f0ae"}.fa-filter:before{content:"\f0b0"}.fa-briefcase:before{content:"\f0b1"}.fa-arrows-alt:before{content:"\f0b2"}.fa-group:before,.fa-users:before{content:"\f0c0"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-cloud:before{content:"\f0c2"}.fa-flask:before{content:"\f0c3"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-copy:before,.fa-files-o:before{content:"\f0c5"}.fa-paperclip:before{content:"\f0c6"}.fa-save:before,.fa-floppy-o:before{content:"\f0c7"}.fa-square:before{content:"\f0c8"}.fa-navicon:before,.fa-reorder:before,.fa-bars:before{content:"\f0c9"}.fa-list-ul:before{content:"\f0ca"}.fa-list-ol:before{content:"\f0cb"}.fa-strikethrough:before{content:"\f0cc"}.fa-underline:before{content:"\f0cd"}.fa-table:before{content:"\f0ce"}.fa-magic:before{content:"\f0d0"}.fa-truck:before{content:"\f0d1"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-square:before{content:"\f0d3"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-plus:before{content:"\f0d5"}.fa-money:before{content:"\f0d6"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-up:before{content:"\f0d8"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-columns:before{content:"\f0db"}.fa-unsorted:before,.fa-sort:before{content:"\f0dc"}.fa-sort-down:before,.fa-sort-desc:before{content:"\f0dd"}.fa-sort-up:before,.fa-sort-asc:before{content:"\f0de"}.fa-envelope:before{content:"\f0e0"}.fa-linkedin:before{content:"\f0e1"}.fa-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-legal:before,.fa-gavel:before{content:"\f0e3"}.fa-dashboard:before,.fa-tachometer:before{content:"\f0e4"}.fa-comment-o:before{content:"\f0e5"}.fa-comments-o:before{content:"\f0e6"}.fa-flash:before,.fa-bolt:before{content:"\f0e7"}.fa-sitemap:before{content:"\f0e8"}.fa-umbrella:before{content:"\f0e9"}.fa-paste:before,.fa-clipboard:before{content:"\f0ea"}.fa-lightbulb-o:before{content:"\f0eb"}.fa-exchange:before{content:"\f0ec"}.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-upload:before{content:"\f0ee"}.fa-user-md:before{content:"\f0f0"}.fa-stethoscope:before{content:"\f0f1"}.fa-suitcase:before{content:"\f0f2"}.fa-bell-o:before{content:"\f0a2"}.fa-coffee:before{content:"\f0f4"}.fa-cutlery:before{content:"\f0f5"}.fa-file-text-o:before{content:"\f0f6"}.fa-building-o:before{content:"\f0f7"}.fa-hospital-o:before{content:"\f0f8"}.fa-ambulance:before{content:"\f0f9"}.fa-medkit:before{content:"\f0fa"}.fa-fighter-jet:before{content:"\f0fb"}.fa-beer:before{content:"\f0fc"}.fa-h-square:before{content:"\f0fd"}.fa-plus-square:before{content:"\f0fe"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-down:before{content:"\f107"}.fa-desktop:before{content:"\f108"}.fa-laptop:before{content:"\f109"}.fa-tablet:before{content:"\f10a"}.fa-mobile-phone:before,.fa-mobile:before{content:"\f10b"}.fa-circle-o:before{content:"\f10c"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-spinner:before{content:"\f110"}.fa-circle:before{content:"\f111"}.fa-mail-reply:before,.fa-reply:before{content:"\f112"}.fa-github-alt:before{content:"\f113"}.fa-folder-o:before{content:"\f114"}.fa-folder-open-o:before{content:"\f115"}.fa-smile-o:before{content:"\f118"}.fa-frown-o:before{content:"\f119"}.fa-meh-o:before{content:"\f11a"}.fa-gamepad:before{content:"\f11b"}.fa-keyboard-o:before{content:"\f11c"}.fa-flag-o:before{content:"\f11d"}.fa-flag-checkered:before{content:"\f11e"}.fa-terminal:before{content:"\f120"}.fa-code:before{content:"\f121"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:"\f123"}.fa-location-arrow:before{content:"\f124"}.fa-crop:before{content:"\f125"}.fa-code-fork:before{content:"\f126"}.fa-unlink:before,.fa-chain-broken:before{content:"\f127"}.fa-question:before{content:"\f128"}.fa-info:before{content:"\f129"}.fa-exclamation:before{content:"\f12a"}.fa-superscript:before{content:"\f12b"}.fa-subscript:before{content:"\f12c"}.fa-eraser:before{content:"\f12d"}.fa-puzzle-piece:before{content:"\f12e"}.fa-microphone:before{content:"\f130"}.fa-microphone-slash:before{content:"\f131"}.fa-shield:before{content:"\f132"}.fa-calendar-o:before{content:"\f133"}.fa-fire-extinguisher:before{content:"\f134"}.fa-rocket:before{content:"\f135"}.fa-maxcdn:before{content:"\f136"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-html5:before{content:"\f13b"}.fa-css3:before{content:"\f13c"}.fa-anchor:before{content:"\f13d"}.fa-unlock-alt:before{content:"\f13e"}.fa-bullseye:before{content:"\f140"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-rss-square:before{content:"\f143"}.fa-play-circle:before{content:"\f144"}.fa-ticket:before{content:"\f145"}.fa-minus-square:before{content:"\f146"}.fa-minus-square-o:before{content:"\f147"}.fa-level-up:before{content:"\f148"}.fa-level-down:before{content:"\f149"}.fa-check-square:before{content:"\f14a"}.fa-pencil-square:before{content:"\f14b"}.fa-external-link-square:before{content:"\f14c"}.fa-share-square:before{content:"\f14d"}.fa-compass:before{content:"\f14e"}.fa-toggle-down:before,.fa-caret-square-o-down:before{content:"\f150"}.fa-toggle-up:before,.fa-caret-square-o-up:before{content:"\f151"}.fa-toggle-right:before,.fa-caret-square-o-right:before{content:"\f152"}.fa-euro:before,.fa-eur:before{content:"\f153"}.fa-gbp:before{content:"\f154"}.fa-dollar:before,.fa-usd:before{content:"\f155"}.fa-rupee:before,.fa-inr:before{content:"\f156"}.fa-cny:before,.fa-rmb:before,.fa-yen:before,.fa-jpy:before{content:"\f157"}.fa-ruble:before,.fa-rouble:before,.fa-rub:before{content:"\f158"}.fa-won:before,.fa-krw:before{content:"\f159"}.fa-bitcoin:before,.fa-btc:before{content:"\f15a"}.fa-file:before{content:"\f15b"}.fa-file-text:before{content:"\f15c"}.fa-sort-alpha-asc:before{content:"\f15d"}.fa-sort-alpha-desc:before{content:"\f15e"}.fa-sort-amount-asc:before{content:"\f160"}.fa-sort-amount-desc:before{content:"\f161"}.fa-sort-numeric-asc:before{content:"\f162"}.fa-sort-numeric-desc:before{content:"\f163"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbs-down:before{content:"\f165"}.fa-youtube-square:before{content:"\f166"}.fa-youtube:before{content:"\f167"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-youtube-play:before{content:"\f16a"}.fa-dropbox:before{content:"\f16b"}.fa-stack-overflow:before{content:"\f16c"}.fa-instagram:before{content:"\f16d"}.fa-flickr:before{content:"\f16e"}.fa-adn:before{content:"\f170"}.fa-bitbucket:before{content:"\f171"}.fa-bitbucket-square:before{content:"\f172"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-long-arrow-down:before{content:"\f175"}.fa-long-arrow-up:before{content:"\f176"}.fa-long-arrow-left:before{content:"\f177"}.fa-long-arrow-right:before{content:"\f178"}.fa-apple:before{content:"\f179"}.fa-windows:before{content:"\f17a"}.fa-android:before{content:"\f17b"}.fa-linux:before{content:"\f17c"}.fa-dribbble:before{content:"\f17d"}.fa-skype:before{content:"\f17e"}.fa-foursquare:before{content:"\f180"}.fa-trello:before{content:"\f181"}.fa-female:before{content:"\f182"}.fa-male:before{content:"\f183"}.fa-gittip:before,.fa-gratipay:before{content:"\f184"}.fa-sun-o:before{content:"\f185"}.fa-moon-o:before{content:"\f186"}.fa-archive:before{content:"\f187"}.fa-bug:before{content:"\f188"}.fa-vk:before{content:"\f189"}.fa-weibo:before{content:"\f18a"}.fa-renren:before{content:"\f18b"}.fa-pagelines:before{content:"\f18c"}.fa-stack-exchange:before{content:"\f18d"}.fa-arrow-circle-o-right:before{content:"\f18e"}.fa-arrow-circle-o-left:before{content:"\f190"}.fa-toggle-left:before,.fa-caret-square-o-left:before{content:"\f191"}.fa-dot-circle-o:before{content:"\f192"}.fa-wheelchair:before{content:"\f193"}.fa-vimeo-square:before{content:"\f194"}.fa-turkish-lira:before,.fa-try:before{content:"\f195"}.fa-plus-square-o:before{content:"\f196"}.fa-space-shuttle:before{content:"\f197"}.fa-slack:before{content:"\f198"}.fa-envelope-square:before{content:"\f199"}.fa-wordpress:before{content:"\f19a"}.fa-openid:before{content:"\f19b"}.fa-institution:before,.fa-bank:before,.fa-university:before{content:"\f19c"}.fa-mortar-board:before,.fa-graduation-cap:before{content:"\f19d"}.fa-yahoo:before{content:"\f19e"}.fa-google:before{content:"\f1a0"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-square:before{content:"\f1a2"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-stumbleupon:before{content:"\f1a4"}.fa-delicious:before{content:"\f1a5"}.fa-digg:before{content:"\f1a6"}.fa-pied-piper:before{content:"\f1a7"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-drupal:before{content:"\f1a9"}.fa-joomla:before{content:"\f1aa"}.fa-language:before{content:"\f1ab"}.fa-fax:before{content:"\f1ac"}.fa-building:before{content:"\f1ad"}.fa-child:before{content:"\f1ae"}.fa-paw:before{content:"\f1b0"}.fa-spoon:before{content:"\f1b1"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-recycle:before{content:"\f1b8"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-tree:before{content:"\f1bb"}.fa-spotify:before{content:"\f1bc"}.fa-deviantart:before{content:"\f1bd"}.fa-soundcloud:before{content:"\f1be"}.fa-database:before{content:"\f1c0"}.fa-file-pdf-o:before{content:"\f1c1"}.fa-file-word-o:before{content:"\f1c2"}.fa-file-excel-o:before{content:"\f1c3"}.fa-file-powerpoint-o:before{content:"\f1c4"}.fa-file-photo-o:before,.fa-file-picture-o:before,.fa-file-image-o:before{content:"\f1c5"}.fa-file-zip-o:before,.fa-file-archive-o:before{content:"\f1c6"}.fa-file-sound-o:before,.fa-file-audio-o:before{content:"\f1c7"}.fa-file-movie-o:before,.fa-file-video-o:before{content:"\f1c8"}.fa-file-code-o:before{content:"\f1c9"}.fa-vine:before{content:"\f1ca"}.fa-codepen:before{content:"\f1cb"}.fa-jsfiddle:before{content:"\f1cc"}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-saver:before,.fa-support:before,.fa-life-ring:before{content:"\f1cd"}.fa-circle-o-notch:before{content:"\f1ce"}.fa-ra:before,.fa-rebel:before{content:"\f1d0"}.fa-ge:before,.fa-empire:before{content:"\f1d1"}.fa-git-square:before{content:"\f1d2"}.fa-git:before{content:"\f1d3"}.fa-hacker-news:before{content:"\f1d4"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-qq:before{content:"\f1d6"}.fa-wechat:before,.fa-weixin:before{content:"\f1d7"}.fa-send:before,.fa-paper-plane:before{content:"\f1d8"}.fa-send-o:before,.fa-paper-plane-o:before{content:"\f1d9"}.fa-history:before{content:"\f1da"}.fa-genderless:before,.fa-circle-thin:before{content:"\f1db"}.fa-header:before{content:"\f1dc"}.fa-paragraph:before{content:"\f1dd"}.fa-sliders:before{content:"\f1de"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-bomb:before{content:"\f1e2"}.fa-soccer-ball-o:before,.fa-futbol-o:before{content:"\f1e3"}.fa-tty:before{content:"\f1e4"}.fa-binoculars:before{content:"\f1e5"}.fa-plug:before{content:"\f1e6"}.fa-slideshare:before{content:"\f1e7"}.fa-twitch:before{content:"\f1e8"}.fa-yelp:before{content:"\f1e9"}.fa-newspaper-o:before{content:"\f1ea"}.fa-wifi:before{content:"\f1eb"}.fa-calculator:before{content:"\f1ec"}.fa-paypal:before{content:"\f1ed"}.fa-google-wallet:before{content:"\f1ee"}.fa-cc-visa:before{content:"\f1f0"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-bell-slash:before{content:"\f1f6"}.fa-bell-slash-o:before{content:"\f1f7"}.fa-trash:before{content:"\f1f8"}.fa-copyright:before{content:"\f1f9"}.fa-at:before{content:"\f1fa"}.fa-eyedropper:before{content:"\f1fb"}.fa-paint-brush:before{content:"\f1fc"}.fa-birthday-cake:before{content:"\f1fd"}.fa-area-chart:before{content:"\f1fe"}.fa-pie-chart:before{content:"\f200"}.fa-line-chart:before{content:"\f201"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-bicycle:before{content:"\f206"}.fa-bus:before{content:"\f207"}.fa-ioxhost:before{content:"\f208"}.fa-angellist:before{content:"\f209"}.fa-cc:before{content:"\f20a"}.fa-shekel:before,.fa-sheqel:before,.fa-ils:before{content:"\f20b"}.fa-meanpath:before{content:"\f20c"}.fa-buysellads:before{content:"\f20d"}.fa-connectdevelop:before{content:"\f20e"}.fa-dashcube:before{content:"\f210"}.fa-forumbee:before{content:"\f211"}.fa-leanpub:before{content:"\f212"}.fa-sellsy:before{content:"\f213"}.fa-shirtsinbulk:before{content:"\f214"}.fa-simplybuilt:before{content:"\f215"}.fa-skyatlas:before{content:"\f216"}.fa-cart-plus:before{content:"\f217"}.fa-cart-arrow-down:before{content:"\f218"}.fa-diamond:before{content:"\f219"}.fa-ship:before{content:"\f21a"}.fa-user-secret:before{content:"\f21b"}.fa-motorcycle:before{content:"\f21c"}.fa-street-view:before{content:"\f21d"}.fa-heartbeat:before{content:"\f21e"}.fa-venus:before{content:"\f221"}.fa-mars:before{content:"\f222"}.fa-mercury:before{content:"\f223"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-venus-double:before{content:"\f226"}.fa-mars-double:before{content:"\f227"}.fa-venus-mars:before{content:"\f228"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-neuter:before{content:"\f22c"}.fa-facebook-official:before{content:"\f230"}.fa-pinterest-p:before{content:"\f231"}.fa-whatsapp:before{content:"\f232"}.fa-server:before{content:"\f233"}.fa-user-plus:before{content:"\f234"}.fa-user-times:before{content:"\f235"}.fa-hotel:before,.fa-bed:before{content:"\f236"}.fa-viacoin:before{content:"\f237"}.fa-train:before{content:"\f238"}.fa-subway:before{content:"\f239"}.fa-medium:before{content:"\f23a"} -------------------------------------------------------------------------------- /sitedata/client/libs/font-awesome-4.3.0/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/sitedata/client/libs/font-awesome-4.3.0/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /sitedata/client/libs/font-awesome-4.3.0/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/sitedata/client/libs/font-awesome-4.3.0/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /sitedata/client/libs/font-awesome-4.3.0/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/sitedata/client/libs/font-awesome-4.3.0/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /sitedata/client/libs/font-awesome-4.3.0/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/sitedata/client/libs/font-awesome-4.3.0/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /sitedata/client/libs/font-awesome-4.3.0/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m3ng9i/qreader/212682fe521f1fd64a8bf37dbeeec314d69640c6/sitedata/client/libs/font-awesome-4.3.0/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /sitedata/client/libs/mousetrap1.4.6-min.js: -------------------------------------------------------------------------------- 1 | /* mousetrap v1.4.6 craig.is/killing/mice */ 2 | (function(J,r,f){function s(a,b,d){a.addEventListener?a.addEventListener(b,d,!1):a.attachEvent("on"+b,d)}function A(a){if("keypress"==a.type){var b=String.fromCharCode(a.which);a.shiftKey||(b=b.toLowerCase());return b}return h[a.which]?h[a.which]:B[a.which]?B[a.which]:String.fromCharCode(a.which).toLowerCase()}function t(a){a=a||{};var b=!1,d;for(d in n)a[d]?b=!0:n[d]=0;b||(u=!1)}function C(a,b,d,c,e,v){var g,k,f=[],h=d.type;if(!l[a])return[];"keyup"==h&&w(a)&&(b=[a]);for(g=0;gg||h.hasOwnProperty(g)&&(p[h[g]]=g)}e=p[d]?"keydown":"keypress"}"keypress"==e&&f.length&&(e="keydown");return{key:c,modifiers:f,action:e}}function F(a,b,d,c,e){q[a+":"+d]=b;a=a.replace(/\s+/g," ");var f=a.split(" ");1":".","?":"/","|":"\\"},G={option:"alt",command:"meta","return":"enter",escape:"esc",mod:/Mac|iPod|iPhone|iPad/.test(navigator.platform)?"meta":"ctrl"},p,l={},q={},n={},D,z=!1,I=!1,u=!1;for(f=1;20>f;++f)h[111+f]="f"+f;for(f=0;9>=f;++f)h[f+96]=f;s(r,"keypress",y);s(r,"keydown",y);s(r,"keyup",y);var m={bind:function(a,b,d){a=a instanceof Array?a:[a];for(var c=0;c