🔀 Merge branch '525-515-548-523-onlyoffice-rework' into release/v1.0.4-rc2
This commit is contained in:
@@ -145,6 +145,15 @@ ended with and without a new version.
|
|||||||
|
|
||||||
See operations `beginEditing` and `updateEditing` in `tdrive/backend/node/src/services/documents/services/index.ts` for operations available on that key.
|
See operations `beginEditing` and `updateEditing` in `tdrive/backend/node/src/services/documents/services/index.ts` for operations available on that key.
|
||||||
|
|
||||||
|
Basic flow is:
|
||||||
|
- Call `beginEditing`
|
||||||
|
- If a key previously was set for that file, uses the `/check` endpoint of the plugin to update the key status first
|
||||||
|
- Twake Drive generates an editing session key and returns it (or returns the existing one).
|
||||||
|
- Optionally call `updateEditing?keepEditing=true` with a file stream to generate intermediary versions
|
||||||
|
- End the session by calling `updateEditing` (without `keepEditing=true`). If a file is provided, a new (and final) FileVersion
|
||||||
|
is created with that content. Otherwise, the editing session is cleared without creating a new version.
|
||||||
|
|
||||||
|
|
||||||
#### API to expose by the application
|
#### API to expose by the application
|
||||||
|
|
||||||
Authentication from the Twake Drive backend is a JWT with the property `type` being
|
Authentication from the Twake Drive backend is a JWT with the property `type` being
|
||||||
@@ -161,7 +170,81 @@ multiple parallel requests for the same key gracefully.
|
|||||||
- `{ status: 'unknown' }`: the key isn't known and maybe used for a new session
|
- `{ status: 'unknown' }`: the key isn't known and maybe used for a new session
|
||||||
- `{ status: 'updated' }`: the key needed updating but is now invalid
|
- `{ status: 'updated' }`: the key needed updating but is now invalid
|
||||||
- `{ status: 'expired' }`: the key was already used in a finished session and can't be used again
|
- `{ status: 'expired' }`: the key was already used in a finished session and can't be used again
|
||||||
- `{ status: 'live' }`: the key is valid and current and should be used again for the same file
|
- `{ status: 'live' }`: the key is valid and current and should be used again for the same file (if multiple writers are allowed)
|
||||||
|
|
||||||
|
#### Example flow of editing session
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor User as User/Browser
|
||||||
|
participant Front as Frontend
|
||||||
|
box Server side
|
||||||
|
participant Back as Backend
|
||||||
|
participant Conn as onlyoffice<br>connector
|
||||||
|
participant OO as Only Office<br>Edition Server
|
||||||
|
end
|
||||||
|
|
||||||
|
User->>Front: Open in<br>editor
|
||||||
|
Front->>Back: Get plugin config
|
||||||
|
Front->>User: Open plugin config<br>editor.edition_url
|
||||||
|
|
||||||
|
User->>Conn: Open editor_url<br>(proxied by backend)
|
||||||
|
Conn->>Back: beginEditing
|
||||||
|
alt existing key
|
||||||
|
Back->>Conn: checkSessionStatus
|
||||||
|
Conn->>OO: getForgotten
|
||||||
|
note over Conn, OO: recover forgotten<br>process, and new key
|
||||||
|
Conn->>OO: info command
|
||||||
|
note right of Conn: decide status of key<br>live or stale
|
||||||
|
note over Conn, OO: detect ended but<br>not changed keys
|
||||||
|
note over Conn, OO: normal callback processing with<br>update if required
|
||||||
|
OO->>Conn: callback with key status
|
||||||
|
Conn->>Back: key status<br>(live/expired/updated/etc)
|
||||||
|
end
|
||||||
|
activate Back
|
||||||
|
Back->>Conn: editing_session_key
|
||||||
|
Conn->>User: HTML host for Editor with<br>special callback URL
|
||||||
|
User->>OO: Load JS Editor directly from OO server
|
||||||
|
activate User
|
||||||
|
loop User editing
|
||||||
|
User->>User: Furious Document<br>Editing
|
||||||
|
User-->>OO: Periodic saving
|
||||||
|
end
|
||||||
|
deactivate User
|
||||||
|
note left of User: Closes editor
|
||||||
|
note over User,OO: 10 seconds after last user closes their editor
|
||||||
|
OO->>Conn: callback to save the new version<br>or close without changes
|
||||||
|
Conn->>Back: updateEditing?keepEditing=false<br>with URL to new version from OO
|
||||||
|
deactivate Back
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Batch processing of unknown keys
|
||||||
|
|
||||||
|
Periodically, the plugin, and twake drive, should run batch cleanup operations on editing session keys
|
||||||
|
to ensure they are live, or removed, as they may block other operations until then.
|
||||||
|
|
||||||
|
Here is an example initiated by the plugin:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
autonumber
|
||||||
|
actor User as User/Browser
|
||||||
|
participant Front as Frontend
|
||||||
|
box Server side
|
||||||
|
participant Back as Backend
|
||||||
|
participant Conn as onlyoffice<br>connector
|
||||||
|
participant OO as Only Office<br>Edition Server
|
||||||
|
end
|
||||||
|
|
||||||
|
alt Periodic scheduled task
|
||||||
|
Conn->>OO: getForgottenList
|
||||||
|
loop Each forgotten file
|
||||||
|
Conn->>Back: Save new version<br>end editing session
|
||||||
|
Conn->>OO: Delete forgotten file
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
### Example: OnlyOffice plugin
|
### Example: OnlyOffice plugin
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import yargs from "yargs";
|
||||||
|
|
||||||
|
import { NonPlatformCommandYargsBuilder } from "../../utils/non-plaform-command-yargs-builder";
|
||||||
|
import { EditingSessionKeyFormat } from "../../../services/documents/entities/drive-file";
|
||||||
|
|
||||||
|
interface ParseArguments {
|
||||||
|
editing_session_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command: yargs.CommandModule<unknown, unknown> = {
|
||||||
|
command: "parse <editing_session_key>",
|
||||||
|
describe: `
|
||||||
|
Parse the provided editing_session_key and output json data (to stderr)
|
||||||
|
`.trim(),
|
||||||
|
|
||||||
|
builder: {
|
||||||
|
...NonPlatformCommandYargsBuilder,
|
||||||
|
},
|
||||||
|
handler: async argv => {
|
||||||
|
const args = argv as unknown as ParseArguments;
|
||||||
|
const parsed = EditingSessionKeyFormat.parse(decodeURIComponent("" + args.editing_session_key));
|
||||||
|
console.error(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
ageH: (new Date().getTime() - parsed.timestamp.getTime()) / (60 * 60 * 1000),
|
||||||
|
...parsed,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default command;
|
||||||
@@ -22,6 +22,7 @@ interface RequestEditorQuery {
|
|||||||
office_token: string;
|
office_token: string;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
file_id: string;
|
file_id: string;
|
||||||
|
drive_file_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,6 +103,7 @@ class BrowserEditorController {
|
|||||||
makeURLTo.editorAbsolute({
|
makeURLTo.editorAbsolute({
|
||||||
token,
|
token,
|
||||||
file_id,
|
file_id,
|
||||||
|
drive_file_id,
|
||||||
editing_session_key: editingSessionKey,
|
editing_session_key: editingSessionKey,
|
||||||
company_id,
|
company_id,
|
||||||
preview,
|
preview,
|
||||||
@@ -131,7 +133,7 @@ class BrowserEditorController {
|
|||||||
throw new Error('Cant start editing without "editing session key"');
|
throw new Error('Cant start editing without "editing session key"');
|
||||||
}
|
}
|
||||||
|
|
||||||
const initResponse = await editorService.init(company_id, file_name, file_id, user, preview, drive_file_id || file_id);
|
const initResponse = await editorService.init(company_id, file_name, file_id, user, preview, drive_file_id);
|
||||||
|
|
||||||
const inPageToken = jwt.sign(
|
const inPageToken = jwt.sign(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ import * as Utils from '@/utils';
|
|||||||
interface RequestQuery {
|
interface RequestQuery {
|
||||||
company_id: string;
|
company_id: string;
|
||||||
file_id: string;
|
file_id: string;
|
||||||
|
drive_file_id: string;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RenameRequestBody {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** These expose a OnlyOffice document storage service methods, called by the OnlyOffice document editing service
|
/** These expose a OnlyOffice document storage service methods, called by the OnlyOffice document editing service
|
||||||
* to load and save files
|
* to load and save files
|
||||||
*/
|
*/
|
||||||
@@ -29,15 +34,27 @@ class OnlyOfficeController {
|
|||||||
const { token } = req.query;
|
const { token } = req.query;
|
||||||
|
|
||||||
const officeTokenPayload = jwt.verify(token, CREDENTIALS_SECRET) as OfficeToken;
|
const officeTokenPayload = jwt.verify(token, CREDENTIALS_SECRET) as OfficeToken;
|
||||||
const { company_id, file_id, in_page_token } = officeTokenPayload;
|
const { company_id, file_id, drive_file_id, in_page_token } = officeTokenPayload;
|
||||||
|
|
||||||
// check token is an in_page_token
|
// check token is an in_page_token
|
||||||
if (!in_page_token) throw new Error('Invalid token, must be a in_page_token');
|
if (!in_page_token) throw new Error('Invalid token, must be a in_page_token');
|
||||||
|
|
||||||
|
let fileId = file_id;
|
||||||
|
if (drive_file_id) {
|
||||||
|
//Get the drive file
|
||||||
|
const driveFile = await driveService.get({
|
||||||
|
company_id,
|
||||||
|
drive_file_id,
|
||||||
|
});
|
||||||
|
if (driveFile) {
|
||||||
|
fileId = driveFile?.item?.last_version_cache?.file_metadata?.external_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!file_id) throw new Error(`File id is missing in the last version cache for ${JSON.stringify(file_id)}`);
|
if (!file_id) throw new Error(`File id is missing in the last version cache for ${JSON.stringify(file_id)}`);
|
||||||
const file = await fileService.download({
|
const file = await fileService.download({
|
||||||
company_id,
|
company_id,
|
||||||
file_id: file_id,
|
file_id: fileId,
|
||||||
});
|
});
|
||||||
|
|
||||||
file.pipe(res);
|
file.pipe(res);
|
||||||
@@ -132,6 +149,25 @@ class OnlyOfficeController {
|
|||||||
next(error || 'error');
|
next(error || 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** This route is called directly by the inline JS in the editor page, called by the client-side OO editor component */
|
||||||
|
public rename = async (req: Request<{}, {}, RenameRequestBody, RequestQuery>, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { token } = req.query;
|
||||||
|
const officeTokenPayload = jwt.verify(token, CREDENTIALS_SECRET) as OfficeToken;
|
||||||
|
const { company_id, drive_file_id } = officeTokenPayload;
|
||||||
|
const { name } = req.body;
|
||||||
|
|
||||||
|
if (!drive_file_id) throw new Error('OO Rename request missing drive_file_id');
|
||||||
|
if (!name) throw new Error('OO Rename request missing name');
|
||||||
|
|
||||||
|
const result = await driveService.update({ company_id, drive_file_id, changes: { name } });
|
||||||
|
res.send(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`OO Rename request root error`, { error });
|
||||||
|
next(error || 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OnlyOfficeController;
|
export default OnlyOfficeController;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export type EditConfigInitResult = {
|
|||||||
onlyoffice_server: string;
|
onlyoffice_server: string;
|
||||||
color: string;
|
color: string;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
file_id: string;
|
drive_file_id: string;
|
||||||
file_version_id: string;
|
file_version_id: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
file_type: string;
|
file_type: string;
|
||||||
@@ -33,6 +33,7 @@ export interface IEditorService {
|
|||||||
user: UserType,
|
user: UserType,
|
||||||
preview: boolean,
|
preview: boolean,
|
||||||
file_id: string,
|
file_id: string,
|
||||||
|
drive_file_id: string,
|
||||||
) => Promise<EditConfigInitResult>;
|
) => Promise<EditConfigInitResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,15 @@ export function mountRoutes(app: Application) {
|
|||||||
export const makeURLTo = {
|
export const makeURLTo = {
|
||||||
rootAbsolute: () => Utils.joinURL([SERVER_ORIGIN, SERVER_PREFIX]),
|
rootAbsolute: () => Utils.joinURL([SERVER_ORIGIN, SERVER_PREFIX]),
|
||||||
assets: () => Utils.joinURL([SERVER_PREFIX, 'assets']),
|
assets: () => Utils.joinURL([SERVER_PREFIX, 'assets']),
|
||||||
editorAbsolute(params: { token: string; file_id: string; editing_session_key: string; company_id: string; preview: string; office_token: string }) {
|
editorAbsolute(params: {
|
||||||
|
token: string;
|
||||||
|
drive_file_id: string;
|
||||||
|
file_id: string;
|
||||||
|
editing_session_key: string;
|
||||||
|
company_id: string;
|
||||||
|
preview: string;
|
||||||
|
office_token: string;
|
||||||
|
}) {
|
||||||
return Utils.joinURL([SERVER_ORIGIN ?? '', SERVER_PREFIX, 'editor'], params);
|
return Utils.joinURL([SERVER_ORIGIN ?? '', SERVER_PREFIX, 'editor'], params);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ export const OnlyOfficeRoutes = {
|
|||||||
const controller = new OnlyOfficeController();
|
const controller = new OnlyOfficeController();
|
||||||
router.get(`/:mode/read`, requirementsMiddleware, controller.read.bind(controller));
|
router.get(`/:mode/read`, requirementsMiddleware, controller.read.bind(controller));
|
||||||
router.post(`/:mode/callback`, requirementsMiddleware, controller.ooCallback.bind(controller));
|
router.post(`/:mode/callback`, requirementsMiddleware, controller.ooCallback.bind(controller));
|
||||||
|
router.post(`/:mode/rename`, requirementsMiddleware, controller.rename.bind(controller));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,28 @@ class DriveService implements IDriveService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public update = async (params: {
|
||||||
|
company_id: string;
|
||||||
|
drive_file_id: string;
|
||||||
|
changes: Partial<DriveFileType['item']>;
|
||||||
|
completeResult?: boolean;
|
||||||
|
}): Promise<(typeof params)['changes']> => {
|
||||||
|
try {
|
||||||
|
const { company_id, drive_file_id } = params;
|
||||||
|
const resource = await apiService.post<(typeof params)['changes'], ReturnType<DriveService['update']>>({
|
||||||
|
url: makeNonEditingSessionItemUrl(company_id, drive_file_id),
|
||||||
|
payload: params.changes,
|
||||||
|
});
|
||||||
|
if (params.completeResult) return resource;
|
||||||
|
const result = {};
|
||||||
|
Object.keys(params.changes).forEach(k => (result[k] = resource[k]));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to update file metadata: ', error.stack);
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
public createVersion = async (params: {
|
public createVersion = async (params: {
|
||||||
company_id: string;
|
company_id: string;
|
||||||
drive_file_id: string;
|
drive_file_id: string;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class EditorService implements IEditorService {
|
|||||||
file_version_id: string,
|
file_version_id: string,
|
||||||
user: UserType,
|
user: UserType,
|
||||||
preview: boolean,
|
preview: boolean,
|
||||||
file_id: string,
|
drive_file_id: string,
|
||||||
): Promise<EditConfigInitResult> => {
|
): Promise<EditConfigInitResult> => {
|
||||||
const { color, mode: fileMode } = this.getFileMode(file_name);
|
const { color, mode: fileMode } = this.getFileMode(file_name);
|
||||||
let [, extension] = Utils.splitFilename(file_name);
|
let [, extension] = Utils.splitFilename(file_name);
|
||||||
@@ -18,7 +18,7 @@ class EditorService implements IEditorService {
|
|||||||
extension = extension.toLocaleLowerCase();
|
extension = extension.toLocaleLowerCase();
|
||||||
return {
|
return {
|
||||||
color,
|
color,
|
||||||
file_id,
|
drive_file_id,
|
||||||
file_version_id,
|
file_version_id,
|
||||||
file_type: extension,
|
file_type: extension,
|
||||||
filename: file_name,
|
filename: file_name,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.0/jquery.min.js"></script>
|
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.0/jquery.min.js"></script>
|
||||||
<script type="text/javascript" src="<%= it.onlyoffice_server %>web-apps/apps/api/documents/api.js"></script>
|
<script type="text/javascript" src="<%= it.onlyoffice_server %>web-apps/apps/api/documents/api.js"></script>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no,shrink-to-fit=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no,shrink-to-fit=no" />
|
||||||
|
<title>Twake Drive</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body style="height: 100%">
|
<body style="height: 100%">
|
||||||
@@ -22,18 +23,24 @@
|
|||||||
|
|
||||||
$('#onlyoffice_container').html("<div id='onlyoffice_container_instance'></div>");
|
$('#onlyoffice_container').html("<div id='onlyoffice_container_instance'></div>");
|
||||||
|
|
||||||
|
const callbackQueryString = '?drive_file_id=<%= it.drive_file_id %>&company_id=<%= it.company_id %>&token=<%= it.token %>';
|
||||||
let doc = {
|
let doc = {
|
||||||
title: "<%= it.filename %>",
|
title: "<%= it.filename %>",
|
||||||
url: `${window.baseURL}read?file_id=<%= it.file_id %>&company_id=<%= it.company_id %>&token=<%= it.token %>`,
|
url: `${window.baseURL}read${callbackQueryString}`,
|
||||||
fileType: "<%= it.file_type %>",
|
fileType: "<%= it.file_type %>",
|
||||||
key: "<%= it.docId %>",
|
key: "<%= it.docId %>",
|
||||||
token: "<%= it.file_id %>",
|
token: "<%= it.drive_file_id %>",
|
||||||
permissions: {
|
permissions: {
|
||||||
download: true,
|
download: true,
|
||||||
edit: <%= it.editable %>,
|
edit: <%= it.editable %>,
|
||||||
preview: <%= it.preview %>,
|
preview: <%= it.preview %>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function updateTitle(title) {
|
||||||
|
const el = document.querySelector('head title');
|
||||||
|
if (el) el.innerText = title + ' — Twake Drive';
|
||||||
|
}
|
||||||
|
updateTitle(doc.title);
|
||||||
|
|
||||||
window.docEditor = new DocsAPI.DocEditor('onlyoffice_container_instance', {
|
window.docEditor = new DocsAPI.DocEditor('onlyoffice_container_instance', {
|
||||||
scrollSensitivity: window.mode === 'text' ? 100 : 40,
|
scrollSensitivity: window.mode === 'text' ? 100 : 40,
|
||||||
@@ -41,10 +48,28 @@
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
documentType: window.mode,
|
documentType: window.mode,
|
||||||
document: doc,
|
document: doc,
|
||||||
token: "<%= it.file_id %>",
|
token: "<%= it.drive_file_id %>",
|
||||||
type: screen.width < 600 ? 'mobile' : 'desktop',
|
type: screen.width < 600 ? 'mobile' : 'desktop',
|
||||||
|
events: {
|
||||||
|
onRequestRename: function (event) {
|
||||||
|
let name = event.data;
|
||||||
|
const prevExtension = /\.[^.]+$/.exec(doc.title);
|
||||||
|
if (prevExtension) name += prevExtension[0];
|
||||||
|
$.
|
||||||
|
post(`${window.baseURL}rename${callbackQueryString}`, { name }).
|
||||||
|
done((changed) => {
|
||||||
|
if (changed.name != name)
|
||||||
|
window.docEditor.showMessage(`✅ Renamed to: ${changed.name}`);
|
||||||
|
}).
|
||||||
|
fail(() => window.docEditor.showMessage('🚨 Error renaming the file'));
|
||||||
|
},
|
||||||
|
onMetaChange: function (event) {
|
||||||
|
const { title } = event.data;
|
||||||
|
if (title) updateTitle(title);
|
||||||
|
},
|
||||||
|
},
|
||||||
editorConfig: {
|
editorConfig: {
|
||||||
callbackUrl: `${window.baseURL}callback?file_id=<%= it.file_id %>&company_id=<%= it.company_id %>&token=<%= it.token %>`,
|
callbackUrl: `${window.baseURL}callback${callbackQueryString}`,
|
||||||
lang: window.user.language,
|
lang: window.user.language,
|
||||||
user: {
|
user: {
|
||||||
id: window.user.id,
|
id: window.user.id,
|
||||||
|
|||||||
@@ -20,10 +20,6 @@
|
|||||||
"compenents.ConfirmTrashModalContent_move_to_trash": "Move to trash",
|
"compenents.ConfirmTrashModalContent_move_to_trash": "Move to trash",
|
||||||
"compenents.ConfirmTrashModalContent_move_to_trash_desc": "Click 'Move to trash' to move the selected items to the trash folder. You can restore them later from the trash.",
|
"compenents.ConfirmTrashModalContent_move_to_trash_desc": "Click 'Move to trash' to move the selected items to the trash folder. You can restore them later from the trash.",
|
||||||
"compenents.ConfirmTrashModalContent_to_trash": "to trash",
|
"compenents.ConfirmTrashModalContent_to_trash": "to trash",
|
||||||
"compenents.ProprietiesModalContent_name": "Name",
|
|
||||||
"compenents.ProprietiesModalContent_place_holder": "Document or folder name",
|
|
||||||
"compenents.ProprietiesModalContent_rename": "Rename",
|
|
||||||
"compenents.ProprietiesModalContent_update_button": "Update name",
|
|
||||||
"compenents.VersionModalContent_create": "Create version",
|
"compenents.VersionModalContent_create": "Create version",
|
||||||
"compenents.VersionModalContent_donwload": "Download",
|
"compenents.VersionModalContent_donwload": "Download",
|
||||||
"compenents.VersionModalContent_version": "Versions of",
|
"compenents.VersionModalContent_version": "Versions of",
|
||||||
@@ -100,7 +96,6 @@
|
|||||||
"components.item_context_menu.to_trash_multiple": "Move selected items to trash",
|
"components.item_context_menu.to_trash_multiple": "Move selected items to trash",
|
||||||
"components.item_context_menu.today": "Today",
|
"components.item_context_menu.today": "Today",
|
||||||
"components.item_context_menu.trash.empty": "Empty trash",
|
"components.item_context_menu.trash.empty": "Empty trash",
|
||||||
"components.item_context_menu.trash.exit": "Exit trash",
|
|
||||||
"components.item_context_menu.versions": "Versions",
|
"components.item_context_menu.versions": "Versions",
|
||||||
"components.locked_features.locked_drive_popup.subtitle": "In the free version, you can store only 6GB.",
|
"components.locked_features.locked_drive_popup.subtitle": "In the free version, you can store only 6GB.",
|
||||||
"components.locked_features.locked_drive_popup.title": "You have just reached the drive limit",
|
"components.locked_features.locked_drive_popup.title": "You have just reached the drive limit",
|
||||||
@@ -127,6 +122,10 @@
|
|||||||
"components.open_desktop_popup.subtitle": "Opened in Tdrive app",
|
"components.open_desktop_popup.subtitle": "Opened in Tdrive app",
|
||||||
"components.pending_file_list.estimation.approximations": "Waiting for time approximations...",
|
"components.pending_file_list.estimation.approximations": "Waiting for time approximations...",
|
||||||
"components.pending_file_list.estimation.end": "Will end",
|
"components.pending_file_list.estimation.end": "Will end",
|
||||||
|
"components.PropertiesModalContent_name": "Name",
|
||||||
|
"components.PropertiesModalContent_place_holder": "Document or folder name",
|
||||||
|
"components.PropertiesModalContent_rename": "Rename",
|
||||||
|
"components.PropertiesModalContent_update_button": "Update name",
|
||||||
"components.public-link-access-level-create": "Anyone with the link will have access to",
|
"components.public-link-access-level-create": "Anyone with the link will have access to",
|
||||||
"components.public-link-access-level-delete": "Delete link",
|
"components.public-link-access-level-delete": "Delete link",
|
||||||
"components.public-link-access-level-update-subtitle": "Anyone with the link has access to",
|
"components.public-link-access-level-update-subtitle": "Anyone with the link has access to",
|
||||||
|
|||||||
@@ -218,10 +218,10 @@
|
|||||||
"components.SelectorModalContent_no_items": "Pas de fichier sélectionné",
|
"components.SelectorModalContent_no_items": "Pas de fichier sélectionné",
|
||||||
"components.SelectorModalContent_select": "sélectionné(s)",
|
"components.SelectorModalContent_select": "sélectionné(s)",
|
||||||
"components.SelectorModalContent_files": "fichier(s)",
|
"components.SelectorModalContent_files": "fichier(s)",
|
||||||
"compenents.ProprietiesModalContent_rename": "Renommer",
|
"components.PropertiesModalContent_rename": "Renommer",
|
||||||
"compenents.ProprietiesModalContent_name": "Nom",
|
"components.PropertiesModalContent_name": "Nom",
|
||||||
"compenents.ProprietiesModalContent_place_holder": "Nom du fichier ou du document",
|
"components.PropertiesModalContent_place_holder": "Nom du fichier ou du document",
|
||||||
"compenents.ProprietiesModalContent_update_button": "Renommer",
|
"components.PropertiesModalContent_update_button": "Renommer",
|
||||||
"compenents.ConfirmTrashModalContent_move": "Déplacer",
|
"compenents.ConfirmTrashModalContent_move": "Déplacer",
|
||||||
"compenents.ConfirmTrashModalContent_to_trash": "vers la corbeille",
|
"compenents.ConfirmTrashModalContent_to_trash": "vers la corbeille",
|
||||||
"compenents.ConfirmTrashModalContent_items_to_trash": "éléments vers la corbeille",
|
"compenents.ConfirmTrashModalContent_items_to_trash": "éléments vers la corbeille",
|
||||||
@@ -337,7 +337,6 @@
|
|||||||
"components.item_context_menu.clear_selection": "Annuler la sélection",
|
"components.item_context_menu.clear_selection": "Annuler la sélection",
|
||||||
"components.item_context_menu.delete_multiple": "Supprimer",
|
"components.item_context_menu.delete_multiple": "Supprimer",
|
||||||
"components.item_context_menu.to_trash_multiple": "Supprimer",
|
"components.item_context_menu.to_trash_multiple": "Supprimer",
|
||||||
"components.item_context_menu.trash.exit": "Quitter la corbeille",
|
|
||||||
"components.item_context_menu.trash.empty": "Vider la corbeille",
|
"components.item_context_menu.trash.empty": "Vider la corbeille",
|
||||||
"components.item_context_menu.add_documents": "Ajouter un document ou un dossier",
|
"components.item_context_menu.add_documents": "Ajouter un document ou un dossier",
|
||||||
"components.item_context_menu.download_folder": "Télécharger le dossier",
|
"components.item_context_menu.download_folder": "Télécharger le dossier",
|
||||||
|
|||||||
@@ -20,10 +20,6 @@
|
|||||||
"compenents.ConfirmTrashModalContent_move_to_trash": "Удалить",
|
"compenents.ConfirmTrashModalContent_move_to_trash": "Удалить",
|
||||||
"compenents.ConfirmTrashModalContent_move_to_trash_desc": "Нажмите 'Удалить' чтобы переместить выделенные элементы в корзину. Вы сможете восстановить их позже.",
|
"compenents.ConfirmTrashModalContent_move_to_trash_desc": "Нажмите 'Удалить' чтобы переместить выделенные элементы в корзину. Вы сможете восстановить их позже.",
|
||||||
"compenents.ConfirmTrashModalContent_to_trash": "в корзину",
|
"compenents.ConfirmTrashModalContent_to_trash": "в корзину",
|
||||||
"compenents.ProprietiesModalContent_name": "Имя",
|
|
||||||
"compenents.ProprietiesModalContent_place_holder": "Имя папки или документа",
|
|
||||||
"compenents.ProprietiesModalContent_rename": "Переименовать",
|
|
||||||
"compenents.ProprietiesModalContent_update_button": "Переименовать",
|
|
||||||
"compenents.VersionModalContent_create": "Создать версию",
|
"compenents.VersionModalContent_create": "Создать версию",
|
||||||
"compenents.VersionModalContent_donwload": "Скачать",
|
"compenents.VersionModalContent_donwload": "Скачать",
|
||||||
"compenents.VersionModalContent_version": "Версии ...",
|
"compenents.VersionModalContent_version": "Версии ...",
|
||||||
@@ -100,7 +96,6 @@
|
|||||||
"components.item_context_menu.to_trash_multiple": "Удалить",
|
"components.item_context_menu.to_trash_multiple": "Удалить",
|
||||||
"components.item_context_menu.today": "Сегодня",
|
"components.item_context_menu.today": "Сегодня",
|
||||||
"components.item_context_menu.trash.empty": "Очистить корзину",
|
"components.item_context_menu.trash.empty": "Очистить корзину",
|
||||||
"components.item_context_menu.trash.exit": "Выйти из корзины",
|
|
||||||
"components.item_context_menu.versions": "История изменений",
|
"components.item_context_menu.versions": "История изменений",
|
||||||
"components.locked_features.locked_drive_popup.subtitle": "В бесплатной версии можно хранить только 6 ГБ.",
|
"components.locked_features.locked_drive_popup.subtitle": "В бесплатной версии можно хранить только 6 ГБ.",
|
||||||
"components.locked_features.locked_drive_popup.title": "Вы не можете загружать больше файлов",
|
"components.locked_features.locked_drive_popup.title": "Вы не можете загружать больше файлов",
|
||||||
@@ -127,6 +122,10 @@
|
|||||||
"components.open_desktop_popup.subtitle": "открыто в приложении Tdrive",
|
"components.open_desktop_popup.subtitle": "открыто в приложении Tdrive",
|
||||||
"components.pending_file_list.estimation.approximations": "Примерное время ожидания...",
|
"components.pending_file_list.estimation.approximations": "Примерное время ожидания...",
|
||||||
"components.pending_file_list.estimation.end": "Закончится через",
|
"components.pending_file_list.estimation.end": "Закончится через",
|
||||||
|
"components.PropertiesModalContent_name": "Имя",
|
||||||
|
"components.PropertiesModalContent_place_holder": "Имя папки или документа",
|
||||||
|
"components.PropertiesModalContent_rename": "Переименовать",
|
||||||
|
"components.PropertiesModalContent_update_button": "Переименовать",
|
||||||
"components.public-link-access-level-create": "Все кто имеет ссылку буду иметь доступ",
|
"components.public-link-access-level-create": "Все кто имеет ссылку буду иметь доступ",
|
||||||
"components.public-link-access-level-delete": "Удалить ссылку",
|
"components.public-link-access-level-delete": "Удалить ссылку",
|
||||||
"components.public-link-access-level-update-subtitle": "У каждого, кто имеет ссылку, есть доступ к",
|
"components.public-link-access-level-update-subtitle": "У каждого, кто имеет ссылку, есть доступ к",
|
||||||
|
|||||||
@@ -233,10 +233,10 @@
|
|||||||
"components.SelectorModalContent_no_items": "Không có mục nào được chọn",
|
"components.SelectorModalContent_no_items": "Không có mục nào được chọn",
|
||||||
"components.SelectorModalContent_select": "Đã chọn",
|
"components.SelectorModalContent_select": "Đã chọn",
|
||||||
"components.SelectorModalContent_files": "tệp",
|
"components.SelectorModalContent_files": "tệp",
|
||||||
"compenents.ProprietiesModalContent_rename": "Đổi tên",
|
"components.PropertiesModalContent_rename": "Đổi tên",
|
||||||
"compenents.ProprietiesModalContent_name": "Tên",
|
"components.PropertiesModalContent_name": "Tên",
|
||||||
"compenents.ProprietiesModalContent_place_holder": "Tên tài liệu hoặc thư mục",
|
"components.PropertiesModalContent_place_holder": "Tên tài liệu hoặc thư mục",
|
||||||
"compenents.ProprietiesModalContent_update_button": "Cập nhật tên",
|
"components.PropertiesModalContent_update_button": "Cập nhật tên",
|
||||||
"compenents.ConfirmTrashModalContent_move": "Di chuyển",
|
"compenents.ConfirmTrashModalContent_move": "Di chuyển",
|
||||||
"compenents.ConfirmTrashModalContent_to_trash": "vào thùng rác",
|
"compenents.ConfirmTrashModalContent_to_trash": "vào thùng rác",
|
||||||
"compenents.ConfirmTrashModalContent_items_to_trash": "mục vào thùng rác",
|
"compenents.ConfirmTrashModalContent_items_to_trash": "mục vào thùng rác",
|
||||||
@@ -326,7 +326,6 @@
|
|||||||
"components.item_context_menu.clear_selection": "Xóa lựa chọn",
|
"components.item_context_menu.clear_selection": "Xóa lựa chọn",
|
||||||
"components.item_context_menu.delete_multiple": "Xóa",
|
"components.item_context_menu.delete_multiple": "Xóa",
|
||||||
"components.item_context_menu.to_trash_multiple": "Di chuyển các mục đã chọn vào thùng rác",
|
"components.item_context_menu.to_trash_multiple": "Di chuyển các mục đã chọn vào thùng rác",
|
||||||
"components.item_context_menu.trash.exit": "Thoát thùng rác",
|
|
||||||
"components.item_context_menu.trash.empty": "Làm trống thùng rác",
|
"components.item_context_menu.trash.empty": "Làm trống thùng rác",
|
||||||
"components.item_context_menu.add_documents": "Thêm tài liệu hoặc thư mục",
|
"components.item_context_menu.add_documents": "Thêm tài liệu hoặc thư mục",
|
||||||
"components.item_context_menu.download_folder": "Tải xuống thư mục",
|
"components.item_context_menu.download_folder": "Tải xuống thư mục",
|
||||||
|
|||||||
@@ -268,12 +268,6 @@ export const useOnBuildContextMenu = (children: DriveItem[], initialParentId?: s
|
|||||||
//Add parent related menus
|
//Add parent related menus
|
||||||
const newMenuActions: any[] = inTrash
|
const newMenuActions: any[] = inTrash
|
||||||
? [
|
? [
|
||||||
{
|
|
||||||
type: 'menu',
|
|
||||||
text: Languages.t('components.item_context_menu.trash.exit'),
|
|
||||||
onClick: () => setParentId('root'),
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
{
|
||||||
type: 'menu',
|
type: 'menu',
|
||||||
text: Languages.t('components.item_context_menu.trash.empty'),
|
text: Languages.t('components.item_context_menu.trash.empty'),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Input } from '@atoms/input/input-text';
|
|||||||
import { Modal, ModalContent } from '@atoms/modal';
|
import { Modal, ModalContent } from '@atoms/modal';
|
||||||
import { useDriveActions } from '@features/drive/hooks/use-drive-actions';
|
import { useDriveActions } from '@features/drive/hooks/use-drive-actions';
|
||||||
import { useDriveItem } from '@features/drive/hooks/use-drive-item';
|
import { useDriveItem } from '@features/drive/hooks/use-drive-item';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { atom, useRecoilState } from 'recoil';
|
import { atom, useRecoilState } from 'recoil';
|
||||||
import Languages from '@features/global/services/languages-service';
|
import Languages from '@features/global/services/languages-service';
|
||||||
|
|
||||||
@@ -38,27 +38,67 @@ const PropertiesModalContent = ({ id, onClose }: { id: string; onClose: () => vo
|
|||||||
const { update } = useDriveActions();
|
const { update } = useDriveActions();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh(id);
|
refresh(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
const lastDot = inputRef.current.value.lastIndexOf('.');
|
||||||
|
const endRange = lastDot >= 0 ? lastDot : inputRef.current.value.length;
|
||||||
|
inputRef.current.setSelectionRange(0, endRange);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!name) setName(item?.name || '');
|
if (!name) setName(item?.name || '');
|
||||||
}, [item?.name]);
|
}, [item?.name]);
|
||||||
|
|
||||||
|
const doSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
if (item) {
|
||||||
|
let finalName = name;
|
||||||
|
//TODO: Confirm rename if extension changed ?
|
||||||
|
if (!item?.is_directory) {
|
||||||
|
//TODO: Why do we trim extensions on folders ?
|
||||||
|
const lastDotIndex = finalName.lastIndexOf('.');
|
||||||
|
if (lastDotIndex !== -1) {
|
||||||
|
const fileExtension = name.slice(lastDotIndex);
|
||||||
|
finalName = finalName.slice(0, lastDotIndex) + fileExtension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await update({ name: finalName }, id, item.parent_id);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title={Languages.t('compenents.ProprietiesModalContent_rename') + ' ' + item?.name}
|
title={Languages.t('components.PropertiesModalContent_rename') + ' ' + item?.name}
|
||||||
>
|
>
|
||||||
<InputLabel
|
<InputLabel
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
label={Languages.t('compenents.ProprietiesModalContent_name')}
|
label={Languages.t('components.PropertiesModalContent_name')}
|
||||||
input={
|
input={
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
|
inputRef={inputRef}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
placeholder={Languages.t('compenents.ProprietiesModalContent_place_holder')}
|
onKeyUp={({ key }) => {
|
||||||
|
if (!loading) {
|
||||||
|
if (key === 'Enter')
|
||||||
|
doSave();
|
||||||
|
else if (key === "Escape")
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={Languages.t('components.PropertiesModalContent_place_holder')}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -68,24 +108,9 @@ const PropertiesModalContent = ({ id, onClose }: { id: string; onClose: () => vo
|
|||||||
className="float-right mt-4"
|
className="float-right mt-4"
|
||||||
theme="primary"
|
theme="primary"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onClick={async () => {
|
onClick={doSave}
|
||||||
setLoading(true);
|
|
||||||
if (item) {
|
|
||||||
let finalName = name;
|
|
||||||
if (!item?.is_directory) {
|
|
||||||
const lastDotIndex = finalName.lastIndexOf('.');
|
|
||||||
if (lastDotIndex !== -1) {
|
|
||||||
const fileExtension = name.slice(lastDotIndex);
|
|
||||||
finalName = finalName.slice(0, lastDotIndex) + fileExtension;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await update({ name: finalName }, id, item.parent_id);
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
setLoading(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{Languages.t('compenents.ProprietiesModalContent_update_button')}
|
{Languages.t('components.PropertiesModalContent_update_button')}
|
||||||
</Button>
|
</Button>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user