mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-30 20:05:48 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b2f90f681 | |||
| 7daec62680 | |||
| 440b1e442a | |||
| 1057295d5d | |||
| dad093c6f8 | |||
| 4d85ea8657 | |||
| b1980f1287 | |||
| f42840ad03 | |||
| a01b6af7c1 | |||
| 5b8fdabcf2 | |||
| 41c20ce140 | |||
| 6aad11766a | |||
| 66498639bf | |||
| 76389b684c | |||
| 4b82eab304 | |||
| 3ead8d840e | |||
| c93b093bff | |||
| b33ec08cbf | |||
| 0d677f288a | |||
| 8a3e33218e |
+11
-1
@@ -1312,6 +1312,7 @@ noteOfThisUser: "このユーザーのノート一覧"
|
|||||||
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
|
clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。"
|
||||||
performance: "パフォーマンス"
|
performance: "パフォーマンス"
|
||||||
modified: "変更あり"
|
modified: "変更あり"
|
||||||
|
modifiedAt: "変更日時"
|
||||||
discard: "破棄"
|
discard: "破棄"
|
||||||
thereAreNChanges: "{n}件の変更があります"
|
thereAreNChanges: "{n}件の変更があります"
|
||||||
signinWithPasskey: "パスキーでログイン"
|
signinWithPasskey: "パスキーでログイン"
|
||||||
@@ -1421,6 +1422,8 @@ append: "末尾に追加"
|
|||||||
prepend: "先頭に追加"
|
prepend: "先頭に追加"
|
||||||
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
|
urlPreviewSensitiveList: "サムネイルの表示を制限するURL"
|
||||||
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
|
urlPreviewSensitiveListDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。"
|
||||||
|
latestBackupAt: "最終バックアップ"
|
||||||
|
latestSyncAt: "最終同期"
|
||||||
|
|
||||||
_imageEditing:
|
_imageEditing:
|
||||||
_vars:
|
_vars:
|
||||||
@@ -1589,7 +1592,9 @@ _preferencesProfile:
|
|||||||
useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "複数のデバイスで同期したい設定項目が存在する場合は、個別に「複数のデバイスで同期」オプションを有効にしてください。"
|
useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "複数のデバイスで同期したい設定項目が存在する場合は、個別に「複数のデバイスで同期」オプションを有効にしてください。"
|
||||||
|
|
||||||
_preferencesBackup:
|
_preferencesBackup:
|
||||||
|
backupAndSync: "バックアップと同期"
|
||||||
autoBackup: "自動バックアップ"
|
autoBackup: "自動バックアップ"
|
||||||
|
autoBackup_description: "設定を自動でサーバーに保存し、いつでも復元できるようにします"
|
||||||
restoreFromBackup: "バックアップから復元"
|
restoreFromBackup: "バックアップから復元"
|
||||||
noBackupsFoundTitle: "バックアップが見つかりませんでした"
|
noBackupsFoundTitle: "バックアップが見つかりませんでした"
|
||||||
noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。"
|
noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。"
|
||||||
@@ -1597,7 +1602,12 @@ _preferencesBackup:
|
|||||||
youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。"
|
youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。"
|
||||||
autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。"
|
autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。"
|
||||||
backupFound: "設定のバックアップが見つかりました"
|
backupFound: "設定のバックアップが見つかりました"
|
||||||
forceBackup: "設定の強制バックアップ"
|
forceBackup: "今すぐバックアップ"
|
||||||
|
autoSync: "デバイス間同期"
|
||||||
|
autoSync_description: "サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします"
|
||||||
|
forceSync: "今すぐ同期"
|
||||||
|
autoSyncAreYouSure: "デバイス間の同期をオンにしますか?"
|
||||||
|
autoSyncAreYouSure_description: "通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。"
|
||||||
|
|
||||||
_accountSettings:
|
_accountSettings:
|
||||||
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Preferences system
|
||||||
|
|
||||||
|
ユーザーの環境設定を管理するシステム。
|
||||||
|
|
||||||
|
## 指針
|
||||||
|
|
||||||
|
実装上のミスで、**設定値が意図せず失われる(古い値で上書きされる)ことが絶対にあってはならない。**
|
||||||
|
|
||||||
|
設定値が失われる、考えられるシナリオの例:
|
||||||
|
|
||||||
|
- 複数のタブでMisskeyを開いていて、タブAで設定を編集した後、タブBを開いたところ、タブBの古い状態の内容で設定が保存され、タブAで行った編集が巻き戻ってしまった。
|
||||||
|
- プロファイルの同期機能をオンにしたところ、サーバーに保存されていた古い設定でローカルが上書きされてしまった。
|
||||||
|
|
||||||
|
上記のシナリオが絶対に発生しない設計・実装にしなければならない。
|
||||||
|
|
||||||
|
上記以外にも考えられるシナリオがあれば、必ず適切な対処を行う設計・実装にしなければならない。
|
||||||
|
|
||||||
|
## 仕様
|
||||||
|
|
||||||
|
### タブ間同期
|
||||||
|
|
||||||
|
ブラウザで複数タブを開いているとき、あるタブで変更した設定が他のタブでも反映されるようにし、UXを向上させ、古い設定情報が上書き保存されることを防ぐ。
|
||||||
|
|
||||||
|
### 自動バックアップ(cloudBackup)
|
||||||
|
|
||||||
|
定期的、または特定のタイミングなどで、プロファイルをサーバーに自動的にアップロードする。
|
||||||
|
|
||||||
|
### デバイス間同期(cloudSync)
|
||||||
|
|
||||||
|
定期的、または特定のタイミングなどで、サーバーにアップロードされたプロファイルをダウンロードし、ローカルに適用する。
|
||||||
|
|
||||||
|
### 設定項目: デバイス間で同期(syncBetweenDevices, ValueMeta.sync)
|
||||||
|
|
||||||
|
デバイス間・プロファイル横断でその設定項目の設定値を同期する。
|
||||||
|
|
||||||
|
当オプションを有効にするかどうかは設定項目ごとに設定可能。
|
||||||
|
|
||||||
|
## ユーザーストーリー(ユースケース)
|
||||||
|
|
||||||
|
### 同じプロファイルを複数のデバイスで使いたい(同期したい)
|
||||||
|
|
||||||
|
autoBackupとautoSyncをオンにする。
|
||||||
|
|
||||||
|
### (新しいデバイスなどで)既存のプロファイルを継承した新しいプロファイルを作りたい
|
||||||
|
|
||||||
|
継承したいプロファイルをバックアップから復元した後、プロファイルの名前を変える。
|
||||||
|
|
||||||
|
## メモ
|
||||||
|
|
||||||
|
autoBackupとautoSyncの違いがユーザーにとって分かりにくい可能性があるので、機能を一本化するか、個別にオンオフを切り替えられるのではなく「自動バックアップ:『しない/有効/有効+同期』」みたいな選択方式にするなどがいいかもしれない?
|
||||||
|
|
||||||
|
autoBackupはするがautoSyncしたくないケースはあまりないと思うが、一本化すると複数のデバイスで同一のプロファイルを共有しない人(大多数だと思われる)にとっては無駄に通信量が増えるだけになる
|
||||||
@@ -191,6 +191,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]"
|
:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]"
|
||||||
|
:disabled="unref(item.disabled)"
|
||||||
@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
|
@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
|
||||||
@mouseenter.passive="onItemMouseEnter"
|
@mouseenter.passive="onItemMouseEnter"
|
||||||
@mouseleave.passive="onItemMouseLeave"
|
@mouseleave.passive="onItemMouseLeave"
|
||||||
@@ -629,6 +630,7 @@ function guardMouseMove(ev: MouseEvent) {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
width: max-content;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
@@ -701,6 +703,7 @@ function guardMouseMove(ev: MouseEvent) {
|
|||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.danger {
|
&.danger {
|
||||||
|
|||||||
@@ -144,7 +144,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
|
|
||||||
<MkButton v-if="storagePersistenceSupported && !storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton>
|
<MkButton v-if="storagePersistenceSupported && !storagePersisted" @click="enableStoragePersistence">{{ i18n.ts._settings.settingsPersistence_title }}</MkButton>
|
||||||
|
|
||||||
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
|
<SearchMarker :keywords="['profile', 'preferences']">
|
||||||
|
<MkFolder>
|
||||||
|
<template #icon><SearchIcon><i class="ti ti-cogs"></i></SearchIcon></template>
|
||||||
|
<template #label><SearchLabel>{{ i18n.ts.preferencesProfile }}</SearchLabel></template>
|
||||||
|
|
||||||
|
<div class="_buttons">
|
||||||
|
<MkButton @click="forceCloudBackup">{{ i18n.ts._preferencesBackup.forceBackup }}</MkButton>
|
||||||
|
<MkButton @click="forceCloudSync">{{ i18n.ts._preferencesBackup.forceSync }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
|
</SearchMarker>
|
||||||
</div>
|
</div>
|
||||||
</SearchMarker>
|
</SearchMarker>
|
||||||
</template>
|
</template>
|
||||||
@@ -170,7 +180,7 @@ import MkRolePreview from '@/components/MkRolePreview.vue';
|
|||||||
import { signout } from '@/signout.js';
|
import { signout } from '@/signout.js';
|
||||||
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
|
import { hideAllTips as _hideAllTips, resetAllTips as _resetAllTips } from '@/tips.js';
|
||||||
import { suggestReload } from '@/utility/reload-suggest.js';
|
import { suggestReload } from '@/utility/reload-suggest.js';
|
||||||
import { cloudBackup } from '@/preferences/utility.js';
|
import { cloudBackup, cloudSync } from '@/preferences/utility.js';
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
@@ -232,6 +242,11 @@ async function forceCloudBackup() {
|
|||||||
os.success();
|
os.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function forceCloudSync() {
|
||||||
|
await cloudSync();
|
||||||
|
os.success();
|
||||||
|
}
|
||||||
|
|
||||||
const headerActions = computed(() => []);
|
const headerActions = computed(() => []);
|
||||||
|
|
||||||
const headerTabs = computed(() => []);
|
const headerTabs = computed(() => []);
|
||||||
|
|||||||
@@ -3,16 +3,14 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BroadcastChannel } from 'broadcast-channel';
|
|
||||||
import { createVisibilityAwareInterval } from '@@/js/interval.js';
|
import { createVisibilityAwareInterval } from '@@/js/interval.js';
|
||||||
import type { StorageProvider } from '@/preferences/manager.js';
|
import type { StorageProvider } from '@/preferences/manager.js';
|
||||||
import { cloudBackup } from '@/preferences/utility.js';
|
import { cloudBackup, cloudSync } from '@/preferences/utility.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
import { miLocalStorage } from '@/local-storage.js';
|
||||||
import { isSameScope, PreferencesManager } from '@/preferences/manager.js';
|
import { isSameScope, PreferencesManager } from '@/preferences/manager.js';
|
||||||
import { store } from '@/store.js';
|
import { store } from '@/store.js';
|
||||||
import { $i } from '@/i.js';
|
import { $i } from '@/i.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { TAB_ID } from '@/tab-id.js';
|
|
||||||
|
|
||||||
// クラウド同期用グループ名
|
// クラウド同期用グループ名
|
||||||
const syncGroup = 'default';
|
const syncGroup = 'default';
|
||||||
@@ -38,11 +36,12 @@ const io: StorageProvider = {
|
|||||||
const cloudData = await misskeyApi('i/registry/get', {
|
const cloudData = await misskeyApi('i/registry/get', {
|
||||||
scope: ['client', 'preferences', 'sync'],
|
scope: ['client', 'preferences', 'sync'],
|
||||||
key: syncGroup + ':' + ctx.key,
|
key: syncGroup + ':' + ctx.key,
|
||||||
}) as [any, any][];
|
}) as [any, any, any][];
|
||||||
const target = cloudData.find(([scope]) => isSameScope(scope, ctx.scope));
|
const target = cloudData.find(([scope]) => isSameScope(scope, ctx.scope));
|
||||||
if (target == null) return null;
|
if (target == null) return null;
|
||||||
return {
|
return {
|
||||||
value: target[1],
|
value: target[1],
|
||||||
|
meta: target[2],
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する
|
if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する
|
||||||
@@ -54,12 +53,12 @@ const io: StorageProvider = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
cloudSet: async (ctx) => {
|
cloudSet: async (ctx) => {
|
||||||
let cloudData: [any, any][] = [];
|
let cloudData: [any, any, any][] = [];
|
||||||
try {
|
try {
|
||||||
cloudData = await misskeyApi('i/registry/get', {
|
cloudData = await misskeyApi('i/registry/get', {
|
||||||
scope: ['client', 'preferences', 'sync'],
|
scope: ['client', 'preferences', 'sync'],
|
||||||
key: syncGroup + ':' + ctx.key,
|
key: syncGroup + ':' + ctx.key,
|
||||||
}) as [any, any][];
|
}) as [any, any, any][];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する
|
if (err.code === 'NO_SUCH_KEY') { // TODO: いちいちエラーキャッチするのは面倒なのでキーが無くてもエラーにならない maybe-get のようなエンドポイントをバックエンドに実装する
|
||||||
cloudData = [];
|
cloudData = [];
|
||||||
@@ -71,9 +70,9 @@ const io: StorageProvider = {
|
|||||||
const i = cloudData.findIndex(([scope]) => isSameScope(scope, ctx.scope));
|
const i = cloudData.findIndex(([scope]) => isSameScope(scope, ctx.scope));
|
||||||
|
|
||||||
if (i === -1) {
|
if (i === -1) {
|
||||||
cloudData.push([ctx.scope, ctx.value]);
|
cloudData.push([ctx.scope, ctx.value, ctx.meta]);
|
||||||
} else {
|
} else {
|
||||||
cloudData[i] = [ctx.scope, ctx.value];
|
cloudData[i] = [ctx.scope, ctx.value, ctx.meta];
|
||||||
}
|
}
|
||||||
|
|
||||||
await misskeyApi('i/registry/set', {
|
await misskeyApi('i/registry/set', {
|
||||||
@@ -88,10 +87,10 @@ const io: StorageProvider = {
|
|||||||
const fetchings = ctx.needs.map(need => io.cloudGet(need).then(res => [need.key, res] as const));
|
const fetchings = ctx.needs.map(need => io.cloudGet(need).then(res => [need.key, res] as const));
|
||||||
const cloudDatas = await Promise.all(fetchings);
|
const cloudDatas = await Promise.all(fetchings);
|
||||||
|
|
||||||
const res = {} as Partial<Record<string, any>>;
|
const res = {} as Partial<Record<string, { value: any; meta: any; }>>;
|
||||||
for (const cloudData of cloudDatas) {
|
for (const cloudData of cloudDatas) {
|
||||||
if (cloudData[1] != null) {
|
if (cloudData[1] != null) {
|
||||||
res[cloudData[0]] = cloudData[1].value;
|
res[cloudData[0]] = cloudData[1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,41 +101,10 @@ const io: StorageProvider = {
|
|||||||
export const prefer = new PreferencesManager(io, $i);
|
export const prefer = new PreferencesManager(io, $i);
|
||||||
|
|
||||||
//#region タブ間同期
|
//#region タブ間同期
|
||||||
let latestPreferencesUpdate: {
|
window.addEventListener('storage', (ev) => {
|
||||||
tabId: string;
|
if (ev.key === 'preferences') {
|
||||||
timestamp: number;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
const preferencesChannel = new BroadcastChannel<{
|
|
||||||
type: 'preferencesUpdate';
|
|
||||||
tabId: string;
|
|
||||||
timestamp: number;
|
|
||||||
}>('preferences');
|
|
||||||
|
|
||||||
prefer.on('committed', () => {
|
|
||||||
latestPreferencesUpdate = {
|
|
||||||
tabId: TAB_ID,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
preferencesChannel.postMessage({
|
|
||||||
type: 'preferencesUpdate',
|
|
||||||
tabId: TAB_ID,
|
|
||||||
timestamp: latestPreferencesUpdate.timestamp,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
preferencesChannel.addEventListener('message', (msg) => {
|
|
||||||
if (msg.type === 'preferencesUpdate') {
|
|
||||||
if (msg.tabId === TAB_ID) return;
|
|
||||||
if (latestPreferencesUpdate != null) {
|
|
||||||
if (msg.timestamp <= latestPreferencesUpdate.timestamp) return;
|
|
||||||
}
|
|
||||||
prefer.reloadProfile();
|
prefer.reloadProfile();
|
||||||
if (_DEV_) console.log('prefer:received update from other tab');
|
if (_DEV_) console.log('prefer: received update from other tab');
|
||||||
latestPreferencesUpdate = {
|
|
||||||
tabId: msg.tabId,
|
|
||||||
timestamp: msg.timestamp,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
//#endregion
|
//#endregion
|
||||||
@@ -156,6 +124,13 @@ createVisibilityAwareInterval(() => {
|
|||||||
}, 1000 * 60 * 3);
|
}, 1000 * 60 * 3);
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
store.loaded.then(() => {
|
||||||
|
if (store.s.enablePreferencesAutoCloudSync) {
|
||||||
|
// TODO: 前回同期してから10分以上経過している場合のみ
|
||||||
|
cloudSync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (_DEV_) {
|
if (_DEV_) {
|
||||||
(window as any).prefer = prefer;
|
(window as any).prefer = prefer;
|
||||||
(window as any).cloudBackup = cloudBackup;
|
(window as any).cloudBackup = cloudBackup;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type Scope = Partial<{
|
|||||||
|
|
||||||
type ValueMeta = Partial<{
|
type ValueMeta = Partial<{
|
||||||
sync: boolean;
|
sync: boolean;
|
||||||
|
modifiedAt?: number; // 設定値を変更した日時。同期した日時などではない。つまり別のデバイスでA日に変更したものをB日に同期して取得したとしてもmodifiedAtはA日である必要がある
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type PrefRecord<K extends keyof PREF> = [scope: Scope, value: ValueOf<K>, meta: ValueMeta];
|
type PrefRecord<K extends keyof PREF> = [scope: Scope, value: ValueOf<K>, meta: ValueMeta];
|
||||||
@@ -74,7 +75,7 @@ export type PreferencesProfile = {
|
|||||||
id: string;
|
id: string;
|
||||||
version: string;
|
version: string;
|
||||||
type: 'main';
|
type: 'main';
|
||||||
modifiedAt: number;
|
modifiedAt: number; // 仕様が若干直感的ではない(syncされた値が降ってきたときは更新されないなど)ため、一応残してはいるが積極的な利用はしない方が無難。設定値の新旧比較が必要なら項目ごとのmodifiedAtを使うべし
|
||||||
name: string;
|
name: string;
|
||||||
preferences: {
|
preferences: {
|
||||||
[K in keyof PREF]: PrefRecord<K>[];
|
[K in keyof PREF]: PrefRecord<K>[];
|
||||||
@@ -88,9 +89,9 @@ export type PossiblyNonNormalizedPreferencesProfile = Omit<PreferencesProfile, '
|
|||||||
export type StorageProvider = {
|
export type StorageProvider = {
|
||||||
load: () => PossiblyNonNormalizedPreferencesProfile | null;
|
load: () => PossiblyNonNormalizedPreferencesProfile | null;
|
||||||
save: (ctx: { profile: PreferencesProfile; }) => void;
|
save: (ctx: { profile: PreferencesProfile; }) => void;
|
||||||
cloudGetBulk: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, ValueOf<K>>>>;
|
cloudGetBulk: <K extends keyof PREF>(ctx: { needs: { key: K; scope: Scope; }[] }) => Promise<Partial<Record<K, { value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; }>>>;
|
||||||
cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; } | null>;
|
cloudGet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; }) => Promise<{ value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; } | null>;
|
||||||
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; }) => Promise<void>;
|
cloudSet: <K extends keyof PREF>(ctx: { key: K; scope: Scope; value: ValueOf<K>; meta: { modifiedAt: ValueMeta['modifiedAt'] }; }) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
|
type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) => infer R ? R : Default> = {
|
||||||
@@ -102,14 +103,6 @@ type PreferencesDefinitionRecord<Default, T = Default extends (...args: any) =>
|
|||||||
|
|
||||||
export type PreferencesDefinition = Record<string, PreferencesDefinitionRecord<any>>;
|
export type PreferencesDefinition = Record<string, PreferencesDefinitionRecord<any>>;
|
||||||
|
|
||||||
type PreferencesManagerEvents = {
|
|
||||||
'committed': <K extends keyof PREF>(ctx: {
|
|
||||||
key: K;
|
|
||||||
value: ValueOf<K>;
|
|
||||||
oldValue: ValueOf<K>;
|
|
||||||
}) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function definePreferences<T extends Record<string, unknown>>(x: {
|
export function definePreferences<T extends Record<string, unknown>>(x: {
|
||||||
[K in keyof T]: PreferencesDefinitionRecord<T[K]>
|
[K in keyof T]: PreferencesDefinitionRecord<T[K]>
|
||||||
}): {
|
}): {
|
||||||
@@ -188,6 +181,44 @@ function normalizePreferences(preferences: PossiblyNonNormalizedPreferencesProfi
|
|||||||
return data as PreferencesProfile['preferences'];
|
return data as PreferencesProfile['preferences'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 各recordについて、modifiedAtが大きい方を採用する
|
||||||
|
// 引数の参照をmutateしないように注意すること
|
||||||
|
export function mergeProfiles(a: PreferencesProfile, b: PreferencesProfile): PreferencesProfile {
|
||||||
|
const merged = {
|
||||||
|
...a,
|
||||||
|
modifiedAt: Math.max(a.modifiedAt, b.modifiedAt),
|
||||||
|
preferences: {},
|
||||||
|
} as PreferencesProfile;
|
||||||
|
|
||||||
|
for (const _key in PREF_DEF) {
|
||||||
|
const key = _key as keyof PREF;
|
||||||
|
const aRecords = a.preferences[key];
|
||||||
|
const bRecords = b.preferences[key];
|
||||||
|
|
||||||
|
const mergedRecords = [...aRecords];
|
||||||
|
|
||||||
|
for (const bRecord of bRecords) {
|
||||||
|
const existingIndex = mergedRecords.findIndex(([scope]) => isSameScope(scope, bRecord[0]));
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
mergedRecords.push(bRecord);
|
||||||
|
} else {
|
||||||
|
const aRecord = mergedRecords[existingIndex];
|
||||||
|
if ((bRecord[2].modifiedAt ?? 0) > (aRecord[2].modifiedAt ?? 0)) {
|
||||||
|
mergedRecords[existingIndex] = bRecord;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(merged.preferences[key] as PrefRecord<typeof key>[]) = mergedRecords;
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
type PreferencesManagerEvents = {
|
||||||
|
};
|
||||||
|
|
||||||
// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すればthis.currentAccountのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない
|
// TODO: PreferencesManagerForGuest のような非ログイン専用のクラスを分離すればthis.currentAccountのnullチェックやaccountがnullであるスコープのレコード挿入などが不要になり綺麗になるかもしれない
|
||||||
// と思ったけど操作アカウントが存在しない場合も考慮する現在の設計の方が汎用的かつ堅牢かもしれない
|
// と思ったけど操作アカウントが存在しない場合も考慮する現在の設計の方が汎用的かつ堅牢かもしれない
|
||||||
// NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる
|
// NOTE: accountDependentな設定は初期状態であってもアカウントごとのスコープでレコードを作成しておかないと、サーバー同期する際に正しく動作しなくなる
|
||||||
@@ -247,13 +278,13 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: desync対策 cloudの値のfetchが正常に完了していない状態でcommitすると多分値が上書きされる
|
// TODO: desync対策 cloudの値のfetchが正常に完了していない状態でcommitすると多分値が上書きされる
|
||||||
public commit<K extends keyof PREF>(key: K, value: ValueOf<K>) {
|
public commit<K extends keyof PREF>(key: K, value: ValueOf<K>): PrefRecord<K> | null {
|
||||||
const currentAccount = this.currentAccount; // TSを黙らせるため
|
const currentAccount = this.currentAccount; // TSを黙らせるため
|
||||||
const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
|
const v = JSON.parse(JSON.stringify(value)); // deep copy 兼 vueのプロキシ解除
|
||||||
|
|
||||||
if (deepEqual(this.s[key], v)) {
|
if (deepEqual(this.s[key], v)) {
|
||||||
if (_DEV_) console.log('(skip) prefer:commit', key, v);
|
if (_DEV_) console.log('(skip) prefer:commit', key, v);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_DEV_) console.log('prefer:commit', key, v);
|
if (_DEV_) console.log('prefer:commit', key, v);
|
||||||
@@ -262,40 +293,40 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
|||||||
|
|
||||||
const record = this.getMatchedRecordOf(key);
|
const record = this.getMatchedRecordOf(key);
|
||||||
|
|
||||||
const _save = () => {
|
|
||||||
this.save();
|
|
||||||
this.emit('committed', {
|
|
||||||
key,
|
|
||||||
value: v,
|
|
||||||
oldValue: this.s[key],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (parseScope(record[0]).account == null && isAccountDependentKey(key) && currentAccount != null) {
|
if (parseScope(record[0]).account == null && isAccountDependentKey(key) && currentAccount != null) {
|
||||||
this.profile.preferences[key].push([makeScope({
|
const newRecord = [makeScope({
|
||||||
server: host,
|
server: host,
|
||||||
account: currentAccount.id,
|
account: currentAccount.id,
|
||||||
}), v, {}]);
|
}), v, {
|
||||||
_save();
|
modifiedAt: Date.now(),
|
||||||
return;
|
}] as PrefRecord<K>;
|
||||||
|
this.profile.preferences[key].push(newRecord);
|
||||||
|
this.save();
|
||||||
|
return newRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parseScope(record[0]).server == null && isServerDependentKey(key)) {
|
if (parseScope(record[0]).server == null && isServerDependentKey(key)) {
|
||||||
this.profile.preferences[key].push([makeScope({
|
const newRecord = [makeScope({
|
||||||
server: host,
|
server: host,
|
||||||
}), v, {}]);
|
}), v, {
|
||||||
_save();
|
modifiedAt: Date.now(),
|
||||||
return;
|
}] as PrefRecord<K>;
|
||||||
|
this.profile.preferences[key].push(newRecord);
|
||||||
|
this.save();
|
||||||
|
return newRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
record[1] = v;
|
record[1] = v;
|
||||||
_save();
|
record[2].modifiedAt = Date.now();
|
||||||
|
this.save();
|
||||||
|
|
||||||
if (record[2].sync) {
|
if (record[2].sync) {
|
||||||
// awaitの必要なし
|
// awaitの必要なし
|
||||||
// TODO: リクエストを間引く
|
// TODO: リクエストを間引く
|
||||||
this.io.cloudSet({ key, scope: record[0], value: record[1] });
|
this.io.cloudSet({ key, scope: record[0], value: record[1], meta: { modifiedAt: record[2].modifiedAt } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -364,24 +395,29 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
|||||||
|
|
||||||
const cloudValues = await this.io.cloudGetBulk({ needs });
|
const cloudValues = await this.io.cloudGetBulk({ needs });
|
||||||
|
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
for (const _key in PREF_DEF) {
|
for (const _key in PREF_DEF) {
|
||||||
const key = _key as keyof PREF;
|
const key = _key as keyof PREF;
|
||||||
const record = this.getMatchedRecordOf(key);
|
const record = this.getMatchedRecordOf(key);
|
||||||
if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) {
|
if (record[2].sync && Object.hasOwn(cloudValues, key) && cloudValues[key] !== undefined) {
|
||||||
const cloudValue = cloudValues[key];
|
const cloudValue = cloudValues[key];
|
||||||
if (!deepEqual(cloudValue, record[1])) {
|
if (!deepEqual(cloudValue, record[1])) {
|
||||||
this.rewriteRawState(key, cloudValue);
|
this.rewriteRawState(key, cloudValue.value);
|
||||||
record[1] = cloudValue;
|
record[1] = cloudValue.value;
|
||||||
|
record[2].modifiedAt = cloudValue.meta.modifiedAt;
|
||||||
|
modified = true;
|
||||||
if (_DEV_) console.log('cloud fetched', key, cloudValue);
|
if (_DEV_) console.log('cloud fetched', key, cloudValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.save();
|
if (modified) this.save();
|
||||||
|
|
||||||
if (_DEV_) console.log('cloud fetch completed');
|
if (_DEV_) console.log('cloud fetch completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
public save() {
|
private save() {
|
||||||
this.profile.modifiedAt = Date.now();
|
this.profile.modifiedAt = Date.now();
|
||||||
this.profile.version = version;
|
this.profile.version = version;
|
||||||
this.io.save({ profile: this.profile });
|
this.io.save({ profile: this.profile });
|
||||||
@@ -511,12 +547,12 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
|||||||
newValue = resolvedValue;
|
newValue = resolvedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.commit(key, newValue);
|
const commitedRecord = this.commit(key, newValue);
|
||||||
|
|
||||||
const done = os.waiting();
|
const done = os.waiting();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.io.cloudSet({ key, scope: record[0], value: newValue });
|
await this.io.cloudSet({ key, scope: record[0], value: newValue, meta: { modifiedAt: record[2].modifiedAt } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
done();
|
done();
|
||||||
|
|
||||||
@@ -554,20 +590,18 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public reloadProfile() {
|
public reloadProfile() {
|
||||||
const newProfile = this.io.load();
|
const freshProfile = this.io.load();
|
||||||
if (newProfile == null) return;
|
if (freshProfile == null) return;
|
||||||
|
|
||||||
this.profile = {
|
this.profile = {
|
||||||
...newProfile,
|
...freshProfile,
|
||||||
preferences: normalizePreferences(newProfile.preferences, this.currentAccount),
|
preferences: normalizePreferences(freshProfile.preferences, this.currentAccount),
|
||||||
};
|
};
|
||||||
const states = this.genStates();
|
const states = this.genStates();
|
||||||
for (const _key in states) {
|
for (const _key in states) {
|
||||||
const key = _key as keyof PREF;
|
const key = _key as keyof PREF;
|
||||||
this.rewriteRawState(key, states[key]);
|
this.rewriteRawState(key, states[key]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.fetchCloudValues();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] {
|
public getPerPrefMenu<K extends keyof PREF>(key: K): MenuItem[] {
|
||||||
@@ -617,6 +651,11 @@ export class PreferencesManager extends EventEmitter<PreferencesManagerEvents> {
|
|||||||
icon: 'ti ti-cloud-cog',
|
icon: 'ti ti-cloud-cog',
|
||||||
text: i18n.ts.syncBetweenDevices,
|
text: i18n.ts.syncBetweenDevices,
|
||||||
ref: sync,
|
ref: sync,
|
||||||
|
}, {
|
||||||
|
type: 'divider',
|
||||||
|
}, {
|
||||||
|
type: 'label',
|
||||||
|
text: i18n.ts.modifiedAt + ': ' + (this.getMatchedRecordOf(key)[2].modifiedAt ? new Date(this.getMatchedRecordOf(key)[2].modifiedAt!).toLocaleString() : '-'),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { mergeProfiles } from './manager.js';
|
||||||
import type { PreferencesProfile } from './manager.js';
|
import type { PreferencesProfile } from './manager.js';
|
||||||
import type { MenuItem } from '@/types/menu.js';
|
import type { MenuItem } from '@/types/menu.js';
|
||||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||||
@@ -39,6 +40,28 @@ export function getPreferencesProfileMenu(): MenuItem[] {
|
|||||||
cloudBackup();
|
cloudBackup();
|
||||||
} else {
|
} else {
|
||||||
store.set('enablePreferencesAutoCloudBackup', false);
|
store.set('enablePreferencesAutoCloudBackup', false);
|
||||||
|
|
||||||
|
autoSyncEnabled.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const autoSyncEnabled = ref(store.s.enablePreferencesAutoCloudSync);
|
||||||
|
|
||||||
|
watch(autoSyncEnabled, async () => {
|
||||||
|
if (autoSyncEnabled.value) {
|
||||||
|
const confirm = await os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
title: i18n.ts._preferencesBackup.autoSyncAreYouSure,
|
||||||
|
text: i18n.ts._preferencesBackup.autoSyncAreYouSure_description,
|
||||||
|
});
|
||||||
|
if (confirm.canceled) {
|
||||||
|
autoSyncEnabled.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.set('enablePreferencesAutoCloudSync', true);
|
||||||
|
} else {
|
||||||
|
store.set('enablePreferencesAutoCloudSync', false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,10 +75,42 @@ export function getPreferencesProfileMenu(): MenuItem[] {
|
|||||||
renameProfile();
|
renameProfile();
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
type: 'switch',
|
type: 'parent',
|
||||||
icon: 'ti ti-cloud-up',
|
text: i18n.ts._preferencesBackup.backupAndSync,
|
||||||
text: i18n.ts._preferencesBackup.autoBackup,
|
caption: i18n.ts.latestBackupAt + ': ' + (store.s.latestPreferencesBackupAt !== 0 ? new Date(store.s.latestPreferencesBackupAt).toLocaleString() : '-'),
|
||||||
ref: autoBackupEnabled,
|
icon: 'ti ti-cloud',
|
||||||
|
children: [{
|
||||||
|
type: 'switch',
|
||||||
|
icon: 'ti ti-cloud-up',
|
||||||
|
text: i18n.ts._preferencesBackup.autoBackup,
|
||||||
|
caption: i18n.ts._preferencesBackup.autoBackup_description,
|
||||||
|
ref: autoBackupEnabled,
|
||||||
|
}, {
|
||||||
|
type: 'button',
|
||||||
|
icon: 'ti ti-cloud-up',
|
||||||
|
text: i18n.ts._preferencesBackup.forceBackup,
|
||||||
|
disabled: computed(() => !autoBackupEnabled.value),
|
||||||
|
action: () => {
|
||||||
|
cloudBackup();
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
type: 'divider',
|
||||||
|
}, {
|
||||||
|
type: 'switch',
|
||||||
|
icon: 'ti ti-cloud-down',
|
||||||
|
text: i18n.ts._preferencesBackup.autoSync,
|
||||||
|
caption: i18n.ts._preferencesBackup.autoSync_description,
|
||||||
|
ref: autoSyncEnabled,
|
||||||
|
disabled: computed(() => !autoBackupEnabled.value),
|
||||||
|
}, {
|
||||||
|
type: 'button',
|
||||||
|
icon: 'ti ti-cloud-down',
|
||||||
|
text: i18n.ts._preferencesBackup.forceSync,
|
||||||
|
disabled: computed(() => !autoSyncEnabled.value),
|
||||||
|
action: () => {
|
||||||
|
cloudSync();
|
||||||
|
},
|
||||||
|
}],
|
||||||
}, {
|
}, {
|
||||||
text: i18n.ts.export,
|
text: i18n.ts.export,
|
||||||
icon: 'ti ti-download',
|
icon: 'ti ti-download',
|
||||||
@@ -139,17 +194,55 @@ function importProfile() {
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function cloudSync() {
|
||||||
|
if ($i == null) return;
|
||||||
|
|
||||||
|
const cloudProfile = await misskeyApi('i/registry/get', {
|
||||||
|
scope: ['client', 'preferences', 'backups'],
|
||||||
|
key: prefer.profile.name,
|
||||||
|
}) as PreferencesProfile | null;
|
||||||
|
|
||||||
|
if (cloudProfile == null) {
|
||||||
|
if (_DEV_) console.log('no backuped profile found, skipping sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_DEV_) console.log('backuped profile found, syncing', cloudProfile);
|
||||||
|
|
||||||
|
miLocalStorage.setItem('preferences', JSON.stringify(mergeProfiles(prefer.profile, cloudProfile)));
|
||||||
|
|
||||||
|
prefer.reloadProfile();
|
||||||
|
|
||||||
|
store.set('latestPreferencesSyncAt', Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
export async function cloudBackup() {
|
export async function cloudBackup() {
|
||||||
if ($i == null) return;
|
if ($i == null) return;
|
||||||
if (!canAutoBackup()) {
|
if (!canAutoBackup()) {
|
||||||
throw new Error('cannot auto backup for this profile');
|
throw new Error('cannot auto backup for this profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentProfile = prefer.profile;
|
||||||
|
|
||||||
|
if (_DEV_) console.log('cloud backup', currentProfile);
|
||||||
|
|
||||||
|
const backupedProfile = await misskeyApi('i/registry/get', {
|
||||||
|
scope: ['client', 'preferences', 'backups'],
|
||||||
|
key: prefer.profile.name,
|
||||||
|
}) as PreferencesProfile | null;
|
||||||
|
|
||||||
|
// 古い設定で新しいバックアップを上書きしないようにマージ
|
||||||
|
if (backupedProfile != null) {
|
||||||
|
currentProfile = mergeProfiles(currentProfile, backupedProfile);
|
||||||
|
}
|
||||||
|
|
||||||
await misskeyApi('i/registry/set', {
|
await misskeyApi('i/registry/set', {
|
||||||
scope: ['client', 'preferences', 'backups'],
|
scope: ['client', 'preferences', 'backups'],
|
||||||
key: prefer.profile.name,
|
key: prefer.profile.name,
|
||||||
value: prefer.profile,
|
value: currentProfile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
store.set('latestPreferencesBackupAt', Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCloudBackups() {
|
export async function listCloudBackups() {
|
||||||
@@ -186,7 +279,6 @@ export async function restoreFromCloudBackup() {
|
|||||||
|
|
||||||
const select = await os.select({
|
const select = await os.select({
|
||||||
title: i18n.ts._preferencesBackup.selectBackupToRestore,
|
title: i18n.ts._preferencesBackup.selectBackupToRestore,
|
||||||
text: 'ℹ️ ' + i18n.ts._preferencesProfile.shareSameProfileBetweenDevicesIsNotRecommended + ' ' + i18n.ts._preferencesProfile.useSyncBetweenDevicesOptionIfYouWantToSyncSetting,
|
|
||||||
items: backups.map(backup => ({
|
items: backups.map(backup => ({
|
||||||
label: backup.name,
|
label: backup.name,
|
||||||
value: backup.name,
|
value: backup.name,
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ export const store = markRaw(new Pizzax('base', {
|
|||||||
where: 'device',
|
where: 'device',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
enablePreferencesAutoCloudSync: {
|
||||||
|
where: 'device',
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
showPreferencesAutoCloudBackupSuggestion: {
|
showPreferencesAutoCloudBackupSuggestion: {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
default: true,
|
default: true,
|
||||||
@@ -118,6 +122,14 @@ export const store = markRaw(new Pizzax('base', {
|
|||||||
where: 'device',
|
where: 'device',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
latestPreferencesBackupAt: {
|
||||||
|
where: 'device',
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
latestPreferencesSyncAt: {
|
||||||
|
where: 'device',
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// TODO: 他のタブと永続化されたstateを同期
|
// TODO: 他のタブと永続化されたstateを同期
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface MenuButton {
|
|||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
active?: boolean | ComputedRef<boolean>;
|
active?: boolean | ComputedRef<boolean>;
|
||||||
avatar?: Misskey.entities.User;
|
avatar?: Misskey.entities.User;
|
||||||
|
disabled?: boolean | Ref<boolean>;
|
||||||
action: MenuAction;
|
action: MenuAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5260,6 +5260,10 @@ export interface Locale extends ILocale {
|
|||||||
* 変更あり
|
* 変更あり
|
||||||
*/
|
*/
|
||||||
"modified": string;
|
"modified": string;
|
||||||
|
/**
|
||||||
|
* 変更日時
|
||||||
|
*/
|
||||||
|
"modifiedAt": string;
|
||||||
/**
|
/**
|
||||||
* 破棄
|
* 破棄
|
||||||
*/
|
*/
|
||||||
@@ -5699,6 +5703,14 @@ export interface Locale extends ILocale {
|
|||||||
* スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。
|
* スペースで区切るとAND指定になり、改行で区切るとOR指定になります。スラッシュで囲むと正規表現になります。一致した場合、サムネイルが表示されなくなります。
|
||||||
*/
|
*/
|
||||||
"urlPreviewSensitiveListDescription": string;
|
"urlPreviewSensitiveListDescription": string;
|
||||||
|
/**
|
||||||
|
* 最終バックアップ
|
||||||
|
*/
|
||||||
|
"latestBackupAt": string;
|
||||||
|
/**
|
||||||
|
* 最終同期
|
||||||
|
*/
|
||||||
|
"latestSyncAt": string;
|
||||||
"_imageEditing": {
|
"_imageEditing": {
|
||||||
"_vars": {
|
"_vars": {
|
||||||
/**
|
/**
|
||||||
@@ -6303,10 +6315,18 @@ export interface Locale extends ILocale {
|
|||||||
"useSyncBetweenDevicesOptionIfYouWantToSyncSetting": string;
|
"useSyncBetweenDevicesOptionIfYouWantToSyncSetting": string;
|
||||||
};
|
};
|
||||||
"_preferencesBackup": {
|
"_preferencesBackup": {
|
||||||
|
/**
|
||||||
|
* バックアップと同期
|
||||||
|
*/
|
||||||
|
"backupAndSync": string;
|
||||||
/**
|
/**
|
||||||
* 自動バックアップ
|
* 自動バックアップ
|
||||||
*/
|
*/
|
||||||
"autoBackup": string;
|
"autoBackup": string;
|
||||||
|
/**
|
||||||
|
* 設定を自動でサーバーに保存し、いつでも復元できるようにします
|
||||||
|
*/
|
||||||
|
"autoBackup_description": string;
|
||||||
/**
|
/**
|
||||||
* バックアップから復元
|
* バックアップから復元
|
||||||
*/
|
*/
|
||||||
@@ -6336,9 +6356,29 @@ export interface Locale extends ILocale {
|
|||||||
*/
|
*/
|
||||||
"backupFound": string;
|
"backupFound": string;
|
||||||
/**
|
/**
|
||||||
* 設定の強制バックアップ
|
* 今すぐバックアップ
|
||||||
*/
|
*/
|
||||||
"forceBackup": string;
|
"forceBackup": string;
|
||||||
|
/**
|
||||||
|
* デバイス間同期
|
||||||
|
*/
|
||||||
|
"autoSync": string;
|
||||||
|
/**
|
||||||
|
* サーバーに保存された設定を自動で取得し、別のデバイスでの変更と同期できるようにします
|
||||||
|
*/
|
||||||
|
"autoSync_description": string;
|
||||||
|
/**
|
||||||
|
* 今すぐ同期
|
||||||
|
*/
|
||||||
|
"forceSync": string;
|
||||||
|
/**
|
||||||
|
* デバイス間の同期をオンにしますか?
|
||||||
|
*/
|
||||||
|
"autoSyncAreYouSure": string;
|
||||||
|
/**
|
||||||
|
* 通信量が増えるため、他のデバイスとこのプロファイルを共有する予定がない場合はオンにしないでください。
|
||||||
|
*/
|
||||||
|
"autoSyncAreYouSure_description": string;
|
||||||
};
|
};
|
||||||
"_accountSettings": {
|
"_accountSettings": {
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user