From fd74f06dc168f7a805dcbc7af0a9f5e976df0e3a Mon Sep 17 00:00:00 2001 From: Sven Slootweg Date: Tue, 31 May 2016 23:31:39 +0200 Subject: [PATCH] Initial version --- .gitignore | 4 +- assets/icon.png | Bin 0 -> 1951 bytes assets/icon@2x.png | Bin 0 -> 1855 bytes assets/npm.svg | 80 ++++++++ gulpfile.js | 112 +++++++++++ index.js | 3 + package.json | 52 +++++ src/app.js | 88 +++++++++ src/components/app/component.tag | 74 +++++++ src/components/app/index.js | 6 + src/components/search-box/component.tag | 48 +++++ src/components/search-box/index.js | 3 + src/components/search-results/component.tag | 52 +++++ src/components/search-results/index.js | 3 + src/db/escape-collection-name.js | 11 ++ src/db/json-db.js | 202 ++++++++++++++++++++ src/electron/execute-function.js | 10 + src/heuristics/looks-like-code.js | 11 ++ src/heuristics/readme-file.js | 14 ++ src/heuristics/section-type.js | 20 ++ src/package/fetch-metadata.js | 42 ++++ src/search/constructor-io.js | 33 ++++ src/util/errors.js | 7 + src/util/promise-debounce.js | 41 ++++ src/util/wrap-object.js | 15 ++ src/views/index.jade | 14 ++ src/views/index.js | 40 ++++ 27 files changed, 984 insertions(+), 1 deletion(-) create mode 100644 assets/icon.png create mode 100644 assets/icon@2x.png create mode 100644 assets/npm.svg create mode 100644 gulpfile.js create mode 100644 index.js create mode 100644 package.json create mode 100644 src/app.js create mode 100644 src/components/app/component.tag create mode 100644 src/components/app/index.js create mode 100644 src/components/search-box/component.tag create mode 100644 src/components/search-box/index.js create mode 100644 src/components/search-results/component.tag create mode 100644 src/components/search-results/index.js create mode 100644 src/db/escape-collection-name.js create mode 100644 src/db/json-db.js create mode 100644 src/electron/execute-function.js create mode 100644 src/heuristics/looks-like-code.js create mode 100644 src/heuristics/readme-file.js create mode 100644 src/heuristics/section-type.js create mode 100644 src/package/fetch-metadata.js create mode 100644 src/search/constructor-io.js create mode 100644 src/util/errors.js create mode 100644 src/util/promise-debounce.js create mode 100644 src/util/wrap-object.js create mode 100644 src/views/index.jade create mode 100644 src/views/index.js diff --git a/.gitignore b/.gitignore index d3f11de..f4643de 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ # https://help.github.com/articles/ignoring-files # Example .gitignore files: https://github.com/github/gitignore /bower_components/ -/node_modules/ \ No newline at end of file +/node_modules/ +/notes/ +/lib/ diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f310d4e77e1f4fdddc5bc263abe742cac9c6f5ab GIT binary patch literal 1951 zcmZ`)YdjMQ8=tmX?viP#H7ngPyRq3+m}y2Nlx%g{Jv&Pali^6FG^Et2Y`Ok)R4(;K zzfd@GT3d=VIYpSaAzq=#rDE+}=iB??{qQ{h=kohM|NplqD>Q^^Y35)C001m$0hBN! zI{pls6~?@2{ARroKu3LpeF1=)i{?wL<;H#mJBUh|nlcywfQ{m8lyPEuA|NW>i1dHr z#HNN+V*wV^g8jkTl~4f4`N#ODLL(7HqxeQdYdhq^V-SBk_0dETJhV8stm0xcWgWyn zCjew;VYBMpAGaUC9bB@Q=ffBo-O2^?Y%|#<$GTt7r&ro|l$G6B@TLAb{VFb4_rqDX zHwT>qls4|st@}`*EE;{)_xpImV!KHIzQJ5dh+aQirYax>CnUPSfv#S=8er15ikAFb z!i$${e>*mw2tJ7GtY4_CNazqc>_tikT`ZPpcI<0W>mfUGwHtE_`rao&=AMoXP#Ycp zo>qs&*dU^qK+sWPJ73f(#Q#08?pqI-WS=g2{E*)`(n3F}z3Ht8I8we%;Pv==(n`s? zj@T=!Ejk~8Ph~(GY&svbpDbP2oE#aw^hfBw?#!JVGX*c!COC*c2OY)^EYm&ywLIeX zm5ac_vk{Dx$Y!7w9=4T1g@NGcf}Qi=>)u0SC0`M@{K)0oxBonE9erj`m0Rb#ir99O z+}5~9UYRF}*LuA*E#s9{uMFEV(iSpS&6^F+*0ORZxw5N?!Yfb6VUs67T2;r&a7@`{aG?MQFU7C5!W3E+pg7YGSp z8T`~^$4s-RIG%-5-q_MM(94^yDMzsQ8Q{72f#%}m-msL|(&}Bb)Q6VeC-gm=9IE7U z75@G%gtFZ5j8Z`W7q*{9I1g10EJqzU-3e$e($%a?xkc^n)6n@7hgBDEu$xf*->Ov* zqgi(ivp1LmOKT&Y9@@q3rQFSg!B>Ycb&+uVAeE#zK<{F>JkBRaS3R$i3i_ z?#BV0-3)P9gV%ljq-o4Jc_a&nD4v`9yGke#( z8nTBrMh?-X+@uZqkZ$O+C+GUNv)d)l^UJh#r<)TUx8&O|zIlV35Qxg7Qe*DY`UlB` zq%G6AP63d9*m`NSB-?+7xlS->ihV@WhgaYEyy4k-OQ-t5U_Q1sg0rR-nFFmUGF9LM zIeo`79|FOLn8eaeultxP6#2kkXCz$73_2{f$4N`mT@IFZE#;{$n7o0 zl$%O_EM5v6hBC~w)rcIE7yt2eK3=D#{y=B#Y)-RR(vmWQLa$J^Ya^BZU8h#n@;7)D zU+v59m{NQ-d5*J}E1TQwW7~Wd3mXD%GJM{}o6nBWgxQ&u%fj`{A{BeX&V|bjl40vV z58WDRn|P+#=ythwx){ED#|1dt^_$O7vXoi4wuB~5D|xGfS_Llk3>y8U+HQhaRgZpT ze2|Y+6}al+U3E$N;2pdgtQ(e+m=opAYf`lCcUKrVK}auP@-F}Hd%IFs7y8B@&q9pO z9hh1W&79d!JtmvpBF|H|Pci7C{Hr&Rf)&0ChjLB!rI8qHTz&ybJI;1;&Dwu0-B2hY z{BAdQ&hNMSDA;J5pr#2l9oM~c2-!7gINR{-$YeLezM~9r*!WF0i~SFZxhye{#EIh> z0f0qg+>vNcB-T9wjV7VpN$yxAnm|IMPbmf9{~^SPxB`CC|0h^0G>t|=+E0b}lSfHx zRy+@x5GMfe1;=gofWty1oH!<8ae3U`#%(<6rwtUAfF_{OST>(a zBof`xd{2xgkB21?`4}P*&El{ycpe{*b;o(IIUYm;2hHK&Sr|Ts%V%L&ES3k`lg0KR WxEZz2HIo;ORsb~r5XxOLQ~F<*v~*|y literal 0 HcmV?d00001 diff --git a/assets/icon@2x.png b/assets/icon@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..91a4340ef6227cf49746f4daf4c4505c1ecc5c1b GIT binary patch literal 1855 zcmZ`(X14Dtkk_AAI8GE9-f6OIvmfCXR|0A9JTSu0-DQvIVd z6!_hLE%i`0M-ixG1_b%4d{ET|pjPrZX%zs##sYji!`c6Ndwb&YDPN5j+ze5E(bD57 zn6_F8qE6Q*x)kN7d5QhpLQAblq_Gzrjg!N+_IGP~V&Zd(PzaF*RCDPfs^mnE@nLkG zZm5w{chM60z3A3bZS(ZX-Kvq}>tEdXB(#0TyM-^(?O&T)&YVAM+PRr5Wg5-`Mv!$S zW{oDMv5Ww?)=MWobv#;ysUzWB!XhpI0Rd!!*&du8Kl9AG^MMcp`?~w99frjp3{-}Tih^6)#e1a3hp)Q zdlSDf#<6`0&PLYAGeCU2W$H&}>>o2A5l=iY_@KHJP{}#ajbH~>UoyDhv>!Y^pG7nW zPl&+- zrLIV9{mLFmoM~}MbFKp<-52Hm?+ioHrXi$PX-COGnE2X+tC%`JnF1k=@-_H%GXN&U zWVK!+v_@VJjwr;{b{e58s>Kg;_dyK!KkfK(Y+B?oV6|y@->^xZy$SaK8(>#kUs&#F zZT;d9=C<3soEg>>A~CrVU0w`tuW6*w z)I3n1@Lim@Ha|^jwefk*TcU1pniUMhU5_dhoIT8I%X7?wW=^KbSRwrWx-=bTh5IjW znGEScm>AF@CCgj^^c%zYIWaT`S*p3P%Q|DAlnad3M#*dklse(R@Q@X)udS6|privj z&^}yVY<`Q;^HQ)9e9tzhu7%#*8oz3()13QlQJc6cufzXsx84RPbWx(GjQHu!nAL`v z_(|j=e6Bf(*q5!&4t+K;O6{F+S3Pfe4=U8?XoKeZ?AR7;_0lKgMv`wE65sjs1Mn6@ zI9*Wlu|4KlVW(j5Vpjv>tOYwq=PqeC!ACaK^gV~5cE!8j??&lx$tHlSDS6A!(Mb3{8kY>0wMQ`J@7rZdCYJ85|B&F zKOlOuNB;o}F)lJ5qW#DR53Ph$2W>tFDfr%#sD6tZ@GPZusrRxBo}QcncdOrBVo2QK z3+71HYw$VGR+1CF|F?D5m;CrA6Ebi}6o5VMnX zZn0(ae*5*|8?L@A=5&&T>)k1{On=z^F6gQii}xT~IlY5vGayef|EED+&nm3mcgLjV zuN&`>h+Yeh)8hS#$7@hg*<-5Y{XnA$DD2Z`RQ)`2e6_4u=MiipzoEVULRC%=+>*JrhPgaF{w4tsA!Do7ZV!>lOr^0 z=#9(WS`sbD&=G;2)rYm0surPKl~mY}R84O~Pja1nvG8A5LDlREU$?8@ zx{2u}zuj^k~!L+LMc!&|adIK+n%(GYT3-buXD565#?R z2VcSVUCYWg=T&R>$ar`yN$sTiqs%q+hQi&h})VNJO-27&rtav0yBlmpr8By1k|n90tJEl zRUw0wh)*DA&}_2O833J;M8l)e=+h}o8k)?Yq$D#aWOP<)QVN+$qxvbT8R)NTpzUxN z92#SnK&QI6xHw?w&RAy}%?{^6$GW&+$P_ZxiAHy_bFgRRGiHxOVsdO@y gOeQ-fIFl0`akdKW!lz!_idBFB-(a6sulS391Le3x@Bjb+ literal 0 HcmV?d00001 diff --git a/assets/npm.svg b/assets/npm.svg new file mode 100644 index 0000000..b5353b3 --- /dev/null +++ b/assets/npm.svg @@ -0,0 +1,80 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..aab76ae --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,112 @@ +var gulp = require("gulp"); +var fileUrl = require("file-url"); +var path = require("path"); +var xtend = require("xtend"); +var stream = require("stream"); +var rfr = require("rfr"); + +var livereload = require("gulp-livereload"); +var rename = require("gulp-rename"); + +var presetES2015 = require("@joepie91/gulp-preset-es2015"); +var presetJade = require("@joepie91/gulp-preset-jade"); +var presetSCSS = require("@joepie91/gulp-preset-scss"); +var presetRiot = require("@joepie91/gulp-preset-riot"); + +var patchLivereloadLogger = require("@joepie91/gulp-partial-patch-livereload-logger"); +var runElectron = require("@joepie91/gulp-partial-electron"); + +patchLivereloadLogger(livereload); + +var sources = { + "babel-main": "app.js", + "babel-lib": "src/**/*.js", + "jade-views": "src/views/**/*.jade", + "sass-main": "src/stylesheets/**/*.scss", + "riot-components": "src/components/**/*.tag", + "electron": ["lib/**/*.js", "lib/views/**/*.html"] +} + +function waitStream(duration) { + var dummyStream = new stream.Readable(); + + dummyStream._read = function() { + setTimeout(function() { + dummyStream.push(null); + }, duration); + } + + return dummyStream; +} + +var electronProcess; + +gulp.task("electron", ["electron-kill", 'babel-lib', 'babel-main', 'jade-views', 'sass-main', 'riot-components'], function() { + electronProcess = runElectron(); +}); + +gulp.task("electron-kill", function() { + if (electronProcess != null) { + console.log("Killing old Electron process...") + electronProcess.kill("SIGINT"); + + /* To ensure that the process really has exited... */ + return waitStream(200); + } +}) + +gulp.task('babel-main', function() { + return gulp.src(sources["babel-main"]) + .pipe(presetES2015({ + livereload: livereload, + basePath: __dirname + })) + .pipe(rename("app.es5.js")) + .pipe(gulp.dest("./")); +}); + +gulp.task('babel-lib', function() { + return gulp.src(sources["babel-lib"]) + .pipe(presetES2015({ + livereload: livereload, + basePath: __dirname + })) + .pipe(gulp.dest("./lib/")); +}); + +gulp.task("jade-views", function() { + return gulp.src(sources["jade-views"]) + .pipe(presetJade({ + livereload: livereload, + basePath: __dirname + })) + .pipe(gulp.dest("lib/views/")); +}); + +gulp.task("sass-main", function() { + return gulp.src(sources["sass-main"]) + .pipe(presetSCSS({ + livereload: livereload, + basePath: __dirname + })) + .pipe(gulp.dest("lib/stylesheets/")); +}); + +gulp.task("riot-components", function() { + return gulp.src(sources["riot-components"]) + .pipe(presetRiot({ + livereload: livereload, + basePath: __dirname + })) + .pipe(gulp.dest("lib/components/")); +}); + +gulp.task('watch', ['babel-lib', 'babel-main', 'jade-views', 'sass-main', 'riot-components'], function () { + livereload.listen(); + + Object.keys(sources).forEach(function(source) { + gulp.watch(sources[source], [source]); + }); +}); + +gulp.task('default', ['watch', 'electron']); \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..cce3729 --- /dev/null +++ b/index.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require("./lib/app"); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..2ba10a7 --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "npmbar", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git@git.cryto.net:joepie91/npmbar.git" + }, + "author": "Sven Slootweg", + "license": "WTFPL", + "dependencies": { + "appdirectory": "^0.1.0", + "bhttp": "^1.2.1", + "bluebird": "^3.3.5", + "create-error": "^0.3.1", + "default-value": "0.0.3", + "document-offset": "^1.0.4", + "document-ready-promise": "^3.0.1", + "electron-prebuilt": "^1.0.1", + "file-url": "^1.1.0", + "in-array": "^0.1.2", + "is-function": "^1.0.1", + "lokijs": "^1.3.16", + "match-object": "0.0.2", + "menubar": "^4.1.1", + "mkdirp": "^0.5.1", + "object-pick": "^0.1.2", + "ps-tree": "^1.0.1", + "rfr": "^1.2.3", + "riot": "^2.4.0", + "riot-query": "0.0.3", + "sanitize-filename": "^1.6.0", + "xtend": "^4.0.1" + }, + "devDependencies": { + "@joepie91/gulp-partial-electron": "^1.0.1", + "@joepie91/gulp-partial-patch-livereload-logger": "^1.0.1", + "@joepie91/gulp-preset-es2015": "^1.0.1", + "@joepie91/gulp-preset-jade": "^1.0.1", + "@joepie91/gulp-preset-riot": "^1.0.1", + "@joepie91/gulp-preset-scss": "^1.0.1", + "babel-preset-es2015": "^6.6.0", + "file-url": "^1.1.0", + "gulp": "^3.9.1", + "gulp-livereload": "^3.8.1", + "gulp-rename": "^1.2.2" + } +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..c1317d7 --- /dev/null +++ b/src/app.js @@ -0,0 +1,88 @@ +'use strict'; + +const Promise = require("bluebird"); +const path = require("path"); +const menubar = require("menubar"); +const appDirectory = require("appdirectory"); +const xtend = require("xtend"); +const mkdirpAsync = Promise.promisify(require("mkdirp")); +const fileUrl = require("file-url"); +const rfr = require("rfr"); + +const {app, globalShortcut, ipcMain} = require("electron"); + +const errors = rfr("lib/util/errors"); +const executeFunction = rfr("lib/electron/execute-function"); + +let appDirectories = new appDirectory("npmbar"); + +let cacheExpiry = 300; +let baseHeight = 58; + +console.log("Creating menubar...") + +let bar = menubar({ + index: fileUrl(path.join(__dirname, "views", "index.html")), + icon: path.join(__dirname, "..", "assets", "icon.png"), + "preload-window": true, + "always-on-top": true, + width: 700, + height: baseHeight +}); + +bar.on("ready", () => { + //bar.window.toggleDevTools(); + globalShortcut.register("F8", () => { + if (bar.window.isVisible()) { + bar.hideWindow(); + } else { + bar.showWindow(); + } + }); + + bar.on("after-show", () => { + bar.window.setAlwaysOnTop(true); + bar.window.focusOnWebView(); + + bar.window.webContents.send("focusSearch"); + }) + + //bar.showWindow(); +}); + +ipcMain.on("resize", (event, newSize) => { + if (newSize.width != null) { + bar.setOption("width", newSize.width); + } + + if (newSize.height != null) { + bar.setOption("height", newSize.height); + } + + /* Temporary workaround until maxogden/menubar#125 is resolved */ + bar.window.setSize(bar.getOption("width"), bar.getOption("height")); +}) + +Promise.try(() => { + return mkdirpAsync(appDirectories.userData()); +}).then(() => { + return rfr("lib/db/json-db")(path.join(appDirectories.userData(), "db.json")); +}).then((db) => { + let metadataCache = db.collection("metadataCache"); + + const getPackageMetadata = rfr("lib/package/fetch-metadata")({ + CacheError: errors.CacheError, + get: function(packageName) { + try { + return metadataCache.findOne({name: packageName}); + } catch (err) { // FIXME + throw new errors.CacheError("Not in cache"); + } + }, + set: function(packageName, metadata) { + return metadataCache.upsertBy("name", metadata); + } + }); + + +}); \ No newline at end of file diff --git a/src/components/app/component.tag b/src/components/app/component.tag new file mode 100644 index 0000000..7df4232 --- /dev/null +++ b/src/components/app/component.tag @@ -0,0 +1,74 @@ +app + .logo-section + img(src="../../assets/npm.svg", height=40) + .bar-section + search-box + search-results(results="{results}") + .window-height-marker + + script. + const Promise = require("bluebird"); + const rfr = require("rfr"); + + const search = rfr("lib/search/constructor-io")("CD06z4gVeqSXRiDL2ZNK"); + + this.mixin(require("riot-query").mixin); + + let lastQuery; + this.results = [] + + this.on("mount", () => { + let searchBox = this.queryOne("search-box"); + let searchResults = this.queryOne("search-results"); + + searchBox.on("selectionUp", () => { + searchResults.moveSelectionUp(); + }); + + searchBox.on("selectionDown", () => { + searchResults.moveSelectionDown(); + }); + + searchBox.on("queryChanged", (query) => { + Promise.try(() => { + lastQuery = query; + return search(query); + }).then((results) => { + /* Sometimes, search results may come back out of order. Here we check whether + * the lastQuery is still the same as when we initially made the request. */ + if (lastQuery === query) { + this.results = results.packages.map((pkg) => { + return { + name: pkg.value, + description: pkg.data.description + } + }); + this.update(); + } + }); + }) + }); + + this.on("updated", () => { + global.triggerWindowResize(); + }); + + style(scoped, type="scss"). + .logo-section { + position: absolute; + left: 0px; + top: 0px; + bottom: 0px; + width: 120px; + text-align: center; + background-color: #2a333c; + padding: 9px; + } + + .bar-section { + position: absolute; + left: 138px; + top: 0px; + bottom: 0px; + right: 0px; + } \ No newline at end of file diff --git a/src/components/app/index.js b/src/components/app/index.js new file mode 100644 index 0000000..76fc8a2 --- /dev/null +++ b/src/components/app/index.js @@ -0,0 +1,6 @@ +'use strict'; + +require("../search-box"); +require("../search-results"); + +require("./component"); \ No newline at end of file diff --git a/src/components/search-box/component.tag b/src/components/search-box/component.tag new file mode 100644 index 0000000..bde7441 --- /dev/null +++ b/src/components/search-box/component.tag @@ -0,0 +1,48 @@ +search-box + input.search(placeholder="Search...", onkeydown="{_handleKeyDown}") + + script. + let lastKnownQuery; + + Object.assign(this, { + _handleKeyDown: (event) => { + switch (event.code) { + case "ArrowDown": + this.trigger("selectionDown"); + break; + case "ArrowUp": + this.trigger("selectionUp"); + break; + case "Enter": + this.trigger("confirm"); + break; + case "Escape": + this.trigger("cancel"); + break; + } + + let searchInput = this.root.querySelector(".search"); + + if (searchInput.value !== lastKnownQuery) { + lastKnownQuery = searchInput.value; + this.trigger("queryChanged", searchInput.value); + } + + return true; + } + }) + + style(scoped, type="scss"). + .search { + border: 0px; + padding: 17px 9px; + font-size: 21px; + background-color: transparent; + width: 100%; + color: white; + font-weight: bold; + + &::-webkit-input-placeholder { + color: #d99d9d; + } + } \ No newline at end of file diff --git a/src/components/search-box/index.js b/src/components/search-box/index.js new file mode 100644 index 0000000..919fc91 --- /dev/null +++ b/src/components/search-box/index.js @@ -0,0 +1,3 @@ +'use strict'; + +require("./component"); \ No newline at end of file diff --git a/src/components/search-results/component.tag b/src/components/search-results/component.tag new file mode 100644 index 0000000..f47deee --- /dev/null +++ b/src/components/search-results/component.tag @@ -0,0 +1,52 @@ +search-results + .result(each="{result, i in opts.results}", class="{active: i === currentSelection}") + h2 {result.name} + .description {result.description} + + script. + Object.assign(this, { + currentSelection: 0, + moveSelectionDown: function() { + if (this.currentSelection + 1 < opts.results.length) { + this.currentSelection += 1; + this.update(); + } + }, + moveSelectionUp: function() { + if (this.currentSelection >= 1) { + this.currentSelection -= 1; + this.update(); + } + } + }) + + this.on("update", () => { + /* Reset the cursor/selection state... */ + this.currentSelection = 0; + }); + + style(scoped, type="scss"). + .result { + background-color: white; + color: #676767; + padding: 9px; + border-top: 1px solid #2a333c; + + &:first-child { + border-top: 0px; + } + + &.active { + background-color: #e2e2e2; + } + + h2 { + color: black; + font-size: 21px; + margin: 0px; + } + + .description { + font-size: 13px; + } + } \ No newline at end of file diff --git a/src/components/search-results/index.js b/src/components/search-results/index.js new file mode 100644 index 0000000..919fc91 --- /dev/null +++ b/src/components/search-results/index.js @@ -0,0 +1,3 @@ +'use strict'; + +require("./component"); \ No newline at end of file diff --git a/src/db/escape-collection-name.js b/src/db/escape-collection-name.js new file mode 100644 index 0000000..d1b4c8c --- /dev/null +++ b/src/db/escape-collection-name.js @@ -0,0 +1,11 @@ +'use strict'; + +const crypto = require("crypto"); +const sanitizeFilename = require("sanitize-filename"); + +module.exports = function escapeCollectionName(name) { + let hash = crypto.createHash("sha256").update(name, "utf8").digest("hex"); + + /* We 'sanitize' the entire filename with hash included, to ensure that the result has the right maximum length. */ + return sanitizeFilename(`${name}_${hash}.db`); +} \ No newline at end of file diff --git a/src/db/json-db.js b/src/db/json-db.js new file mode 100644 index 0000000..8bb314c --- /dev/null +++ b/src/db/json-db.js @@ -0,0 +1,202 @@ +'use strict'; + +/* Rolling your own database is a bad idea. In this particular + * case, I am doing it anyway, because there are very clear + * constraints, no extreme performance requirements, and most + * importantly, all the other in-memory databases seem to + * -suck- from an API/usability point of view. + * + * Limitations: + * - Only one process can use the DB at the same time. Yes, + * it's a reading lock. + * - Persistence is not guaranteed. If the process crashes, + * writes may be lost. + * - No performance guarantees, whatsoever. + * - No schemas. + */ + +const Promise = require("bluebird"); +const fs = Promise.promisifyAll(require("fs")); +const matchObject = require("match-object"); +const uuid = require("uuid"); +const pick = require("object-pick"); +const rfr = require("rfr"); + +const promiseDebounce = rfr("lib/util/promise-debounce"); +const escapeCollectionName = rfr("lib/db/escape-collection-name"); // FIXME: Actually split this up into collections... + +function getLockPath(path) { + return `${path}.lock`; +} + +function obtainLock(path) { + return Promise.try(() => { + return fs.writeFileAsync(getLockPath(path), "", { + flag: "wx" + }); + }); +} + +function releaseLock(path) { + return Promise.try(() => { + return fs.unlinkAsync(getLockPath(path)); + }); +} + +function loadDatabase(path) { + return Promise.try(() => { + return obtainLock(path); + }).then(() => { + return fs.readFileAsync(path); + }).then((data) => { + return JSON.parse(data); + }).catch({code: "ENOENT"}, (err) => { + /* Initialize a blank database */ + return {}; + }); +} + +function saveDatabase(path, collections) { + return Promise.try(() => { + return fs.writeFileAsync(path, JSON.stringify(collections)); + }); +} + +module.exports = function(path) { + return Promise.try(() => { + return loadDatabase(path); + }).then((collections) => { + let queueWrite = promiseDebounce(function() { + if (db.opened === true) { + saveDatabase(path, collections); + } + }); + + function getCollection(name) { + let indexes; // TODO + + function findIdIndex(id) { + if (id == null) { + return null; + } + + return collections[name].findIndex((item) => item.$id === id); + } + + return { + find: function(query) { + return collections[name].filter((item) => matchObject(query, item)); + }, + findOne: function(query) { + let result = collections[name].find((item) => matchObject(query, item)); + + if (result == null) { + throw new Error("No results found"); + } else { + return result; + } + }, + insert: function(object, options = {}) { + /* Intentional mutation. */ + Object.assign(object, {$id: uuid.v4()}); + collections[name].push(object); + queueWrite(); + }, + update: function(object, options = {}) { + let index; + if (index = findIdIndex(object.$id)) { + if (options.patch === true) { + Object.assign(collections[name][index], object); + } else { + collections[name][index] = object; + } + + queueWrite(); + } else { + throw new Error("No such object exists"); + } + }, + upsert: function(object, options = {}) { + try { + this.update(object, options); + } catch (err) { + this.insert(object, options); + } + }, + upsertBy: function(keys, object, options = {}) { + let query = pick(object, keys); + + try { + let result = this.findOne(query); + // FIXME: The following shouldn't be in the try block... + object.$id = result.$id; + return this.update(object, options); + } catch (err) { + return this.insert(object, options); + } + + }, + delete: function(object) { + let index; + if (index = findIdIndex(object.$id)) { + collections[name].splice(index, 1); + queueWrite(); + } else { + throw new Error("No such object exists"); + } + }, + deleteBy: function(query) { + collections[name] = collections[name].filter((item) => !matchObject(query, item)); + queueWrite(); + }, + ensureIndex: function(property) { + // TODO + } + } + } + + // TODO: Cache/TTL collections + // TODO: .bak file + + let db = { + opened: true, + close: function() { + return Promise.try(() => { + return queueWrite(); + }).then(() => { + return releaseLock(path); + }).then(() => { + this.opened = false; + }); + }, + collection: function(name) { + if (collections[name] == null) { + collections[name] = []; + } + + return getCollection(name); + } + } + + function cleanup() { + console.log("Doing cleanup..."); + if (db.opened === true) { + db.close(); + } + } + + if (process.versions.electron != null) { + /* Running in Electron */ + require("electron").app.on("will-quit", () => { + cleanup(); + }) + } else { + /* Running in a different Node-y environment */ + process.on("beforeExit", () => { + cleanup(); + }); + } + + return db; + }); +} \ No newline at end of file diff --git a/src/electron/execute-function.js b/src/electron/execute-function.js new file mode 100644 index 0000000..8fb32bb --- /dev/null +++ b/src/electron/execute-function.js @@ -0,0 +1,10 @@ +'use strict'; + +function packFunction(func) { + return `;${func.toString()};${func.name}();`; +} + +module.exports = function(window, func) { + let stringifiedFunc = packFunction(func); + window.webContents.executeJavaScript(stringifiedFunc); +} \ No newline at end of file diff --git a/src/heuristics/looks-like-code.js b/src/heuristics/looks-like-code.js new file mode 100644 index 0000000..396e794 --- /dev/null +++ b/src/heuristics/looks-like-code.js @@ -0,0 +1,11 @@ +'use strict'; + +let regexes = [ + /^(?:event:)?'[a-z:\$%-]+'$/i, // events + /[a-z0-9_]+\(/, // function signatures + /^[a-z0-9_]+(\.[a-z0-9_]+)+/ // attributes +] + +module.exports = function(string) { + return regexes.some((regex) => regex.test(string)); +} \ No newline at end of file diff --git a/src/heuristics/readme-file.js b/src/heuristics/readme-file.js new file mode 100644 index 0000000..2103abd --- /dev/null +++ b/src/heuristics/readme-file.js @@ -0,0 +1,14 @@ +'use strict'; + +let readmeFilenames = [ + "readme.md", + "readme.rst", + "readme", + "read me" +] + +module.exports = function(filenames) { + /* We invert the search here, so that we prioritize by the order of entries + * in readmeFilenames, rather than the filenames passed in. */ + return readmeFilenames.find((filename) => inArray(filenames, filename)); +} \ No newline at end of file diff --git a/src/heuristics/section-type.js b/src/heuristics/section-type.js new file mode 100644 index 0000000..b3576f6 --- /dev/null +++ b/src/heuristics/section-type.js @@ -0,0 +1,20 @@ +'use strict'; + +module.exports = function(header, contents) { + if (/^(?:module )?api$/i.test(header)) { + return "api"; + } else if (/^events$/i.test(header)) { + return "events"; + } else if (/^options$/i.test(header)) { + return "options"; + } else if (/^(?:tips|notes)$/i.test(header)) { + return "notes"; + } else if (/(?:usage|examples?)/i.test(header)) { + // FIXME: Detect API methods under 'usage' + return "example"; + } else if (/(?:caution|^warning$)/i.test(header)) { + return "warning"; + } else { + return "other"; + } +} \ No newline at end of file diff --git a/src/package/fetch-metadata.js b/src/package/fetch-metadata.js new file mode 100644 index 0000000..686a445 --- /dev/null +++ b/src/package/fetch-metadata.js @@ -0,0 +1,42 @@ +'use strict'; + +const Promise = require("bluebird"); +const bhttp = require("bhttp"); + +module.exports = function(cache) { + function getFromRemote(packageName) { + return Promise.try(() => { + let encodedPackageName = encodeURIComponent(packageName).replace(/%40/g, "@"); + return bhttp.get(`https://registry.npmjs.org/${encodedPackageName}`); + }).then((response) => { + if (response.statusCode !== 200) { + // FIXME: Proper error types + throw new Error(`Got non-200 status code from NPM registry: ${response.statusCode}`); + } else { + return response.body; + } + }).tap((metadata) => { + cache.set(packageName, metadata); + }); + } + + function getFromCache(packageName) { + return Promise.try(() => { + return cache.get(packageName); + }).then((metadata) => { + if (metadata._cacheExpiry < Date.now()) { + throw new cache.CacheError("Cached data expired"); + } else { + return metadata; + } + }); + } + + return function(packageName) { + return Promise.try(() => { + return getFromCache(packageName); + }).catch(cache.CacheError, (err) => { + return getFromRemote(packageName); + }); + } +} \ No newline at end of file diff --git a/src/search/constructor-io.js b/src/search/constructor-io.js new file mode 100644 index 0000000..8bc1ab0 --- /dev/null +++ b/src/search/constructor-io.js @@ -0,0 +1,33 @@ +'use strict'; + +const Promise = require("bluebird"); +const bhttp = require("bhttp"); +const querystring = require("querystring"); +const defaultValue = require("default-value"); + +module.exports = function(autocompleteKey) { + function createQueryString(query, options) { + return querystring.stringify({ + autocomplete_key: autocompleteKey, + _: Date.now(), + query: query, + num_results: defaultValue(options.resultCount, 20) + }); + } + + function createUrl(query, options) { + return `https://ac.cnstrc.com/autocomplete/${encodeURIComponent(query)}?${createQueryString(query, options)}`; + } + + return function doSearch(query, options) { + return Promise.try(() => { + return bhttp.get(createUrl(query, options)); + }).then((response) => { + if (response.statusCode === 200) { + return response.body; + } else { + throw new Error(`Non-200 status code encountered: ${response.statusCode}`) + } + }); + } +} \ No newline at end of file diff --git a/src/util/errors.js b/src/util/errors.js new file mode 100644 index 0000000..48b72a2 --- /dev/null +++ b/src/util/errors.js @@ -0,0 +1,7 @@ +'use strict'; + +const createError = require("create-error"); + +module.exports = { + CacheError: createError("CacheError") +} \ No newline at end of file diff --git a/src/util/promise-debounce.js b/src/util/promise-debounce.js new file mode 100644 index 0000000..f87ee74 --- /dev/null +++ b/src/util/promise-debounce.js @@ -0,0 +1,41 @@ +'use strict'; + +const Promise = require("bluebird"); + +module.exports = function(func) { + let operationQueued = false; + let queuedHandlers = []; + let currentPromise; + + function runFunc() { + return Promise.try(() => { + return func(); + }).tap(() => { + if (operationQueued === true) { + currentPromise = runFunc(); + + queuedHandlers.forEach((handler) => { + handler(currentPromise); + }); + + operationQueued = false; + queuedHandlers = []; + } else { + currentPromise = null; + } + }); + } + + return function() { + return new Promise((resolve, reject) => { + if (currentPromise == null) { + currentPromise = runFunc(); + resolve(currentPromise); + } else { + operationQueued = true; + queuedHandlers.push(resolve); + } + }) + + } +} \ No newline at end of file diff --git a/src/util/wrap-object.js b/src/util/wrap-object.js new file mode 100644 index 0000000..78d116a --- /dev/null +++ b/src/util/wrap-object.js @@ -0,0 +1,15 @@ +'use strict'; + +const isFunction = require("is-function"); + +module.exports = function(object, additions) { + let wrappedObject = {}; + + Object.getOwnPropertyNames(object).forEach((key) => { + if (isFunction(object[key])) { + wrappedObject[key] = object[key].bind(object); + } + }); + + return Object.assign(wrappedObject, additions); +} \ No newline at end of file diff --git a/src/views/index.jade b/src/views/index.jade new file mode 100644 index 0000000..88be30e --- /dev/null +++ b/src/views/index.jade @@ -0,0 +1,14 @@ +html + head + meta(charset="UTF-8") + title Hello World! + style. + body { + background-color: #C12127; + color: white; + font-family: sans-serif; + overflow: hidden; + } + script(src="index.js") + body + app \ No newline at end of file diff --git a/src/views/index.js b/src/views/index.js new file mode 100644 index 0000000..ae5ba60 --- /dev/null +++ b/src/views/index.js @@ -0,0 +1,40 @@ +'use strict' + +const Promise = require("bluebird"); +const documentReady = require("document-ready-promise"); +const documentOffset = require("document-offset"); +const util = require("util"); +const riot = require("riot"); + +const {ipcRenderer} = require("electron"); + +require("../components/app"); + +global.triggerWindowResize = function() { + let marker = document.querySelector(".window-height-marker"); + let pageHeight; + + if (marker != null) { + pageHeight = documentOffset(marker).top; + } else { + let body = document.querySelector("body"); + let rootElement = document.documentElement; + + pageHeight = Math.max(body.scrollHeight, body.offsetHeight, rootElement.scrollHeight, rootElement.offsetHeight, rootElement.clientHeight); + } + + ipcRenderer.send("resize", { + height: pageHeight + }); +} + +Promise.try(() => { + return documentReady(); +}).then(() => { + riot.mount("app"); + global.triggerWindowResize(); + + ipcRenderer.on("focusSearch", () => { + document.querySelector("input.search").focus(); + }); +}) \ No newline at end of file