mirror of
https://git.sr.ht/~cadence/bibliogram
synced 2024-11-22 16:17:29 +00:00
First release
This commit is contained in:
parent
32e4f3d854
commit
6fd7cc501e
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.vscode
|
54
README.md
Normal file
54
README.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Bibliogram
|
||||||
|
|
||||||
|
## An alternative front-end for Instagram.
|
||||||
|
|
||||||
|
Bibliogram works without client-side JavaScript, has no ads or tracking, and doesn't urge you to sign up.
|
||||||
|
|
||||||
|
See also: [Invidious, a front-end for YouTube.](https://github.com/omarroth/invidious)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [x] View profile and timeline
|
||||||
|
- [x] User memory cache
|
||||||
|
- [ ] Image disk cache
|
||||||
|
- [ ] View post
|
||||||
|
- [ ] Homepage
|
||||||
|
- [ ] Optimised for mobile
|
||||||
|
- [ ] Favicon
|
||||||
|
- [ ] Infinite scroll
|
||||||
|
- [ ] Settings (e.g. data saving)
|
||||||
|
- [ ] Galleries
|
||||||
|
- [ ] List view
|
||||||
|
- [ ] Videos
|
||||||
|
- [ ] IGTV
|
||||||
|
- [ ] Public API
|
||||||
|
- [ ] Rate limiting
|
||||||
|
- [ ] Explore tags
|
||||||
|
- [ ] Explore locations
|
||||||
|
- [ ] _more..._
|
||||||
|
|
||||||
|
These features may not be able to be implemented for technical reasons:
|
||||||
|
|
||||||
|
- Stories
|
||||||
|
|
||||||
|
These features will not be added:
|
||||||
|
|
||||||
|
- Comments
|
||||||
|
|
||||||
|
## Instances
|
||||||
|
|
||||||
|
There is currently no official Bibliogram instance. You will have to run your own, or find someone else's.
|
||||||
|
|
||||||
|
If you only use one computer, you can install Bibliogram on that computer and then access the instance through localhost.
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
1. `git clone https://github.com/cloudrac3r/bibliogram`
|
||||||
|
1. `npm install`
|
||||||
|
1. `npm start`
|
||||||
|
|
||||||
|
Bibliogram is now running on `0.0.0.0:10407`.
|
||||||
|
|
||||||
|
## User-facing endpoints
|
||||||
|
|
||||||
|
- `/u/{username}` - load a user's profile and timeline
|
12
html/static/js/templates/.gitrepo
Normal file
12
html/static/js/templates/.gitrepo
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
; DO NOT EDIT (unless you know what you are doing)
|
||||||
|
;
|
||||||
|
; This subdirectory is a git "subrepo", and this file is maintained by the
|
||||||
|
; git-subrepo command. See https://github.com/git-commands/git-subrepo#readme
|
||||||
|
;
|
||||||
|
[subrepo]
|
||||||
|
remote = /home/cloud/Code/pinski-plugins/templates
|
||||||
|
branch = master
|
||||||
|
commit = a768add65e7eb7cfd608ef5672342940b2232b4c
|
||||||
|
parent = e684a04b0313ed5a511a9e3cc50269ebc2b64691
|
||||||
|
method = merge
|
||||||
|
cmdver = 0.4.0
|
18
html/static/js/templates/api/templates.js
Normal file
18
html/static/js/templates/api/templates.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
const passthrough = require("../../../../../passthrough")
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
route: "/api/templates", methods: ["GET"], code: async () => {
|
||||||
|
const result = {}
|
||||||
|
const entries = passthrough.instance.pugCache.entries()
|
||||||
|
for (const [file, value] of entries) {
|
||||||
|
const match = file.match(/client\/.*?([^/]+)\.pug$/)
|
||||||
|
if (match) {
|
||||||
|
const name = match[1]
|
||||||
|
result[name] = value.client.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [200, result]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
14
html/static/js/templates/templates.js
Normal file
14
html/static/js/templates/templates.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import {AsyncValueCache} from "../avc/avc.js"
|
||||||
|
|
||||||
|
const tc = new AsyncValueCache(true, () => {
|
||||||
|
return fetch("/api/templates").then(res => res.json()).then(data => {
|
||||||
|
Object.keys(data).forEach(key => {
|
||||||
|
let fn = Function(data[key] + "; return template")()
|
||||||
|
data[key] = fn
|
||||||
|
})
|
||||||
|
console.log(`Loaded ${Object.keys(data).length} templates`)
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export {tc}
|
8
jsconfig.json
Normal file
8
jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"checkJs": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
}
|
||||||
|
}
|
1804
package-lock.json
generated
Normal file
1804
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "bibliogram",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "cd src/site && node server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"dependencies": {
|
||||||
|
"gm": "^1.23.1",
|
||||||
|
"node-dir": "^0.1.17",
|
||||||
|
"node-fetch": "^2.6.0",
|
||||||
|
"pinski": "github:cloudrac3r/pinski#oop"
|
||||||
|
}
|
||||||
|
}
|
66
src/lib/cache.js
Normal file
66
src/lib/cache.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
class InstaCache {
|
||||||
|
/**
|
||||||
|
* @property {number} ttl time to keep each resource in milliseconds
|
||||||
|
*/
|
||||||
|
constructor(ttl) {
|
||||||
|
this.ttl = ttl
|
||||||
|
/** @type {Map<string, {data: any, time: number}>} */
|
||||||
|
this.cache = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
clean() {
|
||||||
|
for (const key of this.cache.keys()) {
|
||||||
|
const value = this.cache.get(key)
|
||||||
|
if (Date.now() > value.time + this.ttl) this.cache.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
*/
|
||||||
|
get(key) {
|
||||||
|
return this.cache.get(key).data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {any} data
|
||||||
|
*/
|
||||||
|
set(key, data) {
|
||||||
|
this.cache.set(key, {data, time: Date.now()})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {() => Promise<T>} callback
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
* @template T
|
||||||
|
*/
|
||||||
|
getOrFetch(key, callback) {
|
||||||
|
this.clean()
|
||||||
|
if (this.cache.has(key)) return Promise.resolve(this.get(key))
|
||||||
|
else {
|
||||||
|
const pending = callback().then(result => {
|
||||||
|
this.set(key, result)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
this.set(key, pending)
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {() => Promise<T>} callback
|
||||||
|
* @returns {Promise<T>}
|
||||||
|
* @template T
|
||||||
|
*/
|
||||||
|
getOrFetchPromise(key, callback) {
|
||||||
|
return this.getOrFetch(key, callback).then(result => {
|
||||||
|
this.cache.delete(key)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = InstaCache
|
43
src/lib/collectors.js
Normal file
43
src/lib/collectors.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
const constants = require("./constants")
|
||||||
|
const {request} = require("./utils/request")
|
||||||
|
const {extractSharedData} = require("./utils/body")
|
||||||
|
const InstaCache = require("./cache")
|
||||||
|
const {User} = require("./structures")
|
||||||
|
require("./testimports")(constants, request, extractSharedData, InstaCache, User)
|
||||||
|
|
||||||
|
const cache = new InstaCache(600e3)
|
||||||
|
|
||||||
|
function fetchUser(username) {
|
||||||
|
return cache.getOrFetch("user/"+username, () => {
|
||||||
|
return request(`https://www.instagram.com/${username}/`).then(res => res.text()).then(text => {
|
||||||
|
const sharedData = extractSharedData(text)
|
||||||
|
const user = new User(sharedData.entry_data.ProfilePage[0].graphql.user)
|
||||||
|
return user
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} userID
|
||||||
|
* @param {string} after
|
||||||
|
* @returns {Promise<import("./types").PagedEdges<import("./types").GraphImage>>}
|
||||||
|
*/
|
||||||
|
function fetchTimelinePage(userID, after) {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
p.set("query_hash", constants.external.timeline_query_hash)
|
||||||
|
p.set("variables", JSON.stringify({
|
||||||
|
id: userID,
|
||||||
|
first: constants.external.timeline_fetch_first,
|
||||||
|
after: after
|
||||||
|
}))
|
||||||
|
return cache.getOrFetchPromise("page/"+after, () => {
|
||||||
|
return request(`https://www.instagram.com/graphql/query/?${p.toString()}`).then(res => res.json()).then(root => {
|
||||||
|
/** @type {import("./types").PagedEdges<import("./types").GraphImage>} */
|
||||||
|
const timeline = root.data.user.edge_owner_to_timeline_media
|
||||||
|
return timeline
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.fetchUser = fetchUser
|
||||||
|
module.exports.fetchTimelinePage = fetchTimelinePage
|
12
src/lib/constants.js
Normal file
12
src/lib/constants.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
image_cache_control: `public, max-age=${7*24*60*60}`,
|
||||||
|
|
||||||
|
external: {
|
||||||
|
timeline_query_hash: "e769aa130647d2354c40ea6a439bfc08",
|
||||||
|
timeline_fetch_first: 12
|
||||||
|
},
|
||||||
|
|
||||||
|
symbols: {
|
||||||
|
NO_MORE_PAGES: Symbol("NO_MORE_PAGES")
|
||||||
|
}
|
||||||
|
}
|
45
src/lib/structures/Timeline.js
Normal file
45
src/lib/structures/Timeline.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
const constants = require("../constants")
|
||||||
|
const TimelineImage = require("./TimelineImage")
|
||||||
|
const collectors = require("../collectors")
|
||||||
|
require("../testimports")(constants, TimelineImage)
|
||||||
|
|
||||||
|
function transformEdges(edges) {
|
||||||
|
return edges.map(e => new TimelineImage(e.node))
|
||||||
|
}
|
||||||
|
|
||||||
|
class Timeline {
|
||||||
|
/**
|
||||||
|
* @param {import("./User")} user
|
||||||
|
*/
|
||||||
|
constructor(user) {
|
||||||
|
this.user = user
|
||||||
|
this.pages = []
|
||||||
|
this.addPage(this.user.data.edge_owner_to_timeline_media)
|
||||||
|
this.page_info = this.user.data.edge_owner_to_timeline_media.page_info
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNextPage() {
|
||||||
|
return this.page_info.has_next_page
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchNextPage() {
|
||||||
|
if (!this.hasNextPage()) return constants.symbols.NO_MORE_PAGES
|
||||||
|
return collectors.fetchTimelinePage(this.user.data.id, this.page_info.end_cursor).then(page => {
|
||||||
|
this.addPage(page)
|
||||||
|
return this.pages.slice(-1)[0]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchUpToPage(index) {
|
||||||
|
while (this.pages[index] === undefined && this.hasNextPage()) {
|
||||||
|
await this.fetchNextPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addPage(page) {
|
||||||
|
this.pages.push(transformEdges(page.edges))
|
||||||
|
this.page_info = page.page_info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Timeline
|
41
src/lib/structures/TimelineImage.js
Normal file
41
src/lib/structures/TimelineImage.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
class GraphImage {
|
||||||
|
/**
|
||||||
|
* @param {import("../types").GraphImage} data
|
||||||
|
*/
|
||||||
|
constructor(data) {
|
||||||
|
this.data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} size
|
||||||
|
*/
|
||||||
|
getSuggestedThumbnail(size) {
|
||||||
|
let found = null
|
||||||
|
for (const tr of this.data.thumbnail_resources) {
|
||||||
|
found = tr
|
||||||
|
if (tr.config_width >= size) break
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
getSrcset() {
|
||||||
|
return this.data.thumbnail_resources.map(tr => {
|
||||||
|
const p = new URLSearchParams()
|
||||||
|
p.set("width", String(tr.config_width))
|
||||||
|
p.set("url", tr.src)
|
||||||
|
return `/imageproxy?${p.toString()} ${tr.config_width}w`
|
||||||
|
}).join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
getCaption() {
|
||||||
|
if (this.data.edge_media_to_caption.edges[0]) return this.data.edge_media_to_caption.edges[0].node.text
|
||||||
|
else return null
|
||||||
|
}
|
||||||
|
|
||||||
|
getAlt() {
|
||||||
|
// For some reason, pages 2+ don't contain a11y data. Instagram web client falls back to image caption.
|
||||||
|
return this.data.accessibility_caption || this.getCaption() || "No image description available."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GraphImage
|
21
src/lib/structures/User.js
Normal file
21
src/lib/structures/User.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const Timeline = require("./Timeline")
|
||||||
|
require("../testimports")(Timeline)
|
||||||
|
|
||||||
|
class User {
|
||||||
|
/**
|
||||||
|
* @param {import("../types").GraphUser} data
|
||||||
|
*/
|
||||||
|
constructor(data) {
|
||||||
|
this.data = data
|
||||||
|
this.following = data.edge_follow.count
|
||||||
|
this.followedBy = data.edge_followed_by.count
|
||||||
|
this.posts = data.edge_owner_to_timeline_media.count
|
||||||
|
this.timeline = new Timeline(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
export() {
|
||||||
|
return this.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = User
|
3
src/lib/structures/index.js
Normal file
3
src/lib/structures/index.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module.exports = {
|
||||||
|
User: require("./User")
|
||||||
|
}
|
7
src/lib/testimports.js
Normal file
7
src/lib/testimports.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module.exports = function(...items) {
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item === undefined || (item && item.constructor && item.constructor.name == "Object" && Object.keys(item).length == 0)) {
|
||||||
|
throw new Error("Bad import: item looks like this: "+JSON.stringify(item))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
64
src/lib/types.js
Normal file
64
src/lib/types.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @typedef GraphEdgeCount
|
||||||
|
* @property {number} count
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef GraphEdgesText
|
||||||
|
* @type {{edges: {node: {text: string}}[]}}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef PagedEdges<T>
|
||||||
|
* @property {number} count
|
||||||
|
* @property {{has_next_page: boolean, end_cursor: string}} page_info
|
||||||
|
* @property {{node: T}[]} edges
|
||||||
|
* @template T
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef GraphUser
|
||||||
|
* @property {string} biography
|
||||||
|
* @property {string} external_url
|
||||||
|
* @property {GraphEdgeCount} edge_followed_by
|
||||||
|
* @property {GraphEdgeCount} edge_follow
|
||||||
|
* @property {string} full_name
|
||||||
|
* @property {string} id
|
||||||
|
* @property {boolean} is_business_account
|
||||||
|
* @property {boolean} is_joined_recently
|
||||||
|
* @property {boolean} is_verified
|
||||||
|
* @property {string} profile_pic_url
|
||||||
|
* @property {string} profile_pic_url_hd
|
||||||
|
* @property {string} username
|
||||||
|
*
|
||||||
|
* @property {any} edge_felix_video_timeline
|
||||||
|
* @property {PagedEdges<GraphImage>} edge_owner_to_timeline_media
|
||||||
|
* @property {any} edge_saved_media
|
||||||
|
* @property {any} edge_media_collections
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef Thumbnail
|
||||||
|
* @property {string} src
|
||||||
|
* @property {number} config_width
|
||||||
|
* @property {number} config_height
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef GraphImage
|
||||||
|
* @property {string} id
|
||||||
|
* @property {GraphEdgesText} edge_media_to_caption
|
||||||
|
* @property {string} shortcode
|
||||||
|
* @property {GraphEdgeCount} edge_media_to_comment
|
||||||
|
* @property {number} taken_at_timestamp No milliseconds
|
||||||
|
* @property {GraphEdgeCount} edge_liked_by
|
||||||
|
* @property {GraphEdgeCount} edge_media_preview_like
|
||||||
|
* @property {{width: number, height: number}} dimensions
|
||||||
|
* @property {string} display_url
|
||||||
|
* @property {{id: string, username: string}} owner
|
||||||
|
* @property {string} thumbnail_src
|
||||||
|
* @property {Thumbnail[]} thumbnail_resources
|
||||||
|
* @property {string} accessibility_caption
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {}
|
17
src/lib/utils/body.js
Normal file
17
src/lib/utils/body.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const {Parser} = require("./parser/parser")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text
|
||||||
|
*/
|
||||||
|
function extractSharedData(text) {
|
||||||
|
const parser = new Parser(text)
|
||||||
|
parser.seek("window._sharedData = ", {moveToMatch: true, useEnd: true})
|
||||||
|
parser.store()
|
||||||
|
const end = parser.seek(";</script>")
|
||||||
|
parser.restore()
|
||||||
|
const sharedDataString = parser.slice(end - parser.cursor)
|
||||||
|
const sharedData = JSON.parse(sharedDataString)
|
||||||
|
return sharedData
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.extractSharedData = extractSharedData
|
160
src/lib/utils/parser/parser.js
Normal file
160
src/lib/utils/parser/parser.js
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* @typedef GetOptions
|
||||||
|
* @property {string} [split] Characters to split on
|
||||||
|
* @property {string} [mode] "until" or "between"; choose where to get the content from
|
||||||
|
* @property {function} [transform] Transformation to apply to result before returning
|
||||||
|
*/
|
||||||
|
|
||||||
|
const tf = {
|
||||||
|
lc: s => s.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parser {
|
||||||
|
constructor(string) {
|
||||||
|
this.string = string;
|
||||||
|
this.substore = [];
|
||||||
|
this.cursor = 0;
|
||||||
|
this.cursorStore = [];
|
||||||
|
this.mode = "until";
|
||||||
|
this.transform = s => s;
|
||||||
|
this.split = " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all the remaining text from the buffer, without updating the cursor
|
||||||
|
* @return {String}
|
||||||
|
*/
|
||||||
|
remaining() {
|
||||||
|
return this.string.slice(this.cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Have we reached the end of the string yet?
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
hasRemaining() {
|
||||||
|
return this.cursor < this.string.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {GetOptions} [options]
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
get(options = {}) {
|
||||||
|
["mode", "split", "transform"].forEach(o => {
|
||||||
|
if (!options[o]) options[o] = this[o];
|
||||||
|
});
|
||||||
|
if (options.mode == "until") {
|
||||||
|
let next = this.string.indexOf(options.split, this.cursor+options.split.length);
|
||||||
|
if (next == -1) {
|
||||||
|
let result = this.remaining();
|
||||||
|
this.cursor = this.string.length;
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
let result = this.string.slice(this.cursor, next);
|
||||||
|
this.cursor = next + options.split.length;
|
||||||
|
return options.transform(result);
|
||||||
|
}
|
||||||
|
} else if (options.mode == "between") {
|
||||||
|
let start = this.string.indexOf(options.split, this.cursor);
|
||||||
|
let end = this.string.indexOf(options.split, start+options.split.length);
|
||||||
|
let result = this.string.slice(start+options.split.length, end);
|
||||||
|
this.cursor = end + options.split.length;
|
||||||
|
return options.transform(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a number of chars from the buffer.
|
||||||
|
* @param {number} length Number of chars to get
|
||||||
|
* @param {boolean} [move] Whether to update the cursor
|
||||||
|
*/
|
||||||
|
slice(length, move = false) {
|
||||||
|
let result = this.string.slice(this.cursor, this.cursor+length);
|
||||||
|
if (move) this.cursor += length;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repeatedly swallow a character.
|
||||||
|
* @param {String} char
|
||||||
|
*/
|
||||||
|
swallow(char) {
|
||||||
|
let before = this.cursor;
|
||||||
|
while (this.string[this.cursor] == char) this.cursor++;
|
||||||
|
return this.cursor - before;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the current cursor position to the store
|
||||||
|
*/
|
||||||
|
store() {
|
||||||
|
this.cursorStore.push(this.cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pop the previous cursor position from the store
|
||||||
|
*/
|
||||||
|
restore() {
|
||||||
|
this.cursor = this.cursorStore.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a get operation, test against an input, return success or failure, and restore the cursor.
|
||||||
|
* @param {String} value The value to test against
|
||||||
|
* @param {Object} options Options for get
|
||||||
|
*/
|
||||||
|
test(value, options) {
|
||||||
|
this.store();
|
||||||
|
let next = this.get(options);
|
||||||
|
let result = next == value;
|
||||||
|
this.restore();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a get operation, test against an input, and throw an error if it doesn't match.
|
||||||
|
* @param {String} value
|
||||||
|
* @param {GetOptions} [options]
|
||||||
|
*/
|
||||||
|
expect(value, options = {}) {
|
||||||
|
let next = this.get(options);
|
||||||
|
if (next != value) throw new Error("Expected "+value+", got "+next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek past the next occurance of the string.
|
||||||
|
* @param {string} toFind
|
||||||
|
* @param {{moveToMatch?: boolean, useEnd?: boolean}} options both default to false
|
||||||
|
*/
|
||||||
|
seek(toFind, options = {}) {
|
||||||
|
if (options.moveToMatch === undefined) options.moveToMatch = false
|
||||||
|
if (options.useEnd === undefined) options.useEnd = false
|
||||||
|
let index = this.string.indexOf(toFind, this.cursor)
|
||||||
|
if (index !== -1) {
|
||||||
|
if (options.useEnd) index += toFind.length
|
||||||
|
if (options.moveToMatch) this.cursor = index
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the current string, adding the old one to the substore.
|
||||||
|
* @param {string} string
|
||||||
|
*/
|
||||||
|
unshiftSubstore(string) {
|
||||||
|
this.substore.unshift({string: this.string, cursor: this.cursor, cursorStore: this.cursorStore})
|
||||||
|
this.string = string
|
||||||
|
this.cursor = 0
|
||||||
|
this.cursorStore = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the current string with the first entry from the substore.
|
||||||
|
*/
|
||||||
|
shiftSubstore() {
|
||||||
|
Object.assign(this, this.substore.shift())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.Parser = Parser
|
11
src/lib/utils/request.js
Normal file
11
src/lib/utils/request.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
const fetch = require("node-fetch").default
|
||||||
|
|
||||||
|
function request(url) {
|
||||||
|
return fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.request = request
|
17
src/site/api/api.js
Normal file
17
src/site/api/api.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const {fetchUser} = require("../../lib/collectors")
|
||||||
|
|
||||||
|
function reply(statusCode, content) {
|
||||||
|
return {
|
||||||
|
statusCode: statusCode,
|
||||||
|
contentType: "application/json",
|
||||||
|
content: JSON.stringify(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{route: "/api/user/(\\w+)", methods: ["GET"], code: async ({fill}) => {
|
||||||
|
const user = await fetchUser(fill[0])
|
||||||
|
const data = user.export()
|
||||||
|
return reply(200, data)
|
||||||
|
}}
|
||||||
|
]
|
61
src/site/api/proxy.js
Normal file
61
src/site/api/proxy.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
const constants = require("../../lib/constants")
|
||||||
|
const {request} = require("../../lib/utils/request")
|
||||||
|
const {proxy} = require("pinski/plugins")
|
||||||
|
const gm = require("gm")
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{route: "/imageproxy", methods: ["GET"], code: async (input) => {
|
||||||
|
/** @type {URL} */
|
||||||
|
// check url param exists
|
||||||
|
const completeURL = input.url
|
||||||
|
const params = completeURL.searchParams
|
||||||
|
if (!params.get("url")) return [400, "Must supply `url` query parameter"]
|
||||||
|
try {
|
||||||
|
var url = new URL(params.get("url"))
|
||||||
|
} catch (e) {
|
||||||
|
return [400, "`url` query parameter is not a valid URL"]
|
||||||
|
}
|
||||||
|
// check url protocol
|
||||||
|
if (url.protocol !== "https:") return [400, "URL protocol must be `https:`"]
|
||||||
|
// check url host
|
||||||
|
if (!["fbcdn.net", "cdninstagram.com"].some(host => url.host.endsWith(host))) return [400, "URL host is not allowed"]
|
||||||
|
if (!["png", "jpg"].some(ext => url.pathname.endsWith(ext))) return [400, "URL extension is not allowed"]
|
||||||
|
const width = +params.get("width")
|
||||||
|
if (typeof width === "number" && !isNaN(width) && width > 0) {
|
||||||
|
/*
|
||||||
|
This uses graphicsmagick to force crop the image to a square.
|
||||||
|
Some thumbnails aren't square and will be stretched on the page without this.
|
||||||
|
If I cropped the images client side, it would have to be done with CSS background-image, which means no <img srcset>.
|
||||||
|
*/
|
||||||
|
return request(url).then(res => {
|
||||||
|
const image = gm(res.body).gravity("Center").crop(width, width, 0, 0).repage("+")
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": constants.image_cache_control
|
||||||
|
},
|
||||||
|
stream: image.stream("jpg")
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
Alternative buffer-based method for sending file:
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
image.toBuffer((err, buffer) => {
|
||||||
|
if (err) console.error(err)
|
||||||
|
resolve({
|
||||||
|
statusCode: 200,
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
content: buffer
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
*/
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return proxy(url, {
|
||||||
|
"Cache-Control": constants.image_cache_control
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
]
|
14
src/site/api/routes.js
Normal file
14
src/site/api/routes.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
const {fetchUser} = require("../../lib/collectors")
|
||||||
|
const {render} = require("pinski/plugins")
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{route: "/u/(\\w+)", methods: ["GET"], code: async ({url, fill}) => {
|
||||||
|
const params = url.searchParams
|
||||||
|
const user = await fetchUser(fill[0])
|
||||||
|
const page = +params.get("page")
|
||||||
|
if (typeof page === "number" && !isNaN(page) && page >= 1) {
|
||||||
|
await user.timeline.fetchUpToPage(page - 1)
|
||||||
|
}
|
||||||
|
return render(200, "pug/user.pug", {url, user})
|
||||||
|
}}
|
||||||
|
]
|
15
src/site/passthrough.js
Normal file
15
src/site/passthrough.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @typedef Passthrough
|
||||||
|
* @property {import("pinski").Pinski} instance
|
||||||
|
* @property {import("ws").Server} wss
|
||||||
|
* @property {import("pinski").PugCache} pugCache
|
||||||
|
*/
|
||||||
|
|
||||||
|
void 0
|
||||||
|
|
||||||
|
/** @type {Passthrough} */
|
||||||
|
// @ts-ignore
|
||||||
|
const passthrough = {
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = passthrough
|
5
src/site/pug/includes/image.pug
Normal file
5
src/site/pug/includes/image.pug
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mixin image(url)
|
||||||
|
-
|
||||||
|
let params = new URLSearchParams()
|
||||||
|
params.set("url", url)
|
||||||
|
img(src="/imageproxy?"+params.toString())&attributes(attributes)
|
15
src/site/pug/includes/timeline_page.pug
Normal file
15
src/site/pug/includes/timeline_page.pug
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
//- Needs page, pageIndex
|
||||||
|
|
||||||
|
include image.pug
|
||||||
|
|
||||||
|
mixin timeline_page(page, pageIndex)
|
||||||
|
- const pageNumber = pageIndex + 1
|
||||||
|
if pageNumber > 1
|
||||||
|
.page-number(id=`page-${pageNumber}`)
|
||||||
|
span.number Page #{pageNumber}
|
||||||
|
|
||||||
|
.timeline-inner
|
||||||
|
- const suggestedSize = 300
|
||||||
|
each image in page
|
||||||
|
- const thumbnail = image.getSuggestedThumbnail(suggestedSize) //- use this as the src in case there are problems with srcset
|
||||||
|
+image(thumbnail.src)(alt=image.getAlt() width=thumbnail.config_width height=thumbnail.config_height srcset=image.getSrcset() sizes=`${suggestedSize}px`).image
|
49
src/site/pug/user.pug
Normal file
49
src/site/pug/user.pug
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
include includes/timeline_page.pug
|
||||||
|
|
||||||
|
- const numberFormat = new Intl.NumberFormat().format
|
||||||
|
|
||||||
|
doctype html
|
||||||
|
html
|
||||||
|
head
|
||||||
|
meta(charset="utf-8")
|
||||||
|
meta(name="viewport" content="width=device-width, initial-scale=1")
|
||||||
|
title
|
||||||
|
= `${user.data.full_name} (@${user.data.username}) | Bibliogram`
|
||||||
|
link(rel="stylesheet" type="text/css" href="/static/css/main.css")
|
||||||
|
body
|
||||||
|
.main-divider
|
||||||
|
header.profile-overview
|
||||||
|
.profile-sticky
|
||||||
|
+image(user.data.profile_pic_url)(width="150px" height="150px").pfp
|
||||||
|
//-
|
||||||
|
Instagram only uses the above URL, but an HD version is also available:
|
||||||
|
+image(user.data.profile_pic_url_hd)
|
||||||
|
h1.full-name= user.data.full_name
|
||||||
|
h2.username= `@${user.data.username}`
|
||||||
|
p.bio= user.data.biography
|
||||||
|
div.profile-counter
|
||||||
|
span(data-numberformat=user.posts).count #{numberFormat(user.posts)}
|
||||||
|
|
|
||||||
|
| posts
|
||||||
|
div.profile-counter
|
||||||
|
span(data-numberformat=user.following).count #{numberFormat(user.following)}
|
||||||
|
|
|
||||||
|
| following
|
||||||
|
div.profile-counter
|
||||||
|
span(data-numberformat=user.followedBy).count #{numberFormat(user.followedBy)}
|
||||||
|
|
|
||||||
|
| followed by
|
||||||
|
|
||||||
|
main.timeline
|
||||||
|
each page, pageIndex in user.timeline.pages
|
||||||
|
+timeline_page(page, pageIndex)
|
||||||
|
|
||||||
|
if user.timeline.hasNextPage()
|
||||||
|
div.next-page-container
|
||||||
|
-
|
||||||
|
const nu = new URL(url)
|
||||||
|
nu.searchParams.set("page", user.timeline.pages.length+1)
|
||||||
|
a(href=`${nu.search}#page-${user.timeline.pages.length+1}` data-cursor=user.timeline.page_info.end_cursor)#next-page.next-page Next page
|
||||||
|
else
|
||||||
|
div.page-number.no-more-pages
|
||||||
|
span.number No more posts.
|
137
src/site/sass/main.sass
Normal file
137
src/site/sass/main.sass
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
$layout-a-max: 820px
|
||||||
|
$layout-b-min: 821px
|
||||||
|
|
||||||
|
body
|
||||||
|
margin: 0
|
||||||
|
padding: 0
|
||||||
|
font-size: 18px
|
||||||
|
|
||||||
|
.main-divider
|
||||||
|
display: block
|
||||||
|
|
||||||
|
@media screen and (min-width: $layout-b-min)
|
||||||
|
display: grid
|
||||||
|
grid-template-columns: 235px 1fr
|
||||||
|
min-height: 100vh
|
||||||
|
|
||||||
|
.pfp
|
||||||
|
border-radius: 50%
|
||||||
|
|
||||||
|
@mixin link-button
|
||||||
|
color: hsl(107, 100%, 21.8%)
|
||||||
|
background: hsl(87, 78.4%, 80%)
|
||||||
|
padding: 12px
|
||||||
|
border-radius: 10px
|
||||||
|
border: 1px solid hsl(106.9, 49.8%, 46.9%)
|
||||||
|
line-height: 1
|
||||||
|
text-decoration: none
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
color: hsl(106.4, 100%, 12.9%)
|
||||||
|
background: hsl(102.1, 77.2%, 67.3%)
|
||||||
|
border-color: hsl(104, 51.4%, 43.5%)
|
||||||
|
|
||||||
|
.profile-overview
|
||||||
|
text-align: center
|
||||||
|
z-index: 1
|
||||||
|
position: relative
|
||||||
|
contain: paint // </3 css
|
||||||
|
line-height: 1
|
||||||
|
|
||||||
|
@media screen and (max-width: $layout-a-max)
|
||||||
|
border-bottom: 1px solid #333
|
||||||
|
box-shadow: 0px -2px 4px 4px rgba(0, 0, 0, 0.4)
|
||||||
|
padding-bottom: 25px
|
||||||
|
|
||||||
|
@media screen and (min-width: $layout-b-min)
|
||||||
|
border-right: 1px solid #333
|
||||||
|
box-shadow: -2px 0px 4px 4px rgba(0, 0, 0, 0.4)
|
||||||
|
|
||||||
|
.profile-sticky
|
||||||
|
position: sticky
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
padding: 10px
|
||||||
|
|
||||||
|
.pfp
|
||||||
|
margin: 25px 0
|
||||||
|
|
||||||
|
.full-name
|
||||||
|
margin: 0 0 8px
|
||||||
|
font-size: 30px
|
||||||
|
|
||||||
|
.username
|
||||||
|
margin: 0
|
||||||
|
font-size: 20px
|
||||||
|
font-weight: normal
|
||||||
|
|
||||||
|
.profile-counter
|
||||||
|
line-height: 1.3
|
||||||
|
|
||||||
|
.count
|
||||||
|
font-weight: bold
|
||||||
|
|
||||||
|
.timeline
|
||||||
|
--image-size: 260px
|
||||||
|
$image-size: var(--image-size)
|
||||||
|
$background: #fff4e8
|
||||||
|
|
||||||
|
@media screen and (max-width: $layout-a-max)
|
||||||
|
--image-size: 120px
|
||||||
|
|
||||||
|
background-color: $background
|
||||||
|
padding: 15px 15px 12vh
|
||||||
|
|
||||||
|
.page-number
|
||||||
|
color: #444
|
||||||
|
line-height: 1
|
||||||
|
max-width: 600px
|
||||||
|
margin: 0px auto
|
||||||
|
padding: 20px 0px // separate margin and padding for better page hash jump locations
|
||||||
|
text-align: center
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
.number
|
||||||
|
position: relative
|
||||||
|
z-index: 1
|
||||||
|
padding: 10px
|
||||||
|
background-color: $background
|
||||||
|
|
||||||
|
&::before
|
||||||
|
position: absolute
|
||||||
|
display: block
|
||||||
|
content: ""
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
top: 50%
|
||||||
|
border-top: 1px solid
|
||||||
|
|
||||||
|
.next-page-container
|
||||||
|
margin: 20px 0px
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
|
||||||
|
.next-page
|
||||||
|
@include link-button
|
||||||
|
font-size: 18px
|
||||||
|
|
||||||
|
.timeline-inner
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
flex-wrap: wrap
|
||||||
|
margin: 0 auto
|
||||||
|
|
||||||
|
.image
|
||||||
|
$margin: 5px
|
||||||
|
|
||||||
|
background-color: rgba(40, 40, 40, 0.25)
|
||||||
|
margin: $margin
|
||||||
|
max-width: $image-size
|
||||||
|
max-height: $image-size
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
$border-width: 3px
|
||||||
|
margin: $margin - $border-width
|
||||||
|
border: $border-width solid #111
|
26
src/site/server.js
Normal file
26
src/site/server.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const {Pinski} = require("pinski")
|
||||||
|
const {subdirs} = require("node-dir")
|
||||||
|
|
||||||
|
const passthrough = require("./passthrough")
|
||||||
|
|
||||||
|
const pinski = new Pinski({
|
||||||
|
port: 10407,
|
||||||
|
relativeRoot: __dirname
|
||||||
|
})
|
||||||
|
|
||||||
|
subdirs("pug", (err, dirs) => {
|
||||||
|
if (err) throw err
|
||||||
|
|
||||||
|
//pinski.addRoute("/", "pug/index.pug", "pug")
|
||||||
|
pinski.addRoute("/static/css/main.css", "sass/main.sass", "sass")
|
||||||
|
pinski.addPugDir("pug", dirs)
|
||||||
|
pinski.addAPIDir("html/static/js/templates/api")
|
||||||
|
pinski.addSassDir("sass")
|
||||||
|
pinski.addAPIDir("api")
|
||||||
|
pinski.startServer()
|
||||||
|
pinski.enableWS()
|
||||||
|
|
||||||
|
require("pinski/plugins").setInstance(pinski)
|
||||||
|
|
||||||
|
Object.assign(passthrough, pinski.getExports())
|
||||||
|
})
|
@ -1,348 +0,0 @@
|
|||||||
/**
|
|
||||||
* @typedef {Object} GetOptions
|
|
||||||
* @property {String} split Characters to split on
|
|
||||||
* @property {String} mode "until" or "between"; choose where to get the content from
|
|
||||||
* @property {Function} transform Transformation to apply to result before returning
|
|
||||||
*/
|
|
||||||
|
|
||||||
const tf = {
|
|
||||||
lc: s => s.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
class Parser {
|
|
||||||
constructor(string) {
|
|
||||||
this.string = string;
|
|
||||||
this.substore = [];
|
|
||||||
this.cursor = 0;
|
|
||||||
this.cursorStore = [];
|
|
||||||
this.mode = "until";
|
|
||||||
this.transform = s => s;
|
|
||||||
this.split = " ";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return all the remaining text from the buffer, without updating the cursor
|
|
||||||
* @return {String}
|
|
||||||
*/
|
|
||||||
remaining() {
|
|
||||||
return this.string.slice(this.cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Have we reached the end of the string yet?
|
|
||||||
* @return {boolean}
|
|
||||||
*/
|
|
||||||
hasRemaining() {
|
|
||||||
return this.cursor < this.string.length
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {GetOptions} options
|
|
||||||
* @returns {String}
|
|
||||||
*/
|
|
||||||
get(options = {}) {
|
|
||||||
["mode", "split", "transform"].forEach(o => {
|
|
||||||
if (!options[o]) options[o] = this[o];
|
|
||||||
});
|
|
||||||
if (options.mode == "until") {
|
|
||||||
let next = this.string.indexOf(options.split, this.cursor+options.split.length);
|
|
||||||
if (next == -1) {
|
|
||||||
let result = this.remaining();
|
|
||||||
this.cursor = this.string.length;
|
|
||||||
return result;
|
|
||||||
} else {
|
|
||||||
let result = this.string.slice(this.cursor, next);
|
|
||||||
this.cursor = next + options.split.length;
|
|
||||||
return options.transform(result);
|
|
||||||
}
|
|
||||||
} else if (options.mode == "between") {
|
|
||||||
let start = this.string.indexOf(options.split, this.cursor);
|
|
||||||
let end = this.string.indexOf(options.split, start+options.split.length);
|
|
||||||
let result = this.string.slice(start+options.split.length, end);
|
|
||||||
this.cursor = end + options.split.length;
|
|
||||||
return options.transform(result);
|
|
||||||
} else if (options.mode == "length" || options.length != undefined) {
|
|
||||||
let result = this.string.slice(this.cursor, this.cursor+options.length);
|
|
||||||
this.cursor += options.length
|
|
||||||
return options.transform(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a number of chars from the buffer.
|
|
||||||
* @param {Number} length Number of chars to get
|
|
||||||
* @param {Boolean} move Whether to update the cursor
|
|
||||||
*/
|
|
||||||
slice(length, move) {
|
|
||||||
let result = this.string.slice(this.cursor, this.cursor+length);
|
|
||||||
if (move) this.cursor += length;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repeatedly swallow a character.
|
|
||||||
* @param {String} char
|
|
||||||
*/
|
|
||||||
swallow(char) {
|
|
||||||
let before = this.cursor;
|
|
||||||
while (this.string[this.cursor] == char) this.cursor++;
|
|
||||||
return this.cursor - before;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Push the current cursor position to the store
|
|
||||||
*/
|
|
||||||
store() {
|
|
||||||
this.cursorStore.push(this.cursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pop the previous cursor position from the store
|
|
||||||
*/
|
|
||||||
restore() {
|
|
||||||
this.cursor = this.cursorStore.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a get operation, test against an input, return success or failure, and restore the cursor.
|
|
||||||
* @param {String} value The value to test against
|
|
||||||
* @param {Object} options Options for get
|
|
||||||
*/
|
|
||||||
test(value, options) {
|
|
||||||
this.store();
|
|
||||||
let next = this.get(options);
|
|
||||||
let result = next == value;
|
|
||||||
this.restore();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a get operation, test against an input, and throw an error if it doesn't match.
|
|
||||||
* @param {String} value
|
|
||||||
* @param {GetOptions} options
|
|
||||||
*/
|
|
||||||
expect(value, options) {
|
|
||||||
let next = this.get(options);
|
|
||||||
if (next != value) throw new Error("Expected "+value+", got "+next);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the current string, adding the old one to the substore.
|
|
||||||
* @param {string} string
|
|
||||||
*/
|
|
||||||
unshiftSubstore(string) {
|
|
||||||
this.substore.unshift({string: this.string, cursor: this.cursor, cursorStore: this.cursorStore})
|
|
||||||
this.string = string
|
|
||||||
this.cursor = 0
|
|
||||||
this.cursorStore = []
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the current string with the first entry from the substore.
|
|
||||||
*/
|
|
||||||
shiftSubstore() {
|
|
||||||
Object.assign(this, this.substore.shift())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SQLParser extends Parser {
|
|
||||||
collectAssignments(operators = ["="]) {
|
|
||||||
let assignments = [];
|
|
||||||
let done = false;
|
|
||||||
while (!done) {
|
|
||||||
// Also build up a raw string.
|
|
||||||
let raw = "";
|
|
||||||
// Next word is the field name/index.
|
|
||||||
let name = this.get();
|
|
||||||
raw += name;
|
|
||||||
// Next word should be "=".
|
|
||||||
let operator = this.get();
|
|
||||||
if (!operators.includes(operator)) throw new Error("Invalid operator: received "+operator+", expected one from "+JSON.stringify(operators));
|
|
||||||
raw += " "+operator;
|
|
||||||
// Next word is the value
|
|
||||||
let extraction = this.extractValue();
|
|
||||||
let value = extraction.value;
|
|
||||||
raw += " "+value;
|
|
||||||
assignments.push({name, value, raw});
|
|
||||||
done = extraction.done;
|
|
||||||
this.swallow(" ");
|
|
||||||
}
|
|
||||||
return assignments;
|
|
||||||
}
|
|
||||||
|
|
||||||
extractValue() {
|
|
||||||
// Next word is the value, which may or may not be in quotes.
|
|
||||||
if (`"'`.includes(this.slice(1))) {
|
|
||||||
// Is between quotes
|
|
||||||
let value = this.get({mode: "between", split: this.slice(1)});
|
|
||||||
// Check end
|
|
||||||
let done = this.swallow(",") == 0;
|
|
||||||
return {value, done};
|
|
||||||
} else {
|
|
||||||
// Is not between quotes
|
|
||||||
let value = this.get();
|
|
||||||
// Check end (
|
|
||||||
let done = !value.endsWith(",") || value.endsWith(")");
|
|
||||||
value = value.replace(/(,|\))$/, "");
|
|
||||||
return {value, done};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
collectList() {
|
|
||||||
let items = [];
|
|
||||||
let done = false;
|
|
||||||
while (!done) {
|
|
||||||
let extraction = this.extractValue();
|
|
||||||
items.push(extraction.value);
|
|
||||||
done = extraction.done;
|
|
||||||
this.swallow(" ");
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
parseOptions() {
|
|
||||||
let options = {};
|
|
||||||
while (this.remaining().length) { // While there's still options to collect...
|
|
||||||
// What option are we processing?
|
|
||||||
let optype = this.get({transform: tf.lc});
|
|
||||||
// Limit
|
|
||||||
if (optype == "limit") {
|
|
||||||
// How many are we limiting to?
|
|
||||||
options.limit = +this.get();
|
|
||||||
}
|
|
||||||
// Where
|
|
||||||
else if (optype == "where") {
|
|
||||||
// We'll just pass the filter directly in.
|
|
||||||
if (!options.filters) options.filters = [];
|
|
||||||
options.filters = options.filters.concat(this.collectAssignments([
|
|
||||||
"=", "==", "<", ">", "<=", ">=", "!=", "<>",
|
|
||||||
"#=", "#<", "#>", "#<=", "#>=", "#!=", "#<>"
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
// Single
|
|
||||||
else if (optype == "single") {
|
|
||||||
// Return first row only.
|
|
||||||
options.single = true;
|
|
||||||
}
|
|
||||||
// Join
|
|
||||||
else if (["left", "right", "inner", "outer"].includes(optype)) {
|
|
||||||
if (!options.joins) options.joins = [];
|
|
||||||
// SELECT * FROM Purchases WHERE purchaseID = 2 INNER JOIN PurchaseItems ON Purchases.purchaseID = PurchaseItems.purchaseID
|
|
||||||
// SELECT * FROM Purchases WHERE purchaseID = 2 INNER JOIN PurchaseItems USING (purchaseID)
|
|
||||||
// Left, right, inner, outer
|
|
||||||
let direction = optype;
|
|
||||||
// Join
|
|
||||||
this.expect("join", {transform: tf.lc});
|
|
||||||
// Table
|
|
||||||
let target = this.get();
|
|
||||||
// Mode (on/using)
|
|
||||||
let mode = this.get({transform: tf.lc});
|
|
||||||
if (mode == "using") {
|
|
||||||
this.swallow("(");
|
|
||||||
let field = this.extractValue().value;
|
|
||||||
this.swallow(")");
|
|
||||||
var fields = new Array(2).fill().map(() => ({
|
|
||||||
table: null,
|
|
||||||
field
|
|
||||||
}));
|
|
||||||
} else if (mode == "on") {
|
|
||||||
var fields = [];
|
|
||||||
const extractCombo = () => {
|
|
||||||
// Extract from Table.field
|
|
||||||
let value = this.extractValue().value;
|
|
||||||
let fragments = value.split(".");
|
|
||||||
// Field only
|
|
||||||
if (fragments.length == 1) {
|
|
||||||
return {
|
|
||||||
table: null,
|
|
||||||
field: fragments[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Table.field
|
|
||||||
else {
|
|
||||||
return {
|
|
||||||
table: fragments[0],
|
|
||||||
field: fragments[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fields.push(extractCombo());
|
|
||||||
this.expect("=");
|
|
||||||
fields.push(extractCombo());
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid join mode: "+mode);
|
|
||||||
}
|
|
||||||
options.joins.push({direction, target, fields});
|
|
||||||
}
|
|
||||||
// Unknown option
|
|
||||||
else {
|
|
||||||
throw new Error("Unknown query optype: "+optype);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
parseStatement() {
|
|
||||||
let operation = this.get({transform: tf.lc});
|
|
||||||
// Select
|
|
||||||
if (operation == "select") {
|
|
||||||
// Fields
|
|
||||||
let fields = this.collectList();
|
|
||||||
// From
|
|
||||||
this.expect("from", {transform: tf.lc});
|
|
||||||
// Table
|
|
||||||
let table = this.get();
|
|
||||||
// Where, limit, join, single, etc
|
|
||||||
let options = this.parseOptions();
|
|
||||||
return {operation, fields, table, options};
|
|
||||||
}
|
|
||||||
// Insert
|
|
||||||
else if (operation == "insert") {
|
|
||||||
// Into
|
|
||||||
this.expect("into", {transform: tf.lc});
|
|
||||||
// Table
|
|
||||||
let table = this.get();
|
|
||||||
// Fields?
|
|
||||||
let fields = [];
|
|
||||||
this.swallow("(");
|
|
||||||
if (!this.test("values", {transform: tf.lc})) {
|
|
||||||
fields = this.collectList();
|
|
||||||
}
|
|
||||||
// End of fields
|
|
||||||
this.swallow(")");
|
|
||||||
this.swallow(" ");
|
|
||||||
// Values
|
|
||||||
this.expect("values", {transform: tf.lc});
|
|
||||||
this.swallow("(");
|
|
||||||
let values = this.collectList();
|
|
||||||
this.swallow(")");
|
|
||||||
return {operation, table, fields, values};
|
|
||||||
}
|
|
||||||
// Update
|
|
||||||
else if (operation == "update") {
|
|
||||||
// Table
|
|
||||||
let table = this.get();
|
|
||||||
// Set
|
|
||||||
this.expect("set", {transform: tf.lc});
|
|
||||||
// Assignments
|
|
||||||
let assignments = this.collectAssignments();
|
|
||||||
// Where, limit, join, single, etc
|
|
||||||
let options = this.parseOptions();
|
|
||||||
return {operation, table, assignments, options};
|
|
||||||
}
|
|
||||||
// Delete
|
|
||||||
else if (operation == "delete") {
|
|
||||||
// From
|
|
||||||
this.expect("from", {transform: tf.lc});
|
|
||||||
// Table
|
|
||||||
let table = this.get();
|
|
||||||
// Where, limit, join, single, etc
|
|
||||||
let options = this.parseOptions();
|
|
||||||
return {operation, table, options};
|
|
||||||
}
|
|
||||||
throw new Error("Unknown operation: "+operation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports.Parser = Parser
|
|
||||||
module.exports.SQLParser = SQLParser
|
|
Loading…
Reference in New Issue
Block a user