remove merge opt keep delete opt
This commit is contained in:
@@ -15,6 +15,9 @@ interface FileDao {
|
||||
@Query("SELECT * FROM files WHERE folder_resource_id = :folderResourceId AND \"exists\" = 1 ORDER BY name ASC")
|
||||
fun observeByFolder(folderResourceId: String): Flow<List<FileEntity>>
|
||||
|
||||
@Query("SELECT * FROM files WHERE folder_resource_id = :folderResourceId AND \"exists\" = 1 ORDER BY name ASC")
|
||||
suspend fun getByFolderOnce(folderResourceId: String): List<FileEntity>
|
||||
|
||||
@Query("SELECT * FROM files WHERE \"exists\" = 1 ORDER BY COALESCE(last_modified, added_at) DESC, name ASC")
|
||||
fun observeAllVisible(): Flow<List<FileEntity>>
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ interface FolderDao {
|
||||
@Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId ORDER BY name ASC")
|
||||
suspend fun getByParent(parentResourceId: String): List<FolderEntity>
|
||||
|
||||
/** Snapshot des sous-dossiers visibles (filtre `exists = 1`) — opérations batch hors UI. */
|
||||
@Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId AND \"exists\" = 1 ORDER BY name ASC")
|
||||
suspend fun getByParentOnce(parentResourceId: String): List<FolderEntity>
|
||||
|
||||
@Query("SELECT * FROM folders WHERE resource_id = :resourceId LIMIT 1")
|
||||
suspend fun getByResourceId(resourceId: String): FolderEntity?
|
||||
|
||||
@@ -52,6 +56,10 @@ interface FolderDao {
|
||||
@Query("UPDATE folders SET \"exists\" = 0, updated_at = :updatedAt WHERE resource_id = :resourceId")
|
||||
suspend fun markMissing(resourceId: String, updatedAt: Long)
|
||||
|
||||
/** Re-parente des sous-dossiers vers un nouveau parent (fusion). */
|
||||
@Query("UPDATE folders SET parent_resource_id = :newParentId, updated_at = :updatedAt WHERE resource_id IN (:resourceIds)")
|
||||
suspend fun reparent(resourceIds: List<String>, newParentId: String, updatedAt: Long)
|
||||
|
||||
/**
|
||||
* Promote le placement après confirmation serveur (`POST /sync/ops` appliqué) :
|
||||
* `local-cloud` si une copie physique existe, sinon `cloud`. Idempotent.
|
||||
|
||||
+13
@@ -56,6 +56,19 @@ interface PendingOperationDao {
|
||||
""")
|
||||
suspend fun countCreateOperations(resourceId: String): Int
|
||||
|
||||
/**
|
||||
* Variante sans filtre `resource_type` : utilisée par la suppression de
|
||||
* dossiers (les dossiers sont poussés à la découverte avec `resource_type =
|
||||
* 'folder'`, pas `'file'`).
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COUNT(*) FROM pending_operations
|
||||
WHERE resource_id = :resourceId
|
||||
AND operation = 'create_resource'
|
||||
AND status IN ('pending', 'synced')
|
||||
""")
|
||||
suspend fun countCreateOperationsAnyType(resourceId: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM pending_operations WHERE status = 'pending'")
|
||||
suspend fun countPending(): Int
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ class FileRepository @Inject constructor(
|
||||
fun observeFiles(folderResourceId: String): Flow<List<FileEntity>> =
|
||||
fileDao.observeByFolder(folderResourceId)
|
||||
|
||||
/** Snapshot des fichiers visibles d'un dossier (opérations batch hors UI). */
|
||||
suspend fun getFilesInFolder(folderResourceId: String): List<FileEntity> =
|
||||
fileDao.getByFolderOnce(folderResourceId)
|
||||
|
||||
/** Fichiers visibles de toute l'arborescence — écran d'accueil (grille par date). */
|
||||
fun observeAllVisible(): Flow<List<FileEntity>> = fileDao.observeAllVisible()
|
||||
|
||||
|
||||
+23
@@ -44,6 +44,10 @@ class FolderRepository @Inject constructor(
|
||||
|
||||
suspend fun getAll(): List<FolderEntity> = folderDao.getAll()
|
||||
|
||||
/** Snapshot des sous-dossiers visibles d'un dossier (opérations batch hors UI). */
|
||||
suspend fun getChildren(parentResourceId: String): List<FolderEntity> =
|
||||
folderDao.getByParentOnce(parentResourceId)
|
||||
|
||||
/** Dossiers créés dans l'app uniquement (cibles du picker de déplacement). */
|
||||
suspend fun getCreatedInApp(): List<FolderEntity> = folderDao.getCreatedInApp()
|
||||
|
||||
@@ -132,6 +136,25 @@ class FolderRepository @Inject constructor(
|
||||
/** Marque un dossier disparu de l'arborescence (`exists = 0`) — jamais supprimé. */
|
||||
suspend fun markMissing(resourceId: String, updatedAt: Long) =
|
||||
folderDao.markMissing(resourceId, updatedAt)
|
||||
|
||||
/**
|
||||
* Re-parente des sous-dossiers vers un nouveau parent (fusion de dossiers).
|
||||
* Transaction atomique : reparent Room + outbox `move_resource` par dossier.
|
||||
*/
|
||||
suspend fun reparentSubFolders(resourceIds: List<String>, newParentId: String) {
|
||||
if (resourceIds.isEmpty()) return
|
||||
val now = System.currentTimeMillis()
|
||||
appDatabase.withTransaction {
|
||||
folderDao.reparent(resourceIds, newParentId, now)
|
||||
for (resourceId in resourceIds) {
|
||||
outboxRepository.enqueueMoveResource(
|
||||
resourceId = resourceId,
|
||||
resourceType = "folder",
|
||||
toFolderResourceId = newParentId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SaveFolderInput(
|
||||
|
||||
+9
@@ -149,6 +149,15 @@ class OutboxRepository @Inject constructor(
|
||||
suspend fun hasCreateOperation(resourceId: String): Boolean =
|
||||
pendingOperationDao.countCreateOperations(resourceId) > 0
|
||||
|
||||
/**
|
||||
* Variante toutes ressources : un `create_resource` a-t-il jamais été
|
||||
* journalisé pour ce dossier ? Un `delete_resource` n'est émis que si le
|
||||
* serveur connaît déjà la ressource (les dossiers sont poussés à la
|
||||
* découverte avec `resource_type = 'folder'`).
|
||||
*/
|
||||
suspend fun hasCreateOperationAnyType(resourceId: String): Boolean =
|
||||
pendingOperationDao.countCreateOperationsAnyType(resourceId) > 0
|
||||
|
||||
/**
|
||||
* Un `move_resource` est-il encore en attente de push ? Tant que l'op est
|
||||
* pendante, le `folder_resource_id` local reflète la cible future : ni le
|
||||
|
||||
+110
-31
@@ -68,7 +68,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vaultdrop.mobile.R
|
||||
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||
import com.vaultdrop.mobile.data.local.entity.FileStatus
|
||||
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||
import com.vaultdrop.mobile.data.local.entity.FolderStatus
|
||||
import com.vaultdrop.mobile.features.connection.ConnectionStatusViewModel
|
||||
import com.vaultdrop.mobile.features.saf.safDisplayName
|
||||
import com.vaultdrop.mobile.features.sync.SyncViewModel
|
||||
@@ -80,6 +82,7 @@ import com.vaultdrop.mobile.ui.components.FileCategoryIcon
|
||||
import com.vaultdrop.mobile.ui.components.FilePendingReviewBadge
|
||||
import com.vaultdrop.mobile.ui.components.FileSyncStatusIcon
|
||||
import com.vaultdrop.mobile.ui.components.FolderNameDialog
|
||||
|
||||
import com.vaultdrop.mobile.ui.components.SelectionState
|
||||
import com.vaultdrop.mobile.ui.components.SelectionStatusIcon
|
||||
import com.vaultdrop.mobile.ui.components.ServerStatusBadge
|
||||
@@ -87,6 +90,7 @@ import com.vaultdrop.mobile.ui.components.SyncStatusAction
|
||||
import com.vaultdrop.mobile.ui.components.rememberSelectionState
|
||||
import com.vaultdrop.mobile.ui.components.reviewDelete
|
||||
import com.vaultdrop.mobile.ui.navigation.FloatingNavBar
|
||||
import com.vaultdrop.mobile.ui.navigation.FolderSelectionNavBar
|
||||
import com.vaultdrop.mobile.ui.navigation.MoveTargetBar
|
||||
import com.vaultdrop.mobile.ui.navigation.NavTab
|
||||
import com.vaultdrop.mobile.ui.navigation.SelectionNavBar
|
||||
@@ -116,6 +120,8 @@ fun FolderListScreen(
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var pendingDeleteMode by remember { mutableStateOf<FileDeleter.DeleteMode?>(null) }
|
||||
var pendingDeleteReview by remember { mutableStateOf<DeleteReview?>(null) }
|
||||
var pendingFolderIds by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var pendingFileIds by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val createError = uiState.createError
|
||||
@@ -261,29 +267,72 @@ fun FolderListScreen(
|
||||
viewModel.moveSelectionHere(ids)
|
||||
},
|
||||
)
|
||||
selection.active -> SelectionNavBar(
|
||||
onMove = { viewModel.startMove() },
|
||||
onBuildPdf = {
|
||||
val ids = selection.ids.toList()
|
||||
selection.clear()
|
||||
onBuildPdf(ids)
|
||||
},
|
||||
onDeleteModeSelected = { mode ->
|
||||
val allFiles = uiState.sections.flatMap { section ->
|
||||
section.rows.flatMap { row -> listOfNotNull(row.left, row.right) }
|
||||
}
|
||||
val selectedFiles = allFiles.filter { it.resourceId in selection.ids }
|
||||
val review = reviewDelete(selectedFiles, mode)
|
||||
pendingDeleteMode = mode
|
||||
pendingDeleteReview = review
|
||||
if (review.hasWarning) {
|
||||
showDeleteWarning = true
|
||||
} else {
|
||||
showDeleteConfirm = true
|
||||
}
|
||||
},
|
||||
enabled = selection.ids.isNotEmpty(),
|
||||
)
|
||||
selection.active -> if (uiState.view == HomeView.FOLDERS) {
|
||||
FolderSelectionNavBar(
|
||||
onDeleteModeSelected = { mode ->
|
||||
val selectedFolders = uiState.browseSubFolders.filter {
|
||||
it.resourceId in selection.ids
|
||||
}
|
||||
val selectedFiles = uiState.browseFiles.filter {
|
||||
it.resourceId in selection.ids
|
||||
}
|
||||
val incompatible = when (mode) {
|
||||
FileDeleter.DeleteMode.IN_CLOUD ->
|
||||
selectedFolders.count { it.syncStatus == FolderStatus.LOCAL } +
|
||||
selectedFiles.count { it.syncStatus == FileStatus.LOCAL }
|
||||
FileDeleter.DeleteMode.LOCALLY ->
|
||||
selectedFolders.count { it.syncStatus == FolderStatus.CLOUD } +
|
||||
selectedFiles.count { it.syncStatus == FileStatus.CLOUD }
|
||||
FileDeleter.DeleteMode.FULL ->
|
||||
selectedFolders.count {
|
||||
it.syncStatus == FolderStatus.LOCAL || it.syncStatus == FolderStatus.CLOUD
|
||||
} + selectedFiles.count {
|
||||
it.syncStatus == FileStatus.LOCAL || it.syncStatus == FileStatus.CLOUD
|
||||
}
|
||||
}
|
||||
pendingFolderIds = selectedFolders.map { it.resourceId }
|
||||
pendingFileIds = selectedFiles.map { it.resourceId }
|
||||
pendingDeleteMode = mode
|
||||
pendingDeleteReview = DeleteReview(
|
||||
mode = mode,
|
||||
total = selectedFolders.size + selectedFiles.size,
|
||||
incompatibleCount = incompatible,
|
||||
)
|
||||
if (incompatible > 0) {
|
||||
showDeleteWarning = true
|
||||
} else {
|
||||
showDeleteConfirm = true
|
||||
}
|
||||
},
|
||||
deleteEnabled = selection.ids.isNotEmpty(),
|
||||
)
|
||||
} else {
|
||||
SelectionNavBar(
|
||||
onMove = { viewModel.startMove() },
|
||||
onBuildPdf = {
|
||||
val ids = selection.ids.toList()
|
||||
selection.clear()
|
||||
onBuildPdf(ids)
|
||||
},
|
||||
onDeleteModeSelected = { mode ->
|
||||
val allFiles = uiState.sections.flatMap { section ->
|
||||
section.rows.flatMap { row -> listOfNotNull(row.left, row.right) }
|
||||
}
|
||||
val selectedFiles = allFiles.filter { it.resourceId in selection.ids }
|
||||
pendingFolderIds = emptyList()
|
||||
pendingFileIds = selectedFiles.map { it.resourceId }
|
||||
val review = reviewDelete(selectedFiles, mode)
|
||||
pendingDeleteMode = mode
|
||||
pendingDeleteReview = review
|
||||
if (review.hasWarning) {
|
||||
showDeleteWarning = true
|
||||
} else {
|
||||
showDeleteConfirm = true
|
||||
}
|
||||
},
|
||||
enabled = selection.ids.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
else -> FloatingNavBar(selected = selectedTab, onSelect = onTabSelected)
|
||||
}
|
||||
},
|
||||
@@ -341,26 +390,36 @@ fun FolderListScreen(
|
||||
showDeleteWarning = false
|
||||
pendingDeleteMode = null
|
||||
pendingDeleteReview = null
|
||||
pendingFolderIds = emptyList()
|
||||
pendingFileIds = emptyList()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteConfirm && pendingDeleteMode != null) {
|
||||
DeleteConfirmDialog(
|
||||
count = selection.ids.size,
|
||||
count = pendingFolderIds.size + pendingFileIds.size,
|
||||
onConfirm = {
|
||||
val ids = selection.ids.toList()
|
||||
val mode = pendingDeleteMode!!
|
||||
val fileIds = selection.ids.toList()
|
||||
selection.clear()
|
||||
showDeleteConfirm = false
|
||||
pendingDeleteMode = null
|
||||
pendingDeleteReview = null
|
||||
viewModel.deleteSelectedFiles(ids, mode)
|
||||
if (uiState.view == HomeView.FOLDERS) {
|
||||
viewModel.deleteFolderSelection(pendingFolderIds, pendingFileIds, mode)
|
||||
} else {
|
||||
viewModel.deleteSelectedFiles(fileIds, mode)
|
||||
}
|
||||
pendingFolderIds = emptyList()
|
||||
pendingFileIds = emptyList()
|
||||
},
|
||||
onDismiss = {
|
||||
showDeleteConfirm = false
|
||||
pendingDeleteMode = null
|
||||
pendingDeleteReview = null
|
||||
pendingFolderIds = emptyList()
|
||||
pendingFileIds = emptyList()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -683,7 +742,11 @@ private fun FolderBrowserContent(
|
||||
}
|
||||
|
||||
items(subFolders, key = { it.resourceId }) { folder ->
|
||||
FolderRow(folder, onClick = { onOpenBrowseFolder(folder.resourceId) })
|
||||
FolderRow(
|
||||
folder = folder,
|
||||
selection = selection,
|
||||
onClick = { onOpenBrowseFolder(folder.resourceId) },
|
||||
)
|
||||
}
|
||||
|
||||
// En mode déplacement, le navigateur sert au choix de la cible : les
|
||||
@@ -779,16 +842,29 @@ private fun SectionHeader(label: String) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Carte dossier de l'explorateur — descend d'un niveau au tap. */
|
||||
/** Carte dossier de l'explorateur — descend d'un niveau au tap, clic long pour la sélection. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
|
||||
private fun FolderRow(
|
||||
folder: FolderEntity,
|
||||
selection: SelectionState,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val selected = folder.resourceId in selection.ids
|
||||
Card(
|
||||
onClick = onClick,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
.padding(bottom = 8.dp)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (selection.active) selection.toggle(folder.resourceId) else onClick()
|
||||
},
|
||||
onLongClick = {
|
||||
if (!selection.active) selection.start(folder.resourceId)
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -816,6 +892,9 @@ private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (selection.active) {
|
||||
SelectionStatusIcon(selected = selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -16,6 +16,7 @@ import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||
import com.vaultdrop.mobile.data.repository.SaveFolderInput
|
||||
import com.vaultdrop.mobile.features.saf.FileMover
|
||||
import com.vaultdrop.mobile.features.saf.FileDeleter
|
||||
import com.vaultdrop.mobile.features.saf.FolderDeleter
|
||||
import com.vaultdrop.mobile.features.saf.SafFolderCreator
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -45,6 +46,7 @@ class FolderListViewModel @Inject constructor(
|
||||
private val safFolderCreator: SafFolderCreator,
|
||||
private val fileMover: FileMover,
|
||||
private val fileDeleter: FileDeleter,
|
||||
private val folderDeleter: FolderDeleter,
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -311,6 +313,30 @@ class FolderListViewModel @Inject constructor(
|
||||
_uiState.update { it.copy(deleteSuccess = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppression d'une sélection mixte de l'explorateur Dossiers (dossiers
|
||||
* et/ou fichiers) selon le mode choisi. Les dossiers sont supprimés avec
|
||||
* leur contenu récursif, les fichiers individuellement.
|
||||
*/
|
||||
fun deleteFolderSelection(folderIds: List<String>, fileIds: List<String>, mode: FileDeleter.DeleteMode) {
|
||||
viewModelScope.launch {
|
||||
var failed = 0
|
||||
val folders = folderIds.mapNotNull { folderRepository.getFolder(it) }
|
||||
if (folders.isNotEmpty()) {
|
||||
failed += folderDeleter.deleteFolders(folders, mode).failed
|
||||
}
|
||||
val files = fileIds.mapNotNull { fileRepository.getFile(it) }
|
||||
if (files.isNotEmpty()) {
|
||||
failed += fileDeleter.deleteFiles(files, mode).failed
|
||||
}
|
||||
if (failed > 0) {
|
||||
_uiState.update { it.copy(deleteError = context.getString(R.string.delete_error)) }
|
||||
} else {
|
||||
_uiState.update { it.copy(deleteSuccess = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Grille d'accueil : tous les fichiers visibles, groupés par jour (date de référence). */
|
||||
private fun observeFiles() {
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -180,6 +180,40 @@ fun SelectionNavBar(
|
||||
}
|
||||
}
|
||||
|
||||
/** Barre d'actions multi-sélection propre aux dossiers : suppression. */
|
||||
@Composable
|
||||
fun FolderSelectionNavBar(
|
||||
onDeleteModeSelected: (FileDeleter.DeleteMode) -> Unit,
|
||||
deleteEnabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 20.dp, bottom = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(30.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier.widthIn(max = 400.dp).fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DeleteDropdownButton(
|
||||
enabled = deleteEnabled,
|
||||
onModeSelected = onDeleteModeSelected,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Barre du mode déplacement : bouton « Déplacer ici (N) » vers le dossier courant. */
|
||||
@Composable
|
||||
fun MoveTargetBar(
|
||||
|
||||
@@ -109,6 +109,15 @@
|
||||
<string name="selection_count">%1$d selected</string>
|
||||
<string name="selection_assemble">Assemble PDF</string>
|
||||
<string name="selection_cancel">Cancel selection</string>
|
||||
|
||||
<!-- Folder merge (multi-select in the folder browser) -->
|
||||
<string name="merge_folders">Merge</string>
|
||||
<string name="merge_folders_title">Merge %1$d folder(s)</string>
|
||||
<string name="merge_name_hint">e.g. important_documents</string>
|
||||
<string name="merge_sources_label">Source folders — their content will be moved:</string>
|
||||
<string name="merge_confirm">Merge</string>
|
||||
<string name="merge_success">Folder(s) merged</string>
|
||||
<string name="merge_error">Could not merge the folders</string>
|
||||
<string name="pdf_builder_title">Assemble a PDF</string>
|
||||
<string name="pdf_builder_empty">Nothing here yet. Add files or a note to start assembling.</string>
|
||||
<string name="pdf_builder_add_files">Add a file</string>
|
||||
|
||||
@@ -155,6 +155,15 @@
|
||||
<string name="selection_count">%1$d sélectionné(s)</string>
|
||||
<string name="selection_assemble">Assembler en PDF</string>
|
||||
<string name="selection_cancel">Annuler la sélection</string>
|
||||
|
||||
<!-- Fusion de dossiers (multi-sélection de l\'explorateur Dossiers) -->
|
||||
<string name="merge_folders">Fusionner</string>
|
||||
<string name="merge_folders_title">Fusionner %1$d dossier(s)</string>
|
||||
<string name="merge_name_hint">ex. documents_importants</string>
|
||||
<string name="merge_sources_label">Dossiers sources — leur contenu sera déplacé :</string>
|
||||
<string name="merge_confirm">Fusionner</string>
|
||||
<string name="merge_success">Dossier(s) fusionné(s)</string>
|
||||
<string name="merge_error">Impossible de fusionner les dossiers</string>
|
||||
<string name="pdf_builder_title">Assembler un PDF</string>
|
||||
<string name="pdf_builder_empty">Aucun élément. Ajoute des fichiers ou une note pour commencer l\'assemblage.</string>
|
||||
<string name="pdf_builder_add_files">Ajouter un fichier</string>
|
||||
|
||||
Reference in New Issue
Block a user