Merge pull request #9705 from nextcloud/feature/9163/whatsnew-admin-gui

display whats new info in admin settings
This commit is contained in:
Morris Jobke 2018-06-29 10:36:04 +02:00 committed by GitHub
commit 3ff3141a1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1045 additions and 77 deletions

View File

@ -24,7 +24,7 @@ pipeline:
vue-build-updatenotification:
image: node
commands:
- ./build/vue-builds.sh ./apps/updatenotification/js/merged.js
- ./build/vue-builds.sh ./apps/updatenotification/js/updatenotification.js
when:
matrix:
TESTS: vue-build-updatenotification

View File

@ -24,8 +24,8 @@ watch-js:
npm run watch
clean:
rm -f js/merged.js
rm -f js/merged.js.map
rm -f js/$(app_name).js
rm -f js/$(app_name).js.map
rm -rf $(build_dir)
clean-dev:

View File

@ -3,7 +3,6 @@
}
#updatenotification div.update,
#updatenotification ul,
#updatenotification p:not(.inlineblock) {
margin-bottom: 25px;
}
@ -44,3 +43,21 @@
#updatenotification .warning {
color: #ce3702;
}
#updatenotification .whatsNew {
display: inline-block;
}
#updatenotification .toggleWhatsNew {
position: relative;
}
#updatenotification .popovermenu p {
margin-bottom: 0;
width: 100%;
}
#updatenotification .popovermenu {
margin-top: 5px;
width: 300px;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -31,6 +31,8 @@ use OCP\AppFramework\Http\TemplateResponse;
use OCP\IConfig;
use OCP\IDateTimeFormatter;
use OCP\IGroupManager;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Settings\ISettings;
use OCP\Util;
@ -43,21 +45,25 @@ class Admin implements ISettings {
private $groupManager;
/** @var IDateTimeFormatter */
private $dateTimeFormatter;
/** @var IUserSession */
private $session;
/** @var IFactory */
private $l10nFactory;
/**
* @param IConfig $config
* @param UpdateChecker $updateChecker
* @param IGroupManager $groupManager
* @param IDateTimeFormatter $dateTimeFormatter
*/
public function __construct(IConfig $config,
UpdateChecker $updateChecker,
IGroupManager $groupManager,
IDateTimeFormatter $dateTimeFormatter) {
public function __construct(
IConfig $config,
UpdateChecker $updateChecker,
IGroupManager $groupManager,
IDateTimeFormatter $dateTimeFormatter,
IUserSession $session,
IFactory $l10nFactory
) {
$this->config = $config;
$this->updateChecker = $updateChecker;
$this->groupManager = $groupManager;
$this->dateTimeFormatter = $dateTimeFormatter;
$this->session = $session;
$this->l10nFactory = $l10nFactory;
}
/**
@ -93,6 +99,7 @@ class Admin implements ISettings {
'channels' => $channels,
'newVersionString' => empty($updateState['updateVersion']) ? '' : $updateState['updateVersion'],
'downloadLink' => empty($updateState['downloadLink']) ? '' : $updateState['downloadLink'],
'changes' => $this->filterChanges($updateState['changes'] ?? []),
'updaterEnabled' => empty($updateState['updaterEnabled']) ? false : $updateState['updaterEnabled'],
'versionIsEol' => empty($updateState['versionIsEol']) ? false : $updateState['versionIsEol'],
'isDefaultUpdateServerURL' => $updateServerURL === $defaultUpdateServerURL,
@ -107,6 +114,48 @@ class Admin implements ISettings {
return new TemplateResponse('updatenotification', 'admin', $params, '');
}
protected function filterChanges(array $changes) {
$filtered = [];
if(isset($changes['changelogURL'])) {
$filtered['changelogURL'] = $changes['changelogURL'];
}
if(!isset($changes['whatsNew'])) {
return $filtered;
}
$isFirstCall = true;
do {
$lang = $this->l10nFactory->iterateLanguage($isFirstCall);
if($this->findWhatsNewTranslation($lang, $filtered, $changes['whatsNew'])) {
return $filtered;
}
$isFirstCall = false;
} while($lang !== 'en');
return $filtered;
}
protected function getLangTrunk(string $lang):string {
$pos = strpos($lang, '_');
if($pos !== false) {
$lang = substr($lang, 0, $pos);
}
return $lang;
}
protected function findWhatsNewTranslation(string $lang, array &$result, array $whatsNew): bool {
if(isset($whatsNew[$lang])) {
$result['whatsNew'] = $whatsNew[$lang];
return true;
}
$trunkedLang = $this->getLangTrunk($lang);
if($trunkedLang !== $lang && isset($whatsNew[$trunkedLang])) {
$result['whatsNew'] = $whatsNew[$trunkedLang];
return true;
}
return false;
}
/**
* @param array $groupIds
* @return array

View File

@ -25,17 +25,21 @@ declare(strict_types=1);
namespace OCA\UpdateNotification;
use OC\Updater\ChangesCheck;
use OC\Updater\VersionCheck;
class UpdateChecker {
/** @var VersionCheck */
private $updater;
/** @var ChangesCheck */
private $changesCheck;
/**
* @param VersionCheck $updater
*/
public function __construct(VersionCheck $updater) {
public function __construct(VersionCheck $updater, ChangesCheck $changesCheck) {
$this->updater = $updater;
$this->changesCheck = $changesCheck;
}
/**
@ -56,6 +60,13 @@ class UpdateChecker {
if (strpos($data['url'], 'https://') === 0) {
$result['downloadLink'] = $data['url'];
}
if (strpos($data['changes'], 'https://') === 0) {
try {
$result['changes'] = $this->changesCheck->check($data['changes'], $data['version']);
} catch (\Exception $e) {
// no info, not a problem
}
}
return $result;
}

View File

@ -1,6 +1,6 @@
{
"name": "notifications",
"version": "2.3.0",
"version": "2.4.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -639,7 +639,7 @@
"bluebird": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz",
"integrity": "sha1-2VUfnemPH82h5oPRfukaBgLuLrk=",
"integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA==",
"dev": true
},
"bn.js": {
@ -2076,7 +2076,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -2491,7 +2492,8 @@
"safe-buffer": {
"version": "5.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -2547,6 +2549,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -2590,12 +2593,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},
@ -6839,6 +6844,11 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-2.5.16.tgz",
"integrity": "sha512-/ffmsiVuPC8PsWcFkZngdpas19ABm5mh2wA7iDqcltyCTwlgZjHGeJYOXkBMo422iPwIcviOtrTCUpSfXmToLQ=="
},
"vue-click-outside": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/vue-click-outside/-/vue-click-outside-1.0.7.tgz",
"integrity": "sha1-zdKxYF48SUR4TheU6uShKg9wC9Y="
},
"vue-hot-reload-api": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vue-hot-reload-api/-/vue-hot-reload-api-2.3.0.tgz",
@ -6886,7 +6896,7 @@
"vue-template-es2015-compiler": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.6.0.tgz",
"integrity": "sha1-3EJpcTMwLOMBdSQ1amxht7abShg=",
"integrity": "sha512-x3LV3wdmmERhVCYy3quqA57NJW7F3i6faas++pJQWtknWT+n7k30F4TVdHvCLn48peTJFRvCpxs3UuFPqgeELg==",
"dev": true
},
"watchpack": {

View File

@ -1,6 +1,6 @@
{
"name": "notifications",
"version": "2.3.0",
"version": "2.4.0",
"description": "This app provides a backend and frontend for the notification API available in Nextcloud.",
"main": "init.js",
"directories": {
@ -8,10 +8,9 @@
"test": "tests"
},
"scripts": {
"dev": "webpack --config js-src/webpack.dev.js",
"watch": "webpack --progress --watch --config js-src/webpack.dev.js",
"build": "webpack --progress --hide-modules --config js-src/webpack.prod.js",
"test": "echo \"Error: no test specified\" && exit 1"
"dev": "webpack --config webpack.dev.js",
"watch": "webpack --progress --watch --config webpack.dev.js",
"build": "webpack --progress --hide-modules --config webpack.prod.js"
},
"repository": {
"type": "git",
@ -25,6 +24,7 @@
"homepage": "https://github.com/nextcloud/notifications#readme",
"dependencies": {
"vue": "^2.5.16",
"vue-click-outside": "^1.0.7",
"vue-select": "^2.4.0"
},
"devDependencies": {

View File

@ -0,0 +1,18 @@
<template>
<ul>
<popover-item v-for="(item, key) in menu" :item="item" :key="key" />
</ul>
</template>
<script>
import popoverItem from './popoverMenu/popoverItem';
export default {
name: 'popoverMenu',
props: ['menu'],
components: {
popoverItem
}
}
</script>

View File

@ -0,0 +1,28 @@
<template>
<li>
<!-- If item.href is set, a link will be directly used -->
<a @click="item.action" v-if="item.href" :href="(item.href) ? item.href : '#' " :target="(item.target) ? item.target : '' " rel="noreferrer noopener">
<span :class="item.icon"></span>
<span v-if="item.text">{{item.text}}</span>
<p v-else-if="item.longtext">{{item.longtext}}</p>
</a>
<!-- If item.action is set instead, a button will be used -->
<button @click="item.action" v-else-if="item.action">
<span :class="item.icon"></span>
<span v-if="item.text">{{item.text}}</span>
<p v-else-if="item.longtext">{{item.longtext}}</p>
</button>
<!-- If item.longtext is set AND the item does not have an action -->
<span class="menuitem" v-else>
<span :class="item.icon"></span>
<span v-if="item.text">{{item.text}}</span>
<p v-else-if="item.longtext">{{item.longtext}}</p>
</span>
</li>
</template>
<script>
export default {
props: ['item']
}
</script>

View File

@ -39,6 +39,14 @@
<a v-if="updaterEnabled" href="#" class="button" @click="clickUpdaterButton">{{ t('updatenotification', 'Open updater') }}</a>
<a v-if="downloadLink" :href="downloadLink" class="button" :class="{ hidden: !updaterEnabled }">{{ t('updatenotification', 'Download now') }}</a>
<div class="whatsNew" v-if="whatsNew">
<div class="toggleWhatsNew">
<span v-click-outside="hideMenu" @click="toggleMenu">{{ t('updatenotification', 'What\'s new?') }}</span>
<div class="popovermenu" :class="{ 'menu-center': true, open: openedWhatsNew }">
<popover-menu :menu="whatsNew" />
</div>
</div>
</div>
</template>
<template v-else-if="!isUpdateChecked">{{ t('updatenotification', 'The update check is not yet finished. Please refresh the page.') }}</template>
<template v-else>
@ -80,11 +88,17 @@
<script>
import vSelect from 'vue-select';
import popoverMenu from './popoverMenu';
import ClickOutside from 'vue-click-outside';
export default {
name: 'root',
components: {
vSelect,
popoverMenu,
},
directives: {
ClickOutside
},
data: function () {
return {
@ -96,6 +110,8 @@
downloadLink: '',
isNewVersionAvailable: false,
updateServerURL: '',
changelogURL: '',
whatsNewData: [],
currentChannel: '',
channels: [],
notifyGroups: '',
@ -109,7 +125,8 @@
appStoreDisabled: false,
isListFetched: false,
hideMissingUpdates: false,
hideAvailableUpdates: true
hideAvailableUpdates: true,
openedWhatsNew: false,
};
},
@ -202,6 +219,26 @@
betaInfoString: function() {
return t('updatenotification', '<strong>beta</strong> is a pre-release version only for testing new features, not for production environments.');
},
whatsNew: function () {
if(this.whatsNewData.length === 0) {
return null;
}
var whatsNew = [];
for (var i in this.whatsNewData) {
whatsNew[i] = { icon: 'icon-star-dark', longtext: this.whatsNewData[i] };
}
if(this.changelogURL) {
whatsNew.push({
href: this.changelogURL,
text: t('updatenotificaiton', 'View changelog'),
icon: 'icon-link',
target: '_blank',
action: ''
});
}
return whatsNew;
}
},
@ -261,7 +298,13 @@
},
toggleHideAvailableUpdates: function() {
this.hideAvailableUpdates = !this.hideAvailableUpdates;
}
},
toggleMenu: function() {
this.openedWhatsNew = !this.openedWhatsNew;
},
hideMenu: function() {
this.openedWhatsNew = false;
},
},
beforeMount: function() {
// Parse server data
@ -279,6 +322,15 @@
this.notifyGroups = data.notifyGroups;
this.isDefaultUpdateServerURL = data.isDefaultUpdateServerURL;
this.versionIsEol = data.versionIsEol;
if(data.changes && data.changes.changelogURL) {
this.changelogURL = data.changes.changelogURL;
}
if(data.changes && data.changes.whatsNew) {
if(data.changes.whatsNew.admin) {
this.whatsNewData = this.whatsNewData.concat(data.changes.whatsNew.admin);
}
this.whatsNewData = this.whatsNewData.concat(data.changes.whatsNew.regular);
}
},
mounted: function () {
this._$el = $(this.$el);

View File

@ -8,7 +8,7 @@ declare(strict_types=1);
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*/
script('updatenotification', 'merged');
script('updatenotification', 'updatenotification');
style('updatenotification', 'admin');
/** @var array $_ */
?>

View File

@ -32,10 +32,16 @@ use OCP\IConfig;
use OCP\IDateTimeFormatter;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Util;
use Test\TestCase;
class AdminTest extends TestCase {
/** @var IUserSession|\PHPUnit_Framework_MockObject_MockObject */
protected $userSession;
/** @var IFactory|\PHPUnit_Framework_MockObject_MockObject */
protected $l10nFactory;
/** @var Admin */
private $admin;
/** @var IConfig|\PHPUnit_Framework_MockObject_MockObject */
@ -54,12 +60,11 @@ class AdminTest extends TestCase {
$this->updateChecker = $this->createMock(UpdateChecker::class);
$this->groupManager = $this->createMock(IGroupManager::class);
$this->dateTimeFormatter = $this->createMock(IDateTimeFormatter::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->l10nFactory = $this->createMock(IFactory::class);
$this->admin = new Admin(
$this->config,
$this->updateChecker,
$this->groupManager,
$this->dateTimeFormatter
$this->config, $this->updateChecker, $this->groupManager, $this->dateTimeFormatter, $this->userSession, $this->l10nFactory
);
}
@ -99,6 +104,7 @@ class AdminTest extends TestCase {
'updateAvailable' => true,
'updateVersion' => '8.1.2',
'downloadLink' => 'https://downloads.nextcloud.org/server',
'changes' => [],
'updaterEnabled' => true,
'versionIsEol' => false,
]);
@ -124,6 +130,7 @@ class AdminTest extends TestCase {
'channels' => $channels,
'newVersionString' => '8.1.2',
'downloadLink' => 'https://downloads.nextcloud.org/server',
'changes' => [],
'updaterEnabled' => true,
'versionIsEol' => false,
'isDefaultUpdateServerURL' => true,

View File

@ -25,11 +25,14 @@ declare(strict_types=1);
namespace OCA\UpdateNotification\Tests;
use OC\Updater\ChangesCheck;
use OC\Updater\VersionCheck;
use OCA\UpdateNotification\UpdateChecker;
use Test\TestCase;
class UpdateCheckerTest extends TestCase {
/** @var ChangesCheck|\PHPUnit_Framework_MockObject_MockObject */
protected $changesChecker;
/** @var VersionCheck|\PHPUnit_Framework_MockObject_MockObject */
private $updater;
/** @var UpdateChecker */
@ -39,7 +42,8 @@ class UpdateCheckerTest extends TestCase {
parent::setUp();
$this->updater = $this->createMock(VersionCheck::class);
$this->updateChecker = new UpdateChecker($this->updater);
$this->changesChecker = $this->createMock(ChangesCheck::class);
$this->updateChecker = new UpdateChecker($this->updater, $this->changesChecker);
}
public function testGetUpdateStateWithUpdateAndInvalidLink() {
@ -51,6 +55,7 @@ class UpdateCheckerTest extends TestCase {
'versionstring' => 'Nextcloud 123',
'web'=> 'javascript:alert(1)',
'url'=> 'javascript:alert(2)',
'changes' => 'javascript:alert(3)',
'autoupdater'=> '0',
'eol'=> '1',
]);
@ -65,18 +70,40 @@ class UpdateCheckerTest extends TestCase {
}
public function testGetUpdateStateWithUpdateAndValidLink() {
$changes = [
'changelog' => 'https://nextcloud.com/changelog/#123-0-0',
'whatsNew' => [
'en' => [
'regular' => [
'Yardarm heave to brig spyglass smartly pillage',
'Bounty gangway bilge skysail rope\'s end',
'Maroon cutlass spirits nipperkin Plate Fleet',
],
'admin' => [
'Scourge of the seven seas coffer doubloon',
'Brig me splice the main brace',
]
]
]
];
$this->updater
->expects($this->once())
->method('check')
->willReturn([
'version' => 123,
'version' => '123',
'versionstring' => 'Nextcloud 123',
'web'=> 'https://docs.nextcloud.com/myUrl',
'url'=> 'https://downloads.nextcloud.org/server',
'changes' => 'https://updates.nextcloud.com/changelog_server/?version=123.0.0',
'autoupdater'=> '1',
'eol'=> '0',
]);
$this->changesChecker->expects($this->once())
->method('check')
->willReturn($changes);
$expected = [
'updateAvailable' => true,
'updateVersion' => 'Nextcloud 123',
@ -84,6 +111,7 @@ class UpdateCheckerTest extends TestCase {
'versionIsEol' => false,
'updateLink' => 'https://docs.nextcloud.com/myUrl',
'downloadLink' => 'https://downloads.nextcloud.org/server',
'changes' => $changes,
];
$this->assertSame($expected, $this->updateChecker->getUpdateState());
}

View File

@ -2,11 +2,11 @@ const path = require('path')
const { VueLoaderPlugin } = require('vue-loader');
module.exports = {
entry: './js-src/init.js',
entry: path.join(__dirname, 'src', 'init.js'),
output: {
path: path.resolve(__dirname, '../js'),
publicPath: '/',
filename: 'merged.js'
path: path.resolve(__dirname, './js'),
publicPath: '/js/',
filename: 'updatenotification.js'
},
module: {
rules: [

View File

@ -0,0 +1,69 @@
<?php
/**
* @copyright Copyright (c) 2018 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Core\Migrations;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\SimpleMigrationStep;
class Version14000Date20180626223656 extends SimpleMigrationStep {
public function changeSchema(\OCP\Migration\IOutput $output, \Closure $schemaClosure, array $options) {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if(!$schema->hasTable('whats_new')) {
$table = $schema->createTable('whats_new');
$table->addColumn('id', 'integer', [
'autoincrement' => true,
'notnull' => true,
'length' => 4,
'unsigned' => true,
]);
$table->addColumn('version', 'string', [
'notnull' => true,
'length' => 64,
'default' => '11',
]);
$table->addColumn('etag', 'string', [
'notnull' => true,
'length' => 64,
'default' => '',
]);
$table->addColumn('last_check', 'integer', [
'notnull' => true,
'length' => 4,
'unsigned' => true,
'default' => 0,
]);
$table->addColumn('data', 'text', [
'notnull' => true,
'default' => '',
]);
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['version']);
$table->addIndex(['version', 'etag'], 'version_etag_idx');
}
return $schema;
}
}

View File

@ -595,6 +595,7 @@ return array(
'OC\\Core\\Migrations\\Version14000Date20180516101403' => $baseDir . '/core/Migrations/Version14000Date20180516101403.php',
'OC\\Core\\Migrations\\Version14000Date20180518120534' => $baseDir . '/core/Migrations/Version14000Date20180518120534.php',
'OC\\Core\\Migrations\\Version14000Date20180522074438' => $baseDir . '/core/Migrations/Version14000Date20180522074438.php',
'OC\\Core\\Migrations\\Version14000Date20180626223656' => $baseDir . '/core/Migrations/Version14000Date20180626223656.php',
'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php',
'OC\\DB\\AdapterMySQL' => $baseDir . '/lib/private/DB/AdapterMySQL.php',
'OC\\DB\\AdapterOCI8' => $baseDir . '/lib/private/DB/AdapterOCI8.php',
@ -1003,6 +1004,9 @@ return array(
'OC\\Template\\TemplateFileLocator' => $baseDir . '/lib/private/Template/TemplateFileLocator.php',
'OC\\URLGenerator' => $baseDir . '/lib/private/URLGenerator.php',
'OC\\Updater' => $baseDir . '/lib/private/Updater.php',
'OC\\Updater\\ChangesCheck' => $baseDir . '/lib/private/Updater/ChangesCheck.php',
'OC\\Updater\\ChangesMapper' => $baseDir . '/lib/private/Updater/ChangesMapper.php',
'OC\\Updater\\ChangesResult' => $baseDir . '/lib/private/Updater/ChangesResult.php',
'OC\\Updater\\VersionCheck' => $baseDir . '/lib/private/Updater/VersionCheck.php',
'OC\\User\\Backend' => $baseDir . '/lib/private/User/Backend.php',
'OC\\User\\Database' => $baseDir . '/lib/private/User/Database.php',

View File

@ -625,6 +625,7 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OC\\Core\\Migrations\\Version14000Date20180516101403' => __DIR__ . '/../../..' . '/core/Migrations/Version14000Date20180516101403.php',
'OC\\Core\\Migrations\\Version14000Date20180518120534' => __DIR__ . '/../../..' . '/core/Migrations/Version14000Date20180518120534.php',
'OC\\Core\\Migrations\\Version14000Date20180522074438' => __DIR__ . '/../../..' . '/core/Migrations/Version14000Date20180522074438.php',
'OC\\Core\\Migrations\\Version14000Date20180626223656' => __DIR__ . '/../../..' . '/core/Migrations/Version14000Date20180626223656.php',
'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php',
'OC\\DB\\AdapterMySQL' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterMySQL.php',
'OC\\DB\\AdapterOCI8' => __DIR__ . '/../../..' . '/lib/private/DB/AdapterOCI8.php',
@ -1033,6 +1034,9 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OC\\Template\\TemplateFileLocator' => __DIR__ . '/../../..' . '/lib/private/Template/TemplateFileLocator.php',
'OC\\URLGenerator' => __DIR__ . '/../../..' . '/lib/private/URLGenerator.php',
'OC\\Updater' => __DIR__ . '/../../..' . '/lib/private/Updater.php',
'OC\\Updater\\ChangesCheck' => __DIR__ . '/../../..' . '/lib/private/Updater/ChangesCheck.php',
'OC\\Updater\\ChangesMapper' => __DIR__ . '/../../..' . '/lib/private/Updater/ChangesMapper.php',
'OC\\Updater\\ChangesResult' => __DIR__ . '/../../..' . '/lib/private/Updater/ChangesResult.php',
'OC\\Updater\\VersionCheck' => __DIR__ . '/../../..' . '/lib/private/Updater/VersionCheck.php',
'OC\\User\\Backend' => __DIR__ . '/../../..' . '/lib/private/User/Backend.php',
'OC\\User\\Database' => __DIR__ . '/../../..' . '/lib/private/User/Database.php',

View File

@ -32,6 +32,7 @@ namespace OC\L10N;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use OCP\L10N\IFactory;
@ -321,6 +322,37 @@ class Factory implements IFactory {
return array_search($lang, $languages) !== false;
}
public function iterateLanguage(bool $reset = false): string {
static $i = 0;
if($reset) {
$i = 0;
}
switch($i) {
/** @noinspection PhpMissingBreakStatementInspection */
case 0:
$i++;
$forcedLang = $this->config->getSystemValue('force_language', false);
if(is_string($forcedLang)) {
return $forcedLang;
}
/** @noinspection PhpMissingBreakStatementInspection */
case 1:
$i++;
$user = $this->userSession->getUser();
if($user instanceof IUser) {
$userLang = $this->config->getUserValue($user->getUID(), 'core', 'lang', null);
if(is_string($userLang)) {
return $userLang;
}
}
case 2:
$i++;
return $this->config->getSystemValue('default_language', 'en');
default:
return 'en';
}
}
/**
* @param string $locale
* @return bool

View File

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2018 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Updater;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\ILogger;
class ChangesCheck {
/** @var IClientService */
protected $clientService;
/** @var ChangesMapper */
private $mapper;
/** @var ILogger */
private $logger;
const RESPONSE_NO_CONTENT = 0;
const RESPONSE_USE_CACHE = 1;
const RESPONSE_HAS_CONTENT = 2;
public function __construct(IClientService $clientService, ChangesMapper $mapper, ILogger $logger) {
$this->clientService = $clientService;
$this->mapper = $mapper;
$this->logger = $logger;
}
/**
* @throws \Exception
*/
public function check(string $uri, string $version): array {
try {
$version = $this->normalizeVersion($version);
$changesInfo = $this->mapper->getChanges($version);
if($changesInfo->getLastCheck() + 1800 > time()) {
return json_decode($changesInfo->getData(), true);
}
} catch (DoesNotExistException $e) {
$changesInfo = new ChangesResult();
}
$response = $this->queryChangesServer($uri, $changesInfo);
switch($this->evaluateResponse($response)) {
case self::RESPONSE_NO_CONTENT:
return [];
case self::RESPONSE_USE_CACHE:
return json_decode($changesInfo->getData(), true);
case self::RESPONSE_HAS_CONTENT:
default:
$data = $this->extractData($response->getBody());
$changesInfo->setData(json_encode($data));
$changesInfo->setEtag($response->getHeader('Etag'));
$this->cacheResult($changesInfo, $version);
return $data;
}
}
protected function evaluateResponse(IResponse $response): int {
if($response->getStatusCode() === 304) {
return self::RESPONSE_USE_CACHE;
} else if($response->getStatusCode() === 404) {
return self::RESPONSE_NO_CONTENT;
} else if($response->getStatusCode() === 200) {
return self::RESPONSE_HAS_CONTENT;
}
$this->logger->debug('Unexpected return code {code} from changelog server', [
'app' => 'core',
'code' => $response->getStatusCode(),
]);
return self::RESPONSE_NO_CONTENT;
}
protected function cacheResult(ChangesResult $entry, string $version) {
if($entry->getVersion() === $version) {
$this->mapper->update($entry);
} else {
$entry->setVersion($version);
$this->mapper->insert($entry);
}
}
/**
* @throws \Exception
*/
protected function queryChangesServer(string $uri, ChangesResult $entry): IResponse {
$headers = [];
if($entry->getEtag() !== '') {
$headers['If-None-Match'] = [$entry->getEtag()];
}
$entry->setLastCheck(time());
$client = $this->clientService->newClient();
return $client->get($uri, [
'headers' => $headers,
]);
}
protected function extractData($body):array {
$data = [];
if ($body) {
$loadEntities = libxml_disable_entity_loader(true);
$xml = @simplexml_load_string($body);
libxml_disable_entity_loader($loadEntities);
if ($xml !== false) {
$data['changelogURL'] = (string)$xml->changelog['href'];
$data['whatsNew'] = [];
foreach($xml->whatsNew as $infoSet) {
$data['whatsNew'][(string)$infoSet['lang']] = [
'regular' => (array)$infoSet->regular->item,
'admin' => (array)$infoSet->admin->item,
];
}
} else {
libxml_clear_errors();
}
}
return $data;
}
/**
* returns a x.y.z form of the provided version. Extra numbers will be
* omitted, missing ones added as zeros.
*/
protected function normalizeVersion(string $version): string {
$versionNumbers = array_slice(explode('.', $version), 0, 3);
$versionNumbers[0] = $versionNumbers[0] ?: '0'; // deal with empty input
while(count($versionNumbers) < 3) {
// changelog server expects x.y.z, pad 0 if it is too short
$versionNumbers[] = 0;
}
return implode('.', $versionNumbers);
}
}

View File

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2018 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Updater;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
class ChangesMapper extends QBMapper {
const TABLE_NAME = 'whats_new';
public function __construct(IDBConnection $db) {
parent::__construct($db, self::TABLE_NAME);
}
/**
* @throws DoesNotExistException
*/
public function getChanges(string $version): ChangesResult {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$result = $qb->select('*')
->from(self::TABLE_NAME)
->where($qb->expr()->eq('version', $qb->createNamedParameter($version)))
->execute();
$data = $result->fetch();
$result->closeCursor();
if ($data === false) {
throw new DoesNotExistException('Changes info is not present');
}
return ChangesResult::fromRow($data);
}
}

View File

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2018 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OC\Updater;
use OCP\AppFramework\Db\Entity;
/**
* Class ChangesResult
*
* @package OC\Updater
* @method string getVersion()=1
* @method void setVersion(string $version)
* @method string getEtag()
* @method void setEtag(string $etag)
* @method int getLastCheck()
* @method void setLastCheck(int $lastCheck)
* @method string getData()
* @method void setData(string $data)
*/
class ChangesResult extends Entity {
/** @var string */
protected $version = '';
/** @var string */
protected $etag = '';
/** @var int */
protected $lastCheck = 0;
/** @var string */
protected $data = '';
public function __construct() {
$this->addType('version', 'string');
$this->addType('etag', 'string');
$this->addType('lastCheck', 'int');
$this->addType('data', 'string');
}
}

View File

@ -97,6 +97,7 @@ class VersionCheck {
$tmp['versionstring'] = (string)$data->versionstring;
$tmp['url'] = (string)$data->url;
$tmp['web'] = (string)$data->web;
$tmp['changes'] = isset($data->changes) ? (string)$data->changes : '';
$tmp['autoupdater'] = (string)$data->autoupdater;
$tmp['eol'] = isset($data->eol) ? (string)$data->eol : '0';
} else {

View File

@ -89,4 +89,14 @@ interface IFactory {
* @since 14.0.0
*/
public function createPluralFunction($string);
/**
* iterate through language settings (if provided) in this order:
* 1. returns the forced language or:
* 2. returns the user language or:
* 3. returns the system default language or:
* 4+. returns 'en'
* @since 14.0.0
*/
public function iterateLanguage(bool $reset = false): string;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2600,7 +2600,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -3015,7 +3016,8 @@
"safe-buffer": {
"version": "5.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -3071,6 +3073,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -3114,12 +3117,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},

View File

@ -23,7 +23,7 @@
<template>
<li>
<!-- If item.href is set, a link will be directly used -->
<a @click="item.action" v-if="item.href" :href="(item.href) ? item.href : '#' ">
<a @click="item.action" v-if="item.href" :href="(item.href) ? item.href : '#' " :target="(item.target) ? item.target : '' " rel="noreferrer noopener">
<span :class="item.icon"></span>
<span v-if="item.text">{{item.text}}</span>
<p v-else-if="item.longtext">{{item.longtext}}</p>
@ -35,7 +35,7 @@
<p v-else-if="item.longtext">{{item.longtext}}</p>
</button>
<!-- If item.longtext is set AND the item does not have an action -->
<span v-else>
<span class="menuitem" v-else>
<span :class="item.icon"></span>
<span v-if="item.text">{{item.text}}</span>
<p v-else-if="item.longtext">{{item.longtext}}</p>

View File

@ -0,0 +1,344 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2018 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace Test\Updater;
use OC\Updater\ChangesCheck;
use OC\Updater\ChangesMapper;
use OC\Updater\ChangesResult;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\ILogger;
use const Solarium\QueryType\Select\Query\Component\Facet\INCLUDE_LOWER;
use Test\TestCase;
class ChangesCheckTest extends TestCase {
/** @var IClientService|\PHPUnit_Framework_MockObject_MockObject */
protected $clientService;
/** @var ChangesCheck */
protected $checker;
/** @var ChangesMapper|\PHPUnit_Framework_MockObject_MockObject */
protected $mapper;
/** @var ILogger|\PHPUnit_Framework_MockObject_MockObject */
protected $logger;
public function setUp() {
parent::setUp();
$this->clientService = $this->createMock(IClientService::class);
$this->mapper = $this->createMock(ChangesMapper::class);
$this->logger = $this->createMock(ILogger::class);
$this->checker = new ChangesCheck($this->clientService, $this->mapper, $this->logger);
}
public function statusCodeProvider():array {
return [
[200, ChangesCheck::RESPONSE_HAS_CONTENT],
[304, ChangesCheck::RESPONSE_USE_CACHE],
[404, ChangesCheck::RESPONSE_NO_CONTENT],
[418, ChangesCheck::RESPONSE_NO_CONTENT],
];
}
/**
* @dataProvider statusCodeProvider
*/
public function testEvaluateResponse(int $statusCode, int $expected) {
$response = $this->createMock(IResponse::class);
$response->expects($this->atLeastOnce())
->method('getStatusCode')
->willReturn($statusCode);
if(!in_array($statusCode, [200, 304, 404])) {
$this->logger->expects($this->once())
->method('debug');
}
$evaluation = $this->invokePrivate($this->checker, 'evaluateResponse', [$response]);
$this->assertSame($expected, $evaluation);
}
public function testCacheResultInsert() {
$version = '13.0.4';
$entry = $this->createMock(ChangesResult::class);
$entry->expects($this->exactly(2))
->method('__call')
->withConsecutive(['getVersion'], ['setVersion', [$version]])
->willReturnOnConsecutiveCalls('', null);
$this->mapper->expects($this->once())
->method('insert');
$this->mapper->expects($this->never())
->method('update');
$this->invokePrivate($this->checker, 'cacheResult', [$entry, $version]);
}
public function testCacheResultUpdate() {
$version = '13.0.4';
$entry = $this->createMock(ChangesResult::class);
$entry->expects($this->once())
->method('__call')
->willReturn($version);
$this->mapper->expects($this->never())
->method('insert');
$this->mapper->expects($this->once())
->method('update');
$this->invokePrivate($this->checker, 'cacheResult', [$entry, $version]);
}
public function changesXMLProvider(): array {
return [
[ # 0 - full example
'<?xml version="1.0" encoding="utf-8" ?>
<release xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://updates.nextcloud.com/changelog_server/schema.xsd"
version="13.0.0">
<changelog href="https://nextcloud.com/changelog/#13-0-0"/>
<whatsNew lang="en">
<regular>
<item>Refined user interface</item>
<item>End-to-end Encryption</item>
<item>Video and Text Chat</item>
</regular>
<admin>
<item>Changes to the Nginx configuration</item>
<item>Theming: CSS files were consolidated</item>
</admin>
</whatsNew>
<whatsNew lang="de">
<regular>
<item>Überarbeitete Benutzerschnittstelle</item>
<item>Ende-zu-Ende Verschlüsselung</item>
<item>Video- und Text-Chat</item>
</regular>
<admin>
<item>Änderungen an der Nginx Konfiguration</item>
<item>Theming: CSS Dateien wurden konsolidiert</item>
</admin>
</whatsNew>
</release>',
[
'changelogURL' => 'https://nextcloud.com/changelog/#13-0-0',
'whatsNew' => [
'en' => [
'regular' => [
'Refined user interface',
'End-to-end Encryption',
'Video and Text Chat'
],
'admin' => [
'Changes to the Nginx configuration',
'Theming: CSS files were consolidated'
],
],
'de' => [
'regular' => [
'Überarbeitete Benutzerschnittstelle',
'Ende-zu-Ende Verschlüsselung',
'Video- und Text-Chat'
],
'admin' => [
'Änderungen an der Nginx Konfiguration',
'Theming: CSS Dateien wurden konsolidiert'
],
],
],
]
],
[ # 1- admin part not translated
'<?xml version="1.0" encoding="utf-8" ?>
<release xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://updates.nextcloud.com/changelog_server/schema.xsd"
version="13.0.0">
<changelog href="https://nextcloud.com/changelog/#13-0-0"/>
<whatsNew lang="en">
<regular>
<item>Refined user interface</item>
<item>End-to-end Encryption</item>
<item>Video and Text Chat</item>
</regular>
<admin>
<item>Changes to the Nginx configuration</item>
<item>Theming: CSS files were consolidated</item>
</admin>
</whatsNew>
<whatsNew lang="de">
<regular>
<item>Überarbeitete Benutzerschnittstelle</item>
<item>Ende-zu-Ende Verschlüsselung</item>
<item>Video- und Text-Chat</item>
</regular>
</whatsNew>
</release>',
[
'changelogURL' => 'https://nextcloud.com/changelog/#13-0-0',
'whatsNew' => [
'en' => [
'regular' => [
'Refined user interface',
'End-to-end Encryption',
'Video and Text Chat'
],
'admin' => [
'Changes to the Nginx configuration',
'Theming: CSS files were consolidated'
],
],
'de' => [
'regular' => [
'Überarbeitete Benutzerschnittstelle',
'Ende-zu-Ende Verschlüsselung',
'Video- und Text-Chat'
],
'admin' => [
],
],
],
]
],
[ # 2 - minimal set
'<?xml version="1.0" encoding="utf-8" ?>
<release xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://updates.nextcloud.com/changelog_server/schema.xsd"
version="13.0.0">
<changelog href="https://nextcloud.com/changelog/#13-0-0"/>
<whatsNew lang="en">
<regular>
<item>Refined user interface</item>
<item>End-to-end Encryption</item>
<item>Video and Text Chat</item>
</regular>
</whatsNew>
</release>',
[
'changelogURL' => 'https://nextcloud.com/changelog/#13-0-0',
'whatsNew' => [
'en' => [
'regular' => [
'Refined user interface',
'End-to-end Encryption',
'Video and Text Chat'
],
'admin' => [],
],
],
]
],
[ # 3 - minimal set (procrastinator edition)
'<?xml version="1.0" encoding="utf-8" ?>
<release xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://updates.nextcloud.com/changelog_server/schema.xsd"
version="13.0.0">
<changelog href="https://nextcloud.com/changelog/#13-0-0"/>
<whatsNew lang="en">
<regular>
<item>Write this tomorrow</item>
</regular>
</whatsNew>
</release>',
[
'changelogURL' => 'https://nextcloud.com/changelog/#13-0-0',
'whatsNew' => [
'en' => [
'regular' => [
'Write this tomorrow',
],
'admin' => [],
],
],
]
],
];
}
/**
* @dataProvider changesXMLProvider
*/
public function testExtractData(string $body, array $expected) {
$actual = $this->invokePrivate($this->checker, 'extractData', [$body]);
$this->assertSame($expected, $actual);
}
public function etagProvider() {
return [
[''],
['a27aab83d8205d73978435076e53d143']
];
}
/**
* @dataProvider etagProvider
*/
public function testQueryChangesServer(string $etag) {
$uri = 'https://changes.nextcloud.server/?13.0.5';
$entry = $this->createMock(ChangesResult::class);
$entry->expects($this->any())
->method('__call')
->willReturn($etag);
$expectedHeaders = $etag === '' ? [] : ['If-None-Match' => [$etag]];
$client = $this->createMock(IClient::class);
$client->expects($this->once())
->method('get')
->with($uri, ['headers' => $expectedHeaders])
->willReturn($this->createMock(IResponse::class));
$this->clientService->expects($this->once())
->method('newClient')
->willReturn($client);
$response = $this->invokePrivate($this->checker, 'queryChangesServer', [$uri, $entry]);
$this->assertInstanceOf(IResponse::class, $response);
}
public function versionProvider(): array {
return [
['13.0.7', '13.0.7'],
['13.0.7.3', '13.0.7'],
['13.0.7.3.42', '13.0.7'],
['13.0', '13.0.0'],
['13', '13.0.0'],
['', '0.0.0'],
];
}
/**
* @dataProvider versionProvider
*/
public function testNormalizeVersion(string $input, string $expected) {
$normalized = $this->invokePrivate($this->checker, 'normalizeVersion', [$input]);
$this->assertSame($expected, $normalized);
}
}

View File

@ -62,6 +62,7 @@ class VersionCheckTest extends \Test\TestCase {
'versionstring' => 'ownCloud 8.0.4',
'url' => 'https://download.example.org/community/owncloud-8.0.4.zip',
'web' => 'http://doc.example.org/server/8.0/admin_manual/maintenance/upgrade.html',
'changes' => '',
];
$this->config
@ -84,6 +85,7 @@ class VersionCheckTest extends \Test\TestCase {
'versionstring' => 'ownCloud 8.0.4',
'url' => 'https://download.example.org/community/owncloud-8.0.4.zip',
'web' => 'http://doc.example.org/server/8.0/admin_manual/maintenance/upgrade.html',
'changes' => '',
'autoupdater' => '0',
'eol' => '1',
];
@ -181,6 +183,7 @@ class VersionCheckTest extends \Test\TestCase {
'versionstring' => '',
'url' => '',
'web' => '',
'changes' => '',
'autoupdater' => '',
'eol' => '0',
];
@ -275,6 +278,7 @@ class VersionCheckTest extends \Test\TestCase {
'versionstring' => '',
'url' => '',
'web' => '',
'changes' => '',
'autoupdater' => '',
'eol' => '0',
];

View File

@ -29,7 +29,7 @@
// between betas, final and RCs. This is _not_ the public version number. Reset minor/patchlevel
// when updating major/minor version number.
$OC_Version = array(14, 0, 0, 6);
$OC_Version = array(14, 0, 0, 7);
// The human readable string
$OC_VersionString = '14.0.0 alpha';