Feat backups (#686)
* Scaffold backups * Add FFlags * fixup! Add FFlags * fixup! fixup! Add FFlags * fixup! fixup! fixup! Add FFlags
This commit is contained in:
parent
595252820e
commit
bf281b740d
42 changed files with 3756 additions and 59 deletions
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -28,6 +28,9 @@ jobs:
|
|||
|
||||
- name: Run backend tests
|
||||
run: npm run backend:test
|
||||
env:
|
||||
FF_ENABLE_BACKUPS: 'true'
|
||||
FF_ENABLE_CALENDAR: 'true'
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -23,6 +23,9 @@ backend/coverage/
|
|||
backend/uploads/
|
||||
uploads/
|
||||
|
||||
# Backup files
|
||||
backend/backups/
|
||||
|
||||
# Docker volumes
|
||||
tududi_db/
|
||||
|
||||
|
|
|
|||
|
|
@ -23,3 +23,6 @@ REGISTRATION_TOKEN_EXPIRY_HOURS=24
|
|||
|
||||
DISABLE_SCHEDULER=false
|
||||
DISABLE_TELEGRAM=false
|
||||
|
||||
FF_ENABLE_BACKUPS=true
|
||||
FF_ENABLE_CALENDAR=true
|
||||
|
|
|
|||
|
|
@ -2,3 +2,6 @@
|
|||
NODE_ENV=test
|
||||
TUDUDI_SESSION_SECRET=test-secret-key-for-testing
|
||||
TUDUDI_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
|
||||
|
||||
FF_ENABLE_BACKUPS=true
|
||||
FF_ENABLE_CALENDAR=true
|
||||
|
|
@ -167,6 +167,7 @@ healthPaths.forEach(registerHealthCheck);
|
|||
// Routes
|
||||
const registerApiRoutes = (basePath) => {
|
||||
app.use(basePath, require('./routes/auth'));
|
||||
app.use(basePath, require('./routes/feature-flags'));
|
||||
|
||||
app.use(basePath, requireAuth);
|
||||
app.use(basePath, require('./routes/tasks'));
|
||||
|
|
@ -183,6 +184,7 @@ const registerApiRoutes = (basePath) => {
|
|||
app.use(basePath, require('./routes/quotes'));
|
||||
app.use(basePath, require('./routes/task-events'));
|
||||
app.use(basePath, require('./routes/task-attachments'));
|
||||
app.use(basePath, require('./routes/backup'));
|
||||
app.use(`${basePath}/search`, require('./routes/search'));
|
||||
app.use(`${basePath}/views`, require('./routes/views'));
|
||||
app.use(`${basePath}/notifications`, require('./routes/notifications'));
|
||||
|
|
|
|||
67
backend/migrations/20251208000001-create-backups-table.js
Normal file
67
backend/migrations/20251208000001-create-backups-table.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
'use strict';
|
||||
|
||||
const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils');
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await safeCreateTable(queryInterface, 'backups', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
uid: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
onUpdate: 'CASCADE',
|
||||
},
|
||||
file_path: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
file_size: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
item_counts: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
},
|
||||
version: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '1.0',
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
||||
},
|
||||
});
|
||||
|
||||
await safeAddIndex(queryInterface, 'backups', ['user_id']);
|
||||
await safeAddIndex(queryInterface, 'backups', ['created_at']);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
const tables = await queryInterface.showAllTables();
|
||||
if (tables.includes('backups')) {
|
||||
await queryInterface.dropTable('backups');
|
||||
}
|
||||
},
|
||||
};
|
||||
62
backend/models/backup.js
Normal file
62
backend/models/backup.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const { uid } = require('../utils/uid');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Backup = sequelize.define(
|
||||
'Backup',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
uid: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: uid,
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
file_path: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: 'Path to the backup file on disk',
|
||||
},
|
||||
file_size: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: 'Size of backup file in bytes',
|
||||
},
|
||||
item_counts: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'JSON object with counts of backed up items',
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: '1.0',
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'backups',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id'],
|
||||
},
|
||||
{
|
||||
fields: ['created_at'],
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return Backup;
|
||||
};
|
||||
|
|
@ -36,6 +36,7 @@ const Setting = require('./setting')(sequelize);
|
|||
const Notification = require('./notification')(sequelize);
|
||||
const RecurringCompletion = require('./recurringCompletion')(sequelize);
|
||||
const TaskAttachment = require('./task_attachment')(sequelize);
|
||||
const Backup = require('./backup')(sequelize);
|
||||
|
||||
User.hasMany(Area, { foreignKey: 'user_id' });
|
||||
Area.belongsTo(User, { foreignKey: 'user_id' });
|
||||
|
|
@ -152,6 +153,10 @@ TaskAttachment.belongsTo(User, { foreignKey: 'user_id' });
|
|||
Task.hasMany(TaskAttachment, { foreignKey: 'task_id', as: 'Attachments' });
|
||||
TaskAttachment.belongsTo(Task, { foreignKey: 'task_id' });
|
||||
|
||||
// Backup associations
|
||||
User.hasMany(Backup, { foreignKey: 'user_id', as: 'Backups' });
|
||||
Backup.belongsTo(User, { foreignKey: 'user_id', as: 'User' });
|
||||
|
||||
module.exports = {
|
||||
sequelize,
|
||||
User,
|
||||
|
|
@ -171,4 +176,5 @@ module.exports = {
|
|||
Notification,
|
||||
RecurringCompletion,
|
||||
TaskAttachment,
|
||||
Backup,
|
||||
};
|
||||
|
|
|
|||
363
backend/routes/backup.js
Normal file
363
backend/routes/backup.js
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
const express = require('express');
|
||||
const { logError } = require('../services/logService');
|
||||
const {
|
||||
exportUserData,
|
||||
importUserData,
|
||||
validateBackupData,
|
||||
saveBackup,
|
||||
listBackups,
|
||||
getBackup,
|
||||
deleteBackup,
|
||||
getBackupsDirectory,
|
||||
checkVersionCompatibility,
|
||||
} = require('../services/backupService');
|
||||
const { Backup } = require('../models');
|
||||
const router = express.Router();
|
||||
const { getAuthenticatedUserId } = require('../utils/request-utils');
|
||||
const multer = require('multer');
|
||||
const zlib = require('zlib');
|
||||
const { promisify } = require('util');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
const gzip = promisify(zlib.gzip);
|
||||
|
||||
const checkBackupsEnabled = (req, res, next) => {
|
||||
const backupsEnabled = process.env.FF_ENABLE_BACKUPS === 'true';
|
||||
if (!backupsEnabled) {
|
||||
return res.status(403).json({
|
||||
error: 'Backups feature is disabled',
|
||||
message:
|
||||
'The backups feature is currently disabled. Please contact your administrator.',
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
router.use(checkBackupsEnabled);
|
||||
|
||||
async function parseUploadedBackup(fileBuffer, filename) {
|
||||
let backupJson;
|
||||
|
||||
const isGzipped =
|
||||
filename.toLowerCase().endsWith('.gz') ||
|
||||
(fileBuffer[0] === 0x1f && fileBuffer[1] === 0x8b);
|
||||
|
||||
if (isGzipped) {
|
||||
const decompressed = await gunzip(fileBuffer);
|
||||
backupJson = decompressed.toString('utf8');
|
||||
} else {
|
||||
backupJson = fileBuffer.toString('utf8');
|
||||
}
|
||||
|
||||
return JSON.parse(backupJson);
|
||||
}
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024,
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimes = [
|
||||
'application/json',
|
||||
'application/gzip',
|
||||
'application/x-gzip',
|
||||
];
|
||||
const fileExt = file.originalname.toLowerCase();
|
||||
|
||||
if (
|
||||
allowedMimes.includes(file.mimetype) ||
|
||||
fileExt.endsWith('.json') ||
|
||||
fileExt.endsWith('.gz')
|
||||
) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JSON and gzip files are allowed'), false);
|
||||
}
|
||||
},
|
||||
});
|
||||
router.post('/backup/export', async (req, res) => {
|
||||
try {
|
||||
const userId = getAuthenticatedUserId(req);
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const backupData = await exportUserData(userId);
|
||||
|
||||
const backup = await saveBackup(userId, backupData);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Backup created successfully',
|
||||
backup: {
|
||||
uid: backup.uid,
|
||||
file_size: backup.file_size,
|
||||
item_counts: backup.item_counts,
|
||||
created_at: backup.created_at,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Error exporting user data:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to export data',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/backup/import', upload.single('backup'), async (req, res) => {
|
||||
try {
|
||||
const userId = getAuthenticatedUserId(req);
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No backup file provided' });
|
||||
}
|
||||
|
||||
let backupData;
|
||||
try {
|
||||
backupData = await parseUploadedBackup(
|
||||
req.file.buffer,
|
||||
req.file.originalname
|
||||
);
|
||||
} catch (parseError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid backup file',
|
||||
message: parseError.message,
|
||||
});
|
||||
}
|
||||
|
||||
const validation = validateBackupData(backupData);
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid backup data',
|
||||
errors: validation.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const versionCheck = checkVersionCompatibility(backupData.version);
|
||||
if (!versionCheck.compatible) {
|
||||
return res.status(400).json({
|
||||
error: 'Version incompatible',
|
||||
message: versionCheck.message,
|
||||
backupVersion: backupData.version,
|
||||
});
|
||||
}
|
||||
|
||||
const options = {
|
||||
merge: req.body.merge !== 'false',
|
||||
};
|
||||
|
||||
const stats = await importUserData(userId, backupData, options);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Backup imported successfully',
|
||||
stats,
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Error importing user data:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to import data',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/backup/validate', upload.single('backup'), async (req, res) => {
|
||||
try {
|
||||
const userId = getAuthenticatedUserId(req);
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No backup file provided' });
|
||||
}
|
||||
|
||||
let backupData;
|
||||
try {
|
||||
backupData = await parseUploadedBackup(
|
||||
req.file.buffer,
|
||||
req.file.originalname
|
||||
);
|
||||
} catch (parseError) {
|
||||
return res.status(400).json({
|
||||
valid: false,
|
||||
error: 'Invalid backup file',
|
||||
message: parseError.message,
|
||||
});
|
||||
}
|
||||
|
||||
const validation = validateBackupData(backupData);
|
||||
|
||||
if (!validation.valid) {
|
||||
return res.status(400).json({
|
||||
valid: false,
|
||||
errors: validation.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const versionCheck = checkVersionCompatibility(backupData.version);
|
||||
if (!versionCheck.compatible) {
|
||||
return res.status(400).json({
|
||||
valid: false,
|
||||
versionIncompatible: true,
|
||||
message: versionCheck.message,
|
||||
backupVersion: backupData.version,
|
||||
});
|
||||
}
|
||||
|
||||
const summary = {
|
||||
areas: backupData.data.areas?.length || 0,
|
||||
projects: backupData.data.projects?.length || 0,
|
||||
tasks: backupData.data.tasks?.length || 0,
|
||||
tags: backupData.data.tags?.length || 0,
|
||||
notes: backupData.data.notes?.length || 0,
|
||||
inbox_items: backupData.data.inbox_items?.length || 0,
|
||||
views: backupData.data.views?.length || 0,
|
||||
};
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
message: 'Backup file is valid',
|
||||
version: backupData.version,
|
||||
exported_at: backupData.exported_at,
|
||||
summary,
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Error validating backup file:', error);
|
||||
res.status(500).json({
|
||||
valid: false,
|
||||
error: 'Failed to validate backup file',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/backup/list', async (req, res) => {
|
||||
try {
|
||||
const userId = getAuthenticatedUserId(req);
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const backups = await listBackups(userId, 5);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
backups,
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Error listing backups:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to list backups',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/backup/:uid/download', async (req, res) => {
|
||||
try {
|
||||
const userId = getAuthenticatedUserId(req);
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const backup = await Backup.findOne({
|
||||
where: { uid: req.params.uid, user_id: userId },
|
||||
});
|
||||
|
||||
if (!backup) {
|
||||
return res.status(404).json({ error: 'Backup not found' });
|
||||
}
|
||||
|
||||
const backupsDir = await getBackupsDirectory();
|
||||
const filePath = path.join(backupsDir, backup.file_path);
|
||||
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
const isCompressed = backup.file_path.endsWith('.gz');
|
||||
const filename = `tududi-backup-${new Date().toISOString().split('T')[0]}${isCompressed ? '.json.gz' : '.json'}`;
|
||||
const contentType = isCompressed
|
||||
? 'application/gzip'
|
||||
: 'application/json';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${filename}"`
|
||||
);
|
||||
res.setHeader('Content-Length', fileBuffer.length);
|
||||
|
||||
res.send(fileBuffer);
|
||||
} catch (error) {
|
||||
logError('Error downloading backup:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to download backup',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/backup/:uid/restore', async (req, res) => {
|
||||
try {
|
||||
const userId = getAuthenticatedUserId(req);
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const backupData = await getBackup(userId, req.params.uid);
|
||||
const versionCheck = checkVersionCompatibility(backupData.version);
|
||||
if (!versionCheck.compatible) {
|
||||
return res.status(400).json({
|
||||
error: 'Version incompatible',
|
||||
message: versionCheck.message,
|
||||
backupVersion: backupData.version,
|
||||
});
|
||||
}
|
||||
|
||||
const options = {
|
||||
merge: req.body.merge !== false,
|
||||
};
|
||||
|
||||
const stats = await importUserData(userId, backupData, options);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Backup restored successfully',
|
||||
stats,
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Error restoring backup:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to restore backup',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/backup/:uid', async (req, res) => {
|
||||
try {
|
||||
const userId = getAuthenticatedUserId(req);
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
await deleteBackup(userId, req.params.uid);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Backup deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Error deleting backup:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete backup',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
21
backend/routes/feature-flags.js
Normal file
21
backend/routes/feature-flags.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/feature-flags', (req, res) => {
|
||||
try {
|
||||
const featureFlags = {
|
||||
backups: process.env.FF_ENABLE_BACKUPS === 'true',
|
||||
calendar: process.env.FF_ENABLE_CALENDAR === 'true',
|
||||
};
|
||||
|
||||
res.json({ featureFlags });
|
||||
} catch (error) {
|
||||
console.error('Error fetching feature flags:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch feature flags',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
912
backend/services/backupService.js
Normal file
912
backend/services/backupService.js
Normal file
|
|
@ -0,0 +1,912 @@
|
|||
const {
|
||||
sequelize,
|
||||
User,
|
||||
Area,
|
||||
Project,
|
||||
Task,
|
||||
Tag,
|
||||
Note,
|
||||
InboxItem,
|
||||
TaskEvent,
|
||||
View,
|
||||
RecurringCompletion,
|
||||
TaskAttachment,
|
||||
Backup,
|
||||
} = require('../models');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
const { promisify } = require('util');
|
||||
const { getConfig } = require('../config/config');
|
||||
const config = getConfig();
|
||||
const packageJson = require('../../package.json');
|
||||
|
||||
// Promisify zlib functions
|
||||
const gzip = promisify(zlib.gzip);
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
|
||||
/**
|
||||
* Compare two semantic versions
|
||||
* @param {string} version1 - First version (e.g., "v0.88.0-dev.1")
|
||||
* @param {string} version2 - Second version
|
||||
* @returns {number} - Returns -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
|
||||
*/
|
||||
function compareVersions(version1, version2) {
|
||||
// Remove 'v' prefix if present
|
||||
const v1 = version1.replace(/^v/, '');
|
||||
const v2 = version2.replace(/^v/, '');
|
||||
|
||||
// Split into parts (major.minor.patch-prerelease)
|
||||
const parseVersion = (v) => {
|
||||
const [mainVersion, prerelease] = v.split('-');
|
||||
const [major, minor, patch] = mainVersion.split('.').map(Number);
|
||||
return { major, minor, patch, prerelease };
|
||||
};
|
||||
|
||||
const parsed1 = parseVersion(v1);
|
||||
const parsed2 = parseVersion(v2);
|
||||
|
||||
// Compare major, minor, patch
|
||||
if (parsed1.major !== parsed2.major) return parsed1.major - parsed2.major;
|
||||
if (parsed1.minor !== parsed2.minor) return parsed1.minor - parsed2.minor;
|
||||
if (parsed1.patch !== parsed2.patch) return parsed1.patch - parsed2.patch;
|
||||
|
||||
// If versions are equal so far, check prerelease
|
||||
// No prerelease is considered greater than prerelease
|
||||
if (!parsed1.prerelease && parsed2.prerelease) return 1;
|
||||
if (parsed1.prerelease && !parsed2.prerelease) return -1;
|
||||
if (parsed1.prerelease && parsed2.prerelease) {
|
||||
return parsed1.prerelease.localeCompare(parsed2.prerelease);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if backup version is compatible with current app version
|
||||
* @param {string} backupVersion - Version from backup file
|
||||
* @returns {object} - { compatible: boolean, message?: string }
|
||||
*/
|
||||
function checkVersionCompatibility(backupVersion) {
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
// If backup version is newer than current version, it's not compatible
|
||||
const comparison = compareVersions(backupVersion, currentVersion);
|
||||
|
||||
if (comparison > 0) {
|
||||
return {
|
||||
compatible: false,
|
||||
message: `Cannot restore backup from newer version ${backupVersion} to current version ${currentVersion}. Please upgrade your application first.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { compatible: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all data for a specific user
|
||||
* @param {number} userId - The user ID to export data for
|
||||
* @returns {Promise<object>} - The backup data as JSON
|
||||
*/
|
||||
async function exportUserData(userId) {
|
||||
try {
|
||||
// Fetch user with all preferences (exclude sensitive data)
|
||||
const user = await User.findByPk(userId, {
|
||||
attributes: {
|
||||
exclude: [
|
||||
'id',
|
||||
'password_digest',
|
||||
'email_verification_token',
|
||||
'email_verification_token_expires_at',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Fetch all user-owned entities
|
||||
const [
|
||||
areas,
|
||||
projects,
|
||||
tasks,
|
||||
tags,
|
||||
notes,
|
||||
inboxItems,
|
||||
taskEvents,
|
||||
views,
|
||||
] = await Promise.all([
|
||||
Area.findAll({ where: { user_id: userId } }),
|
||||
Project.findAll({
|
||||
where: { user_id: userId },
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
through: { attributes: [] },
|
||||
attributes: ['uid', 'name'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
Task.findAll({
|
||||
where: { user_id: userId },
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
through: { attributes: [] },
|
||||
attributes: ['uid', 'name'],
|
||||
},
|
||||
{
|
||||
model: RecurringCompletion,
|
||||
as: 'Completions',
|
||||
},
|
||||
{
|
||||
model: TaskAttachment,
|
||||
as: 'Attachments',
|
||||
},
|
||||
],
|
||||
}),
|
||||
Tag.findAll({ where: { user_id: userId } }),
|
||||
Note.findAll({
|
||||
where: { user_id: userId },
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
through: { attributes: [] },
|
||||
attributes: ['uid', 'name'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
InboxItem.findAll({ where: { user_id: userId } }),
|
||||
TaskEvent.findAll({ where: { user_id: userId } }),
|
||||
View.findAll({ where: { user_id: userId } }),
|
||||
]);
|
||||
|
||||
// Build the backup object
|
||||
const backup = {
|
||||
version: packageJson.version,
|
||||
exported_at: new Date().toISOString(),
|
||||
user: {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
surname: user.surname,
|
||||
appearance: user.appearance,
|
||||
language: user.language,
|
||||
timezone: user.timezone,
|
||||
first_day_of_week: user.first_day_of_week,
|
||||
avatar_image: user.avatar_image,
|
||||
telegram_bot_token: user.telegram_bot_token,
|
||||
telegram_chat_id: user.telegram_chat_id,
|
||||
telegram_allowed_users: user.telegram_allowed_users,
|
||||
task_summary_enabled: user.task_summary_enabled,
|
||||
task_summary_frequency: user.task_summary_frequency,
|
||||
task_intelligence_enabled: user.task_intelligence_enabled,
|
||||
auto_suggest_next_actions_enabled:
|
||||
user.auto_suggest_next_actions_enabled,
|
||||
pomodoro_enabled: user.pomodoro_enabled,
|
||||
productivity_assistant_enabled:
|
||||
user.productivity_assistant_enabled,
|
||||
next_task_suggestion_enabled: user.next_task_suggestion_enabled,
|
||||
today_settings: user.today_settings,
|
||||
sidebar_settings: user.sidebar_settings,
|
||||
ui_settings: user.ui_settings,
|
||||
notification_preferences: user.notification_preferences,
|
||||
},
|
||||
data: {
|
||||
areas: areas.map((area) => area.toJSON()),
|
||||
projects: projects.map((project) => {
|
||||
const projectData = project.toJSON();
|
||||
// Extract tag UIDs for relationship mapping
|
||||
projectData.tag_uids = (project.Tags || []).map(
|
||||
(tag) => tag.uid
|
||||
);
|
||||
delete projectData.Tags;
|
||||
return projectData;
|
||||
}),
|
||||
tasks: tasks.map((task) => {
|
||||
const taskData = task.toJSON();
|
||||
// Extract tag UIDs and related data
|
||||
taskData.tag_uids = (task.Tags || []).map((tag) => tag.uid);
|
||||
taskData.completions = taskData.Completions || [];
|
||||
taskData.attachments = taskData.Attachments || [];
|
||||
delete taskData.Tags;
|
||||
delete taskData.Completions;
|
||||
delete taskData.Attachments;
|
||||
return taskData;
|
||||
}),
|
||||
tags: tags.map((tag) => tag.toJSON()),
|
||||
notes: notes.map((note) => {
|
||||
const noteData = note.toJSON();
|
||||
noteData.tag_uids = (note.Tags || []).map((tag) => tag.uid);
|
||||
delete noteData.Tags;
|
||||
return noteData;
|
||||
}),
|
||||
inbox_items: inboxItems.map((item) => item.toJSON()),
|
||||
task_events: taskEvents.map((event) => event.toJSON()),
|
||||
views: views.map((view) => view.toJSON()),
|
||||
},
|
||||
};
|
||||
|
||||
return backup;
|
||||
} catch (error) {
|
||||
console.error('Error exporting user data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import and restore user data from a backup
|
||||
* @param {number} userId - The user ID to import data for
|
||||
* @param {object} backupData - The backup data to import
|
||||
* @param {object} options - Import options
|
||||
* @param {boolean} options.merge - If true, merge with existing data (default: true)
|
||||
* @returns {Promise<object>} - Import statistics
|
||||
*/
|
||||
async function importUserData(userId, backupData, options = { merge: true }) {
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
// Validate backup data structure
|
||||
if (!backupData.version || !backupData.data) {
|
||||
throw new Error('Invalid backup data format');
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const stats = {
|
||||
areas: { created: 0, skipped: 0 },
|
||||
projects: { created: 0, skipped: 0 },
|
||||
tasks: { created: 0, skipped: 0 },
|
||||
tags: { created: 0, skipped: 0 },
|
||||
notes: { created: 0, skipped: 0 },
|
||||
inbox_items: { created: 0, skipped: 0 },
|
||||
views: { created: 0, skipped: 0 },
|
||||
};
|
||||
|
||||
// Map to track old UIDs to new IDs for foreign key relationships
|
||||
const uidToIdMap = {
|
||||
areas: {},
|
||||
projects: {},
|
||||
tasks: {},
|
||||
tags: {},
|
||||
notes: {},
|
||||
};
|
||||
|
||||
// Import tags first (no dependencies)
|
||||
if (backupData.data.tags) {
|
||||
for (const tagData of backupData.data.tags) {
|
||||
const existingTag = await Tag.findOne({
|
||||
where: { uid: tagData.uid, user_id: userId },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (existingTag && options.merge) {
|
||||
stats.tags.skipped++;
|
||||
uidToIdMap.tags[tagData.uid] = existingTag.id;
|
||||
} else if (!existingTag) {
|
||||
const newTag = await Tag.create(
|
||||
{
|
||||
uid: tagData.uid,
|
||||
name: tagData.name,
|
||||
user_id: userId,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
stats.tags.created++;
|
||||
uidToIdMap.tags[tagData.uid] = newTag.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import areas (no dependencies except user)
|
||||
if (backupData.data.areas) {
|
||||
for (const areaData of backupData.data.areas) {
|
||||
const existingArea = await Area.findOne({
|
||||
where: { uid: areaData.uid, user_id: userId },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (existingArea && options.merge) {
|
||||
stats.areas.skipped++;
|
||||
uidToIdMap.areas[areaData.uid] = existingArea.id;
|
||||
} else if (!existingArea) {
|
||||
const newArea = await Area.create(
|
||||
{
|
||||
uid: areaData.uid,
|
||||
name: areaData.name,
|
||||
description: areaData.description,
|
||||
user_id: userId,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
stats.areas.created++;
|
||||
uidToIdMap.areas[areaData.uid] = newArea.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import projects (depends on areas)
|
||||
if (backupData.data.projects) {
|
||||
for (const projectData of backupData.data.projects) {
|
||||
const existingProject = await Project.findOne({
|
||||
where: { uid: projectData.uid, user_id: userId },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (existingProject && options.merge) {
|
||||
stats.projects.skipped++;
|
||||
uidToIdMap.projects[projectData.uid] = existingProject.id;
|
||||
} else if (!existingProject) {
|
||||
// Map area_id if it exists
|
||||
let areaId = null;
|
||||
if (projectData.area_id) {
|
||||
const area = await Area.findOne({
|
||||
where: { id: projectData.area_id },
|
||||
transaction,
|
||||
});
|
||||
areaId = area ? area.id : null;
|
||||
}
|
||||
|
||||
const newProject = await Project.create(
|
||||
{
|
||||
uid: projectData.uid,
|
||||
name: projectData.name,
|
||||
description: projectData.description,
|
||||
pin_to_sidebar: projectData.pin_to_sidebar,
|
||||
priority: projectData.priority,
|
||||
due_date_at: projectData.due_date_at,
|
||||
image_url: projectData.image_url,
|
||||
task_show_completed:
|
||||
projectData.task_show_completed,
|
||||
task_sort_order: projectData.task_sort_order,
|
||||
state: projectData.state,
|
||||
user_id: userId,
|
||||
area_id: areaId,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
stats.projects.created++;
|
||||
uidToIdMap.projects[projectData.uid] = newProject.id;
|
||||
|
||||
// Create project-tag relationships
|
||||
if (
|
||||
projectData.tag_uids &&
|
||||
projectData.tag_uids.length > 0
|
||||
) {
|
||||
const tagIds = projectData.tag_uids
|
||||
.map((uid) => uidToIdMap.tags[uid])
|
||||
.filter(Boolean);
|
||||
if (tagIds.length > 0) {
|
||||
await newProject.setTags(tagIds, { transaction });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import tasks (depends on projects, and self-referential)
|
||||
// First pass: create all tasks without parent/recurring relationships
|
||||
if (backupData.data.tasks) {
|
||||
for (const taskData of backupData.data.tasks) {
|
||||
const existingTask = await Task.findOne({
|
||||
where: { uid: taskData.uid, user_id: userId },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (existingTask && options.merge) {
|
||||
stats.tasks.skipped++;
|
||||
uidToIdMap.tasks[taskData.uid] = existingTask.id;
|
||||
} else if (!existingTask) {
|
||||
// Map project_id if it exists
|
||||
let projectId = null;
|
||||
if (taskData.project_id) {
|
||||
const project = await Project.findOne({
|
||||
where: { id: taskData.project_id },
|
||||
transaction,
|
||||
});
|
||||
projectId = project ? project.id : null;
|
||||
}
|
||||
|
||||
const newTask = await Task.create(
|
||||
{
|
||||
uid: taskData.uid,
|
||||
uuid: taskData.uuid,
|
||||
name: taskData.name,
|
||||
description: taskData.description,
|
||||
due_date: taskData.due_date,
|
||||
defer_until: taskData.defer_until,
|
||||
today: taskData.today,
|
||||
priority: taskData.priority,
|
||||
status: taskData.status,
|
||||
note: taskData.note,
|
||||
recurrence_type: taskData.recurrence_type,
|
||||
recurrence_interval: taskData.recurrence_interval,
|
||||
recurrence_end_date: taskData.recurrence_end_date,
|
||||
recurrence_weekday: taskData.recurrence_weekday,
|
||||
recurrence_weekdays: taskData.recurrence_weekdays,
|
||||
recurrence_month_day: taskData.recurrence_month_day,
|
||||
recurrence_week_of_month:
|
||||
taskData.recurrence_week_of_month,
|
||||
completion_based: taskData.completion_based,
|
||||
order: taskData.order,
|
||||
completed_at: taskData.completed_at,
|
||||
user_id: userId,
|
||||
project_id: projectId,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
stats.tasks.created++;
|
||||
uidToIdMap.tasks[taskData.uid] = newTask.id;
|
||||
|
||||
// Create task-tag relationships
|
||||
if (taskData.tag_uids && taskData.tag_uids.length > 0) {
|
||||
const tagIds = taskData.tag_uids
|
||||
.map((uid) => uidToIdMap.tags[uid])
|
||||
.filter(Boolean);
|
||||
if (tagIds.length > 0) {
|
||||
await newTask.setTags(tagIds, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
// Create recurring completions
|
||||
if (
|
||||
taskData.completions &&
|
||||
taskData.completions.length > 0
|
||||
) {
|
||||
for (const completion of taskData.completions) {
|
||||
await RecurringCompletion.create(
|
||||
{
|
||||
task_id: newTask.id,
|
||||
completion_date: completion.completion_date,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create task attachments
|
||||
if (
|
||||
taskData.attachments &&
|
||||
taskData.attachments.length > 0
|
||||
) {
|
||||
for (const attachment of taskData.attachments) {
|
||||
await TaskAttachment.create(
|
||||
{
|
||||
task_id: newTask.id,
|
||||
user_id: userId,
|
||||
file_name: attachment.file_name,
|
||||
file_url: attachment.file_url,
|
||||
file_size: attachment.file_size,
|
||||
file_type: attachment.file_type,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: update parent_task_id and recurring_parent_id
|
||||
for (const taskData of backupData.data.tasks) {
|
||||
if (taskData.parent_task_id || taskData.recurring_parent_id) {
|
||||
const task = await Task.findOne({
|
||||
where: { uid: taskData.uid, user_id: userId },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (task) {
|
||||
const updates = {};
|
||||
|
||||
if (taskData.parent_task_id) {
|
||||
const parentTask = await Task.findOne({
|
||||
where: {
|
||||
id: taskData.parent_task_id,
|
||||
user_id: userId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
if (parentTask) {
|
||||
updates.parent_task_id = parentTask.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (taskData.recurring_parent_id) {
|
||||
const recurringParent = await Task.findOne({
|
||||
where: {
|
||||
id: taskData.recurring_parent_id,
|
||||
user_id: userId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
if (recurringParent) {
|
||||
updates.recurring_parent_id =
|
||||
recurringParent.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await task.update(updates, { transaction });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import notes (depends on projects)
|
||||
if (backupData.data.notes) {
|
||||
for (const noteData of backupData.data.notes) {
|
||||
const existingNote = await Note.findOne({
|
||||
where: { uid: noteData.uid, user_id: userId },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (existingNote && options.merge) {
|
||||
stats.notes.skipped++;
|
||||
} else if (!existingNote) {
|
||||
// Map project_id if it exists
|
||||
let projectId = null;
|
||||
if (noteData.project_id) {
|
||||
const project = await Project.findOne({
|
||||
where: { id: noteData.project_id },
|
||||
transaction,
|
||||
});
|
||||
projectId = project ? project.id : null;
|
||||
}
|
||||
|
||||
const newNote = await Note.create(
|
||||
{
|
||||
uid: noteData.uid,
|
||||
title: noteData.title,
|
||||
content: noteData.content,
|
||||
color: noteData.color,
|
||||
user_id: userId,
|
||||
project_id: projectId,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
stats.notes.created++;
|
||||
|
||||
// Create note-tag relationships
|
||||
if (noteData.tag_uids && noteData.tag_uids.length > 0) {
|
||||
const tagIds = noteData.tag_uids
|
||||
.map((uid) => uidToIdMap.tags[uid])
|
||||
.filter(Boolean);
|
||||
if (tagIds.length > 0) {
|
||||
await newNote.setTags(tagIds, { transaction });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import inbox items
|
||||
if (backupData.data.inbox_items) {
|
||||
for (const inboxData of backupData.data.inbox_items) {
|
||||
const existingInbox = await InboxItem.findOne({
|
||||
where: { uid: inboxData.uid, user_id: userId },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (existingInbox && options.merge) {
|
||||
stats.inbox_items.skipped++;
|
||||
} else if (!existingInbox) {
|
||||
await InboxItem.create(
|
||||
{
|
||||
uid: inboxData.uid,
|
||||
name: inboxData.name,
|
||||
content: inboxData.content,
|
||||
status: inboxData.status,
|
||||
user_id: userId,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
stats.inbox_items.created++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import views
|
||||
if (backupData.data.views) {
|
||||
for (const viewData of backupData.data.views) {
|
||||
const existingView = await View.findOne({
|
||||
where: { uid: viewData.uid, user_id: userId },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (existingView && options.merge) {
|
||||
stats.views.skipped++;
|
||||
} else if (!existingView) {
|
||||
await View.create(
|
||||
{
|
||||
uid: viewData.uid,
|
||||
name: viewData.name,
|
||||
search_query: viewData.search_query,
|
||||
filters: viewData.filters,
|
||||
priority: viewData.priority,
|
||||
due: viewData.due,
|
||||
tags: viewData.tags,
|
||||
recurring: viewData.recurring,
|
||||
is_pinned: viewData.is_pinned,
|
||||
user_id: userId,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
stats.views.created++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return stats;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error importing user data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate backup data structure
|
||||
* @param {object} backupData - The backup data to validate
|
||||
* @returns {object} - Validation result with errors array
|
||||
*/
|
||||
function validateBackupData(backupData) {
|
||||
const errors = [];
|
||||
|
||||
if (!backupData) {
|
||||
errors.push('Backup data is empty');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
if (!backupData.version) {
|
||||
errors.push('Missing version field');
|
||||
}
|
||||
|
||||
if (!backupData.data) {
|
||||
errors.push('Missing data field');
|
||||
}
|
||||
|
||||
// Check data structure
|
||||
const requiredFields = ['areas', 'projects', 'tasks', 'tags', 'notes'];
|
||||
for (const field of requiredFields) {
|
||||
if (backupData.data && !Array.isArray(backupData.data[field])) {
|
||||
errors.push(`Invalid or missing data.${field} array`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backups directory path and ensure it exists
|
||||
* @returns {Promise<string>} - Path to backups directory
|
||||
*/
|
||||
async function getBackupsDirectory() {
|
||||
const backupsDir = path.join(__dirname, '../backups');
|
||||
try {
|
||||
await fs.access(backupsDir);
|
||||
} catch {
|
||||
await fs.mkdir(backupsDir, { recursive: true });
|
||||
}
|
||||
return backupsDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save backup to disk and create database record
|
||||
* @param {number} userId - The user ID
|
||||
* @param {object} backupData - The backup data
|
||||
* @returns {Promise<object>} - The created Backup record
|
||||
*/
|
||||
async function saveBackup(userId, backupData) {
|
||||
try {
|
||||
const backupsDir = await getBackupsDirectory();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const fileName = `backup-user-${userId}-${timestamp}.json.gz`;
|
||||
const filePath = path.join(backupsDir, fileName);
|
||||
|
||||
// Convert backup to JSON string
|
||||
const backupJson = JSON.stringify(backupData, null, 2);
|
||||
|
||||
// Compress using gzip
|
||||
const compressed = await gzip(backupJson);
|
||||
|
||||
// Write compressed backup to file
|
||||
await fs.writeFile(filePath, compressed);
|
||||
|
||||
// Get file stats
|
||||
const stats = await fs.stat(filePath);
|
||||
|
||||
// Count items in backup
|
||||
const itemCounts = {
|
||||
areas: backupData.data.areas?.length || 0,
|
||||
projects: backupData.data.projects?.length || 0,
|
||||
tasks: backupData.data.tasks?.length || 0,
|
||||
tags: backupData.data.tags?.length || 0,
|
||||
notes: backupData.data.notes?.length || 0,
|
||||
inbox_items: backupData.data.inbox_items?.length || 0,
|
||||
views: backupData.data.views?.length || 0,
|
||||
};
|
||||
|
||||
// Create database record
|
||||
const backup = await Backup.create({
|
||||
user_id: userId,
|
||||
file_path: fileName, // Store relative path
|
||||
file_size: stats.size, // Compressed size
|
||||
item_counts: itemCounts,
|
||||
version: backupData.version,
|
||||
});
|
||||
|
||||
// Keep only last 5 backups for this user
|
||||
await cleanOldBackups(userId);
|
||||
|
||||
return backup;
|
||||
} catch (error) {
|
||||
console.error('Error saving backup:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean old backups, keeping only the last 5 for a user
|
||||
* @param {number} userId - The user ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function cleanOldBackups(userId) {
|
||||
try {
|
||||
// Get all backups for user, ordered by creation date
|
||||
const backups = await Backup.findAll({
|
||||
where: { user_id: userId },
|
||||
order: [['created_at', 'DESC']],
|
||||
});
|
||||
|
||||
// If more than 5, delete the oldest ones
|
||||
if (backups.length > 5) {
|
||||
const backupsToDelete = backups.slice(5);
|
||||
const backupsDir = await getBackupsDirectory();
|
||||
|
||||
for (const backup of backupsToDelete) {
|
||||
// Delete file from disk
|
||||
const filePath = path.join(backupsDir, backup.file_path);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to delete backup file: ${filePath}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
// Delete database record
|
||||
await backup.destroy();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning old backups:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List saved backups for a user
|
||||
* @param {number} userId - The user ID
|
||||
* @param {number} limit - Maximum number of backups to return (default: 5)
|
||||
* @returns {Promise<Array>} - Array of backup records
|
||||
*/
|
||||
async function listBackups(userId, limit = 5) {
|
||||
try {
|
||||
const backups = await Backup.findAll({
|
||||
where: { user_id: userId },
|
||||
order: [['created_at', 'DESC']],
|
||||
limit,
|
||||
attributes: [
|
||||
'id',
|
||||
'uid',
|
||||
'file_path',
|
||||
'file_size',
|
||||
'item_counts',
|
||||
'version',
|
||||
'created_at',
|
||||
],
|
||||
});
|
||||
|
||||
return backups;
|
||||
} catch (error) {
|
||||
console.error('Error listing backups:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific backup by UID
|
||||
* @param {number} userId - The user ID
|
||||
* @param {string} backupUid - The backup UID
|
||||
* @returns {Promise<object>} - The backup data
|
||||
*/
|
||||
async function getBackup(userId, backupUid) {
|
||||
try {
|
||||
const backup = await Backup.findOne({
|
||||
where: { uid: backupUid, user_id: userId },
|
||||
});
|
||||
|
||||
if (!backup) {
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
|
||||
const backupsDir = await getBackupsDirectory();
|
||||
const filePath = path.join(backupsDir, backup.file_path);
|
||||
|
||||
// Read backup file
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
|
||||
// Check if file is compressed (ends with .gz)
|
||||
let backupJson;
|
||||
if (backup.file_path.endsWith('.gz')) {
|
||||
// Decompress gzip
|
||||
const decompressed = await gunzip(fileBuffer);
|
||||
backupJson = decompressed.toString('utf8');
|
||||
} else {
|
||||
// Legacy uncompressed backup
|
||||
backupJson = fileBuffer.toString('utf8');
|
||||
}
|
||||
|
||||
const backupData = JSON.parse(backupJson);
|
||||
|
||||
return backupData;
|
||||
} catch (error) {
|
||||
console.error('Error getting backup:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific backup
|
||||
* @param {number} userId - The user ID
|
||||
* @param {string} backupUid - The backup UID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function deleteBackup(userId, backupUid) {
|
||||
try {
|
||||
const backup = await Backup.findOne({
|
||||
where: { uid: backupUid, user_id: userId },
|
||||
});
|
||||
|
||||
if (!backup) {
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
|
||||
const backupsDir = await getBackupsDirectory();
|
||||
const filePath = path.join(backupsDir, backup.file_path);
|
||||
|
||||
// Delete file from disk
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete backup file: ${filePath}`, err);
|
||||
}
|
||||
|
||||
// Delete database record
|
||||
await backup.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error deleting backup:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
exportUserData,
|
||||
importUserData,
|
||||
validateBackupData,
|
||||
saveBackup,
|
||||
listBackups,
|
||||
getBackup,
|
||||
deleteBackup,
|
||||
getBackupsDirectory,
|
||||
checkVersionCompatibility,
|
||||
};
|
||||
|
|
@ -17,6 +17,7 @@ import NoteDetails from './components/Note/NoteDetails';
|
|||
import Calendar from './components/Calendar';
|
||||
import ProfileSettings from './components/Profile/ProfileSettings';
|
||||
import About from './components/About';
|
||||
import BackupRestore from './components/Backup/BackupRestore';
|
||||
import Layout from './Layout';
|
||||
import { User } from './entities/User';
|
||||
import TasksToday from './components/Task/TasksToday';
|
||||
|
|
@ -260,6 +261,10 @@ const App: React.FC = () => {
|
|||
path="/about"
|
||||
element={<About isDarkMode={isDarkMode} />}
|
||||
/>
|
||||
<Route
|
||||
path="/backup"
|
||||
element={<BackupRestore />}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
|
|
|
|||
688
frontend/components/Backup/BackupRestore.tsx
Normal file
688
frontend/components/Backup/BackupRestore.tsx
Normal file
|
|
@ -0,0 +1,688 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import {
|
||||
createBackup,
|
||||
listSavedBackups,
|
||||
downloadSavedBackup,
|
||||
restoreSavedBackup,
|
||||
deleteSavedBackup,
|
||||
importBackup,
|
||||
validateBackup,
|
||||
ValidationResult,
|
||||
SavedBackup,
|
||||
} from '../../utils/backupService';
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowUpTrayIcon,
|
||||
DocumentCheckIcon,
|
||||
TrashIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface BackupRestoreProps {
|
||||
onImportSuccess?: () => void;
|
||||
}
|
||||
|
||||
type TabType = 'export' | 'import';
|
||||
|
||||
interface ConfirmDialogState {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
const BackupRestore: React.FC<BackupRestoreProps> = ({ onImportSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('export');
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isLoadingBackups, setIsLoadingBackups] = useState(false);
|
||||
const [savedBackups, setSavedBackups] = useState<SavedBackup[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [validationResult, setValidationResult] =
|
||||
useState<ValidationResult | null>(null);
|
||||
const [appVersion, setAppVersion] = useState<string>('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
// Load saved backups and app version on mount
|
||||
useEffect(() => {
|
||||
loadBackups();
|
||||
fetchAppVersion();
|
||||
}, []);
|
||||
|
||||
const fetchAppVersion = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/version', {
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await response.json();
|
||||
setAppVersion(data.version);
|
||||
} catch (error) {
|
||||
console.error('Error fetching app version:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadBackups = async () => {
|
||||
setIsLoadingBackups(true);
|
||||
try {
|
||||
const backups = await listSavedBackups();
|
||||
setSavedBackups(backups);
|
||||
} catch (error) {
|
||||
console.error('Error loading backups:', error);
|
||||
} finally {
|
||||
setIsLoadingBackups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
await createBackup();
|
||||
showSuccessToast(
|
||||
t('backup.exportSuccess', 'Backup created successfully!')
|
||||
);
|
||||
// Reload the backup list
|
||||
await loadBackups();
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
showErrorToast(
|
||||
t('backup.exportError', 'Failed to create backup')
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadBackup = async (backupUid: string) => {
|
||||
try {
|
||||
await downloadSavedBackup(backupUid);
|
||||
showSuccessToast(
|
||||
t('backup.downloadSuccess', 'Backup downloaded successfully!')
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
showErrorToast(
|
||||
t('backup.downloadError', 'Failed to download backup')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreBackup = (backupUid: string) => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: t('backup.confirmRestore', 'Restore Backup'),
|
||||
message: t(
|
||||
'backup.confirmRestoreMessage',
|
||||
'Are you sure you want to restore this backup? This will merge the backed up data with your current data.'
|
||||
),
|
||||
onConfirm: async () => {
|
||||
setConfirmDialog({ ...confirmDialog, isOpen: false });
|
||||
try {
|
||||
const result = await restoreSavedBackup(backupUid, true);
|
||||
showSuccessToast(
|
||||
t('backup.restoreSuccess', {
|
||||
tasks: result.stats.tasks.created,
|
||||
projects: result.stats.projects.created,
|
||||
notes: result.stats.notes.created,
|
||||
})
|
||||
);
|
||||
if (onImportSuccess) {
|
||||
onImportSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Restore error:', error);
|
||||
showErrorToast(
|
||||
t('backup.restoreError', 'Failed to restore backup')
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteBackup = (backupUid: string) => {
|
||||
setConfirmDialog({
|
||||
isOpen: true,
|
||||
title: t('backup.confirmDelete', 'Delete Backup'),
|
||||
message: t(
|
||||
'backup.confirmDeleteMessage',
|
||||
'Are you sure you want to delete this backup? This action cannot be undone.'
|
||||
),
|
||||
onConfirm: async () => {
|
||||
setConfirmDialog({ ...confirmDialog, isOpen: false });
|
||||
try {
|
||||
await deleteSavedBackup(backupUid);
|
||||
showSuccessToast(
|
||||
t('backup.deleteSuccess', 'Backup deleted successfully!')
|
||||
);
|
||||
// Reload the backup list
|
||||
await loadBackups();
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
showErrorToast(
|
||||
t('backup.deleteError', 'Failed to delete backup')
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileSelect = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setSelectedFile(file);
|
||||
setValidationResult(null);
|
||||
|
||||
// Auto-validate on file selection
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const result = await validateBackup(file);
|
||||
setValidationResult(result);
|
||||
if (!result.valid) {
|
||||
showErrorToast(
|
||||
t(
|
||||
'backup.validationError',
|
||||
'The selected file is not a valid backup'
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
showErrorToast(
|
||||
t('backup.validationError', 'Failed to validate backup file')
|
||||
);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedFile || !validationResult?.valid) return;
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const result = await importBackup(selectedFile, true);
|
||||
showSuccessToast(
|
||||
t('backup.importSuccess', {
|
||||
tasks: result.stats.tasks.created,
|
||||
projects: result.stats.projects.created,
|
||||
notes: result.stats.notes.created,
|
||||
})
|
||||
);
|
||||
// Reset file selection
|
||||
setSelectedFile(null);
|
||||
setValidationResult(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
if (onImportSuccess) {
|
||||
onImportSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
showErrorToast(
|
||||
t('backup.importError', 'Failed to import backup')
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDialog.isOpen && (
|
||||
<ConfirmDialog
|
||||
title={confirmDialog.title}
|
||||
message={confirmDialog.message}
|
||||
onConfirm={confirmDialog.onConfirm}
|
||||
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
|
||||
/>
|
||||
)}
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('backup.title', 'Backup & Restore')}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t(
|
||||
'backup.description',
|
||||
'Create backups or restore from previous backups. Your last 5 backups are automatically saved.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('export')}
|
||||
className={`flex-1 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'export'
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||
<span>{t('backup.createBackup', 'Create Backup')}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('import')}
|
||||
className={`flex-1 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'import'
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<ArrowUpTrayIcon className="h-5 w-5" />
|
||||
<span>{t('backup.importFromFile', 'Import from File')}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 sm:p-8">
|
||||
{activeTab === 'export' ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('backup.createNewBackup', 'Create New Backup')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t(
|
||||
'backup.createDescription',
|
||||
'Create a new backup of all your data. Backups are saved on the server and you can restore them later.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={isExporting}
|
||||
className="w-full flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed text-base font-medium"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('backup.creating', 'Creating backup...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowDownTrayIcon className="h-5 w-5 mr-2" />
|
||||
{t('backup.createBackupNow', 'Create Backup Now')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Saved Backups Table */}
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{t('backup.savedBackups', 'Saved Backups')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={loadBackups}
|
||||
disabled={isLoadingBackups}
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center"
|
||||
>
|
||||
<ArrowPathIcon className={`h-4 w-4 mr-1 ${isLoadingBackups ? 'animate-spin' : ''}`} />
|
||||
{t('common.refresh', 'Refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoadingBackups ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading', 'Loading...')}
|
||||
</div>
|
||||
) : savedBackups.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t('backup.noBackups', 'No backups found. Create your first backup above.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t('backup.createdAt', 'Created')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t('backup.version', 'Version')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t('backup.size', 'Size')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t('backup.contents', 'Contents')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t('backup.actions', 'Actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{savedBackups.map((backup) => (
|
||||
<tr key={backup.uid} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{formatDate(backup.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{backup.version}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatFileSize(backup.file_size)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{backup.item_counts.tasks} tasks
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
{backup.item_counts.projects} projects
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||
{backup.item_counts.notes} notes
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => handleRestoreBackup(backup.uid)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title={t('backup.restore', 'Restore')}
|
||||
>
|
||||
<ArrowPathIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownloadBackup(backup.uid)}
|
||||
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
||||
title={t('backup.download', 'Download')}
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBackup(backup.uid)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title={t('backup.delete', 'Delete')}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
{t('backup.importTitle', 'Import from File')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t(
|
||||
'backup.importDescription',
|
||||
'Upload a backup file to restore your data. Your existing data will be preserved, and new items from the backup will be added.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
|
||||
<h4 className="text-sm font-medium text-yellow-900 dark:text-yellow-200 mb-2">
|
||||
{t('backup.importNote', 'Important:')}
|
||||
</h4>
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300">
|
||||
{t(
|
||||
'backup.importNoteDescription',
|
||||
'Import will merge data with your existing items. Duplicate items (same UID) will be skipped.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json,.gz,.json.gz,application/gzip"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full flex items-center justify-center px-6 py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:border-blue-500 dark:hover:border-blue-400 hover:text-blue-600 dark:hover:text-blue-400 transition duration-150 ease-in-out"
|
||||
>
|
||||
<div className="text-center">
|
||||
<ArrowUpTrayIcon className="h-12 w-12 mx-auto mb-2" />
|
||||
<p className="text-base font-medium">
|
||||
{t('backup.selectFile', 'Select Backup File')}
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
{t('backup.clickToUpload', 'Click to browse files')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 border border-gray-200 dark:border-gray-600">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-base font-medium text-gray-900 dark:text-white">
|
||||
{selectedFile.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{(selectedFile.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
</div>
|
||||
{isValidating && (
|
||||
<svg
|
||||
className="animate-spin h-6 w-6 text-blue-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
)}
|
||||
{validationResult?.valid && (
|
||||
<DocumentCheckIcon className="h-6 w-6 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{validationResult?.valid && validationResult.summary && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t('backup.backupContents', 'Backup contents:')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.tasks} tasks
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.projects} projects
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.notes} notes
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.tags} tags
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.areas} areas
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-4 w-4 mr-2 text-green-600" />
|
||||
{validationResult.summary.views} views
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult && !validationResult.valid && (
|
||||
<div className="mt-4 pt-4 border-t border-red-200 dark:border-red-800">
|
||||
{validationResult.versionIncompatible ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
|
||||
{t('backup.versionIncompatible', 'Version Incompatible')}
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{validationResult.message}
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 mt-2">
|
||||
{t('backup.backupVersion', 'Backup version')}: {validationResult.backupVersion}
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t('backup.currentVersion', 'Current version')}: {appVersion}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300 mb-2">
|
||||
{t('backup.validationErrors', 'Validation errors:')}
|
||||
</p>
|
||||
<ul className="text-sm text-red-600 dark:text-red-400 space-y-1">
|
||||
{validationResult.errors?.map((error, index) => (
|
||||
<li key={index}>• {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFile && validationResult?.valid && (
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting}
|
||||
className="w-full flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed text-base font-medium"
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('backup.importing', 'Importing...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUpTrayIcon className="h-5 w-5 mr-2" />
|
||||
{t('backup.restoreBackup', 'Restore Backup')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CheckIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default BackupRestore;
|
||||
|
|
@ -12,6 +12,7 @@ import PomodoroTimer from './Shared/PomodoroTimer';
|
|||
import UniversalSearch from './UniversalSearch/UniversalSearch';
|
||||
import NotificationsDropdown from './Notifications/NotificationsDropdown';
|
||||
import { getApiPath } from '../config/paths';
|
||||
import { getFeatureFlags, FeatureFlags } from '../utils/featureFlags';
|
||||
|
||||
interface NavbarProps {
|
||||
isDarkMode: boolean;
|
||||
|
|
@ -37,6 +38,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
const [pomodoroEnabled, setPomodoroEnabled] = useState(true); // Default to true
|
||||
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>({ backups: false, calendar: false });
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -80,7 +82,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Fetch user's pomodoro setting
|
||||
// Fetch user's pomodoro setting and feature flags
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
|
|
@ -101,7 +103,13 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const fetchFlags = async () => {
|
||||
const flags = await getFeatureFlags();
|
||||
setFeatureFlags(flags);
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
fetchFlags();
|
||||
|
||||
// Listen for Pomodoro setting changes from ProfileSettings
|
||||
const handlePomodoroSettingChange = (event: CustomEvent) => {
|
||||
|
|
@ -250,6 +258,15 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
'Profile Settings'
|
||||
)}
|
||||
</Link>
|
||||
{featureFlags.backups && (
|
||||
<Link
|
||||
to="/backup"
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
{t('navigation.backupRestore', 'Backup & Restore')}
|
||||
</Link>
|
||||
)}
|
||||
{currentUser?.is_admin === true && (
|
||||
<Link
|
||||
to="/admin/users"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Location } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
|
|
@ -6,10 +6,12 @@ import {
|
|||
InboxIcon,
|
||||
ListBulletIcon,
|
||||
ClockIcon,
|
||||
CalendarIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { PlusCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { loadInboxItemsToStore } from '../../utils/inboxService';
|
||||
import { getFeatureFlags, FeatureFlags } from '../../utils/featureFlags';
|
||||
|
||||
interface SidebarNavProps {
|
||||
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
|
||||
|
|
@ -25,14 +27,21 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const store = useStore();
|
||||
const [featureFlags, setFeatureFlags] = useState<FeatureFlags>({ backups: false, calendar: false });
|
||||
|
||||
const inboxItemsCount = store.inboxStore.pagination.total;
|
||||
|
||||
useEffect(() => {
|
||||
loadInboxItemsToStore(false).catch(console.error);
|
||||
|
||||
const fetchFlags = async () => {
|
||||
const flags = await getFeatureFlags();
|
||||
setFeatureFlags(flags);
|
||||
};
|
||||
fetchFlags();
|
||||
}, []);
|
||||
|
||||
const navLinks = [
|
||||
const allNavLinks = [
|
||||
{
|
||||
path: '/inbox',
|
||||
title: t('sidebar.inbox', 'Inbox'),
|
||||
|
|
@ -49,6 +58,12 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
title: t('sidebar.upcoming', 'Upcoming'),
|
||||
icon: <ClockIcon className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
title: t('sidebar.calendar', 'Calendar'),
|
||||
icon: <CalendarIcon className="h-5 w-5" />,
|
||||
featureFlag: 'calendar',
|
||||
},
|
||||
{
|
||||
path: '/tasks?status=active',
|
||||
title: t('sidebar.allTasks', 'All Tasks'),
|
||||
|
|
@ -57,8 +72,15 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
},
|
||||
];
|
||||
|
||||
const navLinks = allNavLinks.filter(link => {
|
||||
if (link.featureFlag) {
|
||||
return featureFlags[link.featureFlag as keyof FeatureFlags];
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const isActive = (path: string, query?: string) => {
|
||||
if (path === '/inbox' || path === '/today') {
|
||||
if (path === '/inbox' || path === '/today' || path === '/calendar') {
|
||||
const isPathMatch = location.pathname === path;
|
||||
return isPathMatch
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
|
|
|
|||
247
frontend/utils/backupService.ts
Normal file
247
frontend/utils/backupService.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import { handleAuthResponse } from './authUtils';
|
||||
import { getApiPath } from '../config/paths';
|
||||
|
||||
export interface BackupData {
|
||||
version: string;
|
||||
exported_at: string;
|
||||
user: {
|
||||
uid: string;
|
||||
email: string;
|
||||
name: string;
|
||||
surname: string;
|
||||
appearance: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
first_day_of_week: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
data: {
|
||||
areas: any[];
|
||||
projects: any[];
|
||||
tasks: any[];
|
||||
tags: any[];
|
||||
notes: any[];
|
||||
inbox_items: any[];
|
||||
views: any[];
|
||||
task_events?: any[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportStats {
|
||||
areas: { created: number; skipped: number };
|
||||
projects: { created: number; skipped: number };
|
||||
tasks: { created: number; skipped: number };
|
||||
tags: { created: number; skipped: number };
|
||||
notes: { created: number; skipped: number };
|
||||
inbox_items: { created: number; skipped: number };
|
||||
views: { created: number; skipped: number };
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
stats: ImportStats;
|
||||
}
|
||||
|
||||
export interface SavedBackup {
|
||||
id: number;
|
||||
uid: string;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
item_counts: {
|
||||
areas: number;
|
||||
projects: number;
|
||||
tasks: number;
|
||||
tags: number;
|
||||
notes: number;
|
||||
inbox_items: number;
|
||||
views: number;
|
||||
};
|
||||
version: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BackupListResult {
|
||||
success: boolean;
|
||||
backups: SavedBackup[];
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
version?: string;
|
||||
exported_at?: string;
|
||||
summary?: {
|
||||
areas: number;
|
||||
projects: number;
|
||||
tasks: number;
|
||||
tags: number;
|
||||
notes: number;
|
||||
inbox_items: number;
|
||||
views: number;
|
||||
};
|
||||
errors?: string[];
|
||||
versionIncompatible?: boolean;
|
||||
backupVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new backup on the server
|
||||
*/
|
||||
export const createBackup = async (): Promise<SavedBackup> => {
|
||||
const response = await fetch(getApiPath('backup/export'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to create backup.');
|
||||
const result = await response.json();
|
||||
return result.backup;
|
||||
};
|
||||
|
||||
/**
|
||||
* List all saved backups
|
||||
*/
|
||||
export const listSavedBackups = async (): Promise<SavedBackup[]> => {
|
||||
const response = await fetch(getApiPath('backup/list'), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to list backups.');
|
||||
const result: BackupListResult = await response.json();
|
||||
return result.backups;
|
||||
};
|
||||
|
||||
/**
|
||||
* Download a saved backup as a compressed file
|
||||
*/
|
||||
export const downloadSavedBackup = async (backupUid: string): Promise<void> => {
|
||||
const response = await fetch(getApiPath(`backup/${backupUid}/download`), {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/gzip, application/json',
|
||||
},
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to download backup.');
|
||||
|
||||
// Get the blob (compressed file)
|
||||
const blob = await response.blob();
|
||||
|
||||
// Get filename from Content-Disposition header or use default
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = `tududi-backup-${new Date().toISOString().split('T')[0]}.json.gz`;
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Create a download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
|
||||
// Trigger the download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Restore from a saved backup
|
||||
*/
|
||||
export const restoreSavedBackup = async (
|
||||
backupUid: string,
|
||||
merge: boolean = true
|
||||
): Promise<ImportResult> => {
|
||||
const response = await fetch(getApiPath(`backup/${backupUid}/restore`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ merge }),
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to restore backup.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a saved backup
|
||||
*/
|
||||
export const deleteSavedBackup = async (backupUid: string): Promise<void> => {
|
||||
const response = await fetch(getApiPath(`backup/${backupUid}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to delete backup.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Import backup data from a file
|
||||
*/
|
||||
export const importBackup = async (
|
||||
file: File,
|
||||
merge: boolean = true
|
||||
): Promise<ImportResult> => {
|
||||
const formData = new FormData();
|
||||
formData.append('backup', file);
|
||||
formData.append('merge', merge.toString());
|
||||
|
||||
const response = await fetch(getApiPath('backup/import'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to import backup.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate backup file without importing
|
||||
*/
|
||||
export const validateBackup = async (
|
||||
file: File
|
||||
): Promise<ValidationResult> => {
|
||||
const formData = new FormData();
|
||||
formData.append('backup', file);
|
||||
|
||||
const response = await fetch(getApiPath('backup/validate'), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
return {
|
||||
valid: false,
|
||||
message: error.error || 'Validation failed',
|
||||
errors: error.errors || [error.message],
|
||||
};
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
42
frontend/utils/featureFlags.ts
Normal file
42
frontend/utils/featureFlags.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { getApiPath } from '../config/paths';
|
||||
|
||||
export interface FeatureFlags {
|
||||
backups: boolean;
|
||||
calendar: boolean;
|
||||
}
|
||||
|
||||
let cachedFeatureFlags: FeatureFlags | null = null;
|
||||
|
||||
export const getFeatureFlags = async (): Promise<FeatureFlags> => {
|
||||
if (cachedFeatureFlags) {
|
||||
return cachedFeatureFlags;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(getApiPath('feature-flags'), {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch feature flags');
|
||||
return {
|
||||
backups: false,
|
||||
calendar: false,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
cachedFeatureFlags = data.featureFlags;
|
||||
return cachedFeatureFlags;
|
||||
} catch (error) {
|
||||
console.error('Error fetching feature flags:', error);
|
||||
return {
|
||||
backups: false,
|
||||
calendar: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const clearFeatureFlagsCache = () => {
|
||||
cachedFeatureFlags = null;
|
||||
};
|
||||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "لديك تغييرات غير محفوظة. هل أنت متأكد أنك تريد إلغاءها؟",
|
||||
"no": "لا، استمر في التحرير",
|
||||
"yesDiscard": "نعم، ألغِ",
|
||||
"uploading": "جارٍ التحميل..."
|
||||
"uploading": "جارٍ التحميل...",
|
||||
"refresh": "تحديث"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "لوحة التحكم",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "إعدادات الملف الشخصي",
|
||||
"settings": "الإعدادات",
|
||||
"about": "حول",
|
||||
"logout": "تسجيل الخروج"
|
||||
"logout": "تسجيل الخروج",
|
||||
"backupRestore": "النسخ الاحتياطي والاستعادة"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "إعدادات صفحة اليوم",
|
||||
|
|
@ -366,7 +368,8 @@
|
|||
"stuckProjectsDesc": "مشاريع لم يتم تحديثها مؤخرًا",
|
||||
"reviewItems": "انقر لمراجعة وتحسين سير العمل الخاص بك",
|
||||
"suggestion": "انقر على أي عنصر أعلاه لفتحه وإجراء تحسينات.",
|
||||
"issuesFound_other": "{{count}} مشكلة في الإنتاجية تحتاج إلى اهتمام"
|
||||
"issuesFound_other": "{{count}} مشكلة في الإنتاجية تحتاج إلى اهتمام",
|
||||
"issuesFound_one": "مشكلة إنتاجية واحدة تحتاج إلى اهتمام"
|
||||
},
|
||||
"nextTask": {
|
||||
"suggestion": "نظرًا لعدم وجود أي شيء قيد التنفيذ، ماذا عن البدء بـ",
|
||||
|
|
@ -1207,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "أضف مهمة فرعية..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "النسخ الاحتياطي والاستعادة",
|
||||
"description": "إنشاء نسخ احتياطية أو الاستعادة من النسخ الاحتياطية السابقة. يتم حفظ آخر 5 نسخ احتياطية تلقائيًا.",
|
||||
"createBackup": "إنشاء نسخة احتياطية",
|
||||
"importFromFile": "استيراد من ملف",
|
||||
"createNewBackup": "إنشاء نسخة احتياطية جديدة",
|
||||
"createDescription": "إنشاء نسخة احتياطية جديدة من جميع بياناتك. يتم حفظ النسخ الاحتياطية على الخادم ويمكنك استعادتها لاحقًا.",
|
||||
"createBackupNow": "إنشاء نسخة احتياطية الآن",
|
||||
"creating": "جارٍ إنشاء النسخة الاحتياطية...",
|
||||
"exportSuccess": "تم إنشاء النسخة الاحتياطية بنجاح!",
|
||||
"exportError": "فشل في إنشاء النسخة الاحتياطية",
|
||||
"savedBackups": "النسخ الاحتياطية المحفوظة",
|
||||
"noBackups": "لم يتم العثور على نسخ احتياطية. أنشئ نسختك الاحتياطية الأولى أعلاه.",
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"version": "الإصدار",
|
||||
"currentVersion": "الإصدار الحالي",
|
||||
"size": "الحجم",
|
||||
"contents": "المحتويات",
|
||||
"actions": "الإجراءات",
|
||||
"restore": "استعادة",
|
||||
"download": "تنزيل",
|
||||
"downloadSuccess": "تم تنزيل النسخة الاحتياطية بنجاح!",
|
||||
"downloadError": "فشل في تنزيل النسخة الاحتياطية",
|
||||
"confirmRestore": "استعادة النسخة الاحتياطية",
|
||||
"confirmRestoreMessage": "هل أنت متأكد أنك تريد استعادة هذه النسخة الاحتياطية؟ سيؤدي ذلك إلى دمج البيانات المدعومة مع بياناتك الحالية.",
|
||||
"restoreSuccess": "تم استعادة النسخة الاحتياطية بنجاح! تم إنشاء: {{tasks}} مهام، {{projects}} مشاريع، {{notes}} ملاحظات",
|
||||
"restoreError": "فشل في استعادة النسخة الاحتياطية",
|
||||
"confirmDelete": "حذف النسخة الاحتياطية",
|
||||
"confirmDeleteMessage": "هل أنت متأكد أنك تريد حذف هذه النسخة الاحتياطية؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||
"deleteSuccess": "تم حذف النسخة الاحتياطية بنجاح!",
|
||||
"deleteError": "فشل في حذف النسخة الاحتياطية",
|
||||
"importTitle": "استيراد من ملف",
|
||||
"importDescription": "قم بتحميل ملف النسخة الاحتياطية لاستعادة بياناتك. سيتم الحفاظ على بياناتك الحالية، وسيتم إضافة العناصر الجديدة من النسخة الاحتياطية.",
|
||||
"importNote": "مهم:",
|
||||
"importNoteDescription": "سيؤدي الاستيراد إلى دمج البيانات مع العناصر الموجودة لديك. سيتم تخطي العناصر المكررة (نفس UID).",
|
||||
"selectFile": "اختر ملف النسخة الاحتياطية",
|
||||
"clickToUpload": "انقر لتصفح الملفات",
|
||||
"restoreBackup": "استعادة النسخة الاحتياطية",
|
||||
"importing": "جارٍ الاستيراد...",
|
||||
"importSuccess": "تم استيراد النسخة الاحتياطية بنجاح! تم إنشاء: {{tasks}} مهام، {{projects}} مشاريع، {{notes}} ملاحظات",
|
||||
"importError": "فشل في استيراد النسخة الاحتياطية",
|
||||
"backupContents": "محتويات النسخة الاحتياطية:",
|
||||
"validationError": "الملف المحدد ليس نسخة احتياطية صالحة",
|
||||
"validationErrors": "أخطاء التحقق:",
|
||||
"versionIncompatible": "الإصدار غير متوافق",
|
||||
"backupVersion": "إصدار النسخة الاحتياطية"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Имате непазени промени. Сигурни ли сте, че искате да ги откажете?",
|
||||
"no": "Не, продължавайте да редактирате",
|
||||
"yesDiscard": "Да, откажете",
|
||||
"uploading": "Качване..."
|
||||
"uploading": "Качване...",
|
||||
"refresh": "Обнови"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Табло",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Настройки на профила",
|
||||
"settings": "Настройки",
|
||||
"about": "За нас",
|
||||
"logout": "Изход"
|
||||
"logout": "Изход",
|
||||
"backupRestore": "Резервно копие и възстановяване"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Настройки на страницата днес",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Добавете подзадача..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Резервно копие и възстановяване",
|
||||
"description": "Създайте резервни копия или възстановете от предишни резервни копия. Последните 5 резервни копия се запазват автоматично.",
|
||||
"createBackup": "Създайте резервно копие",
|
||||
"importFromFile": "Импортиране от файл",
|
||||
"createNewBackup": "Създайте ново резервно копие",
|
||||
"createDescription": "Създайте ново резервно копие на всичките си данни. Резервните копия се запазват на сървъра и можете да ги възстановите по-късно.",
|
||||
"createBackupNow": "Създайте резервно копие сега",
|
||||
"creating": "Създаване на резервно копие...",
|
||||
"exportSuccess": "Резервното копие е създадено успешно!",
|
||||
"exportError": "Неуспешно създаване на резервно копие",
|
||||
"savedBackups": "Запазени резервни копия",
|
||||
"noBackups": "Не са намерени резервни копия. Създайте първото си резервно копие по-горе.",
|
||||
"createdAt": "Създадено",
|
||||
"version": "Версия",
|
||||
"currentVersion": "Текуща версия",
|
||||
"size": "Размер",
|
||||
"contents": "Съдържание",
|
||||
"actions": "Действия",
|
||||
"restore": "Възстановяване",
|
||||
"download": "Изтегляне",
|
||||
"downloadSuccess": "Резервното копие е изтеглено успешно!",
|
||||
"downloadError": "Неуспешно изтегляне на резервното копие",
|
||||
"confirmRestore": "Възстановяване на резервно копие",
|
||||
"confirmRestoreMessage": "Сигурни ли сте, че искате да възстановите това резервно копие? Това ще обедини резервираните данни с вашите текущи данни.",
|
||||
"restoreSuccess": "Резервното копие е възстановено успешно! Създадени: {{tasks}} задачи, {{projects}} проекта, {{notes}} бележки",
|
||||
"restoreError": "Неуспешно възстановяване на резервното копие",
|
||||
"confirmDelete": "Изтриване на резервно копие",
|
||||
"confirmDeleteMessage": "Сигурни ли сте, че искате да изтриете това резервно копие? Тази операция не може да бъде отменена.",
|
||||
"deleteSuccess": "Резервното копие е изтрито успешно!",
|
||||
"deleteError": "Неуспешно изтриване на резервното копие",
|
||||
"importTitle": "Импорт от файл",
|
||||
"importDescription": "Качете файл с резервно копие, за да възстановите вашите данни. Вашите съществуващи данни ще бъдат запазени, а новите елементи от резервното копие ще бъдат добавени.",
|
||||
"importNote": "Важно:",
|
||||
"importNoteDescription": "Импортът ще обедини данните с вашите съществуващи елементи. Дублираните елементи (същия UID) ще бъдат пропуснати.",
|
||||
"selectFile": "Изберете файл с резервно копие",
|
||||
"clickToUpload": "Кликнете, за да прегледате файловете",
|
||||
"restoreBackup": "Възстановяване на резервно копие",
|
||||
"importing": "Импортиране...",
|
||||
"importSuccess": "Резервното копие е импортирано успешно! Създадени: {{tasks}} задачи, {{projects}} проекта, {{notes}} бележки",
|
||||
"importError": "Неуспешно импортиране на резервно копие",
|
||||
"backupContents": "Съдържание на резервното копие:",
|
||||
"validationError": "Избраният файл не е валидно резервно копие",
|
||||
"validationErrors": "Грешки при валидиране:",
|
||||
"versionIncompatible": "Несъвместима версия",
|
||||
"backupVersion": "Версия на резервното копие"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Du har ikke gemte ændringer. Er du sikker på, at du vil forkaste dem?",
|
||||
"no": "Nej, fortsæt med at redigere",
|
||||
"yesDiscard": "Ja, forkast",
|
||||
"uploading": "Uploader..."
|
||||
"uploading": "Uploader...",
|
||||
"refresh": "Opdater"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Profilindstillinger",
|
||||
"settings": "Indstillinger",
|
||||
"about": "Om",
|
||||
"logout": "Log ud"
|
||||
"logout": "Log ud",
|
||||
"backupRestore": "Backup & Gendan"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Indstillinger for i dag",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Tilføj en underopgave..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup & Gendan",
|
||||
"description": "Opret backups eller gendan fra tidligere backups. Dine sidste 5 backups gemmes automatisk.",
|
||||
"createBackup": "Opret Backup",
|
||||
"importFromFile": "Importer fra Fil",
|
||||
"createNewBackup": "Opret Ny Backup",
|
||||
"createDescription": "Opret en ny backup af alle dine data. Backups gemmes på serveren, og du kan gendanne dem senere.",
|
||||
"createBackupNow": "Opret Backup Nu",
|
||||
"creating": "Opretter backup...",
|
||||
"exportSuccess": "Backup oprettet med succes!",
|
||||
"exportError": "Fejl ved oprettelse af backup",
|
||||
"savedBackups": "Gemte Backups",
|
||||
"noBackups": "Ingen backups fundet. Opret din første backup ovenfor.",
|
||||
"createdAt": "Oprettet",
|
||||
"version": "Version",
|
||||
"currentVersion": "Nuværende version",
|
||||
"size": "Størrelse",
|
||||
"contents": "Indhold",
|
||||
"actions": "Handlinger",
|
||||
"restore": "Gendan",
|
||||
"download": "Download",
|
||||
"downloadSuccess": "Backup downloadet med succes!",
|
||||
"downloadError": "Fejl ved download af backup",
|
||||
"confirmRestore": "Gendan Backup",
|
||||
"confirmRestoreMessage": "Er du sikker på, at du vil gendanne denne backup? Dette vil sammenflette de sikkerhedskopierede data med dine nuværende data.",
|
||||
"restoreSuccess": "Backup gendannet med succes! Oprettet: {{tasks}} opgaver, {{projects}} projekter, {{notes}} noter",
|
||||
"restoreError": "Fejl ved gendannelse af backup",
|
||||
"confirmDelete": "Slet Backup",
|
||||
"confirmDeleteMessage": "Er du sikker på, at du vil slette denne backup? Denne handling kan ikke fortrydes.",
|
||||
"deleteSuccess": "Backup slettet med succes!",
|
||||
"deleteError": "Fejl ved sletning af backup",
|
||||
"importTitle": "Importer fra Fil",
|
||||
"importDescription": "Upload en backupfil for at gendanne dine data. Dine eksisterende data vil blive bevaret, og nye elementer fra backupen vil blive tilføjet.",
|
||||
"importNote": "Vigtigt:",
|
||||
"importNoteDescription": "Import vil sammenflette data med dine eksisterende elementer. Duplikerede elementer (samme UID) vil blive sprunget over.",
|
||||
"selectFile": "Vælg Backup Fil",
|
||||
"clickToUpload": "Klik for at gennemse filer",
|
||||
"restoreBackup": "Gendan Backup",
|
||||
"importing": "Importer...",
|
||||
"importSuccess": "Backup importeret med succes! Oprettet: {{tasks}} opgaver, {{projects}} projekter, {{notes}} noter",
|
||||
"importError": "Fejl ved import af backup",
|
||||
"backupContents": "Backup indhold:",
|
||||
"validationError": "Den valgte fil er ikke en gyldig backup",
|
||||
"validationErrors": "Valideringsfejl:",
|
||||
"versionIncompatible": "Version inkompatibel",
|
||||
"backupVersion": "Backup version"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie diese verwerfen möchten?",
|
||||
"no": "Nein, weiter bearbeiten",
|
||||
"yesDiscard": "Ja, verwerfen",
|
||||
"uploading": "Hochladen..."
|
||||
"uploading": "Hochladen...",
|
||||
"refresh": "Aktualisieren"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Profil-Einstellungen",
|
||||
"settings": "Einstellungen",
|
||||
"about": "Über",
|
||||
"logout": "Abmelden"
|
||||
"logout": "Abmelden",
|
||||
"backupRestore": "Sichern & Wiederherstellen"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Heute-Seite Einstellungen",
|
||||
|
|
@ -1217,5 +1219,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Fügen Sie eine Unteraufgabe hinzu..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Sichern & Wiederherstellen",
|
||||
"description": "Erstellen Sie Sicherungen oder stellen Sie von vorherigen Sicherungen wieder her. Ihre letzten 5 Sicherungen werden automatisch gespeichert.",
|
||||
"createBackup": "Sicherung erstellen",
|
||||
"importFromFile": "Von Datei importieren",
|
||||
"createNewBackup": "Neue Sicherung erstellen",
|
||||
"createDescription": "Erstellen Sie eine neue Sicherung aller Ihrer Daten. Sicherungen werden auf dem Server gespeichert und Sie können sie später wiederherstellen.",
|
||||
"createBackupNow": "Sicherung jetzt erstellen",
|
||||
"creating": "Sicherung wird erstellt...",
|
||||
"exportSuccess": "Sicherung erfolgreich erstellt!",
|
||||
"exportError": "Sicherung konnte nicht erstellt werden",
|
||||
"savedBackups": "Gespeicherte Sicherungen",
|
||||
"noBackups": "Keine Sicherungen gefunden. Erstellen Sie oben Ihre erste Sicherung.",
|
||||
"createdAt": "Erstellt",
|
||||
"version": "Version",
|
||||
"currentVersion": "Aktuelle Version",
|
||||
"size": "Größe",
|
||||
"contents": "Inhalt",
|
||||
"actions": "Aktionen",
|
||||
"restore": "Wiederherstellen",
|
||||
"download": "Herunterladen",
|
||||
"downloadSuccess": "Backup erfolgreich heruntergeladen!",
|
||||
"downloadError": "Fehler beim Herunterladen des Backups",
|
||||
"confirmRestore": "Backup wiederherstellen",
|
||||
"confirmRestoreMessage": "Sind Sie sicher, dass Sie dieses Backup wiederherstellen möchten? Dies wird die gesicherten Daten mit Ihren aktuellen Daten zusammenführen.",
|
||||
"restoreSuccess": "Backup erfolgreich wiederhergestellt! Erstellt: {{tasks}} Aufgaben, {{projects}} Projekte, {{notes}} Notizen",
|
||||
"restoreError": "Fehler bei der Wiederherstellung des Backups",
|
||||
"confirmDelete": "Backup löschen",
|
||||
"confirmDeleteMessage": "Sind Sie sicher, dass Sie dieses Backup löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"deleteSuccess": "Backup erfolgreich gelöscht!",
|
||||
"deleteError": "Fehler beim Löschen des Backups",
|
||||
"importTitle": "Von Datei importieren",
|
||||
"importDescription": "Laden Sie eine Backup-Datei hoch, um Ihre Daten wiederherzustellen. Ihre vorhandenen Daten werden beibehalten, und neue Elemente aus dem Backup werden hinzugefügt.",
|
||||
"importNote": "Wichtig:",
|
||||
"importNoteDescription": "Der Import wird die Daten mit Ihren vorhandenen Elementen zusammenführen. Doppelte Elemente (gleiche UID) werden übersprungen.",
|
||||
"selectFile": "Backup-Datei auswählen",
|
||||
"clickToUpload": "Klicken Sie, um Dateien auszuwählen",
|
||||
"restoreBackup": "Backup wiederherstellen",
|
||||
"importing": "Importiere...",
|
||||
"importSuccess": "Backup erfolgreich importiert! Erstellt: {{tasks}} Aufgaben, {{projects}} Projekte, {{notes}} Notizen",
|
||||
"importError": "Import des Backups fehlgeschlagen",
|
||||
"backupContents": "Inhalt des Backups:",
|
||||
"validationError": "Die ausgewählte Datei ist kein gültiges Backup",
|
||||
"validationErrors": "Validierungsfehler:",
|
||||
"versionIncompatible": "Version inkompatibel",
|
||||
"backupVersion": "Backup-Version"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Έχετε μη αποθηκευμένες αλλαγές. Είστε σίγουροι ότι θέλετε να τις απορρίψετε;",
|
||||
"no": "Όχι, συνέχισε την επεξεργασία",
|
||||
"yesDiscard": "Ναι, απόρριψη",
|
||||
"uploading": "Ανεβάζω..."
|
||||
"uploading": "Ανεβάζω...",
|
||||
"refresh": "Ανανέωση"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Πίνακας Ελέγχου",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Ρυθμίσεις Προφίλ",
|
||||
"settings": "Ρυθμίσεις",
|
||||
"about": "Σχετικά",
|
||||
"logout": "Αποσύνδεση"
|
||||
"logout": "Αποσύνδεση",
|
||||
"backupRestore": "Αντίγραφα ασφαλείας & Επαναφορά"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Ρυθμίσεις Σελίδας Σήμερα",
|
||||
|
|
@ -1212,5 +1214,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Προσθέστε μια υποεργασία..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Αντίγραφα ασφαλείας & Επαναφορά",
|
||||
"description": "Δημιουργήστε αντίγραφα ασφαλείας ή επαναφέρετε από προηγούμενα αντίγραφα ασφαλείας. Τα τελευταία 5 αντίγραφα ασφαλείας σας αποθηκεύονται αυτόματα.",
|
||||
"createBackup": "Δημιουργία Αντιγράφου Ασφαλείας",
|
||||
"importFromFile": "Εισαγωγή από Αρχείο",
|
||||
"createNewBackup": "Δημιουργία Νέου Αντιγράφου Ασφαλείας",
|
||||
"createDescription": "Δημιουργήστε ένα νέο αντίγραφο ασφαλείας όλων των δεδομένων σας. Τα αντίγραφα ασφαλείας αποθηκεύονται στον διακομιστή και μπορείτε να τα επαναφέρετε αργότερα.",
|
||||
"createBackupNow": "Δημιουργία Αντιγράφου Ασφαλείας Τώρα",
|
||||
"creating": "Δημιουργία αντιγράφου ασφαλείας...",
|
||||
"exportSuccess": "Το αντίγραφο ασφαλείας δημιουργήθηκε με επιτυχία!",
|
||||
"exportError": "Αποτυχία δημιουργίας αντιγράφου ασφαλείας",
|
||||
"savedBackups": "Αποθηκευμένα Αντίγραφα Ασφαλείας",
|
||||
"noBackups": "Δεν βρέθηκαν αντίγραφα ασφαλείας. Δημιουργήστε το πρώτο σας αντίγραφο ασφαλείας παραπάνω.",
|
||||
"createdAt": "Δημιουργήθηκε",
|
||||
"version": "Έκδοση",
|
||||
"currentVersion": "Τρέχουσα έκδοση",
|
||||
"size": "Μέγεθος",
|
||||
"contents": "Περιεχόμενα",
|
||||
"actions": "Ενέργειες",
|
||||
"restore": "Αποκατάσταση",
|
||||
"download": "Λήψη",
|
||||
"downloadSuccess": "Η λήψη του αντιγράφου ασφαλείας ολοκληρώθηκε με επιτυχία!",
|
||||
"downloadError": "Αποτυχία λήψης αντιγράφου ασφαλείας",
|
||||
"confirmRestore": "Αποκατάσταση Αντιγράφου Ασφαλείας",
|
||||
"confirmRestoreMessage": "Είστε σίγουροι ότι θέλετε να αποκαταστήσετε αυτό το αντίγραφο ασφαλείας; Αυτό θα συγχωνεύσει τα δεδομένα του αντιγράφου ασφαλείας με τα τρέχοντα δεδομένα σας.",
|
||||
"restoreSuccess": "Το αντίγραφο ασφαλείας αποκαταστάθηκε με επιτυχία! Δημιουργήθηκαν: {{tasks}} εργασίες, {{projects}} έργα, {{notes}} σημειώσεις",
|
||||
"restoreError": "Αποτυχία αποκατάστασης αντιγράφου ασφαλείας",
|
||||
"confirmDelete": "Διαγραφή Αντιγράφου Ασφαλείας",
|
||||
"confirmDeleteMessage": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το αντίγραφο ασφαλείας; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
|
||||
"deleteSuccess": "Το αντίγραφο ασφαλείας διαγράφηκε με επιτυχία!",
|
||||
"deleteError": "Αποτυχία διαγραφής αντιγράφου ασφαλείας",
|
||||
"importTitle": "Εισαγωγή από Αρχείο",
|
||||
"importDescription": "Ανεβάστε ένα αρχείο αντιγράφου ασφαλείας για να αποκαταστήσετε τα δεδομένα σας. Τα υπάρχοντα δεδομένα σας θα διατηρηθούν και νέα στοιχεία από το αντίγραφο ασφαλείας θα προστεθούν.",
|
||||
"importNote": "Σημαντικό:",
|
||||
"importNoteDescription": "Η εισαγωγή θα συγχωνεύσει τα δεδομένα με τα υπάρχοντα στοιχεία σας. Τα διπλά στοιχεία (ίδιο UID) θα παραλειφθούν.",
|
||||
"selectFile": "Επιλέξτε Αρχείο Αντιγράφου Ασφαλείας",
|
||||
"clickToUpload": "Κάντε κλικ για να περιηγηθείτε σε αρχεία",
|
||||
"restoreBackup": "Αποκατάσταση Αντιγράφου Ασφαλείας",
|
||||
"importing": "Εισαγωγή...",
|
||||
"importSuccess": "Η εισαγωγή του αντιγράφου ασφαλείας ολοκληρώθηκε με επιτυχία! Δημιουργήθηκαν: {{tasks}} εργασίες, {{projects}} έργα, {{notes}} σημειώσεις",
|
||||
"importError": "Αποτυχία στην εισαγωγή του αντιγράφου ασφαλείας",
|
||||
"backupContents": "Περιεχόμενα αντιγράφου ασφαλείας:",
|
||||
"validationError": "Το επιλεγμένο αρχείο δεν είναι έγκυρο αντίγραφο ασφαλείας",
|
||||
"validationErrors": "Σφάλματα επικύρωσης:",
|
||||
"versionIncompatible": "Μη συμβατή έκδοση",
|
||||
"backupVersion": "Έκδοση αντιγράφου ασφαλείας"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"uploading": "Uploading...",
|
||||
"settings": "Settings",
|
||||
"none": "None",
|
||||
"refresh": "Refresh",
|
||||
"discardChanges": "Discard changes?",
|
||||
"discardChangesMessage": "You have unsaved changes. Are you sure you want to discard them?",
|
||||
"no": "No, keep editing",
|
||||
|
|
@ -61,6 +62,7 @@
|
|||
"profile": "Profile",
|
||||
"profileSettings": "Profile Settings",
|
||||
"settings": "Settings",
|
||||
"backupRestore": "Backup & Restore",
|
||||
"about": "About",
|
||||
"logout": "Logout"
|
||||
},
|
||||
|
|
@ -1199,5 +1201,52 @@
|
|||
"area": "area",
|
||||
"note": "note"
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup & Restore",
|
||||
"description": "Create backups or restore from previous backups. Your last 5 backups are automatically saved.",
|
||||
"createBackup": "Create Backup",
|
||||
"importFromFile": "Import from File",
|
||||
"createNewBackup": "Create New Backup",
|
||||
"createDescription": "Create a new backup of all your data. Backups are saved on the server and you can restore them later.",
|
||||
"createBackupNow": "Create Backup Now",
|
||||
"creating": "Creating backup...",
|
||||
"exportSuccess": "Backup created successfully!",
|
||||
"exportError": "Failed to create backup",
|
||||
"savedBackups": "Saved Backups",
|
||||
"noBackups": "No backups found. Create your first backup above.",
|
||||
"createdAt": "Created",
|
||||
"version": "Version",
|
||||
"currentVersion": "Current version",
|
||||
"size": "Size",
|
||||
"contents": "Contents",
|
||||
"actions": "Actions",
|
||||
"restore": "Restore",
|
||||
"download": "Download",
|
||||
"downloadSuccess": "Backup downloaded successfully!",
|
||||
"downloadError": "Failed to download backup",
|
||||
"confirmRestore": "Restore Backup",
|
||||
"confirmRestoreMessage": "Are you sure you want to restore this backup? This will merge the backed up data with your current data.",
|
||||
"restoreSuccess": "Backup restored successfully! Created: {{tasks}} tasks, {{projects}} projects, {{notes}} notes",
|
||||
"restoreError": "Failed to restore backup",
|
||||
"confirmDelete": "Delete Backup",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete this backup? This action cannot be undone.",
|
||||
"deleteSuccess": "Backup deleted successfully!",
|
||||
"deleteError": "Failed to delete backup",
|
||||
"importTitle": "Import from File",
|
||||
"importDescription": "Upload a backup file to restore your data. Your existing data will be preserved, and new items from the backup will be added.",
|
||||
"importNote": "Important:",
|
||||
"importNoteDescription": "Import will merge data with your existing items. Duplicate items (same UID) will be skipped.",
|
||||
"selectFile": "Select Backup File",
|
||||
"clickToUpload": "Click to browse files",
|
||||
"restoreBackup": "Restore Backup",
|
||||
"importing": "Importing...",
|
||||
"importSuccess": "Backup imported successfully! Created: {{tasks}} tasks, {{projects}} projects, {{notes}} notes",
|
||||
"importError": "Failed to import backup",
|
||||
"backupContents": "Backup contents:",
|
||||
"validationError": "The selected file is not a valid backup",
|
||||
"validationErrors": "Validation errors:",
|
||||
"versionIncompatible": "Version Incompatible",
|
||||
"backupVersion": "Backup version"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Tienes cambios no guardados. ¿Estás seguro de que quieres descartarlos?",
|
||||
"no": "No, seguir editando",
|
||||
"yesDiscard": "Sí, descartar",
|
||||
"uploading": "Subiendo..."
|
||||
"uploading": "Subiendo...",
|
||||
"refresh": "Actualizar"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Tablero",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Configuración de Perfil",
|
||||
"settings": "Ajustes",
|
||||
"about": "Acerca de",
|
||||
"logout": "Cerrar Sesión"
|
||||
"logout": "Cerrar Sesión",
|
||||
"backupRestore": "Copia de seguridad y restauración"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Configuración de Página Hoy",
|
||||
|
|
@ -1209,5 +1211,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Agregar una subtarea..."
|
||||
},
|
||||
"backup": {
|
||||
"restore": "Restaurar",
|
||||
"download": "Descargar",
|
||||
"downloadSuccess": "¡Copia de seguridad descargada con éxito!",
|
||||
"downloadError": "Error al descargar la copia de seguridad",
|
||||
"confirmRestore": "Restaurar copia de seguridad",
|
||||
"confirmRestoreMessage": "¿Está seguro de que desea restaurar esta copia de seguridad? Esto fusionará los datos respaldados con sus datos actuales.",
|
||||
"restoreSuccess": "¡Copia de seguridad restaurada con éxito! Creado: {{tasks}} tareas, {{projects}} proyectos, {{notes}} notas",
|
||||
"restoreError": "Error al restaurar la copia de seguridad",
|
||||
"confirmDelete": "Eliminar copia de seguridad",
|
||||
"confirmDeleteMessage": "¿Está seguro de que desea eliminar esta copia de seguridad? Esta acción no se puede deshacer.",
|
||||
"deleteSuccess": "¡Copia de seguridad eliminada con éxito!",
|
||||
"deleteError": "Error al eliminar la copia de seguridad",
|
||||
"importTitle": "Importar desde archivo",
|
||||
"importDescription": "Suba un archivo de copia de seguridad para restaurar sus datos. Sus datos existentes se preservarán y se agregarán nuevos elementos de la copia de seguridad.",
|
||||
"importNote": "Importante:",
|
||||
"importNoteDescription": "La importación fusionará los datos con sus elementos existentes. Los elementos duplicados (mismo UID) serán omitidos.",
|
||||
"selectFile": "Seleccionar archivo de copia de seguridad",
|
||||
"clickToUpload": "Haga clic para buscar archivos",
|
||||
"restoreBackup": "Restaurar copia de seguridad",
|
||||
"importing": "Importando...",
|
||||
"importSuccess": "¡Copia de seguridad importada con éxito! Creado: {{tasks}} tareas, {{projects}} proyectos, {{notes}} notas",
|
||||
"importError": "Error al importar la copia de seguridad",
|
||||
"backupContents": "Contenido de la copia de seguridad:",
|
||||
"validationError": "El archivo seleccionado no es una copia de seguridad válida",
|
||||
"validationErrors": "Errores de validación:",
|
||||
"versionIncompatible": "Versión incompatible",
|
||||
"backupVersion": "Versión de la copia de seguridad",
|
||||
"title": "Copia de seguridad y restauración",
|
||||
"description": "Crea copias de seguridad o restaura desde copias de seguridad anteriores. Tus últimas 5 copias de seguridad se guardan automáticamente.",
|
||||
"createBackup": "Crear copia de seguridad",
|
||||
"importFromFile": "Importar desde archivo",
|
||||
"createNewBackup": "Crear nueva copia de seguridad",
|
||||
"createDescription": "Crea una nueva copia de seguridad de todos tus datos. Las copias de seguridad se guardan en el servidor y puedes restaurarlas más tarde.",
|
||||
"createBackupNow": "Crear copia de seguridad ahora",
|
||||
"creating": "Creando copia de seguridad...",
|
||||
"exportSuccess": "¡Copia de seguridad creada con éxito!",
|
||||
"exportError": "Error al crear la copia de seguridad",
|
||||
"savedBackups": "Copias de seguridad guardadas",
|
||||
"noBackups": "No se encontraron copias de seguridad. Crea tu primera copia de seguridad arriba.",
|
||||
"createdAt": "Creado",
|
||||
"version": "Versión",
|
||||
"currentVersion": "Versión actual",
|
||||
"size": "Tamaño",
|
||||
"contents": "Contenido",
|
||||
"actions": "Acciones"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Sinulla on tallentamattomia muutoksia. Oletko varma, että haluat hylätä ne?",
|
||||
"no": "Ei, jatka muokkaamista",
|
||||
"yesDiscard": "Kyllä, hylkää",
|
||||
"uploading": "Lähetetään..."
|
||||
"uploading": "Lähetetään...",
|
||||
"refresh": "Päivitä"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Ohjauspaneeli",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Profiiliasetukset",
|
||||
"settings": "Asetukset",
|
||||
"about": "Tietoja",
|
||||
"logout": "Kirjaudu ulos"
|
||||
"logout": "Kirjaudu ulos",
|
||||
"backupRestore": "Varmuuskopioi & Palauta"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Tänään-sivun asetukset",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Lisää alitehtävä..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Varmuuskopioi & Palauta",
|
||||
"description": "Luo varmuuskopioita tai palauta aiemmista varmuuskopioista. Viimeiset 5 varmuuskopiota tallennetaan automaattisesti.",
|
||||
"createBackup": "Luo varmuuskopio",
|
||||
"importFromFile": "Tuo tiedostosta",
|
||||
"createNewBackup": "Luo uusi varmuuskopio",
|
||||
"createDescription": "Luo uusi varmuuskopio kaikista tiedoistasi. Varmuuskopiot tallennetaan palvelimelle ja voit palauttaa ne myöhemmin.",
|
||||
"createBackupNow": "Luo varmuuskopio nyt",
|
||||
"creating": "Luodaan varmuuskopiota...",
|
||||
"exportSuccess": "Varmuuskopio luotiin onnistuneesti!",
|
||||
"exportError": "Varmuuskopion luominen epäonnistui",
|
||||
"savedBackups": "Tallennetut varmuuskopiot",
|
||||
"noBackups": "Varmuuskopioita ei löytynyt. Luo ensimmäinen varmuuskopiosi yllä.",
|
||||
"createdAt": "Luotu",
|
||||
"version": "Versio",
|
||||
"currentVersion": "Nykyinen versio",
|
||||
"size": "Koko",
|
||||
"contents": "Sisältö",
|
||||
"actions": "Toiminnot",
|
||||
"restore": "Palauta",
|
||||
"download": "Lataa",
|
||||
"downloadSuccess": "Varasto ladattu onnistuneesti!",
|
||||
"downloadError": "Varaston lataaminen epäonnistui",
|
||||
"confirmRestore": "Palauta varasto",
|
||||
"confirmRestoreMessage": "Oletko varma, että haluat palauttaa tämän varaston? Tämä yhdistää varatun tiedon nykyisiin tietoihisi.",
|
||||
"restoreSuccess": "Varasto palautettu onnistuneesti! Luotu: {{tasks}} tehtävää, {{projects}} projektia, {{notes}} muistiota",
|
||||
"restoreError": "Varaston palauttaminen epäonnistui",
|
||||
"confirmDelete": "Poista varasto",
|
||||
"confirmDeleteMessage": "Oletko varma, että haluat poistaa tämän varaston? Tätä toimintoa ei voi peruuttaa.",
|
||||
"deleteSuccess": "Varasto poistettu onnistuneesti!",
|
||||
"deleteError": "Varaston poistaminen epäonnistui",
|
||||
"importTitle": "Tuo tiedostosta",
|
||||
"importDescription": "Lataa varastotiedosto palauttaaksesi tietosi. Nykyiset tietosi säilytetään, ja varastosta lisätään uusia kohteita.",
|
||||
"importNote": "Tärkeää:",
|
||||
"importNoteDescription": "Tuo yhdistää tiedot nykyisiin kohteisiisi. Kaksoiskappaleet (sama UID) ohitetaan.",
|
||||
"selectFile": "Valitse varastotiedosto",
|
||||
"clickToUpload": "Napsauta selataksesi tiedostoja",
|
||||
"restoreBackup": "Palauta varasto",
|
||||
"importing": "Tuodaan...",
|
||||
"importSuccess": "Varmuuskopio tuotiin onnistuneesti! Luotu: {{tasks}} tehtävää, {{projects}} projektia, {{notes}} muistiota",
|
||||
"importError": "Varmuuskopion tuonti epäonnistui",
|
||||
"backupContents": "Varmuuskopion sisältö:",
|
||||
"validationError": "Valittu tiedosto ei ole voimassa oleva varmuuskopio",
|
||||
"validationErrors": "Validointivirheet:",
|
||||
"versionIncompatible": "Versio yhteensopimaton",
|
||||
"backupVersion": "Varmuuskopion versio"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir les abandonner ?",
|
||||
"no": "Non, continuer à éditer",
|
||||
"yesDiscard": "Oui, abandonner",
|
||||
"uploading": "Téléchargement..."
|
||||
"uploading": "Téléchargement...",
|
||||
"refresh": "Rafraîchir"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Tableau de bord",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Paramètres du profil",
|
||||
"settings": "Paramètres",
|
||||
"about": "À propos",
|
||||
"logout": "Déconnexion"
|
||||
"logout": "Déconnexion",
|
||||
"backupRestore": "Sauvegarde & Restauration"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Paramètres de la page Aujourd'hui",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Ajouter une sous-tâche..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Sauvegarde & Restauration",
|
||||
"description": "Créez des sauvegardes ou restaurez à partir de sauvegardes précédentes. Vos 5 dernières sauvegardes sont automatiquement enregistrées.",
|
||||
"createBackup": "Créer une sauvegarde",
|
||||
"importFromFile": "Importer depuis un fichier",
|
||||
"createNewBackup": "Créer une nouvelle sauvegarde",
|
||||
"createDescription": "Créez une nouvelle sauvegarde de toutes vos données. Les sauvegardes sont enregistrées sur le serveur et vous pouvez les restaurer plus tard.",
|
||||
"createBackupNow": "Créer une sauvegarde maintenant",
|
||||
"creating": "Création de la sauvegarde...",
|
||||
"exportSuccess": "Sauvegarde créée avec succès !",
|
||||
"exportError": "Échec de la création de la sauvegarde",
|
||||
"savedBackups": "Sauvegardes enregistrées",
|
||||
"noBackups": "Aucune sauvegarde trouvée. Créez votre première sauvegarde ci-dessus.",
|
||||
"createdAt": "Créé",
|
||||
"version": "Version",
|
||||
"currentVersion": "Version actuelle",
|
||||
"size": "Taille",
|
||||
"contents": "Contenu",
|
||||
"actions": "Actions",
|
||||
"restore": "Restaurer",
|
||||
"download": "Télécharger",
|
||||
"downloadSuccess": "Sauvegarde téléchargée avec succès !",
|
||||
"downloadError": "Échec du téléchargement de la sauvegarde",
|
||||
"confirmRestore": "Restaurer la sauvegarde",
|
||||
"confirmRestoreMessage": "Êtes-vous sûr de vouloir restaurer cette sauvegarde ? Cela fusionnera les données sauvegardées avec vos données actuelles.",
|
||||
"restoreSuccess": "Sauvegarde restaurée avec succès ! Créé : {{tasks}} tâches, {{projects}} projets, {{notes}} notes",
|
||||
"restoreError": "Échec de la restauration de la sauvegarde",
|
||||
"confirmDelete": "Supprimer la sauvegarde",
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer cette sauvegarde ? Cette action ne peut pas être annulée.",
|
||||
"deleteSuccess": "Sauvegarde supprimée avec succès !",
|
||||
"deleteError": "Échec de la suppression de la sauvegarde",
|
||||
"importTitle": "Importer depuis un fichier",
|
||||
"importDescription": "Téléchargez un fichier de sauvegarde pour restaurer vos données. Vos données existantes seront préservées, et les nouveaux éléments de la sauvegarde seront ajoutés.",
|
||||
"importNote": "Important :",
|
||||
"importNoteDescription": "L'importation fusionnera les données avec vos éléments existants. Les éléments en double (même UID) seront ignorés.",
|
||||
"selectFile": "Sélectionner le fichier de sauvegarde",
|
||||
"clickToUpload": "Cliquez pour parcourir les fichiers",
|
||||
"restoreBackup": "Restaurer la sauvegarde",
|
||||
"importing": "Importation...",
|
||||
"importSuccess": "Sauvegarde importée avec succès ! Créé : {{tasks}} tâches, {{projects}} projets, {{notes}} notes",
|
||||
"importError": "Échec de l'importation de la sauvegarde",
|
||||
"backupContents": "Contenu de la sauvegarde :",
|
||||
"validationError": "Le fichier sélectionné n'est pas une sauvegarde valide",
|
||||
"validationErrors": "Erreurs de validation :",
|
||||
"versionIncompatible": "Version incompatible",
|
||||
"backupVersion": "Version de la sauvegarde"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Anda memiliki perubahan yang belum disimpan. Apakah Anda yakin ingin membuangnya?",
|
||||
"no": "Tidak, lanjutkan mengedit",
|
||||
"yesDiscard": "Ya, buang",
|
||||
"uploading": "Mengunggah..."
|
||||
"uploading": "Mengunggah...",
|
||||
"refresh": "Segarkan"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dasbor",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Pengaturan Profil",
|
||||
"settings": "Pengaturan",
|
||||
"about": "Tentang",
|
||||
"logout": "Keluar"
|
||||
"logout": "Keluar",
|
||||
"backupRestore": "Cadangkan & Pulihkan"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Pengaturan Halaman Hari Ini",
|
||||
|
|
@ -366,7 +368,8 @@
|
|||
"stuckProjectsDesc": "Proyek yang tidak diperbarui baru-baru ini",
|
||||
"reviewItems": "Klik untuk meninjau dan meningkatkan alur kerja Anda",
|
||||
"suggestion": "Klik pada item mana pun di atas untuk membukanya dan melakukan perbaikan.",
|
||||
"issuesFound_other": "{{count}} masalah produktivitas memerlukan perhatian"
|
||||
"issuesFound_other": "{{count}} masalah produktivitas memerlukan perhatian",
|
||||
"issuesFound_one": "1 masalah produktivitas membutuhkan perhatian"
|
||||
},
|
||||
"nextTask": {
|
||||
"suggestion": "Karena tidak ada yang sedang berlangsung, bagaimana jika mulai dengan",
|
||||
|
|
@ -1207,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Tambahkan subtugas..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Cadangkan & Pulihkan",
|
||||
"description": "Buat cadangan atau pulihkan dari cadangan sebelumnya. 5 cadangan terakhir Anda disimpan secara otomatis.",
|
||||
"createBackup": "Buat Cadangan",
|
||||
"importFromFile": "Impor dari Berkas",
|
||||
"createNewBackup": "Buat Cadangan Baru",
|
||||
"createDescription": "Buat cadangan baru dari semua data Anda. Cadangan disimpan di server dan Anda dapat memulihkannya nanti.",
|
||||
"createBackupNow": "Buat Cadangan Sekarang",
|
||||
"creating": "Membuat cadangan...",
|
||||
"exportSuccess": "Cadangan berhasil dibuat!",
|
||||
"exportError": "Gagal membuat cadangan",
|
||||
"savedBackups": "Cadangan yang Disimpan",
|
||||
"noBackups": "Tidak ada cadangan yang ditemukan. Buat cadangan pertama Anda di atas.",
|
||||
"createdAt": "Dibuat",
|
||||
"version": "Versi",
|
||||
"currentVersion": "Versi saat ini",
|
||||
"size": "Ukuran",
|
||||
"contents": "Isi",
|
||||
"actions": "Tindakan",
|
||||
"restore": "Pulihkan",
|
||||
"download": "Unduh",
|
||||
"downloadSuccess": "Cadangan berhasil diunduh!",
|
||||
"downloadError": "Gagal mengunduh cadangan",
|
||||
"confirmRestore": "Pulihkan Cadangan",
|
||||
"confirmRestoreMessage": "Apakah Anda yakin ingin memulihkan cadangan ini? Ini akan menggabungkan data yang dicadangkan dengan data Anda saat ini.",
|
||||
"restoreSuccess": "Cadangan berhasil dipulihkan! Dibuat: {{tasks}} tugas, {{projects}} proyek, {{notes}} catatan",
|
||||
"restoreError": "Gagal memulihkan cadangan",
|
||||
"confirmDelete": "Hapus Cadangan",
|
||||
"confirmDeleteMessage": "Apakah Anda yakin ingin menghapus cadangan ini? Tindakan ini tidak dapat dibatalkan.",
|
||||
"deleteSuccess": "Cadangan berhasil dihapus!",
|
||||
"deleteError": "Gagal menghapus cadangan",
|
||||
"importTitle": "Impor dari File",
|
||||
"importDescription": "Unggah file cadangan untuk memulihkan data Anda. Data Anda yang ada akan dilestarikan, dan item baru dari cadangan akan ditambahkan.",
|
||||
"importNote": "Penting:",
|
||||
"importNoteDescription": "Impor akan menggabungkan data dengan item Anda yang ada. Item duplikat (UID yang sama) akan dilewati.",
|
||||
"selectFile": "Pilih File Cadangan",
|
||||
"clickToUpload": "Klik untuk menjelajahi file",
|
||||
"restoreBackup": "Pulihkan Cadangan",
|
||||
"importing": "Mengimpor...",
|
||||
"importSuccess": "Cadangan berhasil diimpor! Dibuat: {{tasks}} tugas, {{projects}} proyek, {{notes}} catatan",
|
||||
"importError": "Gagal mengimpor cadangan",
|
||||
"backupContents": "Isi cadangan:",
|
||||
"validationError": "File yang dipilih bukan cadangan yang valid",
|
||||
"validationErrors": "Kesalahan validasi:",
|
||||
"versionIncompatible": "Versi Tidak Kompatibel",
|
||||
"backupVersion": "Versi cadangan"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Hai modifiche non salvate. Sei sicuro di volerle scartare?",
|
||||
"no": "No, continua a modificare",
|
||||
"yesDiscard": "Sì, scarta",
|
||||
"uploading": "Caricamento..."
|
||||
"uploading": "Caricamento...",
|
||||
"refresh": "Aggiorna"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Impostazioni Profilo",
|
||||
"settings": "Impostazioni",
|
||||
"about": "Informazioni",
|
||||
"logout": "Disconnetti"
|
||||
"logout": "Disconnetti",
|
||||
"backupRestore": "Backup e Ripristino"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Impostazioni Pagina Oggi",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Aggiungi un sottocompito..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup e Ripristino",
|
||||
"description": "Crea backup o ripristina da backup precedenti. I tuoi ultimi 5 backup vengono salvati automaticamente.",
|
||||
"createBackup": "Crea Backup",
|
||||
"importFromFile": "Importa da File",
|
||||
"createNewBackup": "Crea Nuovo Backup",
|
||||
"createDescription": "Crea un nuovo backup di tutti i tuoi dati. I backup vengono salvati sul server e puoi ripristinarli in seguito.",
|
||||
"createBackupNow": "Crea Backup Ora",
|
||||
"creating": "Creazione del backup...",
|
||||
"exportSuccess": "Backup creato con successo!",
|
||||
"exportError": "Creazione del backup fallita",
|
||||
"savedBackups": "Backup Salvati",
|
||||
"noBackups": "Nessun backup trovato. Crea il tuo primo backup sopra.",
|
||||
"createdAt": "Creato",
|
||||
"version": "Versione",
|
||||
"currentVersion": "Versione attuale",
|
||||
"size": "Dimensione",
|
||||
"contents": "Contenuti",
|
||||
"actions": "Azioni",
|
||||
"restore": "Ripristina",
|
||||
"download": "Scarica",
|
||||
"downloadSuccess": "Backup scaricato con successo!",
|
||||
"downloadError": "Impossibile scaricare il backup",
|
||||
"confirmRestore": "Ripristina Backup",
|
||||
"confirmRestoreMessage": "Sei sicuro di voler ripristinare questo backup? Questo fonderà i dati di backup con i tuoi dati attuali.",
|
||||
"restoreSuccess": "Backup ripristinato con successo! Creati: {{tasks}} attività, {{projects}} progetti, {{notes}} note",
|
||||
"restoreError": "Impossibile ripristinare il backup",
|
||||
"confirmDelete": "Elimina Backup",
|
||||
"confirmDeleteMessage": "Sei sicuro di voler eliminare questo backup? Questa azione non può essere annullata.",
|
||||
"deleteSuccess": "Backup eliminato con successo!",
|
||||
"deleteError": "Impossibile eliminare il backup",
|
||||
"importTitle": "Importa da File",
|
||||
"importDescription": "Carica un file di backup per ripristinare i tuoi dati. I tuoi dati esistenti saranno preservati e i nuovi elementi dal backup saranno aggiunti.",
|
||||
"importNote": "Importante:",
|
||||
"importNoteDescription": "L'importazione fonderà i dati con i tuoi elementi esistenti. Gli elementi duplicati (stesso UID) saranno ignorati.",
|
||||
"selectFile": "Seleziona File di Backup",
|
||||
"clickToUpload": "Clicca per sfogliare i file",
|
||||
"restoreBackup": "Ripristina Backup",
|
||||
"importing": "Importazione...",
|
||||
"importSuccess": "Backup importato con successo! Creati: {{tasks}} attività, {{projects}} progetti, {{notes}} note",
|
||||
"importError": "Impossibile importare il backup",
|
||||
"backupContents": "Contenuti del backup:",
|
||||
"validationError": "Il file selezionato non è un backup valido",
|
||||
"validationErrors": "Errori di convalida:",
|
||||
"versionIncompatible": "Versione incompatibile",
|
||||
"backupVersion": "Versione del backup"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "保存されていない変更があります。破棄してもよろしいですか?",
|
||||
"no": "いいえ、編集を続ける",
|
||||
"yesDiscard": "はい、破棄する",
|
||||
"uploading": "アップロード中..."
|
||||
"uploading": "アップロード中...",
|
||||
"refresh": "更新"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "ダッシュボード",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "プロフィール設定",
|
||||
"settings": "設定",
|
||||
"about": "について",
|
||||
"logout": "ログアウト"
|
||||
"logout": "ログアウト",
|
||||
"backupRestore": "バックアップと復元"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "今日のページ設定",
|
||||
|
|
@ -923,7 +925,8 @@
|
|||
"stuckProjectsDesc": "最近更新されていないプロジェクト",
|
||||
"reviewItems": "クリックしてレビューし、ワークフローを改善します",
|
||||
"suggestion": "上記の任意のアイテムをクリックして開き、改善を行います。",
|
||||
"issuesFound_other": "{{count}}の生産性の問題に注意が必要です"
|
||||
"issuesFound_other": "{{count}}の生産性の問題に注意が必要です",
|
||||
"issuesFound_one": "1つの生産性の問題に注意が必要です"
|
||||
},
|
||||
"priority": {
|
||||
"low": "低",
|
||||
|
|
@ -1207,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "サブタスクを追加..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "バックアップと復元",
|
||||
"description": "バックアップを作成するか、以前のバックアップから復元します。最後の5つのバックアップは自動的に保存されます。",
|
||||
"createBackup": "バックアップを作成",
|
||||
"importFromFile": "ファイルからインポート",
|
||||
"createNewBackup": "新しいバックアップを作成",
|
||||
"createDescription": "すべてのデータの新しいバックアップを作成します。バックアップはサーバーに保存され、後で復元できます。",
|
||||
"createBackupNow": "今すぐバックアップを作成",
|
||||
"creating": "バックアップを作成中...",
|
||||
"exportSuccess": "バックアップが正常に作成されました!",
|
||||
"exportError": "バックアップの作成に失敗しました",
|
||||
"savedBackups": "保存されたバックアップ",
|
||||
"noBackups": "バックアップが見つかりません。上記で最初のバックアップを作成してください。",
|
||||
"createdAt": "作成日",
|
||||
"version": "バージョン",
|
||||
"currentVersion": "現在のバージョン",
|
||||
"size": "サイズ",
|
||||
"contents": "内容",
|
||||
"actions": "アクション",
|
||||
"restore": "復元",
|
||||
"download": "ダウンロード",
|
||||
"downloadSuccess": "バックアップが正常にダウンロードされました!",
|
||||
"downloadError": "バックアップのダウンロードに失敗しました",
|
||||
"confirmRestore": "バックアップを復元",
|
||||
"confirmRestoreMessage": "このバックアップを復元してもよろしいですか?これはバックアップされたデータを現在のデータと統合します。",
|
||||
"restoreSuccess": "バックアップが正常に復元されました!作成されたタスク: {{tasks}}、プロジェクト: {{projects}}、ノート: {{notes}}",
|
||||
"restoreError": "バックアップの復元に失敗しました",
|
||||
"confirmDelete": "バックアップを削除",
|
||||
"confirmDeleteMessage": "このバックアップを削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"deleteSuccess": "バックアップが正常に削除されました!",
|
||||
"deleteError": "バックアップの削除に失敗しました",
|
||||
"importTitle": "ファイルからインポート",
|
||||
"importDescription": "バックアップファイルをアップロードしてデータを復元します。既存のデータは保持され、バックアップからの新しいアイテムが追加されます。",
|
||||
"importNote": "重要:",
|
||||
"importNoteDescription": "インポートは既存のアイテムとデータを統合します。重複アイテム(同じUID)はスキップされます。",
|
||||
"selectFile": "バックアップファイルを選択",
|
||||
"clickToUpload": "ファイルを参照するにはクリック",
|
||||
"restoreBackup": "バックアップを復元",
|
||||
"importing": "インポート中...",
|
||||
"importSuccess": "バックアップが正常にインポートされました! 作成されたタスク: {{tasks}}、プロジェクト: {{projects}}、ノート: {{notes}}",
|
||||
"importError": "バックアップのインポートに失敗しました",
|
||||
"backupContents": "バックアップの内容:",
|
||||
"validationError": "選択したファイルは有効なバックアップではありません",
|
||||
"validationErrors": "検証エラー:",
|
||||
"versionIncompatible": "バージョンが互換性がありません",
|
||||
"backupVersion": "バックアップバージョン"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "저장되지 않은 변경 사항이 있습니다. 정말로 이를 버리시겠습니까?",
|
||||
"no": "아니요, 편집 계속하기",
|
||||
"yesDiscard": "네, 버리기",
|
||||
"uploading": "업로드 중..."
|
||||
"uploading": "업로드 중...",
|
||||
"refresh": "새로 고침"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "대시보드",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "프로필 설정",
|
||||
"settings": "설정",
|
||||
"about": "정보",
|
||||
"logout": "로그아웃"
|
||||
"logout": "로그아웃",
|
||||
"backupRestore": "백업 및 복원"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "오늘 페이지 설정",
|
||||
|
|
@ -366,7 +368,8 @@
|
|||
"stuckProjectsDesc": "최근에 업데이트되지 않은 프로젝트",
|
||||
"reviewItems": "워크플로를 검토하고 개선하려면 클릭하세요.",
|
||||
"suggestion": "위의 항목을 클릭하여 열고 개선하세요.",
|
||||
"issuesFound_other": "{{count}}개의 생산성 문제에 주의가 필요합니다"
|
||||
"issuesFound_other": "{{count}}개의 생산성 문제에 주의가 필요합니다",
|
||||
"issuesFound_one": "1개의 생산성 문제가 필요합니다"
|
||||
},
|
||||
"nextTask": {
|
||||
"suggestion": "진행 중인 작업이 없으니, 다음 작업으로 시작하는 것은 어떨까요?",
|
||||
|
|
@ -1207,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "하위 작업 추가..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "백업 및 복원",
|
||||
"description": "백업을 생성하거나 이전 백업에서 복원합니다. 마지막 5개의 백업이 자동으로 저장됩니다.",
|
||||
"createBackup": "백업 생성",
|
||||
"importFromFile": "파일에서 가져오기",
|
||||
"createNewBackup": "새 백업 생성",
|
||||
"createDescription": "모든 데이터의 새 백업을 생성합니다. 백업은 서버에 저장되며 나중에 복원할 수 있습니다.",
|
||||
"createBackupNow": "지금 백업 생성",
|
||||
"creating": "백업 생성 중...",
|
||||
"exportSuccess": "백업이 성공적으로 생성되었습니다!",
|
||||
"exportError": "백업 생성에 실패했습니다",
|
||||
"savedBackups": "저장된 백업",
|
||||
"noBackups": "백업이 없습니다. 위에서 첫 번째 백업을 생성하세요.",
|
||||
"createdAt": "생성됨",
|
||||
"version": "버전",
|
||||
"currentVersion": "현재 버전",
|
||||
"size": "크기",
|
||||
"contents": "내용",
|
||||
"actions": "작업",
|
||||
"restore": "복원",
|
||||
"download": "다운로드",
|
||||
"downloadSuccess": "백업이 성공적으로 다운로드되었습니다!",
|
||||
"downloadError": "백업 다운로드에 실패했습니다",
|
||||
"confirmRestore": "백업 복원",
|
||||
"confirmRestoreMessage": "이 백업을 복원하시겠습니까? 이는 백업된 데이터를 현재 데이터와 병합합니다.",
|
||||
"restoreSuccess": "백업이 성공적으로 복원되었습니다! 생성된 항목: {{tasks}} 작업, {{projects}} 프로젝트, {{notes}} 노트",
|
||||
"restoreError": "백업 복원에 실패했습니다",
|
||||
"confirmDelete": "백업 삭제",
|
||||
"confirmDeleteMessage": "이 백업을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
|
||||
"deleteSuccess": "백업이 성공적으로 삭제되었습니다!",
|
||||
"deleteError": "백업 삭제에 실패했습니다",
|
||||
"importTitle": "파일에서 가져오기",
|
||||
"importDescription": "데이터를 복원하기 위해 백업 파일을 업로드하세요. 기존 데이터는 보존되며, 백업의 새 항목이 추가됩니다.",
|
||||
"importNote": "중요:",
|
||||
"importNoteDescription": "가져오기는 기존 항목과 데이터를 병합합니다. 중복 항목(같은 UID)은 건너뜁니다.",
|
||||
"selectFile": "백업 파일 선택",
|
||||
"clickToUpload": "파일 탐색을 위해 클릭하세요",
|
||||
"restoreBackup": "백업 복원",
|
||||
"importing": "가져오는 중...",
|
||||
"importSuccess": "백업이 성공적으로 가져와졌습니다! 생성된 작업: {{tasks}}개, 프로젝트: {{projects}}개, 노트: {{notes}}개",
|
||||
"importError": "백업 가져오기에 실패했습니다",
|
||||
"backupContents": "백업 내용:",
|
||||
"validationError": "선택한 파일은 유효한 백업이 아닙니다",
|
||||
"validationErrors": "유효성 검사 오류:",
|
||||
"versionIncompatible": "버전 호환성 없음",
|
||||
"backupVersion": "백업 버전"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Je hebt niet-opgeslagen wijzigingen. Weet je zeker dat je ze wilt negeren?",
|
||||
"no": "Nee, blijf bewerken",
|
||||
"yesDiscard": "Ja, negeren",
|
||||
"uploading": "Uploaden..."
|
||||
"uploading": "Uploaden...",
|
||||
"refresh": "Vernieuwen"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Profielinstellingen",
|
||||
"settings": "Instellingen",
|
||||
"about": "Over",
|
||||
"logout": "Uitloggen"
|
||||
"logout": "Uitloggen",
|
||||
"backupRestore": "Back-up & Herstellen"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Instellingen Vandaag Pagina",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Voeg een subtaak toe..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Back-up & Herstellen",
|
||||
"description": "Maak back-ups of herstel van eerdere back-ups. Je laatste 5 back-ups worden automatisch opgeslagen.",
|
||||
"createBackup": "Maak Back-up",
|
||||
"importFromFile": "Importeren vanuit Bestand",
|
||||
"createNewBackup": "Maak Nieuwe Back-up",
|
||||
"createDescription": "Maak een nieuwe back-up van al je gegevens. Back-ups worden op de server opgeslagen en je kunt ze later herstellen.",
|
||||
"createBackupNow": "Maak Nu Back-up",
|
||||
"creating": "Back-up aanmaken...",
|
||||
"exportSuccess": "Back-up succesvol aangemaakt!",
|
||||
"exportError": "Aanmaken van back-up mislukt",
|
||||
"savedBackups": "Opgeslagen Back-ups",
|
||||
"noBackups": "Geen back-ups gevonden. Maak hierboven je eerste back-up.",
|
||||
"createdAt": "Aangemaakt",
|
||||
"version": "Versie",
|
||||
"currentVersion": "Huidige versie",
|
||||
"size": "Grootte",
|
||||
"contents": "Inhoud",
|
||||
"actions": "Acties",
|
||||
"restore": "Herstellen",
|
||||
"download": "Downloaden",
|
||||
"downloadSuccess": "Backup succesvol gedownload!",
|
||||
"downloadError": "Het downloaden van de backup is mislukt",
|
||||
"confirmRestore": "Backup Herstellen",
|
||||
"confirmRestoreMessage": "Weet je zeker dat je deze backup wilt herstellen? Dit zal de geback-upte gegevens samenvoegen met je huidige gegevens.",
|
||||
"restoreSuccess": "Backup succesvol hersteld! Aangemaakt: {{tasks}} taken, {{projects}} projecten, {{notes}} notities",
|
||||
"restoreError": "Het herstellen van de backup is mislukt",
|
||||
"confirmDelete": "Backup Verwijderen",
|
||||
"confirmDeleteMessage": "Weet je zeker dat je deze backup wilt verwijderen? Deze actie kan niet ongedaan gemaakt worden.",
|
||||
"deleteSuccess": "Backup succesvol verwijderd!",
|
||||
"deleteError": "Het verwijderen van de backup is mislukt",
|
||||
"importTitle": "Importeren vanuit Bestand",
|
||||
"importDescription": "Upload een backupbestand om je gegevens te herstellen. Je bestaande gegevens blijven behouden en nieuwe items uit de backup worden toegevoegd.",
|
||||
"importNote": "Belangrijk:",
|
||||
"importNoteDescription": "Importeren zal gegevens samenvoegen met je bestaande items. Dubbele items (dezelfde UID) worden overgeslagen.",
|
||||
"selectFile": "Selecteer Backupbestand",
|
||||
"clickToUpload": "Klik om bestanden te doorbladeren",
|
||||
"restoreBackup": "Backup Herstellen",
|
||||
"importing": "Importeren...",
|
||||
"importSuccess": "Backup succesvol geïmporteerd! Aangemaakt: {{tasks}} taken, {{projects}} projecten, {{notes}} notities",
|
||||
"importError": "Importeren van backup mislukt",
|
||||
"backupContents": "Inhoud van de backup:",
|
||||
"validationError": "Het geselecteerde bestand is geen geldige backup",
|
||||
"validationErrors": "Validatiefouten:",
|
||||
"versionIncompatible": "Versie incompatibel",
|
||||
"backupVersion": "Backupversie"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Du har usikrede endringer. Er du sikker på at du vil forkaste dem?",
|
||||
"no": "Nei, fortsett å redigere",
|
||||
"yesDiscard": "Ja, forkast",
|
||||
"uploading": "Laster opp..."
|
||||
"uploading": "Laster opp...",
|
||||
"refresh": "Oppdater"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashbord",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Profilinnstillinger",
|
||||
"settings": "Innstillinger",
|
||||
"about": "Om",
|
||||
"logout": "Logg ut"
|
||||
"logout": "Logg ut",
|
||||
"backupRestore": "Sikkerhetskopiering og gjenoppretting"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Innstillinger for Dagens Side",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Legg til en underoppgave..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Sikkerhetskopiering og gjenoppretting",
|
||||
"description": "Lag sikkerhetskopier eller gjenopprett fra tidligere sikkerhetskopier. Dine siste 5 sikkerhetskopier lagres automatisk.",
|
||||
"createBackup": "Lag sikkerhetskopi",
|
||||
"importFromFile": "Importer fra fil",
|
||||
"createNewBackup": "Lag ny sikkerhetskopi",
|
||||
"createDescription": "Lag en ny sikkerhetskopi av alle dataene dine. Sikkerhetskopier lagres på serveren, og du kan gjenopprette dem senere.",
|
||||
"createBackupNow": "Lag sikkerhetskopi nå",
|
||||
"creating": "Lager sikkerhetskopi...",
|
||||
"exportSuccess": "Sikkerhetskopi opprettet med suksess!",
|
||||
"exportError": "Feil ved oppretting av sikkerhetskopi",
|
||||
"savedBackups": "Lagrede sikkerhetskopier",
|
||||
"noBackups": "Ingen sikkerhetskopier funnet. Lag din første sikkerhetskopi ovenfor.",
|
||||
"createdAt": "Opprettet",
|
||||
"version": "Versjon",
|
||||
"currentVersion": "Nåværende versjon",
|
||||
"size": "Størrelse",
|
||||
"contents": "Innhold",
|
||||
"actions": "Handlinger",
|
||||
"restore": "Gjenopprett",
|
||||
"download": "Last ned",
|
||||
"downloadSuccess": "Sikkerhetskopiering lastet ned med suksess!",
|
||||
"downloadError": "Feil ved nedlasting av sikkerhetskopi",
|
||||
"confirmRestore": "Gjenopprett sikkerhetskopi",
|
||||
"confirmRestoreMessage": "Er du sikker på at du vil gjenopprette denne sikkerhetskopien? Dette vil slå sammen de sikkerhetskopierte dataene med dine nåværende data.",
|
||||
"restoreSuccess": "Sikkerhetskopi gjenopprettet med suksess! Opprettet: {{tasks}} oppgaver, {{projects}} prosjekter, {{notes}} notater",
|
||||
"restoreError": "Feil ved gjenoppretting av sikkerhetskopi",
|
||||
"confirmDelete": "Slett sikkerhetskopi",
|
||||
"confirmDeleteMessage": "Er du sikker på at du vil slette denne sikkerhetskopien? Denne handlingen kan ikke angres.",
|
||||
"deleteSuccess": "Sikkerhetskopi slettet med suksess!",
|
||||
"deleteError": "Feil ved sletting av sikkerhetskopi",
|
||||
"importTitle": "Importer fra fil",
|
||||
"importDescription": "Last opp en sikkerhetskopifil for å gjenopprette dataene dine. Dine eksisterende data vil bli bevart, og nye elementer fra sikkerhetskopien vil bli lagt til.",
|
||||
"importNote": "Viktig:",
|
||||
"importNoteDescription": "Import vil slå sammen data med dine eksisterende elementer. Duplikate elementer (samme UID) vil bli utelatt.",
|
||||
"selectFile": "Velg sikkerhetskopifil",
|
||||
"clickToUpload": "Klikk for å bla gjennom filer",
|
||||
"restoreBackup": "Gjenopprett sikkerhetskopi",
|
||||
"importing": "Importer...",
|
||||
"importSuccess": "Sikkerhetskopi importert med suksess! Opprettet: {{tasks}} oppgaver, {{projects}} prosjekter, {{notes}} notater",
|
||||
"importError": "Feil ved import av sikkerhetskopi",
|
||||
"backupContents": "Innhold i sikkerhetskopi:",
|
||||
"validationError": "Den valgte filen er ikke en gyldig sikkerhetskopi",
|
||||
"validationErrors": "Valideringsfeil:",
|
||||
"versionIncompatible": "Versjon inkompatibel",
|
||||
"backupVersion": "Sikkerhetskopiversjon"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Masz niezapisane zmiany. Czy na pewno chcesz je porzucić?",
|
||||
"no": "Nie, kontynuuj edytowanie",
|
||||
"yesDiscard": "Tak, porzuć",
|
||||
"uploading": "Przesyłanie..."
|
||||
"uploading": "Przesyłanie...",
|
||||
"refresh": "Odśwież"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Panel sterowania",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Ustawienia profilu",
|
||||
"settings": "Ustawienia",
|
||||
"about": "O aplikacji",
|
||||
"logout": "Wyloguj się"
|
||||
"logout": "Wyloguj się",
|
||||
"backupRestore": "Kopia zapasowa i przywracanie"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Ustawienia strony dzisiaj",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Dodaj podzadanie..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Kopia zapasowa i przywracanie",
|
||||
"description": "Twórz kopie zapasowe lub przywracaj z poprzednich kopii zapasowych. Twoje ostatnie 5 kopii zapasowych jest automatycznie zapisywanych.",
|
||||
"createBackup": "Utwórz kopię zapasową",
|
||||
"importFromFile": "Importuj z pliku",
|
||||
"createNewBackup": "Utwórz nową kopię zapasową",
|
||||
"createDescription": "Utwórz nową kopię zapasową wszystkich swoich danych. Kopie zapasowe są zapisywane na serwerze i możesz je przywrócić później.",
|
||||
"createBackupNow": "Utwórz kopię zapasową teraz",
|
||||
"creating": "Tworzenie kopii zapasowej...",
|
||||
"exportSuccess": "Kopia zapasowa została pomyślnie utworzona!",
|
||||
"exportError": "Nie udało się utworzyć kopii zapasowej",
|
||||
"savedBackups": "Zapisane kopie zapasowe",
|
||||
"noBackups": "Nie znaleziono kopii zapasowych. Utwórz swoją pierwszą kopię zapasową powyżej.",
|
||||
"createdAt": "Utworzono",
|
||||
"version": "Wersja",
|
||||
"currentVersion": "Bieżąca wersja",
|
||||
"size": "Rozmiar",
|
||||
"contents": "Zawartość",
|
||||
"actions": "Akcje",
|
||||
"importSuccess": "Kopia zapasowa została pomyślnie zaimportowana! Utworzono: {{tasks}} zadań, {{projects}} projektów, {{notes}} notatek",
|
||||
"importError": "Nie udało się zaimportować kopii zapasowej",
|
||||
"backupContents": "Zawartość kopii zapasowej:",
|
||||
"validationError": "Wybrany plik nie jest prawidłową kopią zapasową",
|
||||
"validationErrors": "Błędy walidacji:",
|
||||
"versionIncompatible": "Wersja niekompatybilna",
|
||||
"backupVersion": "Wersja kopii zapasowej",
|
||||
"restore": "Przywróć",
|
||||
"download": "Pobierz",
|
||||
"downloadSuccess": "Kopia zapasowa została pomyślnie pobrana!",
|
||||
"downloadError": "Nie udało się pobrać kopii zapasowej",
|
||||
"confirmRestore": "Przywróć kopię zapasową",
|
||||
"confirmRestoreMessage": "Czy na pewno chcesz przywrócić tę kopię zapasową? To połączy dane z kopii zapasowej z twoimi aktualnymi danymi.",
|
||||
"restoreSuccess": "Kopia zapasowa została pomyślnie przywrócona! Utworzono: {{tasks}} zadań, {{projects}} projektów, {{notes}} notatek",
|
||||
"restoreError": "Nie udało się przywrócić kopii zapasowej",
|
||||
"confirmDelete": "Usuń kopię zapasową",
|
||||
"confirmDeleteMessage": "Czy na pewno chcesz usunąć tę kopię zapasową? Ta akcja nie może być cofnięta.",
|
||||
"deleteSuccess": "Kopia zapasowa została pomyślnie usunięta!",
|
||||
"deleteError": "Nie udało się usunąć kopii zapasowej",
|
||||
"importTitle": "Importuj z pliku",
|
||||
"importDescription": "Prześlij plik kopii zapasowej, aby przywrócić swoje dane. Twoje istniejące dane zostaną zachowane, a nowe elementy z kopii zapasowej zostaną dodane.",
|
||||
"importNote": "WaŜne:",
|
||||
"importNoteDescription": "Import połączy dane z twoimi istniejącymi elementami. Duplikaty (ten sam UID) zostaną pominięte.",
|
||||
"selectFile": "Wybierz plik kopii zapasowej",
|
||||
"clickToUpload": "Kliknij, aby przeszukać pliki",
|
||||
"restoreBackup": "Przywróć kopię zapasową",
|
||||
"importing": "Importowanie..."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Você tem alterações não salvas. Tem certeza de que deseja descartá-las?",
|
||||
"no": "Não, continuar editando",
|
||||
"yesDiscard": "Sim, descartar",
|
||||
"uploading": "Carregando..."
|
||||
"uploading": "Carregando...",
|
||||
"refresh": "Atualizar"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Painel",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Configurações do Perfil",
|
||||
"settings": "Configurações",
|
||||
"about": "Sobre",
|
||||
"logout": "Sair"
|
||||
"logout": "Sair",
|
||||
"backupRestore": "Backup e Restauração"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Configurações da Página de Hoje",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Adicionar uma subtarefa..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup e Restauração",
|
||||
"description": "Crie backups ou restaure a partir de backups anteriores. Seus últimos 5 backups são salvos automaticamente.",
|
||||
"createBackup": "Criar Backup",
|
||||
"importFromFile": "Importar de Arquivo",
|
||||
"createNewBackup": "Criar Novo Backup",
|
||||
"createDescription": "Crie um novo backup de todos os seus dados. Os backups são salvos no servidor e você pode restaurá-los mais tarde.",
|
||||
"createBackupNow": "Criar Backup Agora",
|
||||
"creating": "Criando backup...",
|
||||
"exportSuccess": "Backup criado com sucesso!",
|
||||
"exportError": "Falha ao criar backup",
|
||||
"savedBackups": "Backups Salvos",
|
||||
"noBackups": "Nenhum backup encontrado. Crie seu primeiro backup acima.",
|
||||
"createdAt": "Criado",
|
||||
"version": "Versão",
|
||||
"currentVersion": "Versão atual",
|
||||
"size": "Tamanho",
|
||||
"contents": "Conteúdo",
|
||||
"actions": "Ações",
|
||||
"restore": "Restaurar",
|
||||
"download": "Baixar",
|
||||
"downloadSuccess": "Backup baixado com sucesso!",
|
||||
"downloadError": "Falha ao baixar o backup",
|
||||
"confirmRestore": "Restaurar Backup",
|
||||
"confirmRestoreMessage": "Você tem certeza de que deseja restaurar este backup? Isso irá mesclar os dados do backup com seus dados atuais.",
|
||||
"restoreSuccess": "Backup restaurado com sucesso! Criado: {{tasks}} tarefas, {{projects}} projetos, {{notes}} notas",
|
||||
"restoreError": "Falha ao restaurar o backup",
|
||||
"confirmDelete": "Excluir Backup",
|
||||
"confirmDeleteMessage": "Você tem certeza de que deseja excluir este backup? Esta ação não pode ser desfeita.",
|
||||
"deleteSuccess": "Backup excluído com sucesso!",
|
||||
"deleteError": "Falha ao excluir o backup",
|
||||
"importTitle": "Importar de Arquivo",
|
||||
"importDescription": "Carregue um arquivo de backup para restaurar seus dados. Seus dados existentes serão preservados e novos itens do backup serão adicionados.",
|
||||
"importNote": "Importante:",
|
||||
"importNoteDescription": "A importação irá mesclar dados com seus itens existentes. Itens duplicados (mesmo UID) serão ignorados.",
|
||||
"selectFile": "Selecionar Arquivo de Backup",
|
||||
"clickToUpload": "Clique para navegar nos arquivos",
|
||||
"restoreBackup": "Restaurar Backup",
|
||||
"importing": "Importando...",
|
||||
"importSuccess": "Backup importado com sucesso! Criado: {{tasks}} tarefas, {{projects}} projetos, {{notes}} notas",
|
||||
"importError": "Falha ao importar o backup",
|
||||
"backupContents": "Conteúdo do backup:",
|
||||
"validationError": "O arquivo selecionado não é um backup válido",
|
||||
"validationErrors": "Erros de validação:",
|
||||
"versionIncompatible": "Versão Incompatível",
|
||||
"backupVersion": "Versão do backup"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Ai modificări nesalvate. Ești sigur că vrei să le renunți?",
|
||||
"no": "Nu, continuă editarea",
|
||||
"yesDiscard": "Da, renunță",
|
||||
"uploading": "Se încarcă..."
|
||||
"uploading": "Se încarcă...",
|
||||
"refresh": "Reîmprospătare"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Tabloul de bord",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Setări Profil",
|
||||
"settings": "Setări",
|
||||
"about": "Despre",
|
||||
"logout": "Deconectare"
|
||||
"logout": "Deconectare",
|
||||
"backupRestore": "Backup și Restaurare"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Setări Pagina de Azi",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Adaugă o subtask..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup și Restaurare",
|
||||
"description": "Creează backup-uri sau restaurează din backup-uri anterioare. Ultimele 5 backup-uri sunt salvate automat.",
|
||||
"createBackup": "Creează Backup",
|
||||
"importFromFile": "Importă din Fișier",
|
||||
"createNewBackup": "Creează Backup Nou",
|
||||
"createDescription": "Creează un nou backup al tuturor datelor tale. Backup-urile sunt salvate pe server și le poți restaura mai târziu.",
|
||||
"createBackupNow": "Creează Backup Acum",
|
||||
"creating": "Se creează backup...",
|
||||
"exportSuccess": "Backup creat cu succes!",
|
||||
"exportError": "Crearea backup-ului a eșuat",
|
||||
"savedBackups": "Backup-uri Salvate",
|
||||
"noBackups": "Nu au fost găsite backup-uri. Creează primul tău backup mai sus.",
|
||||
"createdAt": "Creat",
|
||||
"version": "Versiune",
|
||||
"currentVersion": "Versiunea curentă",
|
||||
"size": "Dimensiune",
|
||||
"contents": "Conținut",
|
||||
"actions": "Acțiuni",
|
||||
"importSuccess": "Backup importat cu succes! Creat: {{tasks}} sarcini, {{projects}} proiecte, {{notes}} note",
|
||||
"importError": "Importul backup-ului a eșuat",
|
||||
"backupContents": "Conținutul backup-ului:",
|
||||
"validationError": "Fișierul selectat nu este un backup valid",
|
||||
"validationErrors": "Erori de validare:",
|
||||
"versionIncompatible": "Versiune incompatibilă",
|
||||
"backupVersion": "Versiunea backup-ului",
|
||||
"restore": "Restaurare",
|
||||
"download": "Descarcă",
|
||||
"downloadSuccess": "Backup descărcat cu succes!",
|
||||
"downloadError": "Eșec la descărcarea backup-ului",
|
||||
"confirmRestore": "Restaurare Backup",
|
||||
"confirmRestoreMessage": "Ești sigur că vrei să restaurezi acest backup? Aceasta va combina datele salvate cu datele tale curente.",
|
||||
"restoreSuccess": "Backup restaurat cu succes! Creat: {{tasks}} sarcini, {{projects}} proiecte, {{notes}} note",
|
||||
"restoreError": "Eșec la restaurarea backup-ului",
|
||||
"confirmDelete": "Șterge Backup",
|
||||
"confirmDeleteMessage": "Ești sigur că vrei să ștergi acest backup? Această acțiune nu poate fi anulată.",
|
||||
"deleteSuccess": "Backup șters cu succes!",
|
||||
"deleteError": "Eșec la ștergerea backup-ului",
|
||||
"importTitle": "Import din Fișier",
|
||||
"importDescription": "Încarcă un fișier de backup pentru a-ți restaura datele. Datele tale existente vor fi păstrate, iar elementele noi din backup vor fi adăugate.",
|
||||
"importNote": "Important:",
|
||||
"importNoteDescription": "Importul va combina datele cu elementele tale existente. Elementele duplicate (același UID) vor fi omise.",
|
||||
"selectFile": "Selectează Fișierul de Backup",
|
||||
"clickToUpload": "Click pentru a naviga la fișiere",
|
||||
"restoreBackup": "Restaurare Backup",
|
||||
"importing": "Importare..."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "У вас есть несохраненные изменения. Вы уверены, что хотите их отменить?",
|
||||
"no": "Нет, продолжить редактирование",
|
||||
"yesDiscard": "Да, отменить",
|
||||
"uploading": "Загрузка..."
|
||||
"uploading": "Загрузка...",
|
||||
"refresh": "Обновить"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Панель управления",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Настройки профиля",
|
||||
"settings": "Настройки",
|
||||
"about": "О программе",
|
||||
"logout": "Выход"
|
||||
"logout": "Выход",
|
||||
"backupRestore": "Резервное копирование и восстановление"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Настройки страницы «Сегодня»",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Добавить подзадачу..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Резервное копирование и восстановление",
|
||||
"description": "Создайте резервные копии или восстановите из предыдущих резервных копий. Ваши последние 5 резервных копий сохраняются автоматически.",
|
||||
"createBackup": "Создать резервную копию",
|
||||
"importFromFile": "Импорт из файла",
|
||||
"createNewBackup": "Создать новую резервную копию",
|
||||
"createDescription": "Создайте новую резервную копию всех ваших данных. Резервные копии сохраняются на сервере, и вы можете восстановить их позже.",
|
||||
"createBackupNow": "Создать резервную копию сейчас",
|
||||
"creating": "Создание резервной копии...",
|
||||
"exportSuccess": "Резервная копия успешно создана!",
|
||||
"exportError": "Не удалось создать резервную копию",
|
||||
"savedBackups": "Сохраненные резервные копии",
|
||||
"noBackups": "Резервные копии не найдены. Создайте свою первую резервную копию выше.",
|
||||
"createdAt": "Создано",
|
||||
"version": "Версия",
|
||||
"currentVersion": "Текущая версия",
|
||||
"size": "Размер",
|
||||
"contents": "Содержимое",
|
||||
"actions": "Действия",
|
||||
"restore": "Восстановить",
|
||||
"download": "Скачать",
|
||||
"downloadSuccess": "Резервная копия успешно загружена!",
|
||||
"downloadError": "Не удалось загрузить резервную копию",
|
||||
"confirmRestore": "Восстановить резервную копию",
|
||||
"confirmRestoreMessage": "Вы уверены, что хотите восстановить эту резервную копию? Это объединит данные из резервной копии с вашими текущими данными.",
|
||||
"restoreSuccess": "Резервная копия успешно восстановлена! Создано: {{tasks}} задач, {{projects}} проектов, {{notes}} заметок",
|
||||
"restoreError": "Не удалось восстановить резервную копию",
|
||||
"confirmDelete": "Удалить резервную копию",
|
||||
"confirmDeleteMessage": "Вы уверены, что хотите удалить эту резервную копию? Это действие нельзя отменить.",
|
||||
"deleteSuccess": "Резервная копия успешно удалена!",
|
||||
"deleteError": "Не удалось удалить резервную копию",
|
||||
"importTitle": "Импорт из файла",
|
||||
"importDescription": "Загрузите файл резервной копии, чтобы восстановить ваши данные. Ваши существующие данные будут сохранены, а новые элементы из резервной копии будут добавлены.",
|
||||
"importNote": "Важно:",
|
||||
"importNoteDescription": "Импорт объединит данные с вашими существующими элементами. Дублирующие элементы (одинаковый UID) будут пропущены.",
|
||||
"selectFile": "Выбрать файл резервной копии",
|
||||
"clickToUpload": "Нажмите, чтобы выбрать файлы",
|
||||
"restoreBackup": "Восстановить резервную копию",
|
||||
"importing": "Импортирование...",
|
||||
"importSuccess": "Резервная копия успешно импортирована! Создано: {{tasks}} задач, {{projects}} проектов, {{notes}} заметок",
|
||||
"importError": "Не удалось импортировать резервную копию",
|
||||
"backupContents": "Содержимое резервной копии:",
|
||||
"validationError": "Выбранный файл не является действительной резервной копией",
|
||||
"validationErrors": "Ошибки валидации:",
|
||||
"versionIncompatible": "Версия несовместима",
|
||||
"backupVersion": "Версия резервной копии"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Imate neshranjene spremembe. Ste prepričani, da jih želite zavreči?",
|
||||
"no": "Ne, nadaljuj z urejanjem",
|
||||
"yesDiscard": "Da, zavrzi",
|
||||
"uploading": "Nalagam..."
|
||||
"uploading": "Nalagam...",
|
||||
"refresh": "Osveži"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Nadzorna plošča",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Nastavitve profila",
|
||||
"settings": "Nastavitve",
|
||||
"about": "O aplikaciji",
|
||||
"logout": "Odjava"
|
||||
"logout": "Odjava",
|
||||
"backupRestore": "Varnostna kopija in obnova"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Nastavitve strani Danes",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Dodaj podnalogo..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Varnostna kopija in obnova",
|
||||
"description": "Ustvarite varnostne kopije ali obnovite iz prejšnjih varnostnih kopij. Vaših zadnjih 5 varnostnih kopij je samodejno shranjenih.",
|
||||
"createBackup": "Ustvari varnostno kopijo",
|
||||
"importFromFile": "Uvozi iz datoteke",
|
||||
"createNewBackup": "Ustvari novo varnostno kopijo",
|
||||
"createDescription": "Ustvarite novo varnostno kopijo vseh vaših podatkov. Varnostne kopije so shranjene na strežniku in jih lahko obnovite kasneje.",
|
||||
"createBackupNow": "Ustvari varnostno kopijo zdaj",
|
||||
"creating": "Ustvarjanje varnostne kopije...",
|
||||
"exportSuccess": "Varnostna kopija je bila uspešno ustvarjena!",
|
||||
"exportError": "Ustvarjanje varnostne kopije je spodletelo",
|
||||
"savedBackups": "Shranjene varnostne kopije",
|
||||
"noBackups": "Nobena varnostna kopija ni bila najdena. Ustvarite svojo prvo varnostno kopijo zgoraj.",
|
||||
"createdAt": "Ustvarjeno",
|
||||
"version": "Različica",
|
||||
"currentVersion": "Trenutna različica",
|
||||
"size": "Velikost",
|
||||
"contents": "Vsebina",
|
||||
"actions": "Dejanja",
|
||||
"restore": "Obnovi",
|
||||
"download": "Prenesi",
|
||||
"downloadSuccess": "Varnostna kopija je bila uspešno prenesena!",
|
||||
"downloadError": "Prenos varnostne kopije ni uspel",
|
||||
"confirmRestore": "Obnovi varnostno kopijo",
|
||||
"confirmRestoreMessage": "Ali ste prepričani, da želite obnoviti to varnostno kopijo? To bo združilo podatke iz varnostne kopije z vašimi trenutnimi podatki.",
|
||||
"restoreSuccess": "Varnostna kopija je bila uspešno obnovljena! Ustvarjeno: {{tasks}} nalog, {{projects}} projektov, {{notes}} opomb",
|
||||
"restoreError": "Obnova varnostne kopije ni uspela",
|
||||
"confirmDelete": "Izbriši varnostno kopijo",
|
||||
"confirmDeleteMessage": "Ali ste prepričani, da želite izbrisati to varnostno kopijo? Teh dejanj ni mogoče razveljaviti.",
|
||||
"deleteSuccess": "Varnostna kopija je bila uspešno izbrisana!",
|
||||
"deleteError": "Brisanje varnostne kopije ni uspelo",
|
||||
"importTitle": "Uvozi iz datoteke",
|
||||
"importDescription": "Naložite datoteko varnostne kopije za obnovitev vaših podatkov. Vaši obstoječi podatki bodo ohranjeni, novi elementi iz varnostne kopije pa bodo dodani.",
|
||||
"importNote": "Pomembno:",
|
||||
"importNoteDescription": "Uvoz bo združil podatke z vašimi obstoječimi elementi. Podvojeni elementi (isti UID) bodo preskočeni.",
|
||||
"selectFile": "Izberite datoteko varnostne kopije",
|
||||
"clickToUpload": "Kliknite za iskanje datotek",
|
||||
"restoreBackup": "Obnovi varnostno kopijo",
|
||||
"importing": "Uvažam...",
|
||||
"importSuccess": "Varnostna kopija je bila uspešno uvožena! Ustvarjeno: {{tasks}} nalog, {{projects}} projektov, {{notes}} opomb",
|
||||
"importError": "Uvoz varnostne kopije je neuspešen",
|
||||
"backupContents": "Vsebina varnostne kopije:",
|
||||
"validationError": "Izbrana datoteka ni veljavna varnostna kopija",
|
||||
"validationErrors": "Napake pri preverjanju:",
|
||||
"versionIncompatible": "Različica ni združljiva",
|
||||
"backupVersion": "Različica varnostne kopije"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Du har osparade ändringar. Är du säker på att du vill kasta bort dem?",
|
||||
"no": "Nej, fortsätt redigera",
|
||||
"yesDiscard": "Ja, kasta bort",
|
||||
"uploading": "Laddar upp..."
|
||||
"uploading": "Laddar upp...",
|
||||
"refresh": "Uppdatera"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Profilinställningar",
|
||||
"settings": "Inställningar",
|
||||
"about": "Om",
|
||||
"logout": "Logga ut"
|
||||
"logout": "Logga ut",
|
||||
"backupRestore": "Säkerhetskopiera & Återställ"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Inställningar för dagens sida",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Lägg till en deluppgift..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Säkerhetskopiera & Återställ",
|
||||
"description": "Skapa säkerhetskopior eller återställ från tidigare säkerhetskopior. Dina senaste 5 säkerhetskopior sparas automatiskt.",
|
||||
"createBackup": "Skapa säkerhetskopia",
|
||||
"importFromFile": "Importera från fil",
|
||||
"createNewBackup": "Skapa ny säkerhetskopia",
|
||||
"createDescription": "Skapa en ny säkerhetskopia av alla dina data. Säkerhetskopior sparas på servern och du kan återställa dem senare.",
|
||||
"createBackupNow": "Skapa säkerhetskopia nu",
|
||||
"creating": "Skapar säkerhetskopia...",
|
||||
"exportSuccess": "Säkerhetskopian skapades framgångsrikt!",
|
||||
"exportError": "Misslyckades med att skapa säkerhetskopia",
|
||||
"savedBackups": "Sparade säkerhetskopior",
|
||||
"noBackups": "Inga säkerhetskopior hittades. Skapa din första säkerhetskopia ovan.",
|
||||
"createdAt": "Skapad",
|
||||
"version": "Version",
|
||||
"currentVersion": "Aktuell version",
|
||||
"size": "Storlek",
|
||||
"contents": "Innehåll",
|
||||
"actions": "Åtgärder",
|
||||
"restore": "Återställ",
|
||||
"download": "Ladda ner",
|
||||
"downloadSuccess": "Backup nedladdad framgångsrikt!",
|
||||
"downloadError": "Misslyckades med att ladda ner backup",
|
||||
"confirmRestore": "Återställ Backup",
|
||||
"confirmRestoreMessage": "Är du säker på att du vill återställa denna backup? Detta kommer att slå samman den säkerhetskopierade datan med din nuvarande data.",
|
||||
"restoreSuccess": "Backup återställd framgångsrikt! Skapade: {{tasks}} uppgifter, {{projects}} projekt, {{notes}} anteckningar",
|
||||
"restoreError": "Misslyckades med att återställa backup",
|
||||
"confirmDelete": "Ta bort Backup",
|
||||
"confirmDeleteMessage": "Är du säker på att du vill ta bort denna backup? Denna åtgärd kan inte ångras.",
|
||||
"deleteSuccess": "Backup borttagen framgångsrikt!",
|
||||
"deleteError": "Misslyckades med att ta bort backup",
|
||||
"importTitle": "Importera från Fil",
|
||||
"importDescription": "Ladda upp en backupfil för att återställa din data. Din befintliga data kommer att bevaras, och nya objekt från backupen kommer att läggas till.",
|
||||
"importNote": "Viktigt:",
|
||||
"importNoteDescription": "Import kommer att slå samman data med dina befintliga objekt. Duplicerade objekt (samma UID) kommer att hoppas över.",
|
||||
"selectFile": "Välj Backupfil",
|
||||
"clickToUpload": "Klicka för att bläddra bland filer",
|
||||
"restoreBackup": "Återställ Backup",
|
||||
"importing": "Importerar...",
|
||||
"importSuccess": "Backup importerad framgångsrikt! Skapade: {{tasks}} uppgifter, {{projects}} projekt, {{notes}} anteckningar",
|
||||
"importError": "Misslyckades med att importera backup",
|
||||
"backupContents": "Backupinnehåll:",
|
||||
"validationError": "Den valda filen är inte en giltig backup",
|
||||
"validationErrors": "Valideringsfel:",
|
||||
"versionIncompatible": "Version inkompatibel",
|
||||
"backupVersion": "Backupversion"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Kaydedilmemiş değişiklikleriniz var. Onları iptal etmek istediğinizden emin misiniz?",
|
||||
"no": "Hayır, düzenlemeye devam et",
|
||||
"yesDiscard": "Evet, iptal et",
|
||||
"uploading": "Yükleniyor..."
|
||||
"uploading": "Yükleniyor...",
|
||||
"refresh": "Yenile"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Gösterge Paneli",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Profil Ayarları",
|
||||
"settings": "Ayarlar",
|
||||
"about": "Hakkında",
|
||||
"logout": "Çıkış Yap"
|
||||
"logout": "Çıkış Yap",
|
||||
"backupRestore": "Yedekleme & Geri Yükleme"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Bugün Sayfası Ayarları",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Bir alt görev ekle..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Yedekleme & Geri Yükleme",
|
||||
"description": "Yedeklemeler oluşturun veya önceki yedeklemelerden geri yükleyin. Son 5 yedeğiniz otomatik olarak kaydedilir.",
|
||||
"createBackup": "Yedek Oluştur",
|
||||
"importFromFile": "Dosyadan İçe Aktar",
|
||||
"createNewBackup": "Yeni Yedek Oluştur",
|
||||
"createDescription": "Tüm verilerinizin yeni bir yedeğini oluşturun. Yedekler sunucuda kaydedilir ve daha sonra geri yükleyebilirsiniz.",
|
||||
"createBackupNow": "Şimdi Yedek Oluştur",
|
||||
"creating": "Yedek oluşturuluyor...",
|
||||
"exportSuccess": "Yedek başarıyla oluşturuldu!",
|
||||
"exportError": "Yedek oluşturulamadı",
|
||||
"savedBackups": "Kaydedilen Yedekler",
|
||||
"noBackups": "Yedek bulunamadı. Yukarıdan ilk yedeğinizi oluşturun.",
|
||||
"createdAt": "Oluşturuldu",
|
||||
"version": "Sürüm",
|
||||
"currentVersion": "Mevcut sürüm",
|
||||
"size": "Boyut",
|
||||
"contents": "İçerik",
|
||||
"actions": "Eylemler",
|
||||
"restore": "Geri Yükle",
|
||||
"download": "İndir",
|
||||
"downloadSuccess": "Yedek başarıyla indirildi!",
|
||||
"downloadError": "Yedeği indirmek başarısız oldu",
|
||||
"confirmRestore": "Yedeği Geri Yükle",
|
||||
"confirmRestoreMessage": "Bu yedeği geri yüklemek istediğinizden emin misiniz? Bu, yedeklenen verileri mevcut verilerinizle birleştirecektir.",
|
||||
"restoreSuccess": "Yedek başarıyla geri yüklendi! Oluşturulan: {{tasks}} görev, {{projects}} proje, {{notes}} not",
|
||||
"restoreError": "Yedeği geri yüklemek başarısız oldu",
|
||||
"confirmDelete": "Yedeği Sil",
|
||||
"confirmDeleteMessage": "Bu yedeği silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"deleteSuccess": "Yedek başarıyla silindi!",
|
||||
"deleteError": "Yedeği silmek başarısız oldu",
|
||||
"importTitle": "Dosyadan İçe Aktar",
|
||||
"importDescription": "Verilerinizi geri yüklemek için bir yedek dosyası yükleyin. Mevcut verileriniz korunacak ve yedekten yeni öğeler eklenecektir.",
|
||||
"importNote": "Önemli:",
|
||||
"importNoteDescription": "İçe aktarma, verileri mevcut öğelerinizle birleştirecektir. Tekrar eden öğeler (aynı UID) atlanacaktır.",
|
||||
"selectFile": "Yedek Dosyasını Seç",
|
||||
"clickToUpload": "Dosyaları taramak için tıklayın",
|
||||
"restoreBackup": "Yedeği Geri Yükle",
|
||||
"importing": "İçe Aktarılıyor...",
|
||||
"importSuccess": "Yedek başarıyla içe aktarıldı! Oluşturuldu: {{tasks}} görev, {{projects}} proje, {{notes}} not",
|
||||
"importError": "Yedeği içe aktarmak başarısız oldu",
|
||||
"backupContents": "Yedek içeriği:",
|
||||
"validationError": "Seçilen dosya geçerli bir yedek değil",
|
||||
"validationErrors": "Doğrulama hataları:",
|
||||
"versionIncompatible": "Sürüm Uyuşmaz",
|
||||
"backupVersion": "Yedek sürümü"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "У вас є незбережені зміни. Ви впевнені, що хочете їх відкинути?",
|
||||
"no": "Ні, продовжити редагування",
|
||||
"yesDiscard": "Так, відкинути",
|
||||
"uploading": "Завантаження..."
|
||||
"uploading": "Завантаження...",
|
||||
"refresh": "Оновити"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Дашборд",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Налаштування профілю",
|
||||
"settings": "Налаштування",
|
||||
"about": "Про програму",
|
||||
"logout": "Вийти"
|
||||
"logout": "Вийти",
|
||||
"backupRestore": "Резервне копіювання та відновлення"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Налаштування сторінки Сьогодні",
|
||||
|
|
@ -1208,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Додати підзадачу..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Резервне копіювання та відновлення",
|
||||
"description": "Створіть резервні копії або відновіть з попередніх резервних копій. Ваші останні 5 резервних копій автоматично зберігаються.",
|
||||
"createBackup": "Створити резервну копію",
|
||||
"importFromFile": "Імпортувати з файлу",
|
||||
"createNewBackup": "Створити нову резервну копію",
|
||||
"createDescription": "Створіть нову резервну копію всіх ваших даних. Резервні копії зберігаються на сервері, і ви можете відновити їх пізніше.",
|
||||
"createBackupNow": "Створити резервну копію зараз",
|
||||
"creating": "Створення резервної копії...",
|
||||
"exportSuccess": "Резервну копію успішно створено!",
|
||||
"exportError": "Не вдалося створити резервну копію",
|
||||
"savedBackups": "Збережені резервні копії",
|
||||
"noBackups": "Резервні копії не знайдено. Створіть свою першу резервну копію вище.",
|
||||
"createdAt": "Створено",
|
||||
"version": "Версія",
|
||||
"currentVersion": "Поточна версія",
|
||||
"size": "Розмір",
|
||||
"contents": "Вміст",
|
||||
"actions": "Дії",
|
||||
"restore": "Відновити",
|
||||
"download": "Завантажити",
|
||||
"downloadSuccess": "Резервну копію успішно завантажено!",
|
||||
"downloadError": "Не вдалося завантажити резервну копію",
|
||||
"confirmRestore": "Відновити резервну копію",
|
||||
"confirmRestoreMessage": "Ви впевнені, що хочете відновити цю резервну копію? Це об'єднає дані резервної копії з вашими поточними даними.",
|
||||
"restoreSuccess": "Резервну копію успішно відновлено! Створено: {{tasks}} завдань, {{projects}} проектів, {{notes}} нотаток",
|
||||
"restoreError": "Не вдалося відновити резервну копію",
|
||||
"confirmDelete": "Видалити резервну копію",
|
||||
"confirmDeleteMessage": "Ви впевнені, що хочете видалити цю резервну копію? Цю дію не можна скасувати.",
|
||||
"deleteSuccess": "Резервну копію успішно видалено!",
|
||||
"deleteError": "Не вдалося видалити резервну копію",
|
||||
"importTitle": "Імпорт з файлу",
|
||||
"importDescription": "Завантажте файл резервної копії, щоб відновити ваші дані. Ваші існуючі дані будуть збережені, а нові елементи з резервної копії будуть додані.",
|
||||
"importNote": "Важливо:",
|
||||
"importNoteDescription": "Імпорт об'єднає дані з вашими існуючими елементами. Дублікати (однаковий UID) будуть пропущені.",
|
||||
"selectFile": "Вибрати файл резервної копії",
|
||||
"clickToUpload": "Натисніть, щоб вибрати файли",
|
||||
"restoreBackup": "Відновити резервну копію",
|
||||
"importing": "Імпортується...",
|
||||
"importSuccess": "Резервну копію успішно імпортовано! Створено: {{tasks}} завдань, {{projects}} проектів, {{notes}} нотаток",
|
||||
"importError": "Не вдалося імпортувати резервну копію",
|
||||
"backupContents": "Зміст резервної копії:",
|
||||
"validationError": "Вибраний файл не є дійсною резервною копією",
|
||||
"validationErrors": "Помилки валідації:",
|
||||
"versionIncompatible": "Несумісна версія",
|
||||
"backupVersion": "Версія резервної копії"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "Bạn có thay đổi chưa lưu. Bạn có chắc chắn muốn bỏ qua chúng không?",
|
||||
"no": "Không, tiếp tục chỉnh sửa",
|
||||
"yesDiscard": "Có, bỏ qua",
|
||||
"uploading": "Đang tải lên..."
|
||||
"uploading": "Đang tải lên...",
|
||||
"refresh": "Làm mới"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Bảng điều khiển",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "Cài đặt Hồ sơ",
|
||||
"settings": "Cài đặt",
|
||||
"about": "Giới thiệu",
|
||||
"logout": "Đăng xuất"
|
||||
"logout": "Đăng xuất",
|
||||
"backupRestore": "Sao lưu & Khôi phục"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "Cài đặt Trang Hôm nay",
|
||||
|
|
@ -366,7 +368,8 @@
|
|||
"stuckProjectsDesc": "Dự án không được cập nhật gần đây",
|
||||
"reviewItems": "Nhấn để xem xét và cải thiện quy trình làm việc của bạn",
|
||||
"suggestion": "Nhấn vào bất kỳ mục nào ở trên để mở nó và thực hiện cải tiến.",
|
||||
"issuesFound_other": "{{count}} vấn đề năng suất cần được chú ý"
|
||||
"issuesFound_other": "{{count}} vấn đề năng suất cần được chú ý",
|
||||
"issuesFound_one": "1 vấn đề năng suất cần được chú ý"
|
||||
},
|
||||
"nextTask": {
|
||||
"suggestion": "Vì không có gì đang tiến hành, bạn có muốn bắt đầu với",
|
||||
|
|
@ -1207,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "Thêm một công việc phụ..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "Sao lưu & Khôi phục",
|
||||
"description": "Tạo bản sao lưu hoặc khôi phục từ các bản sao lưu trước đó. 5 bản sao lưu cuối cùng của bạn sẽ được lưu tự động.",
|
||||
"createBackup": "Tạo bản sao lưu",
|
||||
"importFromFile": "Nhập từ tệp",
|
||||
"createNewBackup": "Tạo bản sao lưu mới",
|
||||
"createDescription": "Tạo một bản sao lưu mới cho tất cả dữ liệu của bạn. Các bản sao lưu được lưu trên máy chủ và bạn có thể khôi phục chúng sau.",
|
||||
"createBackupNow": "Tạo bản sao lưu ngay bây giờ",
|
||||
"creating": "Đang tạo bản sao lưu...",
|
||||
"exportSuccess": "Tạo bản sao lưu thành công!",
|
||||
"exportError": "Tạo bản sao lưu không thành công",
|
||||
"savedBackups": "Các bản sao lưu đã lưu",
|
||||
"noBackups": "Không tìm thấy bản sao lưu nào. Tạo bản sao lưu đầu tiên của bạn ở trên.",
|
||||
"createdAt": "Đã tạo",
|
||||
"version": "Phiên bản",
|
||||
"currentVersion": "Phiên bản hiện tại",
|
||||
"size": "Kích thước",
|
||||
"contents": "Nội dung",
|
||||
"actions": "Hành động",
|
||||
"restore": "Khôi phục",
|
||||
"download": "Tải xuống",
|
||||
"downloadSuccess": "Sao lưu đã được tải xuống thành công!",
|
||||
"downloadError": "Tải xuống sao lưu thất bại",
|
||||
"confirmRestore": "Khôi phục Sao lưu",
|
||||
"confirmRestoreMessage": "Bạn có chắc chắn muốn khôi phục sao lưu này không? Điều này sẽ hợp nhất dữ liệu đã sao lưu với dữ liệu hiện tại của bạn.",
|
||||
"restoreSuccess": "Sao lưu đã được khôi phục thành công! Đã tạo: {{tasks}} nhiệm vụ, {{projects}} dự án, {{notes}} ghi chú",
|
||||
"restoreError": "Khôi phục sao lưu thất bại",
|
||||
"confirmDelete": "Xóa Sao lưu",
|
||||
"confirmDeleteMessage": "Bạn có chắc chắn muốn xóa sao lưu này không? Hành động này không thể hoàn tác.",
|
||||
"deleteSuccess": "Sao lưu đã được xóa thành công!",
|
||||
"deleteError": "Xóa sao lưu thất bại",
|
||||
"importTitle": "Nhập từ Tệp",
|
||||
"importDescription": "Tải lên một tệp sao lưu để khôi phục dữ liệu của bạn. Dữ liệu hiện có của bạn sẽ được bảo tồn, và các mục mới từ sao lưu sẽ được thêm vào.",
|
||||
"importNote": "Quan trọng:",
|
||||
"importNoteDescription": "Nhập sẽ hợp nhất dữ liệu với các mục hiện có của bạn. Các mục trùng lặp (cùng UID) sẽ bị bỏ qua.",
|
||||
"selectFile": "Chọn Tệp Sao lưu",
|
||||
"clickToUpload": "Nhấp để duyệt tệp",
|
||||
"restoreBackup": "Khôi phục Sao lưu",
|
||||
"importing": "Đang nhập...",
|
||||
"importSuccess": "Nhập bản sao lưu thành công! Đã tạo: {{tasks}} nhiệm vụ, {{projects}} dự án, {{notes}} ghi chú",
|
||||
"importError": "Không thể nhập bản sao lưu",
|
||||
"backupContents": "Nội dung bản sao lưu:",
|
||||
"validationError": "Tệp được chọn không phải là bản sao lưu hợp lệ",
|
||||
"validationErrors": "Lỗi xác thực:",
|
||||
"versionIncompatible": "Phiên bản không tương thích",
|
||||
"backupVersion": "Phiên bản bản sao lưu"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
"discardChangesMessage": "您有未保存的更改。您确定要放弃它们吗?",
|
||||
"no": "不,继续编辑",
|
||||
"yesDiscard": "是的,放弃",
|
||||
"uploading": "上传中..."
|
||||
"uploading": "上传中...",
|
||||
"refresh": "刷新"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "仪表板",
|
||||
|
|
@ -63,7 +64,8 @@
|
|||
"profileSettings": "个人设置",
|
||||
"settings": "设置",
|
||||
"about": "关于",
|
||||
"logout": "注销"
|
||||
"logout": "注销",
|
||||
"backupRestore": "备份与恢复"
|
||||
},
|
||||
"settings": {
|
||||
"todayPageSettings": "今日页面设置",
|
||||
|
|
@ -366,7 +368,8 @@
|
|||
"stuckProjectsDesc": "最近未更新的项目",
|
||||
"reviewItems": "点击以审查和改善您的工作流程",
|
||||
"suggestion": "点击上面的任何项目以打开并进行改进。",
|
||||
"issuesFound_other": "{{count}}个生产力问题需要关注"
|
||||
"issuesFound_other": "{{count}}个生产力问题需要关注",
|
||||
"issuesFound_one": "1 个生产力问题需要关注"
|
||||
},
|
||||
"nextTask": {
|
||||
"suggestion": "由于没有进行中的任务,您觉得从这个开始怎么样",
|
||||
|
|
@ -1207,5 +1210,52 @@
|
|||
},
|
||||
"subtasks": {
|
||||
"placeholder": "添加子任务..."
|
||||
},
|
||||
"backup": {
|
||||
"title": "备份与恢复",
|
||||
"description": "创建备份或从以前的备份中恢复。您的最后 5 个备份会自动保存。",
|
||||
"createBackup": "创建备份",
|
||||
"importFromFile": "从文件导入",
|
||||
"createNewBackup": "创建新备份",
|
||||
"createDescription": "创建您所有数据的新备份。备份保存在服务器上,您可以稍后恢复。",
|
||||
"createBackupNow": "立即创建备份",
|
||||
"creating": "正在创建备份...",
|
||||
"exportSuccess": "备份创建成功!",
|
||||
"exportError": "创建备份失败",
|
||||
"savedBackups": "已保存的备份",
|
||||
"noBackups": "未找到备份。请在上方创建您的第一个备份。",
|
||||
"createdAt": "创建于",
|
||||
"version": "版本",
|
||||
"currentVersion": "当前版本",
|
||||
"size": "大小",
|
||||
"contents": "内容",
|
||||
"actions": "操作",
|
||||
"restore": "恢复",
|
||||
"download": "下载",
|
||||
"downloadSuccess": "备份下载成功!",
|
||||
"downloadError": "下载备份失败",
|
||||
"confirmRestore": "恢复备份",
|
||||
"confirmRestoreMessage": "您确定要恢复此备份吗?这将把备份的数据与您当前的数据合并。",
|
||||
"restoreSuccess": "备份恢复成功!创建了:{{tasks}} 个任务,{{projects}} 个项目,{{notes}} 条笔记",
|
||||
"restoreError": "恢复备份失败",
|
||||
"confirmDelete": "删除备份",
|
||||
"confirmDeleteMessage": "您确定要删除此备份吗?此操作无法撤销。",
|
||||
"deleteSuccess": "备份删除成功!",
|
||||
"deleteError": "删除备份失败",
|
||||
"importTitle": "从文件导入",
|
||||
"importDescription": "上传备份文件以恢复您的数据。您现有的数据将被保留,备份中的新项目将被添加。",
|
||||
"importNote": "重要:",
|
||||
"importNoteDescription": "导入将与您现有的项目合并数据。重复项目(相同的UID)将被跳过。",
|
||||
"selectFile": "选择备份文件",
|
||||
"clickToUpload": "点击浏览文件",
|
||||
"restoreBackup": "恢复备份",
|
||||
"importing": "导入中...",
|
||||
"importSuccess": "备份导入成功!创建了:{{tasks}} 个任务,{{projects}} 个项目,{{notes}} 个笔记",
|
||||
"importError": "导入备份失败",
|
||||
"backupContents": "备份内容:",
|
||||
"validationError": "所选文件不是有效的备份",
|
||||
"validationErrors": "验证错误:",
|
||||
"versionIncompatible": "版本不兼容",
|
||||
"backupVersion": "备份版本"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue