From b3964f3be3ec6208b7a9ccf0a26fb5fea861c29b Mon Sep 17 00:00:00 2001 From: Tyler Schicke Date: Wed, 10 Jul 2019 20:27:25 -0400 Subject: Refactored how History stuff works to make readonly work better --- package.json | 1 + 1 file changed, 1 insertion(+) (limited to 'package.json') diff --git a/package.json b/package.json index 51d1bab5d..85fc7f1be 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "prosemirror-transform": "^1.1.3", "prosemirror-view": "^1.8.3", "pug": "^2.0.3", + "query-string": "^6.8.1", "raw-loader": "^1.0.0", "react": "^16.8.4", "react-anime": "^2.2.0", -- cgit v1.2.3-70-g09d2 From 3a4a7ba1f35eeba7d48c954f80a92c6904e5d065 Mon Sep 17 00:00:00 2001 From: bob Date: Fri, 12 Jul 2019 11:35:25 -0400 Subject: playing with videos --- package.json | 4 +++- src/client/documents/Documents.ts | 2 +- src/client/views/nodes/VideoBox.tsx | 27 ++++++++++++++++++++++----- src/client/views/nodes/WebBox.tsx | 35 ++++++++++++++++++++++++++++++++++- 4 files changed, 60 insertions(+), 8 deletions(-) (limited to 'package.json') diff --git a/package.json b/package.json index 85fc7f1be..6681c4c1c 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@types/typescript": "^2.0.0", "@types/uuid": "^3.4.4", "@types/webpack": "^4.4.25", + "@types/youtube": "0.0.38", "async": "^2.6.2", "babel-runtime": "^6.26.0", "bcrypt-nodejs": "0.0.3", @@ -195,6 +196,7 @@ "typescript-collections": "^1.3.2", "url-loader": "^1.1.2", "uuid": "^3.3.2", - "xoauth2": "^1.2.0" + "xoauth2": "^1.2.0", + "youtube": "^0.1.0" } } diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 30e4637b0..2a3827782 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -118,7 +118,7 @@ export namespace Docs { options: { nativeWidth: 600, curPage: 0 } }], [DocumentType.WEB, { - layout: { view: WebBox }, + layout: { view: WebBox, collectionView: [CollectionView, data, anno] as CollectionViewType }, options: { height: 300 } }], [DocumentType.COL, { diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index c65dfe0bd..895d9a422 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -70,6 +70,18 @@ export class VideoBox extends DocComponent(VideoD componentDidMount() { if (this.props.setVideoBox) this.props.setVideoBox(this); + + let field = Cast(this.Document[this.props.fieldKey], VideoField); + if (field && field.url.href.indexOf("youtube") !== -1) { + let youtubeaspect = 400 / 315; + var nativeWidth = FieldValue(this.Document.nativeWidth, 0); + var nativeHeight = FieldValue(this.Document.nativeHeight, 0); + if (!nativeWidth || !nativeHeight || Math.abs(nativeWidth / nativeHeight - youtubeaspect) > 0.05) { + if (!this.Document.nativeWidth) this.Document.nativeWidth = 600; + this.Document.nativeHeight = this.Document.nativeWidth / youtubeaspect; + this.Document.height = FieldValue(this.Document.width, 0) / youtubeaspect; + } + } } componentWillUnmount() { this.Pause(); @@ -145,12 +157,17 @@ export class VideoBox extends DocComponent(VideoD // }); // // - let interactive = InkingControl.Instance.selectedTool ? "" : "-interactive"; + let interactive = InkingControl.Instance.selectedTool || !this.props.isSelected() ? "" : "-interactive"; let style = "videoBox-cont" + (this._fullScreen ? "-fullScreen" : interactive); + let videoid = field ? ((arr: string[]) => arr[arr.length - 1])(field.url.href.split("/")) : ""; return !field ?
Loading
: - ; + field.url.href.indexOf("youtube") !== -1 ? + + : + ; } } \ No newline at end of file diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 98c57fc75..96b972a1c 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -6,12 +6,45 @@ import { InkingControl } from "../InkingControl"; import { FieldView, FieldViewProps } from './FieldView'; import "./WebBox.scss"; import React = require("react"); +import { InkTool } from "../../../new_fields/InkField"; +import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; +export function onYouTubeIframeAPIReady() { + console.log("player"); + return; + let player = new YT.Player('player', { + events: { + 'onReady': onPlayerReady + } + }); +} +// must cast as any to set property on window +const _global = (window /* browser */ || global /* node */) as any +_global.onYouTubeIframeAPIReady = onYouTubeIframeAPIReady; + +function onPlayerReady(event: any) { + event.target.playVideo(); +} @observer export class WebBox extends React.Component { public static LayoutString() { return FieldView.LayoutString(WebBox); } + componentWillMount() { + + let field = Cast(this.props.Document[this.props.fieldKey], WebField); + if (field && field.url.href.indexOf("youtube") !== -1) { + let youtubeaspect = 400 / 315; + var nativeWidth = NumCast(this.props.Document.nativeWidth, 0); + var nativeHeight = NumCast(this.props.Document.nativeHeight, 0); + if (!nativeWidth || !nativeHeight || Math.abs(nativeWidth / nativeHeight - youtubeaspect) > 0.05) { + if (!nativeWidth) this.props.Document.nativeWidth = 600; + this.props.Document.nativeHeight = NumCast(this.props.Document.nativeWidth) / youtubeaspect; + this.props.Document.height = NumCast(this.props.Document.width) / youtubeaspect; + } + } + } + _ignore = 0; onPreWheel = (e: React.WheelEvent) => { this._ignore = e.timeStamp; @@ -46,7 +79,7 @@ export class WebBox extends React.Component { let frozen = !this.props.isSelected() || DocumentDecorations.Instance.Interacting; - let classname = "webBox-cont" + (this.props.isSelected() && !InkingControl.Instance.selectedTool && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); + let classname = "webBox-cont" + (this.props.isSelected() && InkingControl.Instance.selectedTool === InkTool.None && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); return ( <>
-- cgit v1.2.3-70-g09d2 From 8e45625865b20a9e25b120d6938d3ec31aa880e3 Mon Sep 17 00:00:00 2001 From: Tyler Schicke Date: Sat, 13 Jul 2019 20:12:27 -0400 Subject: Kinda added auto complete --- package.json | 2 ++ src/client/views/DocumentDecorations.tsx | 2 +- src/client/views/MetadataEntryMenu.tsx | 49 ++++++++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 3 deletions(-) (limited to 'package.json') diff --git a/package.json b/package.json index 6681c4c1c..22b3a6b21 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "@types/prosemirror-view": "^1.3.1", "@types/pug": "^2.0.4", "@types/react": "^16.8.7", + "@types/react-autosuggest": "^9.3.9", "@types/react-color": "^2.14.1", "@types/react-measure": "^2.0.4", "@types/react-table": "^6.7.22", @@ -169,6 +170,7 @@ "raw-loader": "^1.0.0", "react": "^16.8.4", "react-anime": "^2.2.0", + "react-autosuggest": "^9.4.3", "react-bootstrap": "^1.0.0-beta.5", "react-bootstrap-dropdown-menu": "^1.1.15", "react-color": "^2.17.0", diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 56fbd75a0..2cb3de50f 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -639,7 +639,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> return (
SelectionManager.SelectedDocuments().map(dv => dv.props.Document)} />}>{/* tfs: @bcz This might need to be the data document? */} + content={ SelectionManager.SelectedDocuments().map(dv => dv.props.Document)} suggestWithFunction />}>{/* tfs: @bcz This might need to be the data document? */}
diff --git a/src/client/views/MetadataEntryMenu.tsx b/src/client/views/MetadataEntryMenu.tsx index 0dc7e0220..0c8fd3909 100644 --- a/src/client/views/MetadataEntryMenu.tsx +++ b/src/client/views/MetadataEntryMenu.tsx @@ -1,20 +1,23 @@ import * as React from 'react'; import "./MetadataEntryMenu.scss"; import { observer } from 'mobx-react'; -import { observable, action } from 'mobx'; +import { observable, action, runInAction } from 'mobx'; import { KeyValueBox } from './nodes/KeyValueBox'; import { Doc } from '../../new_fields/Doc'; +import * as Autosuggest from 'react-autosuggest'; export type DocLike = Doc | Doc[] | Promise | Promise; export interface MetadataEntryProps { docs: DocLike | (() => DocLike); onError?: () => boolean; + suggestWithFunction?: boolean; } @observer export class MetadataEntryMenu extends React.Component{ @observable private _currentKey: string = ""; @observable private _currentValue: string = ""; + @observable private suggestions: string[] = []; @action onKeyChange = (e: React.ChangeEvent) => { @@ -61,11 +64,53 @@ export class MetadataEntryMenu extends React.Component{ this._currentValue = ""; } + getKeySuggestions = async (value: string): Promise => { + value = value.toLowerCase(); + let docs = this.props.docs; + if (typeof docs === "function") { + if (this.props.suggestWithFunction) { + docs = docs(); + } else { + return []; + } + } + docs = await docs; + if (docs instanceof Doc) { + return Object.keys(docs).filter(key => key.toLowerCase().startsWith(value)); + } else { + const keys = new Set(); + docs.forEach(doc => Doc.allKeys(doc).forEach(key => keys.add(key))); + return Array.from(keys).filter(key => key.toLowerCase().startsWith(value)); + } + } + getSuggestionValue = (suggestion: string) => suggestion; + + renderSuggestion = (suggestion: string) => { + return

{suggestion}

; + } + + onSuggestionFetch = async ({ value }: { value: string }) => { + const sugg = await this.getKeySuggestions(value); + runInAction(() => { + this.suggestions = sugg; + }); + } + + @action + onSuggestionClear = () => { + this.suggestions = []; + } + render() { return (
Key: - + Value:
-- cgit v1.2.3-70-g09d2 From f36afcf2b00da1df5aa39deff5d9e931a823605c Mon Sep 17 00:00:00 2001 From: Sam Wilkins Date: Tue, 16 Jul 2019 11:56:25 -0400 Subject: mongoose connection bug and usercontroller errors fixed --- package.json | 4 ++-- .../authentication/controllers/user_controller.ts | 25 +++++++++++++--------- src/server/authentication/models/user_model.ts | 2 +- src/server/index.ts | 2 +- 4 files changed, 19 insertions(+), 14 deletions(-) (limited to 'package.json') diff --git a/package.json b/package.json index 22b3a6b21..7407a719f 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "@types/lodash": "^4.14.121", "@types/mobile-detect": "^1.3.4", "@types/mongodb": "^3.1.22", - "@types/mongoose": "^5.3.21", + "@types/mongoose": "^5.5.8", "@types/node": "^10.12.30", "@types/nodemailer": "^4.6.6", "@types/passport": "^1.0.0", @@ -144,7 +144,7 @@ "mobx-react-devtools": "^6.1.1", "mobx-utils": "^5.4.0", "mongodb": "^3.1.13", - "mongoose": "^5.4.18", + "mongoose": "^5.6.4", "node-sass": "^4.12.0", "nodemailer": "^5.1.1", "nodemon": "^1.18.10", diff --git a/src/server/authentication/controllers/user_controller.ts b/src/server/authentication/controllers/user_controller.ts index ca4fc171c..fa1cd647d 100644 --- a/src/server/authentication/controllers/user_controller.ts +++ b/src/server/authentication/controllers/user_controller.ts @@ -12,6 +12,9 @@ import * as nodemailer from 'nodemailer'; import c = require("crypto"); import { RouteStore } from "../../RouteStore"; import { Utils } from "../../../Utils"; +import { Schema } from "mongoose"; +import { Opt } from "../../../new_fields/Doc"; +import { MailOptions } from "nodemailer/lib/stream-transport"; /** * GET /signup @@ -45,21 +48,23 @@ export let postSignup = (req: Request, res: Response, next: NextFunction) => { return res.redirect(RouteStore.signup); } - const email = req.body.email; + const email = req.body.email as String; const password = req.body.password; - const user = new User({ - email, + const model = { + email: { type: email, unique: true }, password, userDocumentId: Utils.GenerateGuid() - }); + } as Partial; + + const user = new User(model); User.findOne({ email }, (err, existingUser) => { if (err) { return next(err); } if (existingUser) { return res.redirect(RouteStore.login); } - user.save((err) => { + user.save((err: any) => { if (err) { return next(err); } req.logIn(user, (err) => { if (err) { return next(err); } @@ -181,15 +186,15 @@ export let postForgot = function (req: Request, res: Response, next: NextFunctio } }); const mailOptions = { - to: user.email, + to: user.email.type, from: 'brownptcdash@gmail.com', subject: 'Dash Password Reset', text: 'You are receiving this because you (or someone else) have requested the reset of the password for your account.\n\n' + 'Please click on the following link, or paste this into your browser to complete the process:\n\n' + 'http://' + req.headers.host + '/reset/' + token + '\n\n' + 'If you did not request this, please ignore this email and your password will remain unchanged.\n' - }; - smtpTransport.sendMail(mailOptions, function (err) { + } as MailOptions; + smtpTransport.sendMail(mailOptions, function (err: Error | null) { // req.flash('info', 'An e-mail has been sent to ' + user.email + ' with further instructions.'); done(null, err, 'done'); }); @@ -254,12 +259,12 @@ export let postReset = function (req: Request, res: Response) { } }); const mailOptions = { - to: user.email, + to: user.email.type, from: 'brownptcdash@gmail.com', subject: 'Your password has been changed', text: 'Hello,\n\n' + 'This is a confirmation that the password for your account ' + user.email + ' has just been changed.\n' - }; + } as MailOptions; smtpTransport.sendMail(mailOptions, function (err) { done(null, err); }); diff --git a/src/server/authentication/models/user_model.ts b/src/server/authentication/models/user_model.ts index ee85e1c05..fb62de1c8 100644 --- a/src/server/authentication/models/user_model.ts +++ b/src/server/authentication/models/user_model.ts @@ -16,7 +16,7 @@ mongoose.connection.on('disconnected', function () { console.log('connection closed'); }); export type DashUserModel = mongoose.Document & { - email: string, + email: { type: String, unique: true }, password: string, passwordResetToken?: string, passwordResetExpires?: Date, diff --git a/src/server/index.ts b/src/server/index.ts index 1c0dec05b..06f8358e1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -58,7 +58,7 @@ clientUtils = `//AUTO-GENERATED FILE: DO NOT EDIT\n${clientUtils.replace('"mode" fs.writeFileSync("./src/client/util/ClientUtils.ts", clientUtils, "utf8"); const mongoUrl = 'mongodb://localhost:27017/Dash'; -mongoose.connect(mongoUrl); +mongoose.connection.readyState === 0 && mongoose.connect(mongoUrl); mongoose.connection.on('connected', () => console.log("connected")); // SESSION MANAGEMENT AND AUTHENTICATION MIDDLEWARE -- cgit v1.2.3-70-g09d2 From 3593c1c4a67e8fb398e5a456ad4d758305294625 Mon Sep 17 00:00:00 2001 From: Mohammad Amoush Date: Tue, 16 Jul 2019 12:37:04 -0400 Subject: Uncommented code --- package.json | 4 ++-- src/client/views/presentationview/PresentationElement.tsx | 15 --------------- 2 files changed, 2 insertions(+), 17 deletions(-) (limited to 'package.json') diff --git a/package.json b/package.json index dc829a045..7aff71b57 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "ts-node-dev": "^1.0.0-pre.32", "tslint": "^5.15.0", "tslint-loader": "^3.5.4", - "typescript": "^3.4.1", + "typescript": "^3.5.3", "webpack": "^4.29.6", "webpack-cli": "^3.2.3", "webpack-dev-middleware": "^3.6.1", @@ -196,4 +196,4 @@ "uuid": "^3.3.2", "xoauth2": "^1.2.0" } -} \ No newline at end of file +} diff --git a/src/client/views/presentationview/PresentationElement.tsx b/src/client/views/presentationview/PresentationElement.tsx index 733bbff13..55bcbfcd7 100644 --- a/src/client/views/presentationview/PresentationElement.tsx +++ b/src/client/views/presentationview/PresentationElement.tsx @@ -142,7 +142,6 @@ export default class PresentationElement extends React.Component this.selectedButtons = selectedButtonOfDoc); - // } - // } - } /** -- cgit v1.2.3-70-g09d2 From de6e2427cf5eed2805432a37b5284a4c8b934e62 Mon Sep 17 00:00:00 2001 From: bob Date: Wed, 17 Jul 2019 12:49:12 -0400 Subject: fixed some search box things. added more to audio annotations. --- package.json | 1 + src/client/views/nodes/ImageBox.tsx | 67 +++++++++++++++++++++++++++++++--- src/client/views/search/SearchBox.tsx | 2 +- src/client/views/search/SearchItem.tsx | 4 +- 4 files changed, 66 insertions(+), 8 deletions(-) (limited to 'package.json') diff --git a/package.json b/package.json index 7407a719f..2cec44473 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "font-awesome": "^4.7.0", "formidable": "^1.2.1", "golden-layout": "^1.5.9", + "howler": "^2.1.2", "html-to-image": "^0.1.0", "i": "^0.3.6", "image-data-uri": "^2.0.0", diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 4c5ad7a7d..7c29724a2 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -1,6 +1,6 @@ import { library } from '@fortawesome/fontawesome-svg-core'; -import { faImage } from '@fortawesome/free-solid-svg-icons'; -import { action, observable, computed } from 'mobx'; +import { faImage, faFileAudio } from '@fortawesome/free-solid-svg-icons'; +import { action, observable, computed, runInAction } from 'mobx'; import { observer } from "mobx-react"; import Lightbox from 'react-image-lightbox'; import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app @@ -8,7 +8,7 @@ import { Doc, HeightSym, WidthSym, DocListCast } from '../../../new_fields/Doc'; import { List } from '../../../new_fields/List'; import { createSchema, listSpec, makeInterface } from '../../../new_fields/Schema'; import { Cast, FieldValue, NumCast, StrCast, BoolCast } from '../../../new_fields/Types'; -import { ImageField } from '../../../new_fields/URLField'; +import { ImageField, AudioField } from '../../../new_fields/URLField'; import { Utils } from '../../../Utils'; import { DragManager } from '../../util/DragManager'; import { undoBatch } from '../../util/UndoManager'; @@ -23,11 +23,15 @@ import React = require("react"); import { RouteStore } from '../../../server/RouteStore'; import { Docs } from '../../documents/Documents'; import { DocServer } from '../../DocServer'; +import { Font } from '@react-pdf/renderer'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; var requestImageSize = require('../../util/request-image-size'); var path = require('path'); +const { Howl, Howler } = require('howler'); library.add(faImage); +library.add(faFileAudio); export const pageSchema = createSchema({ @@ -177,10 +181,11 @@ export class ImageBox extends DocComponent(ImageD audioAnnos.push(audioDoc); } }; + runInAction(() => self._audioState = 2); recorder.start(); setTimeout(() => { recorder.stop(); - + runInAction(() => self._audioState = 0); gumStream.getAudioTracks()[0].stop(); }, 5000); }); @@ -272,6 +277,46 @@ export class ImageBox extends DocComponent(ImageD }); } + @observable _audioState = 0; + + @action + onPointerEnter = () => { + let self = this; + let audioAnnos = DocListCast(this.extensionDoc.audioAnnotations); + if (audioAnnos.length && this._audioState === 0) { + audioAnnos.map(anno => anno.data instanceof AudioField && new Howl({ + src: [anno.data.url.href], + autoplay: true, + loop: false, + volume: 0.5, + onend: function () { + runInAction(() => self._audioState = 0); + } + })); + this._audioState = 1; + } + else { + if (this._audioState === 0) { + this._audioState = 1; + new Howl({ + src: ["https://www.kozco.com/tech/piano2-CoolEdit.mp3"], + autoplay: true, + loop: false, + volume: 0.5, + onend: function () { + runInAction(() => self._audioState = 0); + } + }); + } + } + } + + @action + audioDown = () => { + this.recordAudioAnnotation(); + } + + render() { // let transform = this.props.ScreenToLocalTransform().inverse(); let pw = typeof this.props.PanelWidth === "function" ? this.props.PanelWidth() : typeof this.props.PanelWidth === "number" ? (this.props.PanelWidth as any) as number : 50; @@ -282,7 +327,7 @@ export class ImageBox extends DocComponent(ImageD let id = (this.props as any).id; // bcz: used to set id = "isExpander" in templates.tsx let nativeWidth = FieldValue(this.Document.nativeWidth, pw); let nativeHeight = FieldValue(this.Document.nativeHeight, 0); - let paths: string[] = ["http://www.cs.brown.edu/~bcz/noImage.png"]; + let paths: string[] = [window.origin + RouteStore.corsProxy + "/" + "http://www.cs.brown.edu/~bcz/noImage.png"]; // this._curSuffix = ""; // if (w > 20) { Doc.UpdateDocumentExtensionForField(this.dataDoc, this.props.fieldKey); @@ -305,6 +350,7 @@ export class ImageBox extends DocComponent(ImageD return (
(ImageD ref={this._imgRef} onError={this.onError} /> {paths.length > 1 ? this.dots(paths) : (null)} +
+ +
{/* {this.lightbox(paths)} */}
); } diff --git a/src/client/views/search/SearchBox.tsx b/src/client/views/search/SearchBox.tsx index 8399605fb..16c44225a 100644 --- a/src/client/views/search/SearchBox.tsx +++ b/src/client/views/search/SearchBox.tsx @@ -145,7 +145,7 @@ export class SearchBox extends React.Component { const highlighting = res.highlighting || {}; const highlightList = res.docs.map(doc => highlighting[doc[Id]]); - const docs = await Promise.all(res.docs.map(doc => Cast(doc.extendsDoc, Doc, doc as any))); + const docs = await Promise.all(res.docs.map(async doc => (await Cast(doc.extendsDoc, Doc)) || doc)); const highlights: typeof res.highlighting = {}; docs.forEach((doc, index) => highlights[doc[Id]] = highlightList[index]); let filteredDocs = FilterBox.Instance.filterDocsByType(docs); diff --git a/src/client/views/search/SearchItem.tsx b/src/client/views/search/SearchItem.tsx index 26f00e03e..a995140e2 100644 --- a/src/client/views/search/SearchItem.tsx +++ b/src/client/views/search/SearchItem.tsx @@ -101,8 +101,8 @@ export class SearchItem extends React.Component { onClick = () => { // I dont think this is the best functionality because clicking the name of the collection does that. Change it back if you'd like - // DocumentManager.Instance.jumpToDocument(this.props.doc, false); - CollectionDockingView.Instance.AddRightSplit(this.props.doc, undefined); + DocumentManager.Instance.jumpToDocument(this.props.doc, false); + // CollectionDockingView.Instance.AddRightSplit(this.props.doc, undefined); } @observable _useIcons = true; @observable _displayDim = 50; -- cgit v1.2.3-70-g09d2