🔀 Merge branch '525-515-548-523-onlyoffice-rework' into release/v1.0.4-rc2

This commit is contained in:
Eric Doughty-Papassideris
2024-09-26 15:41:21 +02:00
16 changed files with 286 additions and 59 deletions
+84 -1
View File
@@ -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,
+4 -5
View File
@@ -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",
+4 -5
View File
@@ -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",
+4 -5
View File
@@ -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": "У каждого, кто имеет ссылку, есть доступ к",
+4 -5
View File
@@ -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>
); );