Merge branch 'develop' into l10n_develop

This commit is contained in:
syuilo 2018-08-30 03:56:51 +09:00 committed by GitHub
commit 4e11da98d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
171 changed files with 2193 additions and 14550 deletions

View File

@ -138,3 +138,6 @@ drive:
# Clustering
# clusterLimit: 1
# Summaly proxy
# summalyProxy: "http://example.com"

View File

@ -5,6 +5,16 @@ ChangeLog
This document describes breaking changes only.
8.0.0
-----
### Migration
起動する前に、`node cli/migration/8.0.0`してください。
Please run `node cli/migration/8.0.0` before launch.
7.0.0
-----

View File

@ -1,40 +0,0 @@
const { default: User, deleteUser } = require('../built/models/user');
const { default: zip } = require('@prezzemolo/zip')
const migrate = async (user) => {
try {
await deleteUser(user._id);
return true;
} catch (e) {
return false;
}
}
async function main() {
const count = await User.count({
uri: /#/
});
const dop = 1
const idop = ((count - (count % dop)) / dop) + 1
return zip(
1,
async (time) => {
console.log(`${time} / ${idop}`)
const doc = await User.find({
uri: /#/
}, {
limit: dop, skip: time * dop
})
return Promise.all(doc.map(migrate))
},
idop
).then(a => {
const rv = []
a.forEach(e => rv.push(...e))
return rv
})
}
main().then(console.dir).catch(console.error)

144
cli/migration/8.0.0.js Normal file
View File

@ -0,0 +1,144 @@
const { default: Stats } = require('../../built/models/stats');
const { default: User } = require('../../built/models/user');
const { default: Note } = require('../../built/models/note');
const { default: DriveFile } = require('../../built/models/drive-file');
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
const h = now.getHours();
const date = new Date(y, m, d, h);
async function main() {
await Stats.update({}, {
$set: {
span: 'day'
}
}, {
multi: true
});
const localUsersCount = await User.count({
host: null
});
const remoteUsersCount = await User.count({
host: { $ne: null }
});
const localNotesCount = await Note.count({
'_user.host': null
});
const remoteNotesCount = await Note.count({
'_user.host': { $ne: null }
});
const localDriveFilesCount = await DriveFile.count({
'metadata._user.host': null
});
const remoteDriveFilesCount = await DriveFile.count({
'metadata._user.host': { $ne: null }
});
const localDriveFilesSize = await DriveFile
.aggregate([{
$match: {
'metadata._user.host': null,
'metadata.deletedAt': { $exists: false }
}
}, {
$project: {
length: true
}
}, {
$group: {
_id: null,
usage: { $sum: '$length' }
}
}])
.then(aggregates => {
if (aggregates.length > 0) {
return aggregates[0].usage;
}
return 0;
});
const remoteDriveFilesSize = await DriveFile
.aggregate([{
$match: {
'metadata._user.host': { $ne: null },
'metadata.deletedAt': { $exists: false }
}
}, {
$project: {
length: true
}
}, {
$group: {
_id: null,
usage: { $sum: '$length' }
}
}])
.then(aggregates => {
if (aggregates.length > 0) {
return aggregates[0].usage;
}
return 0;
});
await Stats.insert({
date: date,
span: 'hour',
users: {
local: {
total: localUsersCount,
diff: 0
},
remote: {
total: remoteUsersCount,
diff: 0
}
},
notes: {
local: {
total: localNotesCount,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
},
remote: {
total: remoteNotesCount,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
}
},
drive: {
local: {
totalCount: localDriveFilesCount,
totalSize: localDriveFilesSize,
diffCount: 0,
diffSize: 0
},
remote: {
totalCount: remoteDriveFilesCount,
totalSize: remoteDriveFilesSize,
diffCount: 0,
diffSize: 0
}
}
});
console.log('done');
}
main();

View File

@ -59,7 +59,16 @@ gulp.task('build:copy:views', () =>
gulp.src('./src/server/web/views/**/*').pipe(gulp.dest('./built/server/web/views'))
);
gulp.task('build:copy', ['build:copy:views'], () =>
// 互換性のため
gulp.task('build:copy:lang', () =>
gulp.src(['./built/client/assets/*.*-*.js'])
.pipe(rename(path => {
path.basename = path.basename.replace(/\-(.*)$/, '');
}))
.pipe(gulp.dest('./built/client/assets/'))
);
gulp.task('build:copy', ['build:copy:views', 'build:copy:lang'], () =>
gulp.src([
'./build/Release/crypto_key.node',
'./src/const.json',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -11,13 +11,13 @@ const loadLang = lang => yaml.safeLoad(
const native = loadLang('ja-JP');
const langs = {
'de': loadLang('de'),
'en': loadLang('en'),
'fr': loadLang('fr'),
'ja': native,
'de-DE': loadLang('de-DE'),
'en-US': loadLang('en-US'),
'fr-FR': loadLang('fr-FR'),
'ja-JP': native,
'ja-KS': loadLang('ja-KS'),
'pl': loadLang('pl'),
'es': loadLang('es')
'pl-PL': loadLang('pl-PL'),
'es-ES': loadLang('es-ES')
};
Object.values(langs).forEach(locale => {

File diff suppressed because it is too large Load Diff

View File

@ -456,6 +456,7 @@ desktop:
uploading-avatar: "新しいアバターをアップロードしています"
avatar-updated: "アバターを更新しました"
choose-avatar: "アバターにする画像を選択"
invalid-filetype: "この形式のファイルはサポートされていません"
desktop/views/components/activity.chart.vue:
total: "Black ... Total"
@ -473,6 +474,25 @@ desktop/views/components/calendar.vue:
next: "次の月"
go: "クリックして時間遡行"
desktop/views/components/charts.vue:
title: "チャート"
per-day: "1日ごと"
per-hour: "1時間ごと"
notes: "投稿"
users: "ユーザー"
drive: "ドライブ"
charts:
notes: "投稿の増減 (統合)"
local-notes: "投稿の増減 (ローカル)"
remote-notes: "投稿の増減 (リモート)"
notes-total: "投稿の累計"
users: "ユーザーの増減"
users-total: "ユーザーの累計"
drive: "ドライブ使用量の増減"
drive-total: "ドライブ使用量の累計"
drive-files: "ドライブのファイル数の増減"
drive-files-total: "ドライブのファイル数の累計"
desktop/views/components/choose-file-from-drive-window.vue:
choose-file: "ファイル選択中"
upload: "PCからドライブにファイルをアップロード"
@ -713,6 +733,7 @@ desktop/views/components/settings.vue:
gradient-window-header: "ウィンドウのタイトルバーにグラデーションを使用"
post-form-on-timeline: "タイムライン上部に投稿フォームを表示する"
suggest-recent-hashtags: "最近のハッシュタグを投稿フォームに表示する"
show-clock-on-header: "右上に時計を表示する"
show-reply-target: "リプライ先を表示する"
show-my-renotes: "自分の行ったRenoteをタイムラインに表示する"
show-renoted-my-notes: "自分の投稿のRenoteをタイムラインに表示する"
@ -857,6 +878,7 @@ desktop/views/components/ui.header.account.vue:
lists: "リスト"
follow-requests: "フォロー申請"
customize: "ホームのカスタマイズ"
admin: "管理"
settings: "設定"
signout: "サインアウト"
dark: "闇に飲まれる"
@ -914,8 +936,8 @@ desktop/views/pages/admin/admin.dashboard.vue:
dashboard: "ダッシュボード"
all-users: "全てのユーザー"
original-users: "このインスタンスのユーザー"
all-notes: "全てのノート"
original-notes: "このインスタンスのノート"
all-notes: "全ての投稿"
original-notes: "このインスタンスの投稿"
invite: "招待"
desktop/views/pages/admin/admin.suspend-user.vue:
@ -938,21 +960,6 @@ desktop/views/pages/admin/admin.unverify-user.vue:
unverify: "公式アカウントを解除する"
unverified: "公式アカウントを解除しました"
desktop/views/pages/admin/admin.notes-chart.vue:
title: "投稿"
local: "ローカル"
remote: "リモート"
desktop/views/pages/admin/admin.users-chart.vue:
title: "ユーザー"
local: "ローカル"
remote: "リモート"
desktop/views/pages/admin/admin.drive-chart.vue:
title: "ドライブ"
local: "ローカル"
remote: "リモート"
desktop/views/pages/deck/deck.tl-column.vue:
is-media-only: "メディア投稿のみ"
is-media-view: "メディアビュー"
@ -963,6 +970,12 @@ desktop/views/pages/deck/deck.note.vue:
private: "この投稿は非公開です"
deleted: "この投稿は削除されました"
desktop/views/pages/stats/stats.vue:
all-users: "全てのユーザー"
original-users: "このインスタンスのユーザー"
all-notes: "全ての投稿"
original-notes: "このインスタンスの投稿"
desktop/views/pages/welcome.vue:
about: "詳しく..."
gotit: "わかった"
@ -1214,6 +1227,7 @@ mobile/views/components/ui.nav.vue:
game: "ゲーム"
darkmode: "ダークモード"
settings: "設定"
admin: "管理"
about: "Misskeyについて"
mobile/views/components/user-timeline.vue:
@ -1355,6 +1369,8 @@ mobile/views/pages/settings.vue:
update-available-desc: "ページを再度読み込みすると更新が適用されます。"
settings: "設定"
signout: "サインアウト"
sound: "サウンド"
enableSounds: "サウンドを有効にする"
mobile/views/pages/user.vue:
follows-you: "フォローされています"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
{
"name": "misskey",
"author": "syuilo <i@syuilo.com>",
"version": "7.3.0",
"clientVersion": "1.0.8741",
"version": "8.15.0",
"clientVersion": "1.0.9031",
"codename": "nighthike",
"main": "./built/index.js",
"private": true,
@ -32,7 +32,7 @@
"@types/debug": "0.0.30",
"@types/deep-equal": "1.0.1",
"@types/double-ended-queue": "2.1.0",
"@types/elasticsearch": "5.0.25",
"@types/elasticsearch": "5.0.26",
"@types/file-type": "5.2.1",
"@types/gulp": "3.8.36",
"@types/gulp-htmlmin": "1.3.32",
@ -60,7 +60,7 @@
"@types/mocha": "5.2.3",
"@types/mongodb": "3.1.4",
"@types/ms": "0.7.30",
"@types/node": "10.7.1",
"@types/node": "10.9.3",
"@types/portscanner": "2.1.0",
"@types/pug": "2.0.4",
"@types/qrcode": "1.2.0",
@ -70,14 +70,14 @@
"@types/request-promise-native": "1.0.15",
"@types/rimraf": "2.0.2",
"@types/seedrandom": "2.4.27",
"@types/sharp": "0.17.9",
"@types/sharp": "0.17.10",
"@types/showdown": "1.7.5",
"@types/single-line-log": "1.1.0",
"@types/speakeasy": "2.0.2",
"@types/systeminformation": "3.23.0",
"@types/tmp": "0.0.33",
"@types/uuid": "3.4.3",
"@types/webpack": "4.4.10",
"@types/webpack": "4.4.11",
"@types/webpack-stream": "3.2.10",
"@types/websocket": "0.0.39",
"@types/ws": "6.0.0",
@ -89,6 +89,7 @@
"bootstrap-vue": "2.0.0-rc.11",
"cafy": "11.3.0",
"chalk": "2.4.1",
"chart.js": "2.7.2",
"commander": "2.17.1",
"crc-32": "1.2.0",
"css-loader": "1.0.0",
@ -149,6 +150,7 @@
"loader-utils": "1.1.0",
"lodash.assign": "4.2.0",
"mecab-async": "0.1.2",
"merge-options": "1.0.1",
"minio": "7.0.0",
"mkdirp": "0.5.1",
"mocha": "5.2.0",
@ -156,7 +158,7 @@
"mongodb": "3.1.1",
"monk": "6.0.6",
"ms": "2.1.1",
"nan": "2.10.0",
"nan": "2.11.0",
"nested-property": "0.0.7",
"node-sass": "4.9.3",
"node-sass-json-importer": "3.3.1",
@ -188,11 +190,11 @@
"single-line-log": "1.1.2",
"speakeasy": "2.0.0",
"stringz": "1.0.0",
"style-loader": "0.22.1",
"style-loader": "0.23.0",
"stylus": "0.54.5",
"stylus-loader": "3.0.2",
"summaly": "2.1.4",
"systeminformation": "3.42.9",
"systeminformation": "3.44.2",
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
"tmp": "0.0.33",
@ -206,10 +208,11 @@
"uuid": "3.3.2",
"v-animate-css": "0.0.2",
"vue": "2.5.17",
"vue-chartjs": "3.4.0",
"vue-cropperjs": "2.2.1",
"vue-js-modal": "1.3.17",
"vue-js-modal": "1.3.23",
"vue-json-tree-view": "2.1.4",
"vue-loader": "15.4.0",
"vue-loader": "15.4.1",
"vue-router": "3.0.1",
"vue-style-loader": "4.1.2",
"vue-template-compiler": "2.5.17",
@ -218,7 +221,7 @@
"vuex-persistedstate": "2.5.4",
"web-push": "3.3.2",
"webfinger.js": "2.6.6",
"webpack": "4.17.0",
"webpack": "4.17.1",
"webpack-cli": "3.1.0",
"websocket": "1.0.26",
"ws": "6.0.0",

View File

@ -38,15 +38,22 @@
//#endregion
//#region Detect the user language
let lang = navigator.language;
let lang = null;
if (!LANGS.includes(lang)) lang = lang.split('-')[0];
if (LANGS.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = LANGS.find(x => x.split('-')[0] == navigator.language);
// The default language is English
if (!LANGS.includes(lang)) lang = 'en';
if (lang == null) {
// Fallback
lang = 'en-US';
}
}
if (settings) {
if (settings.device.lang) lang = settings.device.lang;
if (settings && settings.device.lang &&
LANGS.includes(settings.device.lang)) {
lang = settings.device.lang;
}
//#endregion

View File

@ -26,8 +26,8 @@ export default Vue.extend({
},
created() {
(this as any).os.getMeta().then(meta => {
if (meta.repositoryUrl) this.repositoryUrl = meta.repositoryUrl;
if (meta.feedbackUrl) this.feedbackUrl = meta.feedbackUrl;
if (meta.maintainer.repository_url) this.repositoryUrl = meta.maintainer.repository_url;
if (meta.maintainer.feedback_url) this.feedbackUrl = meta.maintainer.feedback_url;
});
}
});

View File

@ -28,69 +28,8 @@
import Vue from 'vue';
import { url as misskeyUrl } from '../../../config';
export default Vue.extend({
props: {
url: {
type: String,
require: true
},
detail: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
fetching: true,
title: null,
description: null,
thumbnail: null,
icon: null,
sitename: null,
player: {
url: null,
width: null,
height: null
},
tweetUrl: null,
misskeyUrl
};
},
created() {
const url = new URL(this.url);
if (this.detail && url.hostname == 'twitter.com' && /^\/.+\/status(es)?\/\d+/.test(url.pathname)) {
this.tweetUrl = url;
const twttr = (window as any).twttr || {};
const loadTweet = () => twttr.widgets.load(this.$refs.tweet);
if (twttr.widgets) {
Vue.nextTick(loadTweet);
} else {
const wjsId = 'twitter-wjs';
if (!document.getElementById(wjsId)) {
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('id', wjsId);
script.setAttribute('src', 'https://platform.twitter.com/widgets.js');
head.appendChild(script);
}
twttr.ready = loadTweet;
(window as any).twttr = twttr;
}
return;
}
fetch('/url?url=' + encodeURIComponent(this.url)).then(res => {
res.json().then(info => {
if (info.url != null) {
this.title = info.title;
this.description = info.description;
this.thumbnail = info.thumbnail;
this.icon = info.icon;
this.sitename = info.sitename;
this.fetching = false;
if ([ // THIS IS THE WHITELIST FOR THE EMBED PLAYER
// THIS IS THE WHITELIST FOR THE EMBED PLAYER
const whiteList = [
'afreecatv.com',
'aparat.com',
'applemusic.com',
@ -166,17 +105,85 @@ export default Vue.extend({
'web.tv',
'youtube.com',
'youtu.be'
].some(x => x == url.hostname || url.hostname.endsWith(`.${x}`)))
];
export default Vue.extend({
props: {
url: {
type: String,
require: true
},
detail: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
fetching: true,
title: null,
description: null,
thumbnail: null,
icon: null,
sitename: null,
player: {
url: null,
width: null,
height: null
},
tweetUrl: null,
misskeyUrl
};
},
created() {
const url = new URL(this.url);
if (this.detail && url.hostname == 'twitter.com' && /^\/.+\/status(es)?\/\d+/.test(url.pathname)) {
this.tweetUrl = url;
const twttr = (window as any).twttr || {};
const loadTweet = () => twttr.widgets.load(this.$refs.tweet);
if (twttr.widgets) {
Vue.nextTick(loadTweet);
} else {
const wjsId = 'twitter-wjs';
if (!document.getElementById(wjsId)) {
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('id', wjsId);
script.setAttribute('src', 'https://platform.twitter.com/widgets.js');
head.appendChild(script);
}
twttr.ready = loadTweet;
(window as any).twttr = twttr;
}
return;
}
fetch('/url?url=' + encodeURIComponent(this.url)).then(res => {
res.json().then(info => {
if (info.url == null) return;
this.title = info.title;
this.description = info.description;
this.thumbnail = info.thumbnail;
this.icon = info.icon;
this.sitename = info.sitename;
this.fetching = false;
if (whiteList.some(x => x == url.hostname || url.hostname.endsWith(`.${x}`))) {
this.player = info.player;
} // info.url
}) // json
}); // fetch
} // created
}
})
});
}
});
</script>
<style lang="stylus" scoped>
.twitter
.player
position relative
width 100%

View File

@ -1,8 +1,10 @@
import Vue from 'vue';
Vue.filter('bytes', (v, digits = 0) => {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if (v == 0) return '0Byte';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (v == 0) return '0';
const isMinus = v < 0;
if (isMinus) v = -v;
const i = Math.floor(Math.log(v) / Math.log(1024));
return (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i];
return (isMinus ? '-' : '') + (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i];
});

View File

@ -2,9 +2,9 @@
<div class="mkw-donation" :data-mobile="platform == 'mobile'">
<article>
<h1>%fa:heart%%i18n:@title%</h1>
<p>
<p v-if="meta">
{{ '%i18n:@text%'.substr(0, '%i18n:@text%'.indexOf('{')) }}
<a href="https://syuilo.com">@syuilo</a>
<a :href="meta.maintainer.url">{{ meta.maintainer.name }}</a>
{{ '%i18n:@text%'.substr('%i18n:@text%'.indexOf('}') + 1) }}
</p>
</article>
@ -15,6 +15,17 @@
import define from '../../../common/define-widget';
export default define({
name: 'donation'
}).extend({
data() {
return {
meta: null
};
},
created() {
(this as any).os.getMeta().then(meta => {
this.meta = meta;
});
}
});
</script>

View File

@ -3,8 +3,21 @@ import { apiUrl } from '../../config';
import CropWindow from '../views/components/crop-window.vue';
import ProgressDialog from '../views/components/progress-dialog.vue';
export default (os: OS) => (cb, file = null) => {
const fileSelected = file => {
export default (os: OS) => {
const cropImage = file => new Promise((resolve, reject) => {
const regex = RegExp('\.(jpg|jpeg|png|gif|webp|bmp|tiff)$');
if (!regex.test(file.name) ) {
os.apis.dialog({
title: '%fa:info-circle% %i18n:desktop.invalid-filetype%',
text: null,
actions: [{
text: '%i18n:common.got-it%'
}]
});
reject();
}
const w = os.new(CropWindow, {
image: file,
@ -19,27 +32,29 @@ export default (os: OS) => (cb, file = null) => {
os.api('drive/folders/find', {
name: '%i18n:desktop.avatar%'
}).then(iconFolder => {
if (iconFolder.length === 0) {
}).then(avatarFolder => {
if (avatarFolder.length === 0) {
os.api('drive/folders/create', {
name: '%i18n:desktop.avatar%'
}).then(iconFolder => {
upload(data, iconFolder);
resolve(upload(data, iconFolder));
});
} else {
upload(data, iconFolder[0]);
resolve(upload(data, avatarFolder[0]));
}
});
});
w.$once('skipped', () => {
set(file);
resolve(file);
});
document.body.appendChild(w.$el);
};
w.$once('cancelled', reject);
const upload = (data, folder) => {
document.body.appendChild(w.$el);
});
const upload = (data, folder) => new Promise((resolve, reject) => {
const dialog = os.new(ProgressDialog, {
title: '%i18n:desktop.uploading-avatar%'
});
@ -52,18 +67,19 @@ export default (os: OS) => (cb, file = null) => {
xhr.onload = e => {
const file = JSON.parse((e.target as any).response);
(dialog as any).close();
set(file);
resolve(file);
};
xhr.onerror = reject;
xhr.upload.onprogress = e => {
if (e.lengthComputable) (dialog as any).update(e.loaded, e.total);
};
xhr.send(data);
};
});
const set = file => {
os.api('i/update', {
const setAvatar = file => {
return os.api('i/update', {
avatarId: file.id
}).then(i => {
os.store.commit('updateIKeyValue', {
@ -83,18 +99,21 @@ export default (os: OS) => (cb, file = null) => {
}]
});
if (cb) cb(i);
return i;
});
};
if (file) {
fileSelected(file);
} else {
os.apis.chooseDriveFile({
return (file = null) => {
const selectedFile = file
? Promise.resolve(file)
: os.apis.chooseDriveFile({
multiple: false,
title: '%fa:image% %i18n:desktop.choose-avatar%'
}).then(file => {
fileSelected(file);
});
}
return selectedFile
.then(cropImage)
.then(setAvatar)
.catch(err => err && console.warn(err));
};
};

View File

@ -6,6 +6,19 @@ import ProgressDialog from '../views/components/progress-dialog.vue';
export default (os: OS) => {
const cropImage = file => new Promise((resolve, reject) => {
const regex = RegExp('\.(jpg|jpeg|png|gif|webp|bmp|tiff)$');
if (!regex.test(file.name) ) {
os.apis.dialog({
title: '%fa:info-circle% %i18n:desktop.invalid-filetype%',
text: null,
actions: [{
text: '%i18n:common.got-it%'
}]
});
reject();
}
const w = os.new(CropWindow, {
image: file,
title: '%i18n:desktop.banner-crop-title%',

View File

@ -25,6 +25,7 @@ import updateBanner from './api/update-banner';
import MkIndex from './views/pages/index.vue';
import MkDeck from './views/pages/deck/deck.vue';
import MkAdmin from './views/pages/admin/admin.vue';
import MkStats from './views/pages/stats/stats.vue';
import MkUser from './views/pages/user/user.vue';
import MkFavorites from './views/pages/favorites.vue';
import MkSelectDrive from './views/pages/selectdrive.vue';
@ -57,6 +58,7 @@ init(async (launch) => {
{ path: '/', name: 'index', component: MkIndex },
{ path: '/deck', name: 'deck', component: MkDeck },
{ path: '/admin', name: 'admin', component: MkAdmin },
{ path: '/stats', name: 'stats', component: MkStats },
{ path: '/i/customize-home', component: MkHomeCustomize },
{ path: '/i/favorites', component: MkFavorites },
{ path: '/i/messaging/:user', component: MkMessagingRoom },
@ -94,7 +96,7 @@ init(async (launch) => {
/**
* Init Notification
*/
if ('Notification' in window) {
if ('Notification' in window && os.store.getters.isSignedIn) {
// 許可を得ていなかったらリクエスト
if ((Notification as any).permission == 'default') {
await Notification.requestPermission();

View File

@ -0,0 +1,42 @@
import Vue from 'vue';
import { Line } from 'vue-chartjs';
import * as mergeOptions from 'merge-options';
export default Vue.extend({
extends: Line,
props: {
data: {
required: true
},
opts: {
required: false
}
},
watch: {
data() {
this.render();
}
},
mounted() {
this.render();
},
methods: {
render() {
this.renderChart(this.data, mergeOptions({
responsive: true,
maintainAspectRatio: false,
scales: {
xAxes: [{
type: 'time',
distribution: 'series'
}]
},
tooltips: {
intersect: false,
mode: 'x',
position: 'nearest'
}
}, this.opts || {}));
}
}
});

View File

@ -0,0 +1,587 @@
<template>
<div class="gkgckalzgidaygcxnugepioremxvxvpt">
<header>
<b>%i18n:@title%:</b>
<select v-model="chartType">
<optgroup label="%i18n:@users%">
<option value="users">%i18n:@charts.users%</option>
<option value="users-total">%i18n:@charts.users-total%</option>
</optgroup>
<optgroup label="%i18n:@notes%">
<option value="notes">%i18n:@charts.notes%</option>
<option value="local-notes">%i18n:@charts.local-notes%</option>
<option value="remote-notes">%i18n:@charts.remote-notes%</option>
<option value="notes-total">%i18n:@charts.notes-total%</option>
</optgroup>
<optgroup label="%i18n:@drive%">
<option value="drive-files">%i18n:@charts.drive-files%</option>
<option value="drive-files-total">%i18n:@charts.drive-files-total%</option>
<option value="drive">%i18n:@charts.drive%</option>
<option value="drive-total">%i18n:@charts.drive-total%</option>
</optgroup>
</select>
<div>
<span @click="span = 'day'" :class="{ active: span == 'day' }">%i18n:@per-day%</span> | <span @click="span = 'hour'" :class="{ active: span == 'hour' }">%i18n:@per-hour%</span>
</div>
</header>
<div>
<x-chart v-if="chart" :data="data[0]" :opts="data[1]"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XChart from './charts.chart.ts';
const colors = {
local: 'rgb(246, 88, 79)',
remote: 'rgb(65, 221, 222)',
localPlus: 'rgb(52, 178, 118)',
remotePlus: 'rgb(158, 255, 209)',
localMinus: 'rgb(255, 97, 74)',
remoteMinus: 'rgb(255, 149, 134)'
};
const rgba = (color: string): string => {
return color.replace('rgb', 'rgba').replace(')', ', 0.1)');
};
export default Vue.extend({
components: {
XChart
},
data() {
return {
chart: null,
chartType: 'notes',
span: 'hour'
};
},
computed: {
data(): any {
if (this.chart == null) return null;
switch (this.chartType) {
case 'users': return this.usersChart(false);
case 'users-total': return this.usersChart(true);
case 'notes': return this.notesChart('combined');
case 'local-notes': return this.notesChart('local');
case 'remote-notes': return this.notesChart('remote');
case 'notes-total': return this.notesTotalChart();
case 'drive': return this.driveChart();
case 'drive-total': return this.driveTotalChart();
case 'drive-files': return this.driveFilesChart();
case 'drive-files-total': return this.driveFilesTotalChart();
}
},
stats(): any[] {
return (
this.span == 'day' ? this.chart.perDay :
this.span == 'hour' ? this.chart.perHour :
null
);
}
},
created() {
(this as any).api('chart', {
limit: 32
}).then(chart => {
this.chart = chart;
});
},
methods: {
notesChart(type: string): any {
const data = this.stats.slice().reverse().map(x => ({
date: new Date(x.date),
normal: type == 'local' ? x.notes.local.diffs.normal : type == 'remote' ? x.notes.remote.diffs.normal : x.notes.local.diffs.normal + x.notes.remote.diffs.normal,
reply: type == 'local' ? x.notes.local.diffs.reply : type == 'remote' ? x.notes.remote.diffs.reply : x.notes.local.diffs.reply + x.notes.remote.diffs.reply,
renote: type == 'local' ? x.notes.local.diffs.renote : type == 'remote' ? x.notes.remote.diffs.renote : x.notes.local.diffs.renote + x.notes.remote.diffs.renote,
all: type == 'local' ? (x.notes.local.inc + -x.notes.local.dec) : type == 'remote' ? (x.notes.remote.inc + -x.notes.remote.dec) : (x.notes.local.inc + -x.notes.local.dec) + (x.notes.remote.inc + -x.notes.remote.dec)
}));
return [{
datasets: [{
label: 'All',
fill: false,
borderColor: '#555',
borderWidth: 2,
borderDash: [4, 4],
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.all }))
}, {
label: 'Renotes',
fill: true,
backgroundColor: 'rgba(161, 222, 65, 0.1)',
borderColor: '#a1de41',
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.renote }))
}, {
label: 'Replies',
fill: true,
backgroundColor: 'rgba(247, 121, 108, 0.1)',
borderColor: '#f7796c',
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.reply }))
}, {
label: 'Normal',
fill: true,
backgroundColor: 'rgba(65, 221, 222, 0.1)',
borderColor: '#41ddde',
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.normal }))
}]
}, {
scales: {
yAxes: [{
ticks: {
callback: value => {
return Vue.filter('number')(value);
}
}
}]
},
tooltips: {
callbacks: {
label: (tooltipItem, data) => {
const label = data.datasets[tooltipItem.datasetIndex].label || '';
return `${label}: ${Vue.filter('number')(tooltipItem.yLabel)}`;
}
}
}
}];
},
notesTotalChart(): any {
const data = this.stats.slice().reverse().map(x => ({
date: new Date(x.date),
localCount: x.notes.local.total,
remoteCount: x.notes.remote.total
}));
return [{
datasets: [{
label: 'Combined',
fill: false,
borderColor: '#555',
borderWidth: 2,
borderDash: [4, 4],
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteCount + x.localCount }))
}, {
label: 'Local',
fill: true,
backgroundColor: rgba(colors.local),
borderColor: colors.local,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localCount }))
}, {
label: 'Remote',
fill: true,
backgroundColor: rgba(colors.remote),
borderColor: colors.remote,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteCount }))
}]
}, {
scales: {
yAxes: [{
ticks: {
callback: value => {
return Vue.filter('number')(value);
}
}
}]
},
tooltips: {
callbacks: {
label: (tooltipItem, data) => {
const label = data.datasets[tooltipItem.datasetIndex].label || '';
return `${label}: ${Vue.filter('number')(tooltipItem.yLabel)}`;
}
}
}
}];
},
usersChart(total: boolean): any {
const data = this.stats.slice().reverse().map(x => ({
date: new Date(x.date),
localCount: total ? x.users.local.total : (x.users.local.inc + -x.users.local.dec),
remoteCount: total ? x.users.remote.total : (x.users.remote.inc + -x.users.remote.dec)
}));
return [{
datasets: [{
label: 'Combined',
fill: false,
borderColor: '#555',
borderWidth: 2,
borderDash: [4, 4],
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteCount + x.localCount }))
}, {
label: 'Local',
fill: true,
backgroundColor: rgba(colors.local),
borderColor: colors.local,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localCount }))
}, {
label: 'Remote',
fill: true,
backgroundColor: rgba(colors.remote),
borderColor: colors.remote,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteCount }))
}]
}, {
scales: {
yAxes: [{
ticks: {
callback: value => {
return Vue.filter('number')(value);
}
}
}]
},
tooltips: {
callbacks: {
label: (tooltipItem, data) => {
const label = data.datasets[tooltipItem.datasetIndex].label || '';
return `${label}: ${Vue.filter('number')(tooltipItem.yLabel)}`;
}
}
}
}];
},
driveChart(): any {
const data = this.stats.slice().reverse().map(x => ({
date: new Date(x.date),
localInc: x.drive.local.incSize,
localDec: -x.drive.local.decSize,
remoteInc: x.drive.remote.incSize,
remoteDec: -x.drive.remote.decSize,
}));
return [{
datasets: [{
label: 'All',
fill: false,
borderColor: '#555',
borderWidth: 2,
borderDash: [4, 4],
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localInc + x.localDec + x.remoteInc + x.remoteDec }))
}, {
label: 'Local +',
fill: true,
backgroundColor: rgba(colors.localPlus),
borderColor: colors.localPlus,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localInc }))
}, {
label: 'Local -',
fill: true,
backgroundColor: rgba(colors.localMinus),
borderColor: colors.localMinus,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localDec }))
}, {
label: 'Remote +',
fill: true,
backgroundColor: rgba(colors.remotePlus),
borderColor: colors.remotePlus,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteInc }))
}, {
label: 'Remote -',
fill: true,
backgroundColor: rgba(colors.remoteMinus),
borderColor: colors.remoteMinus,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteDec }))
}]
}, {
scales: {
yAxes: [{
ticks: {
callback: value => {
return Vue.filter('bytes')(value, 1);
}
}
}]
},
tooltips: {
callbacks: {
label: (tooltipItem, data) => {
const label = data.datasets[tooltipItem.datasetIndex].label || '';
return `${label}: ${Vue.filter('bytes')(tooltipItem.yLabel, 1)}`;
}
}
}
}];
},
driveTotalChart(): any {
const data = this.stats.slice().reverse().map(x => ({
date: new Date(x.date),
localSize: x.drive.local.totalSize,
remoteSize: x.drive.remote.totalSize
}));
return [{
datasets: [{
label: 'Combined',
fill: false,
borderColor: '#555',
borderWidth: 2,
borderDash: [4, 4],
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteSize + x.localSize }))
}, {
label: 'Local',
fill: true,
backgroundColor: rgba(colors.local),
borderColor: colors.local,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localSize }))
}, {
label: 'Remote',
fill: true,
backgroundColor: rgba(colors.remote),
borderColor: colors.remote,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteSize }))
}]
}, {
scales: {
yAxes: [{
ticks: {
callback: value => {
return Vue.filter('bytes')(value, 1);
}
}
}]
},
tooltips: {
callbacks: {
label: (tooltipItem, data) => {
const label = data.datasets[tooltipItem.datasetIndex].label || '';
return `${label}: ${Vue.filter('bytes')(tooltipItem.yLabel, 1)}`;
}
}
}
}];
},
driveFilesChart(): any {
const data = this.stats.slice().reverse().map(x => ({
date: new Date(x.date),
localInc: x.drive.local.incCount,
localDec: -x.drive.local.decCount,
remoteInc: x.drive.remote.incCount,
remoteDec: -x.drive.remote.decCount
}));
return [{
datasets: [{
label: 'All',
fill: false,
borderColor: '#555',
borderWidth: 2,
borderDash: [4, 4],
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localInc + x.localDec + x.remoteInc + x.remoteDec }))
}, {
label: 'Local +',
fill: true,
backgroundColor: rgba(colors.localPlus),
borderColor: colors.localPlus,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localInc }))
}, {
label: 'Local -',
fill: true,
backgroundColor: rgba(colors.localMinus),
borderColor: colors.localMinus,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localDec }))
}, {
label: 'Remote +',
fill: true,
backgroundColor: rgba(colors.remotePlus),
borderColor: colors.remotePlus,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteInc }))
}, {
label: 'Remote -',
fill: true,
backgroundColor: rgba(colors.remoteMinus),
borderColor: colors.remoteMinus,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteDec }))
}]
}, {
scales: {
yAxes: [{
ticks: {
callback: value => {
return Vue.filter('number')(value);
}
}
}]
},
tooltips: {
callbacks: {
label: (tooltipItem, data) => {
const label = data.datasets[tooltipItem.datasetIndex].label || '';
return `${label}: ${Vue.filter('number')(tooltipItem.yLabel)}`;
}
}
}
}];
},
driveFilesTotalChart(): any {
const data = this.stats.slice().reverse().map(x => ({
date: new Date(x.date),
localCount: x.drive.local.totalCount,
remoteCount: x.drive.remote.totalCount,
}));
return [{
datasets: [{
label: 'Combined',
fill: false,
borderColor: '#555',
borderWidth: 2,
borderDash: [4, 4],
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localCount + x.remoteCount }))
}, {
label: 'Local',
fill: true,
backgroundColor: rgba(colors.local),
borderColor: colors.local,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.localCount }))
}, {
label: 'Remote',
fill: true,
backgroundColor: rgba(colors.remote),
borderColor: colors.remote,
borderWidth: 2,
pointBackgroundColor: '#fff',
lineTension: 0,
data: data.map(x => ({ t: x.date, y: x.remoteCount }))
}]
}, {
scales: {
yAxes: [{
ticks: {
callback: value => {
return Vue.filter('number')(value);
}
}
}]
},
tooltips: {
callbacks: {
label: (tooltipItem, data) => {
const label = data.datasets[tooltipItem.datasetIndex].label || '';
return `${label}: ${Vue.filter('number')(tooltipItem.yLabel)}`;
}
}
}
}];
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
.gkgckalzgidaygcxnugepioremxvxvpt
padding 32px
background #fff
box-shadow 0 2px 8px rgba(#000, 0.1)
*
user-select none
> header
display flex
margin 0 0 1em 0
padding 0 0 8px 0
font-size 1em
color #555
border-bottom solid 1px #eee
> b
margin-right 8px
> *:last-child
margin-left auto
*
&:not(.active)
color $theme-color
cursor pointer
> div
> *
display block
height 320px
</style>

View File

@ -47,7 +47,7 @@
</div>
<mk-poll v-if="p.poll" :note="p"/>
<mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/>
<a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
<div class="map" v-if="p.geo" ref="map"></div>
<div class="renote" v-if="p.renote">
<mk-note-preview :note="p.renote"/>

View File

@ -32,7 +32,7 @@
<mk-media-list :media-list="p.media"/>
</div>
<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
<a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
<div class="map" v-if="p.geo" ref="map"></div>
<div class="renote" v-if="p.renote">
<mk-note-preview :note="p.renote"/>

View File

@ -49,6 +49,7 @@
</div>
<mk-switch v-model="$store.state.settings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="%i18n:@post-form-on-timeline%"/>
<mk-switch v-model="$store.state.settings.suggestRecentHashtags" @change="onChangeSuggestRecentHashtags" text="%i18n:@suggest-recent-hashtags%"/>
<mk-switch v-model="$store.state.settings.showClockOnHeader" @change="onChangeShowClockOnHeader" text="%i18n:@show-clock-on-header%"/>
<mk-switch v-model="$store.state.settings.showReplyTarget" @change="onChangeShowReplyTarget" text="%i18n:@show-reply-target%"/>
<mk-switch v-model="$store.state.settings.showMyRenotes" @change="onChangeShowMyRenotes" text="%i18n:@show-my-renotes%"/>
<mk-switch v-model="$store.state.settings.showRenotedMyNotes" @change="onChangeShowRenotedMyNotes" text="%i18n:@show-renoted-my-notes%"/>
@ -333,6 +334,12 @@ export default Vue.extend({
value: v
});
},
onChangeShowClockOnHeader(v) {
this.$store.dispatch('settings/set', {
key: 'showClockOnHeader',
value: v
});
},
onChangeShowReplyTarget(v) {
this.$store.dispatch('settings/set', {
key: 'showReplyTarget',

View File

@ -30,10 +30,8 @@
<li @click="settings">
<p>%fa:cog%<span>%i18n:@settings%</span>%fa:angle-right%</p>
</li>
</ul>
<ul>
<li @click="signout">
<p class="signout">%fa:power-off%<span>%i18n:@signout%</span></p>
<li v-if="$store.state.i.isAdmin">
<router-link to="/admin">%fa:terminal%<span>%i18n:@admin%</span>%fa:angle-right%</router-link>
</li>
</ul>
<ul>
@ -41,6 +39,11 @@
<p><span>%i18n:@dark%</span><template v-if="$store.state.device.darkmode">%fa:moon%</template><template v-else>%fa:R moon%</template></p>
</li>
</ul>
<ul>
<li @click="signout">
<p class="signout">%fa:power-off%<span>%i18n:@signout%</span></p>
</li>
</ul>
</div>
</transition>
</div>

View File

@ -11,7 +11,7 @@
<li class="deck" :class="{ active: $route.name == 'deck' }" @click="goToTop">
<router-link to="/deck">
%fa:columns%
<p>%i18n:@deck% <small>(beta)</small></p>
<p>%i18n:@deck%</p>
</router-link>
</li>
<li class="messaging">

View File

@ -17,7 +17,7 @@
<x-account v-if="$store.getters.isSignedIn"/>
<x-notifications v-if="$store.getters.isSignedIn"/>
<x-post v-if="$store.getters.isSignedIn"/>
<x-clock/>
<x-clock v-if="$store.state.settings.showClockOnHeader"/>
</div>
</div>
</div>

View File

@ -48,7 +48,7 @@ export default Vue.extend({
this.open();
});
} else {
const query = this.user[0] == '@' ?
const query = this.user.startsWith('@') ?
parseAcct(this.user.substr(1)) :
{ userId: this.user };

View File

@ -1,16 +1,20 @@
<template>
<div class="obdskegsannmntldydackcpzezagxqfy card">
<div class="obdskegsannmntldydackcpzezagxqfy mk-admin-card">
<header>%i18n:@dashboard%</header>
<div v-if="stats" class="stats">
<div><b>%fa:user% {{ stats.originalUsersCount | number }}</b><span>%i18n:@original-users%</span></div>
<div><span>%fa:user% {{ stats.usersCount | number }}</span><span>%i18n:@all-users%</span></div>
<div><b>%fa:pen% {{ stats.originalNotesCount | number }}</b><span>%i18n:@original-notes%</span></div>
<div><span>%fa:pen% {{ stats.notesCount | number }}</span><span>%i18n:@all-notes%</span></div>
<div><b>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</b><span>%i18n:@original-notes%</span></div>
<div><span>%fa:pencil-alt% {{ stats.notesCount | number }}</span><span>%i18n:@all-notes%</span></div>
</div>
<div class="cpu-memory">
<x-cpu-memory :connection="connection"/>
</div>
<div>
<label>
<input type="checkbox" v-model="disableRegistration" @change="updateMeta">
<span>disableRegistration</span>
</label>
<button class="ui" @click="invite">%i18n:@invite%</button>
<p v-if="inviteCode">Code: <code>{{ inviteCode }}</code></p>
</div>
@ -28,6 +32,7 @@ export default Vue.extend({
data() {
return {
stats: null,
disableRegistration: false,
inviteCode: null,
connection: null,
connectionId: null
@ -37,6 +42,10 @@ export default Vue.extend({
this.connection = (this as any).os.streams.serverStatsStream.getConnection();
this.connectionId = (this as any).os.streams.serverStatsStream.use();
(this as any).os.getMeta().then(meta => {
this.disableRegistration = meta.disableRegistration;
});
(this as any).api('stats').then(stats => {
this.stats = stats;
});
@ -49,6 +58,11 @@ export default Vue.extend({
(this as any).api('admin/invite').then(x => {
this.inviteCode = x.code;
});
},
updateMeta() {
(this as any).api('admin/update-meta', {
disableRegistration: this.disableRegistration
});
}
}
});

View File

@ -1,51 +0,0 @@
<template>
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
<polyline
:points="points"
fill="none"
stroke-width="1"
stroke="#555"/>
</svg>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
chart: {
required: true
},
type: {
type: String,
required: true
}
},
data() {
return {
viewBoxX: 365,
viewBoxY: 70,
points: null
};
},
created() {
const peak = Math.max.apply(null, this.chart.map(d => this.type == 'local' ? d.drive.local.totalSize : d.drive.remote.totalSize));
if (peak != 0) {
const data = this.chart.slice().reverse().map(x => ({
size: this.type == 'local' ? x.drive.local.totalSize : x.drive.remote.totalSize
}));
this.points = data.map((d, i) => `${i},${(1 - (d.size / peak)) * this.viewBoxY}`).join(' ');
}
}
});
</script>
<style lang="stylus" scoped>
svg
display block
padding 10px
width 100%
</style>

View File

@ -1,34 +0,0 @@
<template>
<div class="card">
<header>%i18n:@title%</header>
<div class="card">
<header>%i18n:@local%</header>
<x-chart v-if="chart" :chart="chart" type="local"/>
</div>
<div class="card">
<header>%i18n:@remote%</header>
<x-chart v-if="chart" :chart="chart" type="remote"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import XChart from "./admin.drive-chart.chart.vue";
export default Vue.extend({
components: {
XChart
},
props: {
chart: {
required: true
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
</style>

View File

@ -1,76 +0,0 @@
<template>
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
<polyline
:points="pointsNote"
fill="none"
stroke-width="1"
stroke="#41ddde"/>
<polyline
:points="pointsReply"
fill="none"
stroke-width="1"
stroke="#f7796c"/>
<polyline
:points="pointsRenote"
fill="none"
stroke-width="1"
stroke="#a1de41"/>
<polyline
:points="pointsTotal"
fill="none"
stroke-width="1"
stroke="#555"
stroke-dasharray="2 2"/>
</svg>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
chart: {
required: true
},
type: {
type: String,
required: true
}
},
data() {
return {
viewBoxX: 365,
viewBoxY: 70,
pointsNote: null,
pointsReply: null,
pointsRenote: null,
pointsTotal: null
};
},
created() {
const peak = Math.max.apply(null, this.chart.map(d => this.type == 'local' ? d.notes.local.diff : d.notes.remote.diff));
if (peak != 0) {
const data = this.chart.slice().reverse().map(x => ({
normal: this.type == 'local' ? x.notes.local.diffs.normal : x.notes.remote.diffs.normal,
reply: this.type == 'local' ? x.notes.local.diffs.reply : x.notes.remote.diffs.reply,
renote: this.type == 'local' ? x.notes.local.diffs.renote : x.notes.remote.diffs.renote,
total: this.type == 'local' ? x.notes.local.diff : x.notes.remote.diff
}));
this.pointsNote = data.map((d, i) => `${i},${(1 - (d.normal / peak)) * this.viewBoxY}`).join(' ');
this.pointsReply = data.map((d, i) => `${i},${(1 - (d.reply / peak)) * this.viewBoxY}`).join(' ');
this.pointsRenote = data.map((d, i) => `${i},${(1 - (d.renote / peak)) * this.viewBoxY}`).join(' ');
this.pointsTotal = data.map((d, i) => `${i},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ');
}
}
});
</script>
<style lang="stylus" scoped>
svg
display block
padding 10px
width 100%
</style>

View File

@ -1,34 +0,0 @@
<template>
<div class="card">
<header>%i18n:@title%</header>
<div class="card">
<header>%i18n:@local%</header>
<x-chart v-if="chart" :chart="chart" type="local"/>
</div>
<div class="card">
<header>%i18n:@remote%</header>
<x-chart v-if="chart" :chart="chart" type="remote"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import XChart from "./admin.notes-chart.chart.vue";
export default Vue.extend({
components: {
XChart
},
props: {
chart: {
required: true
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="card">
<div class="mk-admin-card">
<header>%i18n:@suspend-user%</header>
<input v-model="username" type="text" class="ui"/>
<button class="ui" @click="suspendUser" :disabled="suspending">%i18n:@suspend%</button>

View File

@ -1,5 +1,5 @@
<template>
<div class="card">
<div class="mk-admin-card">
<header>%i18n:@unsuspend-user%</header>
<input v-model="username" type="text" class="ui"/>
<button class="ui" @click="unsuspendUser" :disabled="unsuspending">%i18n:@unsuspend%</button>

View File

@ -1,5 +1,5 @@
<template>
<div class="card">
<div class="mk-admin-card">
<header>%i18n:@unverify-user%</header>
<input v-model="username" type="text" class="ui"/>
<button class="ui" @click="unverifyUser" :disabled="unverifying">%i18n:@unverify%</button>

View File

@ -1,51 +0,0 @@
<template>
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
<polyline
:points="points"
fill="none"
stroke-width="1"
stroke="#555"/>
</svg>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: {
chart: {
required: true
},
type: {
type: String,
required: true
}
},
data() {
return {
viewBoxX: 365,
viewBoxY: 70,
points: null
};
},
created() {
const peak = Math.max.apply(null, this.chart.map(d => this.type == 'local' ? d.users.local.diff : d.users.remote.diff));
if (peak != 0) {
const data = this.chart.slice().reverse().map(x => ({
count: this.type == 'local' ? x.users.local.diff : x.users.remote.diff
}));
this.points = data.map((d, i) => `${i},${(1 - (d.count / peak)) * this.viewBoxY}`).join(' ');
}
}
});
</script>
<style lang="stylus" scoped>
svg
display block
padding 10px
width 100%
</style>

View File

@ -1,34 +0,0 @@
<template>
<div class="card">
<header>%i18n:@title%</header>
<div class="card">
<header>%i18n:@local%</header>
<x-chart v-if="chart" :chart="chart" type="local"/>
</div>
<div class="card">
<header>%i18n:@remote%</header>
<x-chart v-if="chart" :chart="chart" type="remote"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import XChart from "./admin.users-chart.chart.vue";
export default Vue.extend({
components: {
XChart
},
props: {
chart: {
required: true
}
}
});
</script>
<style lang="stylus" scoped>
@import '~const.styl'
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="card">
<div class="mk-admin-card">
<header>%i18n:@verify-user%</header>
<input v-model="username" type="text" class="ui"/>
<button class="ui" @click="verifyUser" :disabled="verifying">%i18n:@verify%</button>

View File

@ -11,9 +11,7 @@
<main>
<div v-show="page == 'dashboard'">
<x-dashboard/>
<x-users-chart :chart="chart"/>
<x-notes-chart :chart="chart"/>
<x-drive-chart :chart="chart"/>
<x-charts/>
</div>
<div v-if="page == 'users'">
<x-suspend-user/>
@ -34,9 +32,7 @@ import XSuspendUser from "./admin.suspend-user.vue";
import XUnsuspendUser from "./admin.unsuspend-user.vue";
import XVerifyUser from "./admin.verify-user.vue";
import XUnverifyUser from "./admin.unverify-user.vue";
import XUsersChart from "./admin.users-chart.vue";
import XNotesChart from "./admin.notes-chart.vue";
import XDriveChart from "./admin.drive-chart.vue";
import XCharts from "../../components/charts.vue";
export default Vue.extend({
components: {
@ -45,21 +41,13 @@ export default Vue.extend({
XUnsuspendUser,
XVerifyUser,
XUnverifyUser,
XUsersChart,
XNotesChart,
XDriveChart
XCharts
},
data() {
return {
page: 'dashboard',
chart: null
page: 'dashboard'
};
},
created() {
(this as any).api('admin/chart').then(chart => {
this.chart = chart;
});
},
methods: {
nav(page: string) {
this.page = page;
@ -115,7 +103,7 @@ export default Vue.extend({
> div
max-width 800px
.card
.mk-admin-card
padding 32px
background #fff
box-shadow 0 2px 8px rgba(#000, 0.1)

View File

@ -32,7 +32,7 @@
<mk-media-list :media-list="p.media"/>
</div>
<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
<a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
<div class="renote" v-if="p.renote">
<mk-note-preview :note="p.renote" :mini="true"/>
</div>

View File

@ -16,7 +16,7 @@ import Vue from 'vue';
export default Vue.extend({
data() {
return {
name: (this as any).os.instanceName,
name: null,
posted: false,
text: new URLSearchParams(location.search).get('text')
};
@ -25,6 +25,11 @@ export default Vue.extend({
close() {
window.close();
}
},
mounted() {
(this as any).os.getMeta().then(meta => {
this.name = meta.name;
});
}
});
</script>

View File

@ -0,0 +1,64 @@
<template>
<div class="tcrwdhwpuxrwmcttxjcsehgpagpstqey">
<div v-if="stats" class="stats">
<div><b>%fa:user% {{ stats.originalUsersCount | number }}</b><span>%i18n:@original-users%</span></div>
<div><span>%fa:user% {{ stats.usersCount | number }}</span><span>%i18n:@all-users%</span></div>
<div><b>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</b><span>%i18n:@original-notes%</span></div>
<div><span>%fa:pencil-alt% {{ stats.notesCount | number }}</span><span>%i18n:@all-notes%</span></div>
</div>
<div>
<x-charts/>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import XCharts from "../../components/charts.vue";
export default Vue.extend({
components: {
XCharts
},
data() {
return {
stats: null
};
},
created() {
(this as any).api('stats').then(stats => {
this.stats = stats;
});
},
});
</script>
<style lang="stylus">
@import '~const.styl'
.tcrwdhwpuxrwmcttxjcsehgpagpstqey
width 100%
padding 16px
> .stats
display flex
justify-content center
margin-bottom 16px
padding 32px
background #fff
box-shadow 0 2px 8px rgba(#000, 0.1)
> div
flex 1
text-align center
> *:first-child
display block
color $theme-color
> *:last-child
font-size 70%
> div
max-width 850px
</style>

View File

@ -40,10 +40,12 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
root(isDark)
.friends
background #fff
background isDark ? #282C37 : #fff
border solid 1px rgba(#000, 0.075)
border-radius 6px
overflow hidden
> .title
z-index 1
@ -52,7 +54,8 @@ export default Vue.extend({
line-height 42px
font-size 0.9em
font-weight bold
color #888
background isDark ? #313543 : inherit
color isDark ? #e3e5e8 : #888
box-shadow 0 1px rgba(#000, 0.07)
> i
@ -70,7 +73,7 @@ export default Vue.extend({
> .user
padding 16px
border-bottom solid 1px #eee
border-bottom solid 1px isDark ? #21242f : #eee
&:last-child
border-bottom none
@ -96,18 +99,24 @@ export default Vue.extend({
margin 0
font-size 16px
line-height 24px
color #555
color isDark ? #ccc : #555
> .username
display block
margin 0
font-size 15px
line-height 16px
color #ccc
color isDark ? #555 : #ccc
> .mk-follow-button
position absolute
top 16px
right 16px
.friends[data-darkmode]
root(true)
.friends:not([data-darkmode])
root(false)
</style>

View File

@ -39,10 +39,12 @@ export default Vue.extend({
</script>
<style lang="stylus" scoped>
root(isDark)
.photos
background #fff
background isDark ? #282C37 : #fff
border solid 1px rgba(#000, 0.075)
border-radius 6px
overflow hidden
> .title
z-index 1
@ -51,7 +53,8 @@ export default Vue.extend({
line-height 42px
font-size 0.9em
font-weight bold
color #888
background: isDark ? #313543 : inherit
color isDark ? #e3e5e8 : #888
box-shadow 0 1px rgba(#000, 0.07)
> i
@ -85,4 +88,10 @@ export default Vue.extend({
> i
margin-right 4px
.photos[data-darkmode]
root(true)
.photos:not([data-darkmode])
root(false)
</style>

View File

@ -138,7 +138,7 @@ root(isDark)
padding 16px
font-size 12px
color #aaa
background #fff
background isDark ? #21242f : #fff
border solid 1px rgba(#000, 0.075)
border-radius 6px

View File

@ -19,8 +19,8 @@ import { version, codename, lang } from './config';
let elementLocale;
switch (lang) {
case 'ja': elementLocale = ElementLocaleJa; break;
case 'en': elementLocale = ElementLocaleEn; break;
case 'ja-JP': elementLocale = ElementLocaleJa; break;
case 'en-US': elementLocale = ElementLocaleEn; break;
default: elementLocale = ElementLocaleEn; break;
}

View File

@ -45,7 +45,7 @@
</div>
<mk-poll v-if="p.poll" :note="p"/>
<mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/>
<a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
<div class="map" v-if="p.geo" ref="map"></div>
<div class="renote" v-if="p.renote">
<mk-note-preview :note="p.renote"/>

View File

@ -33,7 +33,7 @@
</div>
<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
<a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
<div class="map" v-if="p.geo" ref="map"></div>
<div class="renote" v-if="p.renote">
<mk-note-preview :note="p.renote"/>

View File

@ -30,6 +30,7 @@
<ul>
<li><a @click="search">%fa:search%%i18n:@search%%fa:angle-right%</a></li>
<li><router-link to="/i/settings" :data-active="$route.name == 'settings'">%fa:cog%%i18n:@settings%%fa:angle-right%</router-link></li>
<li v-if="$store.getters.isSignedIn && $store.state.i.isAdmin"><router-link to="/admin">%fa:terminal%<span>%i18n:@admin%</span>%fa:angle-right%</router-link></li>
<li @click="dark"><p><template v-if="$store.state.device.darkmode">%fa:moon%</template><template v-else>%fa:R moon%</template><span>%i18n:@darkmode%</span></p></li>
</ul>
</div>

View File

@ -41,6 +41,12 @@
<ui-switch v-model="lightmode">%i18n:@i-am-under-limited-internet%</ui-switch>
</ui-card>
<ui-card>
<div slot="title">%fa:volume-up% %i18n:@sound%</div>
<ui-switch v-model="enableSounds">%i18n:@enable-sounds%</ui-switch>
</ui-card>
<ui-card>
<div slot="title">%fa:language% %i18n:@lang%</div>
@ -142,6 +148,11 @@ export default Vue.extend({
get() { return this.$store.state.device.lang; },
set(value) { this.$store.commit('device/set', { key: 'lang', value }); }
},
enableSounds: {
get() { return this.$store.state.device.enableSounds; },
set(value) { this.$store.commit('device/set', { key: 'enableSounds', value }); }
},
},
mounted() {

View File

@ -16,7 +16,7 @@ import Vue from 'vue';
export default Vue.extend({
data() {
return {
name: (this as any).os.instanceName,
name: null,
posted: false,
text: new URLSearchParams(location.search).get('text')
};
@ -25,6 +25,11 @@ export default Vue.extend({
close() {
window.close();
}
},
mounted() {
(this as any).os.getMeta().then(meta => {
this.name = meta.name;
});
}
});
</script>

View File

@ -11,7 +11,7 @@
<a class="avatar">
<img :src="user.avatarUrl" alt="avatar"/>
</a>
<mk-mute-button v-if="$store.state.i.id != user.id" :user="user"/>
<mk-mute-button v-if="$store.getters.isSignedIn && $store.state.i.id != user.id" :user="user"/>
<mk-follow-button v-if="$store.getters.isSignedIn && $store.state.i.id != user.id" :user="user"/>
</div>
<div class="title">

View File

@ -13,6 +13,7 @@ const defaultSettings = {
showMaps: true,
showPostFormOnTopOfTl: false,
suggestRecentHashtags: true,
showClockOnHeader: true,
circleIcons: true,
gradientWindowHeader: false,
showReplyTarget: true,

View File

@ -53,5 +53,5 @@ export default function load() {
}
function normalizeUrl(url: string) {
return url[url.length - 1] === '/' ? url.substr(0, url.length - 1) : url;
return url.endsWith('/') ? url.substr(0, url.length - 1) : url;
}

View File

@ -62,6 +62,8 @@ export type Source = {
*/
ghost?: string;
summalyProxy?: string;
accesslog?: string;
twitter?: {
consumer_key: string;

View File

@ -15,7 +15,7 @@ block main
span.path= endpointUrl.path
if endpoint.desc
p#desc= endpoint.desc[lang] || endpoint.desc['ja']
p#desc= endpoint.desc[lang] || endpoint.desc['ja-JP']
if endpoint.requireCredential
div.ui.info: p

View File

@ -1,90 +1,90 @@
name: "DriveFile"
desc:
ja: "ドライブのファイル。"
en: "A file of Drive."
ja-JP: "ドライブのファイル。"
en-US: "A file of Drive."
props:
id:
type: "id"
optional: false
desc:
ja: "ファイルID"
en: "The ID of this file"
ja-JP: "ファイルID"
en-US: "The ID of this file"
createdAt:
type: "date"
optional: false
desc:
ja: "アップロード日時"
en: "The upload date of this file"
ja-JP: "アップロード日時"
en-US: "The upload date of this file"
userId:
type: "id(User)"
optional: false
desc:
ja: "所有者ID"
en: "The ID of the owner of this file"
ja-JP: "所有者ID"
en-US: "The ID of the owner of this file"
user:
type: "entity(User)"
optional: true
desc:
ja: "所有者"
en: "The owner of this file"
ja-JP: "所有者"
en-US: "The owner of this file"
name:
type: "string"
optional: false
desc:
ja: "ファイル名"
en: "The name of this file"
ja-JP: "ファイル名"
en-US: "The name of this file"
md5:
type: "string"
optional: false
desc:
ja: "ファイルのMD5ハッシュ値"
en: "The md5 hash value of this file"
ja-JP: "ファイルのMD5ハッシュ値"
en-US: "The md5 hash value of this file"
type:
type: "string"
optional: false
desc:
ja: "ファイルの種類"
en: "The type of this file"
ja-JP: "ファイルの種類"
en-US: "The type of this file"
datasize:
type: "number"
optional: false
desc:
ja: "ファイルサイズ(bytes)"
en: "The size of this file (bytes)"
ja-JP: "ファイルサイズ(bytes)"
en-US: "The size of this file (bytes)"
url:
type: "string"
optional: false
desc:
ja: "ファイルのURL"
en: "The URL of this file"
ja-JP: "ファイルのURL"
en-US: "The URL of this file"
folderId:
type: "id(DriveFolder)"
optional: true
desc:
ja: "フォルダID"
en: "The ID of the folder of this file"
ja-JP: "フォルダID"
en-US: "The ID of the folder of this file"
folder:
type: "entity(DriveFolder)"
optional: true
desc:
ja: "フォルダ"
en: "The folder of this file"
ja-JP: "フォルダ"
en-US: "The folder of this file"
isSensitive:
type: "boolean"
optional: true
desc:
ja: "このメディアが「閲覧注意」(NSFW)かどうか"
en: "Whether this media is NSFW"
ja-JP: "このメディアが「閲覧注意」(NSFW)かどうか"
en-US: "Whether this media is NSFW"

View File

@ -1,41 +1,41 @@
name: "DriveFolder"
desc:
ja: "ドライブのフォルダを表します。"
en: "A folder of Drive."
ja-JP: "ドライブのフォルダを表します。"
en-US: "A folder of Drive."
props:
id:
type: "id"
optional: false
desc:
ja: "フォルダID"
en: "The ID of this folder"
ja-JP: "フォルダID"
en-US: "The ID of this folder"
createdAt:
type: "date"
optional: false
desc:
ja: "作成日時"
en: "The created date of this folder"
ja-JP: "作成日時"
en-US: "The created date of this folder"
userId:
type: "id(User)"
optional: false
desc:
ja: "所有者ID"
en: "The ID of the owner of this folder"
ja-JP: "所有者ID"
en-US: "The ID of the owner of this folder"
parentId:
type: "entity(DriveFolder)"
optional: false
desc:
ja: "親フォルダのID (ルートなら null)"
en: "The ID of parent folder"
ja-JP: "親フォルダのID (ルートなら null)"
en-US: "The ID of parent folder"
name:
type: "string"
optional: false
desc:
ja: "フォルダ名"
en: "The name of this folder"
ja-JP: "フォルダ名"
en-US: "The name of this folder"

View File

@ -1,190 +1,190 @@
name: "Note"
desc:
ja: "投稿。"
en: "A note."
ja-JP: "投稿。"
en-US: "A note."
props:
id:
type: "id"
optional: false
desc:
ja: "投稿ID"
en: "The ID of this note"
ja-JP: "投稿ID"
en-US: "The ID of this note"
createdAt:
type: "date"
optional: false
desc:
ja: "投稿日時"
en: "The posted date of this note"
ja-JP: "投稿日時"
en-US: "The posted date of this note"
viaMobile:
type: "boolean"
optional: true
desc:
ja: "モバイル端末から投稿したか否か(自己申告であることに留意)"
en: "Whether this note sent via a mobile device"
ja-JP: "モバイル端末から投稿したか否か(自己申告であることに留意)"
en-US: "Whether this note sent via a mobile device"
text:
type: "string"
optional: true
desc:
ja: "投稿の本文"
en: "The text of this note"
ja-JP: "投稿の本文"
en-US: "The text of this note"
mediaIds:
type: "id(DriveFile)[]"
optional: true
desc:
ja: "添付されているメディアのID (なければレスポンスでは空配列)"
en: "The IDs of the attached media (empty array for response if no media is attached)"
ja-JP: "添付されているメディアのID (なければレスポンスでは空配列)"
en-US: "The IDs of the attached media (empty array for response if no media is attached)"
media:
type: "entity(DriveFile)[]"
optional: true
desc:
ja: "添付されているメディア"
en: "The attached media"
ja-JP: "添付されているメディア"
en-US: "The attached media"
userId:
type: "id(User)"
optional: false
desc:
ja: "投稿者ID"
en: "The ID of author of this note"
ja-JP: "投稿者ID"
en-US: "The ID of author of this note"
user:
type: "entity(User)"
optional: true
desc:
ja: "投稿者"
en: "The author of this note"
ja-JP: "投稿者"
en-US: "The author of this note"
myReaction:
type: "string"
optional: true
desc:
ja: "この投稿に対する自分の<a href='/docs/api/reactions'>リアクション</a>"
en: "The your <a href='/docs/api/reactions'>reaction</a> of this note"
ja-JP: "この投稿に対する自分の<a href='/docs/api/reactions'>リアクション</a>"
en-US: "The your <a href='/docs/api/reactions'>reaction</a> of this note"
reactionCounts:
type: "object"
optional: false
desc:
ja: "<a href='/docs/api/reactions'>リアクション</a>をキーとし、この投稿に対するそのリアクションの数を値としたオブジェクト"
ja-JP: "<a href='/docs/api/reactions'>リアクション</a>をキーとし、この投稿に対するそのリアクションの数を値としたオブジェクト"
replyId:
type: "id(Note)"
optional: true
desc:
ja: "返信した投稿のID"
en: "The ID of the replyed note"
ja-JP: "返信した投稿のID"
en-US: "The ID of the replyed note"
reply:
type: "entity(Note)"
optional: true
desc:
ja: "返信した投稿"
en: "The replyed note"
ja-JP: "返信した投稿"
en-US: "The replyed note"
renoteId:
type: "id(Note)"
optional: true
desc:
ja: "引用した投稿のID"
en: "The ID of the quoted note"
ja-JP: "引用した投稿のID"
en-US: "The ID of the quoted note"
renote:
type: "entity(Note)"
optional: true
desc:
ja: "引用した投稿"
en: "The quoted note"
ja-JP: "引用した投稿"
en-US: "The quoted note"
poll:
type: "object"
optional: true
desc:
ja: "投票"
en: "The poll"
ja-JP: "投票"
en-US: "The poll"
props:
choices:
type: "object[]"
optional: false
desc:
ja: "投票の選択肢"
en: "The choices of this poll"
ja-JP: "投票の選択肢"
en-US: "The choices of this poll"
props:
id:
type: "number"
optional: false
desc:
ja: "選択肢ID"
en: "The ID of this choice"
ja-JP: "選択肢ID"
en-US: "The ID of this choice"
isVoted:
type: "boolean"
optional: true
desc:
ja: "自分がこの選択肢に投票したかどうか"
en: "Whether you voted to this choice"
ja-JP: "自分がこの選択肢に投票したかどうか"
en-US: "Whether you voted to this choice"
text:
type: "string"
optional: false
desc:
ja: "選択肢本文"
en: "The text of this choice"
ja-JP: "選択肢本文"
en-US: "The text of this choice"
votes:
type: "number"
optional: false
desc:
ja: "この選択肢に投票された数"
en: "The number voted for this choice"
ja-JP: "この選択肢に投票された数"
en-US: "The number voted for this choice"
geo:
type: "object"
optional: true
desc:
ja: "位置情報"
en: "Geo location"
ja-JP: "位置情報"
en-US: "Geo location"
props:
coordinates:
type: "number[]"
optional: false
desc:
ja: "座標。最初に経度:-180〜180で表す。最後に緯度-90〜90で表す。"
ja-JP: "座標。最初に経度:-180〜180で表す。最後に緯度-90〜90で表す。"
altitude:
type: "number"
optional: false
desc:
ja: "高度。メートル単位で表す。"
ja-JP: "高度。メートル単位で表す。"
accuracy:
type: "number"
optional: false
desc:
ja: "緯度、経度の精度。メートル単位で表す。"
ja-JP: "緯度、経度の精度。メートル単位で表す。"
altitudeAccuracy:
type: "number"
optional: false
desc:
ja: "高度の精度。メートル単位で表す。"
ja-JP: "高度の精度。メートル単位で表す。"
heading:
type: "number"
optional: false
desc:
ja: "方角。0〜360の角度で表す。0が北、90が東、180が南、270が西。"
ja-JP: "方角。0〜360の角度で表す。0が北、90が東、180が南、270が西。"
speed:
type: "number"
optional: false
desc:
ja: "速度。メートル / 秒数で表す。"
ja-JP: "速度。メートル / 秒数で表す。"

View File

@ -1,174 +1,174 @@
name: "User"
desc:
ja: "ユーザー。"
en: "A user."
ja-JP: "ユーザー。"
en-US: "A user."
props:
id:
type: "id"
optional: false
desc:
ja: "ユーザーID"
en: "The ID of this user"
ja-JP: "ユーザーID"
en-US: "The ID of this user"
createdAt:
type: "date"
optional: false
desc:
ja: "アカウント作成日時"
en: "The registered date of this user"
ja-JP: "アカウント作成日時"
en-US: "The registered date of this user"
username:
type: "string"
optional: false
desc:
ja: "ユーザー名"
en: "The username of this user"
ja-JP: "ユーザー名"
en-US: "The username of this user"
description:
type: "string"
optional: false
desc:
ja: "アカウントの説明(自己紹介)"
en: "The description of this user"
ja-JP: "アカウントの説明(自己紹介)"
en-US: "The description of this user"
avatarId:
type: "id(DriveFile)"
optional: true
desc:
ja: "アバターのID"
en: "The ID of the avatar of this user"
ja-JP: "アバターのID"
en-US: "The ID of the avatar of this user"
avatarUrl:
type: "string"
optional: false
desc:
ja: "アバターのURL"
en: "The URL of the avatar of this user"
ja-JP: "アバターのURL"
en-US: "The URL of the avatar of this user"
bannerId:
type: "id(DriveFile)"
optional: true
desc:
ja: "バナーのID"
en: "The ID of the banner of this user"
ja-JP: "バナーのID"
en-US: "The ID of the banner of this user"
bannerUrl:
type: "string"
optional: false
desc:
ja: "バナーのURL"
en: "The URL of the banner of this user"
ja-JP: "バナーのURL"
en-US: "The URL of the banner of this user"
followersCount:
type: "number"
optional: false
desc:
ja: "フォロワーの数"
en: "The number of the followers for this user"
ja-JP: "フォロワーの数"
en-US: "The number of the followers for this user"
followingCount:
type: "number"
optional: false
desc:
ja: "フォローしているユーザーの数"
en: "The number of the following users for this user"
ja-JP: "フォローしているユーザーの数"
en-US: "The number of the following users for this user"
isFollowing:
type: "boolean"
optional: true
desc:
ja: "自分がこのユーザーをフォローしているか"
ja-JP: "自分がこのユーザーをフォローしているか"
isFollowed:
type: "boolean"
optional: true
desc:
ja: "自分がこのユーザーにフォローされているか"
ja-JP: "自分がこのユーザーにフォローされているか"
isMuted:
type: "boolean"
optional: true
desc:
ja: "自分がこのユーザーをミュートしているか"
en: "Whether you muted this user"
ja-JP: "自分がこのユーザーをミュートしているか"
en-US: "Whether you muted this user"
notesCount:
type: "number"
optional: false
desc:
ja: "投稿の数"
en: "The number of the notes of this user"
ja-JP: "投稿の数"
en-US: "The number of the notes of this user"
pinnedNote:
type: "entity(Note)"
optional: true
desc:
ja: "ピン留めされた投稿"
en: "The pinned note of this user"
ja-JP: "ピン留めされた投稿"
en-US: "The pinned note of this user"
pinnedNoteId:
type: "id(Note)"
optional: true
desc:
ja: "ピン留めされた投稿のID"
en: "The ID of the pinned note of this user"
ja-JP: "ピン留めされた投稿のID"
en-US: "The ID of the pinned note of this user"
host:
type: "string | null"
optional: false
desc:
ja: "ホスト (例: example.com:3000)"
en: "Host (e.g. example.com:3000)"
ja-JP: "ホスト (例: example.com:3000)"
en-US: "Host (e.g. example.com:3000)"
twitter:
type: "object"
optional: true
desc:
ja: "連携されているTwitterアカウント情報"
en: "The info of the connected twitter account of this user"
ja-JP: "連携されているTwitterアカウント情報"
en-US: "The info of the connected twitter account of this user"
props:
userId:
type: "string"
optional: false
desc:
ja: "ユーザーID"
en: "The user ID"
ja-JP: "ユーザーID"
en-US: "The user ID"
screenName:
type: "string"
optional: false
desc:
ja: "ユーザー名"
en: "The screen name of this user"
ja-JP: "ユーザー名"
en-US: "The screen name of this user"
isBot:
type: "boolean"
optional: true
desc:
ja: "botか否か(自己申告であることに留意)"
en: "Whether is bot or not"
ja-JP: "botか否か(自己申告であることに留意)"
en-US: "Whether is bot or not"
profile:
type: "object"
optional: false
desc:
ja: "プロフィール"
en: "The profile of this user"
ja-JP: "プロフィール"
en-US: "The profile of this user"
props:
location:
type: "string"
optional: true
desc:
ja: "場所"
en: "The location of this user"
ja-JP: "場所"
en-US: "The location of this user"
birthday:
type: "string"
optional: true
desc:
ja: "誕生日 (YYYY-MM-DD)"
en: "The birthday of this user (YYYY-MM-DD)"
ja-JP: "誕生日 (YYYY-MM-DD)"
en-US: "The birthday of this user (YYYY-MM-DD)"

View File

@ -7,7 +7,7 @@ block meta
block main
h1= name
p#desc= desc[lang] || desc['ja']
p#desc= desc[lang] || desc['ja-JP']
section
h2= i18n('docs.api.entities.properties')

View File

@ -31,4 +31,4 @@ mixin propTable(props)
td.name= prop.name
td.type
+type(prop)
td.desc!= prop.desc ? prop.desc[lang] || prop.desc['ja'] : null
td.desc!= prop.desc ? prop.desc[lang] || prop.desc['ja-JP'] : null

View File

@ -16,7 +16,7 @@ html(lang= lang)
nav
ul
each doc in docs
li: a(href=`/docs/${lang}/${doc.name}`)= doc.title[lang] || doc.title['ja']
li: a(href=`/docs/${lang}/${doc.name}`)= doc.title[lang] || doc.title['ja-JP']
section
h2 API
ul

View File

@ -197,7 +197,7 @@ const elements: Element[] = [
if (thisIsNotARegexp) return null;
if (regexp == '') return null;
if (regexp[0] == ' ' && regexp[regexp.length - 1] == ' ') return null;
if (regexp.startsWith(' ') && regexp.endsWith(' ')) return null;
return {
html: `<span class="regexp">/${escape(regexp)}/</span>`,

View File

@ -10,7 +10,7 @@ export type TextElementHashtag = {
export default function(text: string, i: number) {
if (!(/^\s#[^\s]+/.test(text) || (i == 0 && /^#[^\s]+/.test(text)))) return null;
const isHead = text[0] == '#';
const isHead = text.startsWith('#');
const hashtag = text.match(/^\s?#[^\s]+/)[0];
const res: any[] = !isHead ? [{
type: 'text',

View File

@ -13,7 +13,7 @@ export type TextElementLink = {
export default function(text: string) {
const match = text.match(/^\??\[([^\[\]]+?)\]\((https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+?)\)/);
if (!match) return null;
const silent = text[0] == '?';
const silent = text.startsWith('?');
const link = match[0];
const title = match[1];
const url = match[2];

View File

@ -25,9 +25,9 @@ export const replacement = (match: string, key: string) => {
arg == 'S' ? 'fas' :
arg == 'B' ? 'fab' :
'';
} else if (arg[0] == '.') {
} else if (arg.startsWith('.')) {
classes.push('fa-' + arg.substr(1));
} else if (arg[0] == '-') {
} else if (arg.startsWith('-')) {
transform = arg.substr(1).split('|').join(' ');
} else {
name = arg;

View File

@ -27,10 +27,12 @@ export default class Replacer {
let text = texts;
if (path) {
path = path.replace('.ts', '');
if (text.hasOwnProperty(path)) {
text = text[path];
} else {
if (this.lang === 'ja') console.warn(`path '${path}' not found`);
if (this.lang === 'ja-JP') console.warn(`path '${path}' not found`);
return key; // Fallback
}
}
@ -46,10 +48,10 @@ export default class Replacer {
});
if (error) {
if (this.lang === 'ja') console.warn(`key '${key}' not found in '${path}'`);
if (this.lang === 'ja-JP') console.warn(`key '${key}' not found in '${path}'`);
return key; // Fallback
} else if (typeof text !== 'string') {
if (this.lang === 'ja') console.warn(`key '${key}' is not string in '${path}'`);
if (this.lang === 'ja-JP') console.warn(`key '${key}' is not string in '${path}'`);
return key; // Fallback
} else {
return text;

View File

@ -2,40 +2,59 @@ import * as mongo from 'mongodb';
import db from '../db/mongodb';
const Stats = db.get<IStats>('stats');
Stats.createIndex({ date: -1 }, { unique: true });
Stats.dropIndex({ date: -1 }); // 後方互換性のため
Stats.createIndex({ span: -1, date: -1 }, { unique: true });
export default Stats;
export interface IStats {
_id: mongo.ObjectID;
/**
*
*/
date: Date;
/**
*
*/
span: 'day' | 'hour';
/**
*
*/
users: {
local: {
/**
*
* ()
*/
total: number;
/**
*
* ()
*/
diff: number;
inc: number;
/**
* ()
*/
dec: number;
};
remote: {
/**
*
* ()
*/
total: number;
/**
*
* ()
*/
diff: number;
inc: number;
/**
* ()
*/
dec: number;
};
};
@ -45,28 +64,33 @@ export interface IStats {
notes: {
local: {
/**
* 稿
* 稿 ()
*/
total: number;
/**
* 稿
* 稿 ()
*/
diff: number;
inc: number;
/**
* 稿 ()
*/
dec: number;
diffs: {
/**
* 稿
* 稿 ()
*/
normal: number;
/**
* 稿
* 稿 ()
*/
reply: number;
/**
* Renoteの投稿数の前日比
* Renoteの投稿数の差分 ()
*/
renote: number;
};
@ -74,28 +98,33 @@ export interface IStats {
remote: {
/**
* 稿
* 稿 ()
*/
total: number;
/**
* 稿
* 稿 ()
*/
diff: number;
inc: number;
/**
* 稿 ()
*/
dec: number;
diffs: {
/**
* 稿
* 稿 ()
*/
normal: number;
/**
* 稿
* 稿 ()
*/
reply: number;
/**
* Renoteの投稿数の前日比
* Renoteの投稿数の差分 ()
*/
renote: number;
};
@ -108,46 +137,66 @@ export interface IStats {
drive: {
local: {
/**
*
* ()
*/
totalCount: number;
/**
*
* ()
*/
totalSize: number;
/**
*
* ()
*/
diffCount: number;
incCount: number;
/**
*
* 使 ()
*/
diffSize: number;
incSize: number;
/**
* ()
*/
decCount: number;
/**
* 使 ()
*/
decSize: number;
};
remote: {
/**
*
* ()
*/
totalCount: number;
/**
*
* ()
*/
totalSize: number;
/**
*
* ()
*/
diffCount: number;
incCount: number;
/**
*
* 使 ()
*/
diffSize: number;
incSize: number;
/**
* ()
*/
decCount: number;
/**
* 使 ()
*/
decSize: number;
};
};
}

View File

@ -46,7 +46,7 @@ export default async (job: bq.Job, done: any): Promise<void> => {
// アクティビティを送信してきたユーザーがまだMisskeyサーバーに登録されていなかったら登録する
if (user === null) {
user = await resolvePerson(signature.keyId) as IRemoteUser;
user = await resolvePerson(activity.actor) as IRemoteUser;
}
}

View File

@ -131,5 +131,7 @@ export async function resolveNote(value: string | IObject, resolver?: Resolver):
//#endregion
// リモートサーバーからフェッチしてきて登録
return await createNote(value, resolver);
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにートが生成されるが
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
return await createNote(uri, resolver);
}

View File

@ -4,18 +4,25 @@ import * as debug from 'debug';
import config from '../../../config';
import User, { validateUsername, isValidName, IUser, IRemoteUser } from '../../../models/user';
import webFinger from '../../webfinger';
import Resolver from '../resolver';
import { resolveImage } from './image';
import { isCollectionOrOrderedCollection, IObject, IPerson } from '../type';
import { isCollectionOrOrderedCollection, IPerson } from '../type';
import { IDriveFile } from '../../../models/drive-file';
import Meta from '../../../models/meta';
import htmlToMFM from '../../../mfm/html-to-mfm';
import { updateUserStats } from '../../../services/update-chart';
import { URL } from 'url';
const log = debug('misskey:activitypub');
function validatePerson(x: any) {
/**
* Validate Person object
* @param x Fetched person object
* @param uri Fetch target URI
*/
function validatePerson(x: any, uri: string) {
const expectHost = toUnicode(new URL(uri).hostname.toLowerCase());
if (x == null) {
return new Error('invalid person: object is null');
}
@ -40,6 +47,24 @@ function validatePerson(x: any) {
return new Error('invalid person: invalid name');
}
if (typeof x.id !== 'string') {
return new Error('invalid person: id is not a string');
}
const idHost = toUnicode(new URL(x.id).hostname.toLowerCase());
if (idHost !== expectHost) {
return new Error('invalid person: id has different host');
}
if (typeof x.publicKey.id !== 'string') {
return new Error('invalid person: publicKey.id is not a string');
}
const publicKeyIdHost = toUnicode(new URL(x.publicKey.id).hostname.toLowerCase());
if (publicKeyIdHost !== expectHost) {
return new Error('invalid person: publicKey.id has different host');
}
return null;
}
@ -48,8 +73,8 @@ function validatePerson(x: any) {
*
* Misskeyに対象のPersonが登録されていればそれを返します
*/
export async function fetchPerson(value: string | IObject, resolver?: Resolver): Promise<IUser> {
const uri = typeof value == 'string' ? value : value.id;
export async function fetchPerson(uri: string, resolver?: Resolver): Promise<IUser> {
if (typeof uri !== 'string') throw 'uri is not string';
// URIがこのサーバーを指しているならデータベースからフェッチ
if (uri.startsWith(config.url + '/')) {
@ -71,12 +96,14 @@ export async function fetchPerson(value: string | IObject, resolver?: Resolver):
/**
* Personを作成します
*/
export async function createPerson(value: any, resolver?: Resolver): Promise<IUser> {
export async function createPerson(uri: string, resolver?: Resolver): Promise<IUser> {
if (typeof uri !== 'string') throw 'uri is not string';
if (resolver == null) resolver = new Resolver();
const object = await resolver.resolve(value) as any;
const object = await resolver.resolve(uri) as any;
const err = validatePerson(object);
const err = validatePerson(object, uri);
if (err) {
throw err;
@ -86,7 +113,7 @@ export async function createPerson(value: any, resolver?: Resolver): Promise<IUs
log(`Creating the Person: ${person.id}`);
const [followersCount = 0, followingCount = 0, notesCount = 0, finger] = await Promise.all([
const [followersCount = 0, followingCount = 0, notesCount = 0] = await Promise.all([
resolver.resolve(person.followers).then(
resolved => isCollectionOrOrderedCollection(resolved) ? resolved.totalItems : undefined,
() => undefined
@ -98,11 +125,10 @@ export async function createPerson(value: any, resolver?: Resolver): Promise<IUs
resolver.resolve(person.outbox).then(
resolved => isCollectionOrOrderedCollection(resolved) ? resolved.totalItems : undefined,
() => undefined
),
webFinger(person.id)
)
]);
const host = toUnicode(finger.subject.replace(/^.*?@/, '')).toLowerCase();
const host = toUnicode(new URL(object.id).hostname.toLowerCase());
const isBot = object.type == 'Service';
@ -166,8 +192,8 @@ export async function createPerson(value: any, resolver?: Resolver): Promise<IUs
const avatarId = avatar ? avatar._id : null;
const bannerId = banner ? banner._id : null;
const avatarUrl = avatar && avatar.metadata.url ? avatar.metadata.url : null;
const bannerUrl = banner && banner.metadata.url ? banner.metadata.url : null;
const avatarUrl = (avatar && avatar.metadata.thumbnailUrl) ? avatar.metadata.thumbnailUrl : (avatar && avatar.metadata.url) ? avatar.metadata.url : null;
const bannerUrl = (banner && banner.metadata.url) ? banner.metadata.url : null;
await User.update({ _id: user._id }, {
$set: {
@ -192,8 +218,8 @@ export async function createPerson(value: any, resolver?: Resolver): Promise<IUs
*
* Misskeyに対象のPersonが登録されていなければ無視します
*/
export async function updatePerson(value: string | IObject, resolver?: Resolver): Promise<void> {
const uri = typeof value == 'string' ? value : value.id;
export async function updatePerson(uri: string, resolver?: Resolver): Promise<void> {
if (typeof uri !== 'string') throw 'uri is not string';
// URIがこのサーバーを指しているならスキップ
if (uri.startsWith(config.url + '/')) {
@ -210,9 +236,9 @@ export async function updatePerson(value: string | IObject, resolver?: Resolver)
if (resolver == null) resolver = new Resolver();
const object = await resolver.resolve(value) as any;
const object = await resolver.resolve(uri) as any;
const err = validatePerson(object);
const err = validatePerson(object, uri);
if (err) {
throw err;
@ -255,7 +281,7 @@ export async function updatePerson(value: string | IObject, resolver?: Resolver)
sharedInbox: person.sharedInbox,
avatarId: avatar ? avatar._id : null,
bannerId: banner ? banner._id : null,
avatarUrl: avatar && avatar.metadata.url ? avatar.metadata.url : null,
avatarUrl: (avatar && avatar.metadata.thumbnailUrl) ? avatar.metadata.thumbnailUrl : (avatar && avatar.metadata.url) ? avatar.metadata.url : null,
bannerUrl: banner && banner.metadata.url ? banner.metadata.url : null,
description: htmlToMFM(person.summary),
followersCount,
@ -275,8 +301,8 @@ export async function updatePerson(value: string | IObject, resolver?: Resolver)
* Misskeyに対象のPersonが登録されていればそれを返し
* Misskeyに登録しそれを返します
*/
export async function resolvePerson(value: string | IObject, verifier?: string): Promise<IUser> {
const uri = typeof value == 'string' ? value : value.id;
export async function resolvePerson(uri: string, verifier?: string): Promise<IUser> {
if (typeof uri !== 'string') throw 'uri is not string';
//#region このサーバーに既に登録されていたらそれを返す
const exist = await fetchPerson(uri);
@ -287,5 +313,5 @@ export async function resolvePerson(value: string | IObject, verifier?: string):
//#endregion
// リモートサーバーからフェッチしてきて登録
return await createPerson(value);
return await createPerson(uri);
}

View File

@ -6,6 +6,7 @@ export default (object: any, note: INote) => {
return {
id: `${config.url}/notes/${note._id}`,
actor: `${config.url}/users/${note.userId}`,
type: 'Announce',
published: note.createdAt.toISOString(),
to: ['https://www.w3.org/ns/activitystreams#Public'],

View File

@ -1,4 +1,17 @@
export default (object: any) => ({
import config from '../../../config';
import { INote } from '../../../models/note';
export default (object: any, note: INote) => {
const activity = {
id: `${config.url}/notes/${note._id}/activity`,
actor: `${config.url}/users/${note.userId}`,
type: 'Create',
published: note.createdAt.toISOString(),
object
});
} as any;
if (object.to) activity.to = object.to;
if (object.cc) activity.cc = object.cc;
return activity;
};

View File

@ -1,4 +1,8 @@
export default (object: any) => ({
import config from '../../../config';
import { ILocalUser } from "../../../models/user";
export default (object: any, user: ILocalUser) => ({
type: 'Delete',
actor: `${config.url}/users/${user._id}`,
object
});

View File

@ -1,7 +1,16 @@
export default (x: any) => Object.assign({
import config from '../../../config';
import * as uuid from 'uuid';
export default (x: any) => {
if (x !== null && typeof x === 'object' && x.id == null) {
x.id = `${config.url}/${uuid.v4()}`;
}
return Object.assign({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{ Hashtag: 'as:Hashtag' }
]
}, x);
}, x);
};

View File

@ -1,4 +1,8 @@
export default (object: any) => ({
import config from '../../../config';
import { ILocalUser, IUser } from "../../../models/user";
export default (object: any, user: ILocalUser | IUser) => ({
type: 'Undo',
actor: `${config.url}/users/${user._id}`,
object
});

View File

@ -19,6 +19,9 @@ export default (user: ILocalUser, url: string, object: any) => new Promise((reso
port,
method: 'POST',
path: pathname + search,
headers: {
'Content-Type': 'application/activity+json'
}
}, res => {
log(`${url} --> ${res.statusCode}`);
@ -32,7 +35,7 @@ export default (user: ILocalUser, url: string, object: any) => new Promise((reso
sign(req, {
authorizationHeaderName: 'Signature',
key: user.keypair,
keyId: `acct:${user.username}@${config.host}`
keyId: `${config.url}/users/${user._id}/publickey`
});
// Signature: Signature ... => Signature: ...

View File

@ -15,7 +15,7 @@ export default async (username: string, _host: string, option?: any): Promise<IU
const host = toUnicode(hostAscii);
if (config.host == host) {
return await User.findOne({ usernameLower });
return await User.findOne({ usernameLower, host: null });
}
let user = await User.findOne({ usernameLower, host }, option);

View File

@ -25,7 +25,7 @@ function inbox(ctx: Router.IRouterContext) {
ctx.req.headers.authorization = 'Signature ' + ctx.req.headers.signature;
try {
signature = httpSignature.parseRequest(ctx.req);
signature = httpSignature.parseRequest(ctx.req, { 'headers': [] });
} catch (e) {
ctx.status = 401;
return;

View File

@ -1 +1 @@
export default (token: string) => token[0] == '!';
export default (token: string) => token.startsWith('!');

View File

@ -1,101 +0,0 @@
import Stats, { IStats } from '../../../../models/stats';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export const meta = {
requireCredential: true,
requireAdmin: true
};
export default (params: any) => new Promise(async (res, rej) => {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const d = now.getDate();
const stats = await Stats.find({
date: {
$gt: new Date(y - 1, m, d)
}
}, {
sort: {
date: -1
},
fields: {
_id: 0
}
});
const chart: Array<Omit<IStats, '_id'>> = [];
for (let i = 364; i >= 0; i--) {
const day = new Date(y, m, d - i);
const stat = stats.find(s => s.date.getTime() == day.getTime());
if (stat) {
chart.unshift(stat);
} else { // 隙間埋め
const mostRecent = stats.find(s => s.date.getTime() < day.getTime());
if (mostRecent) {
chart.unshift(Object.assign({}, mostRecent, {
date: day
}));
} else {
chart.unshift({
date: day,
users: {
local: {
total: 0,
diff: 0
},
remote: {
total: 0,
diff: 0
}
},
notes: {
local: {
total: 0,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
},
remote: {
total: 0,
diff: 0,
diffs: {
normal: 0,
reply: 0,
renote: 0
}
}
},
drive: {
local: {
totalCount: 0,
totalSize: 0,
diffCount: 0,
diffSize: 0
},
remote: {
totalCount: 0,
totalSize: 0,
diffCount: 0,
diffSize: 0
}
}
});
}
}
}
chart.forEach(x => {
delete x.date;
});
res(chart);
});

View File

@ -3,7 +3,7 @@ import RegistrationTicket from '../../../../models/registration-tickets';
export const meta = {
desc: {
ja: '招待コードを発行します。'
'ja-JP': '招待コードを発行します。'
},
requireCredential: true,

Some files were not shown because too many files have changed in this diff Show More