diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderText.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderText.kt new file mode 100644 index 000000000..279414d28 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderText.kt @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.utils + +import helium314.keyboard.latin.utils.TextExpanderUtils.ShortcutEntry + +/** + * All text expander shortcuts as one plain text, for editing them together, copying and sharing. + * + * Each shortcut starts with a header line `=== shortcut ===`, followed by its text: + * ``` + * === ;cic === + * long prompt … + * + * === regex: \d+eur === + * … + * ``` + * Characters before the first letter or digit of a shortcut are its prefix (`;` above). + * Empty lines around a text are only for readability and are removed. + */ +object TextExpanderText { + private const val REGEX_LABEL = "regex:" + private val HEADER = Regex("""^===\s*(.*?)\s*===\s*$""") + + enum class ErrorKind { TEXT_BEFORE_FIRST, EMPTY_SHORTCUT, SPACE_IN_SHORTCUT, EMPTY_TEXT, DUPLICATE, INVALID_REGEX } + + /** [line] is 1-based */ + data class Error(val line: Int, val kind: ErrorKind, val name: String = "") + + class Result(val entries: LinkedHashMap, val errors: List) + + data class Diff(val added: List, val changed: List, val removed: List) { + val isEmpty get() = added.isEmpty() && changed.isEmpty() && removed.isEmpty() + } + + /** Header name of a stored key, e.g. ";cic" or "regex: \d+eur" */ + fun displayName(key: String): String = + if (key.startsWith(TextExpanderUtils.REGEX_PREFIX)) "$REGEX_LABEL ${key.removePrefix(TextExpanderUtils.REGEX_PREFIX)}" + else key + + fun format(shortcuts: Map): String = + shortcuts.entries.joinToString("") { (key, entry) -> "=== ${displayName(key)} ===\n${entry.template.trimEnd('\n')}\n\n" } + .trimEnd('\n').let { if (it.isEmpty()) it else it + "\n" } + + /** Header names with the offset of their line in [text], for jumping to a shortcut */ + fun headers(text: String): List> { + val result = ArrayList>() + var offset = 0 + for (line in text.split('\n')) { + HEADER.matchEntire(line)?.let { result.add(it.groupValues[1] to offset) } + offset += line.length + 1 + } + return result + } + + /** + * Shortcuts in [text]. [existing] shortcuts keep their prefix split and their exact text + * (e.g. a trailing line break) if the text is otherwise unchanged. + */ + fun parse(text: String, existing: Map): Result { + val entries = LinkedHashMap() + val errors = ArrayList() + var headerLine = 0 + var header: String? = null + val body = ArrayList() + + fun finishBlock() { + val name = header ?: return + val template = body.dropWhile { it.isBlank() }.dropLastWhile { it.isBlank() }.joinToString("\n") + if (name.isEmpty()) { errors.add(Error(headerLine, ErrorKind.EMPTY_SHORTCUT)); return } + val isRegex = name.startsWith(REGEX_LABEL, ignoreCase = true) + val key: String + val prefix: String + if (isRegex) { + val pattern = name.substring(REGEX_LABEL.length).trim() + if (pattern.isEmpty()) { errors.add(Error(headerLine, ErrorKind.EMPTY_SHORTCUT, name)); return } + key = TextExpanderUtils.REGEX_PREFIX + pattern + prefix = existing[key]?.prefix ?: "" + if (runCatching { Regex(pattern.removePrefix(prefix)) }.isFailure) { + errors.add(Error(headerLine, ErrorKind.INVALID_REGEX, pattern)); return + } + } else { + if (name.any { it.isWhitespace() }) { errors.add(Error(headerLine, ErrorKind.SPACE_IN_SHORTCUT, name)); return } + key = name + val leading = name.takeWhile { !it.isLetterOrDigit() } + prefix = existing[key]?.prefix ?: if (leading.length < name.length) leading else "" + } + if (template.isEmpty()) { errors.add(Error(headerLine, ErrorKind.EMPTY_TEXT, name)); return } + if (entries.containsKey(key)) { errors.add(Error(headerLine, ErrorKind.DUPLICATE, name)); return } + val old = existing[key] + entries[key] = if (old != null && old.prefix == prefix && old.template.trimEnd('\n') == template) old + else ShortcutEntry(template, prefix) + } + + text.replace("\r\n", "\n").split('\n').forEachIndexed { index, line -> + val match = HEADER.matchEntire(line) + if (match != null) { + finishBlock() + header = match.groupValues[1] + headerLine = index + 1 + body.clear() + } else if (header == null) { + if (line.isNotBlank() && errors.none { it.kind == ErrorKind.TEXT_BEFORE_FIRST }) + errors.add(Error(index + 1, ErrorKind.TEXT_BEFORE_FIRST)) + } else { + body.add(line) + } + } + finishBlock() + return Result(entries, errors) + } + + fun diff(old: Map, new: Map) = Diff( + added = new.keys.filter { it !in old }.map(::displayName), + changed = new.filter { (key, entry) -> old[key].let { it != null && it != entry } }.keys.map(::displayName), + removed = old.keys.filter { it !in new }.map(::displayName) + ) +} diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index 335882e79..f87d96ef7 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -371,6 +371,9 @@ object TextExpanderUtils { fun expand(template: String, context: Context): String { var result = template + // tab character, e.g. for columns in a table + if (result.contains("%tab%")) result = result.replace("%tab%", "\t") + // Resolve %date[:modifiers]% if (result.contains("%date")) { result = DATE_REGEX.replace(result) { match -> diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index 4e6056e59..ac15f0bfc 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -96,6 +96,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { var editingTemplate by remember { mutableStateOf(TextFieldValue("")) } var originalShortcutToEdit by remember { mutableStateOf(null) } var editingIsRegex by remember { mutableStateOf(false) } + var showTextEditor by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxSize()) { SearchScreen( @@ -351,6 +352,14 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp) ) + androidx.compose.material3.OutlinedButton( + onClick = { showTextEditor = true }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(painterResource(R.drawable.ic_open_in_full), null, Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.text_expander_all_as_text)) + } // 4. List of saved shortcuts if (shortcutsMap.isEmpty()) { @@ -450,6 +459,13 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { } } + if (showTextEditor) { + TextExpanderTextEditor { changed -> + showTextEditor = false + if (changed) shortcutsMap = TextExpanderUtils.getShortcuts(context) + } + } + // Add / Edit Shortcut Dialog if (showAddDialog) { val focusRequester = remember { FocusRequester() } @@ -564,52 +580,60 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { modifier = Modifier.padding(top = 4.dp) ) - androidx.compose.foundation.layout.Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val tags = listOf( - "%clipboard%", "%clipboard:clean%", "%clipboard:singleline%", - "%clipboard:title%", "%clipboard:slug%", "%clipboard:upper%", - "%date%", "%date:yyyy/MM/dd%", "%date:dd-MM-yyyy%", "%time%", "%time12%", - "%day%", "%month%", "%year%", "%week%", - "%battery%", "%language%", "%cursor%", "%greeting%", - "%tomorrow%", "%bullets%", "%list%" - ) - tags.forEach { tag -> - Box( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)) - .clickable { - val text = editingTemplate.text - val selection = editingTemplate.selection - val start = selection.start - val end = selection.end - val newText = text.substring(0, start) + tag + text.substring(end) - val newSelectionRange = androidx.compose.ui.text.TextRange(start + tag.length) - editingTemplate = TextFieldValue(text = newText, selection = newSelectionRange) - } - .padding(horizontal = 8.dp, vertical = 6.dp) - ) { - Text( - text = tag, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace - ) - } - } - } + PlaceholderTagsRow(editingTemplate) { editingTemplate = it } } } ) } } +/** Placeholder tags, a tap inserts the tag at the cursor */ +@Composable +internal fun PlaceholderTagsRow(value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit) { + val editingTemplate = value + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val tags = listOf( + "%clipboard%", "%clipboard:clean%", "%clipboard:singleline%", + "%clipboard:title%", "%clipboard:slug%", "%clipboard:upper%", + "%date%", "%date:yyyy/MM/dd%", "%date:dd-MM-yyyy%", "%time%", "%time12%", + "%day%", "%month%", "%year%", "%week%", + "%battery%", "%language%", "%cursor%", "%greeting%", + "%tomorrow%", "%bullets%", "%list%", + "%tab%" + ) + tags.forEach { tag -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)) + .clickable { + val text = editingTemplate.text + val selection = editingTemplate.selection + val start = selection.start + val end = selection.end + val newText = text.substring(0, start) + tag + text.substring(end) + val newSelectionRange = androidx.compose.ui.text.TextRange(start + tag.length) + onValueChange(TextFieldValue(text = newText, selection = newSelectionRange)) + } + .padding(horizontal = 8.dp, vertical = 6.dp) + ) { + Text( + text = tag, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + } + } + } +} + @Composable private fun ShortcutItem( shortcut: String, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderTextEditor.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderTextEditor.kt new file mode 100644 index 000000000..a8029d98e --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderTextEditor.kt @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings.screens + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.edit +import helium314.keyboard.latin.R +import helium314.keyboard.latin.utils.TextExpanderText +import helium314.keyboard.latin.utils.TextExpanderUtils +import helium314.keyboard.latin.utils.prefs +import kotlinx.coroutines.launch + +/** previous state of the text expander data, kept when saving in the text editor, for restoring it */ +private const val PREF_DATA_PREVIOUS = "pref_text_expander_data_previous" +private const val MAX_FILE_SIZE = 2 * 1024 * 1024 + +/** + * All text expander shortcuts as one text, see [TextExpanderText]. + * [onClose] gets whether the shortcuts were changed. + */ +@Composable +fun TextExpanderTextEditor(onClose: (Boolean) -> Unit) { + val context = LocalContext.current + val prefs = context.prefs() + val scope = rememberCoroutineScope() + val scroll = rememberScrollState() + + var current by remember { mutableStateOf(TextExpanderUtils.getShortcuts(context)) } + var original by remember { mutableStateOf(TextExpanderText.format(current)) } + var value by remember { mutableStateOf(TextFieldValue(original)) } + var layout by remember { mutableStateOf(null) } + var changedSomething by remember { mutableStateOf(false) } + var hasPrevious by remember { mutableStateOf(prefs.contains(PREF_DATA_PREVIOUS)) } + + var errors by remember { mutableStateOf?>(null) } + var pending by remember { mutableStateOf?>(null) } + var askDiscard by remember { mutableStateOf(false) } + var askRestore by remember { mutableStateOf(false) } + + fun close() { if (value.text != original) askDiscard = true else onClose(changedSomething) } + fun replaceText(text: String) { value = TextFieldValue(text.replace("\r\n", "\n")) } + fun reload() { + current = TextExpanderUtils.getShortcuts(context) + original = TextExpanderText.format(current) + value = TextFieldValue(original) + hasPrevious = prefs.contains(PREF_DATA_PREVIOUS) + } + + val saveFile = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { context.contentResolver.openOutputStream(uri)?.use { it.write(value.text.toByteArray()) } } + .onFailure { toast(context, R.string.text_expander_file_error) } + } + val loadFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.openInputStream(uri)?.use { input -> + val bytes = input.readNBytesCompat(MAX_FILE_SIZE + 1) + require(bytes.size <= MAX_FILE_SIZE) + replaceText(String(bytes)) + } + }.onFailure { toast(context, R.string.text_expander_file_error) } + } + + Dialog(onDismissRequest = ::close, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Column(modifier = Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) { + // title bar + Row(modifier = Modifier.fillMaxWidth().padding(4.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = ::close) { + Icon(painterResource(R.drawable.ic_arrow_back), stringResource(android.R.string.cancel)) + } + Text( + stringResource(R.string.text_expander_all_title), style = MaterialTheme.typography.titleLarge, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) + ) + TextButton(onClick = { + val result = TextExpanderText.parse(value.text, current) + if (result.errors.isNotEmpty()) errors = result.errors + else { + val diff = TextExpanderText.diff(current, result.entries) + if (diff.isEmpty) { toast(context, R.string.text_expander_no_changes); original = value.text } + else pending = result to diff + } + }) { Text(stringResource(R.string.text_expander_save)) } + } + // actions + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton(onClick = { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + // marked as sensitive: not shown in clipboard previews and kept out of clipboard histories + cm.setPrimaryClip(ClipData.newPlainText("LeanType", value.text).apply { + description.extras = android.os.PersistableBundle().apply { + putBoolean(if (android.os.Build.VERSION.SDK_INT >= 33) android.content.ClipDescription.EXTRA_IS_SENSITIVE + else "android.content.extra.IS_SENSITIVE", true) + } + }) + toast(context, R.string.text_expander_copied) + }) { Text(stringResource(R.string.text_expander_copy_all)) } + OutlinedButton(onClick = { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val text = cm.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString() + if (text.isNullOrEmpty()) toast(context, R.string.text_expander_clipboard_empty) else replaceText(text) + }) { Text(stringResource(R.string.text_expander_paste_all)) } + OutlinedButton(onClick = { + val intent = Intent(Intent.ACTION_SEND).setType("text/plain") + .putExtra(Intent.EXTRA_TEXT, value.text) + .putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.text_expander_all_title)) + context.startActivity(Intent.createChooser(intent, null)) + }) { Text(stringResource(R.string.text_expander_share)) } + OutlinedButton(onClick = { saveFile.launch(context.getString(R.string.text_expander_file_name)) }) { + Text(stringResource(R.string.text_expander_save_file)) + } + OutlinedButton(onClick = { loadFile.launch(arrayOf("text/*")) }) { Text(stringResource(R.string.text_expander_load_file)) } + OutlinedButton(onClick = { askRestore = true }, enabled = hasPrevious) { + Text(stringResource(R.string.text_expander_restore_previous)) + } + } + // jump bar + val headers = remember(value.text) { TextExpanderText.headers(value.text) } + if (headers.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + headers.forEach { (name, offset) -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable { + val l = layout ?: return@clickable + val line = l.getLineForOffset(offset.coerceAtMost(value.text.length)) + val lineEnd = value.text.indexOf('\n', offset).let { if (it < 0) value.text.length else it + 1 } + value = value.copy(selection = TextRange(lineEnd)) + scope.launch { scroll.animateScrollTo(l.getLineTop(line).toInt()) } + } + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Text(name.ifEmpty { "?" }, style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer) + } + } + } + } + // the text + Box(modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp) + .clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))) { + Column(modifier = Modifier.fillMaxSize().verticalScroll(scroll).padding(12.dp)) { + val headerStyle = SpanStyle(color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + BasicTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + visualTransformation = remember(headerStyle) { HeaderHighlight(headerStyle) }, + onTextLayout = { layout = it }, + ) + } + } + Text( + stringResource(R.string.text_expander_all_hint), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) + Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) { + PlaceholderTagsRow(value) { value = it } + } + } + } + } + + errors?.let { list -> + AlertDialog( + onDismissRequest = { errors = null }, + confirmButton = { TextButton(onClick = { errors = null }) { Text(stringResource(android.R.string.ok)) } }, + title = { Text(stringResource(R.string.text_expander_errors_title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(6.dp)) { + list.forEach { Text(errorText(it)) } + } + } + ) + } + pending?.let { (result, diff) -> + AlertDialog( + onDismissRequest = { pending = null }, + confirmButton = { + TextButton(onClick = { + prefs.edit { putString(PREF_DATA_PREVIOUS, prefs.getString(TextExpanderUtils.PREF_DATA, null) ?: "{}") } + TextExpanderUtils.saveShortcuts(context, result.entries) + changedSomething = true + pending = null + reload() + toast(context, R.string.text_expander_saved) + }) { Text(stringResource(R.string.text_expander_save)) } + }, + dismissButton = { TextButton(onClick = { pending = null }) { Text(stringResource(android.R.string.cancel)) } }, + title = { Text(stringResource(R.string.text_expander_save_question)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(stringResource(R.string.text_expander_changes, diff.added.size, diff.changed.size, diff.removed.size)) + if (diff.removed.isNotEmpty()) + Text(stringResource(R.string.text_expander_changes_deleted, diff.removed.joinToString(", ")), + color = MaterialTheme.colorScheme.error) + } + } + ) + } + if (askRestore) { + AlertDialog( + onDismissRequest = { askRestore = false }, + confirmButton = { + TextButton(onClick = { + val now = prefs.getString(TextExpanderUtils.PREF_DATA, null) ?: "{}" + val previous = prefs.getString(PREF_DATA_PREVIOUS, null) ?: "{}" + prefs.edit { putString(TextExpanderUtils.PREF_DATA, previous); putString(PREF_DATA_PREVIOUS, now) } + changedSomething = true + askRestore = false + reload() + }) { Text(stringResource(R.string.text_expander_restore_previous)) } + }, + dismissButton = { TextButton(onClick = { askRestore = false }) { Text(stringResource(android.R.string.cancel)) } }, + text = { Text(stringResource(R.string.text_expander_restore_previous_confirm)) } + ) + } + if (askDiscard) { + AlertDialog( + onDismissRequest = { askDiscard = false }, + confirmButton = { + TextButton(onClick = { askDiscard = false; onClose(changedSomething) }) { Text(stringResource(R.string.text_expander_discard)) } + }, + dismissButton = { TextButton(onClick = { askDiscard = false }) { Text(stringResource(android.R.string.cancel)) } }, + text = { Text(stringResource(R.string.text_expander_discard_title)) } + ) + } +} + +@Composable +private fun errorText(error: TextExpanderText.Error): String = when (error.kind) { + TextExpanderText.ErrorKind.TEXT_BEFORE_FIRST -> stringResource(R.string.text_expander_error_text_before, error.line) + TextExpanderText.ErrorKind.EMPTY_SHORTCUT -> stringResource(R.string.text_expander_error_empty_shortcut, error.line) + TextExpanderText.ErrorKind.SPACE_IN_SHORTCUT -> stringResource(R.string.text_expander_error_space, error.line, error.name) + TextExpanderText.ErrorKind.EMPTY_TEXT -> stringResource(R.string.text_expander_error_empty_text, error.line, error.name) + TextExpanderText.ErrorKind.DUPLICATE -> stringResource(R.string.text_expander_error_duplicate, error.line, error.name) + TextExpanderText.ErrorKind.INVALID_REGEX -> stringResource(R.string.text_expander_error_regex, error.line, error.name) +} + +/** Shows the "=== shortcut ===" lines highlighted, the text itself is unchanged */ +private class HeaderHighlight(private val style: SpanStyle) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val styled = buildAnnotatedString { + append(text) + TextExpanderText.headers(text.text).forEach { (_, offset) -> + val end = text.text.indexOf('\n', offset).let { if (it < 0) text.length else it } + addStyle(style, offset, end) + } + } + return TransformedText(styled, OffsetMapping.Identity) + } +} + +private fun toast(context: Context, res: Int) = Toast.makeText(context, res, Toast.LENGTH_SHORT).show() + +private fun java.io.InputStream.readNBytesCompat(limit: Int): ByteArray { + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (out.size() < limit) { + val read = read(buffer, 0, minOf(buffer.size, limit - out.size())) + if (read < 0) break + out.write(buffer, 0, read) + } + return out.toByteArray() +} diff --git a/app/src/main/res/drawable/ic_open_in_full.xml b/app/src/main/res/drawable/ic_open_in_full.xml new file mode 100644 index 000000000..38cdd6f87 --- /dev/null +++ b/app/src/main/res/drawable/ic_open_in_full.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index cacf55063..200900d71 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -802,6 +802,34 @@ Regex-Muster Regulärer Ausdruck ⚠️ Ungültiges reguläres Ausdrucksmuster + Alle als Text bearbeiten + Alle Textbausteine + Jeder Baustein beginnt mit einer Zeile === Kürzel ===, darunter steht sein Text. Speichern übernimmt genau diesen Text: neue Blöcke werden angelegt, geänderte aktualisiert, entfernte gelöscht. + Alles kopieren + Alles einfügen + Teilen + Als Datei speichern + Aus Datei laden + Textbausteine.txt + Datei konnte nicht gelesen oder geschrieben werden + Letzten Stand + Die Bausteine so wiederherstellen, wie sie vor dem letzten Speichern waren? Der aktuelle Stand wird gemerkt, Du kannst also zurückwechseln. + Kopiert + Zwischenablage ist leer + Keine Änderungen + Gespeichert + Änderungen speichern? + %1$d neu, %2$d geändert, %3$d gelöscht + Gelöscht werden: %s + Bitte korrigieren + Zeile %d: Text vor der ersten Zeile === Kürzel === + Zeile %d: Kürzel fehlt + Zeile %1$d: keine Leerzeichen in „%2$s“ erlaubt + Zeile %1$d: kein Text für „%2$s“ + Zeile %1$d: „%2$s“ kommt doppelt vor + Zeile %1$d: ungültiger Regex „%2$s“ + Deine Änderungen verwerfen? + Verwerfen Vorlagenerweiterung Be right back! oder Meine E-Mail ist %clipboard% Schnelle Platzhalter (zum Einfügen am Cursor tippen): diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54cfe2795..44f4164bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1822,6 +1822,34 @@ New dictionary: Regex Pattern Regular Expression ⚠️ Invalid regular expression pattern + Edit all as text + All text shortcuts + Each shortcut starts with a line === shortcut ===, followed by its text. Saving applies exactly this text: new blocks are added, changed ones updated, removed ones deleted. + Copy all + Paste all + Share + Save as file + Load from file + text_shortcuts.txt + Could not read or write the file + Restore previous + Restore the shortcuts as they were before the last save? The current state is kept, so you can switch back. + Copied + Clipboard is empty + No changes + Saved + Save changes? + %1$d new, %2$d changed, %3$d deleted + Deleted: %s + Please correct + Line %d: text before the first === shortcut === line + Line %d: shortcut missing + Line %1$d: no spaces allowed in \"%2$s\" + Line %1$d: no text for \"%2$s\" + Line %1$d: \"%2$s\" appears twice + Line %1$d: invalid regex \"%2$s\" + Discard your changes? + Discard Template Expansion Be right back! or My email is %clipboard% Quick Placeholders (tap to insert at cursor): diff --git a/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderTabTest.kt b/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderTabTest.kt new file mode 100644 index 000000000..be463a4ad --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderTabTest.kt @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.utils + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class TextExpanderTabTest { + @Test + fun tabPlaceholderInsertsATabCharacter() { + val context = RuntimeEnvironment.getApplication() + assertEquals("Name\tValue", TextExpanderUtils.expand("Name%tab%Value", context)) + assertEquals("plain text", TextExpanderUtils.expand("plain text", context)) + } +} diff --git a/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderTextTest.kt b/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderTextTest.kt new file mode 100644 index 000000000..0ca20f8ed --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/utils/TextExpanderTextTest.kt @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.utils + +import helium314.keyboard.latin.utils.TextExpanderText.ErrorKind +import helium314.keyboard.latin.utils.TextExpanderUtils.ShortcutEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TextExpanderTextTest { + private val sample = linkedMapOf( + ";cic" to ShortcutEntry("Du bist ein Experte.\n\n## Aufgabe\nFasse zusammen:\n%clipboard%", ";"), + "mfg" to ShortcutEntry("Mit freundlichen Grüßen\nMax"), + TextExpanderUtils.REGEX_PREFIX + "\\d+eur" to ShortcutEntry("Euro"), + ) + + @Test fun `format and parse give the same shortcuts`() { + val text = TextExpanderText.format(sample) + assertTrue(text.startsWith("=== ;cic ===\nDu bist")) + assertTrue(text.contains("\n\n=== regex: \\d+eur ===\nEuro\n")) + val result = TextExpanderText.parse(text, sample) + assertEquals(emptyList(), result.errors) + assertEquals(sample, result.entries) + assertTrue(TextExpanderText.diff(sample, result.entries).isEmpty) + } + + @Test fun `empty lines inside a text stay, around it they are removed`() { + val result = TextExpanderText.parse("\n=== a ===\n\nline 1\n\nline 2\n\n\n=== b ===\nx", emptyMap()) + assertEquals("line 1\n\nline 2", result.entries["a"]!!.template) + assertEquals("x", result.entries["b"]!!.template) + } + + @Test fun `prefix is taken from leading non letters for new shortcuts`() { + val result = TextExpanderText.parse("=== ;cic ===\nx\n=== ->x ===\ny\n=== :: ===\nz", emptyMap()) + assertEquals(";", result.entries[";cic"]!!.prefix) + assertEquals("->", result.entries["->x"]!!.prefix) + assertEquals("", result.entries["::"]!!.prefix) + } + + @Test fun `existing text with trailing line break is unchanged`() { + val existing = mapOf("a" to ShortcutEntry("text\n")) + val result = TextExpanderText.parse(TextExpanderText.format(existing), existing) + assertEquals("text\n", result.entries["a"]!!.template) + assertTrue(TextExpanderText.diff(existing, result.entries).isEmpty) + } + + @Test fun `diff lists new, changed and removed shortcuts`() { + val text = "=== ;cic ===\nneu\n=== neu ===\nx" + val diff = TextExpanderText.diff(sample, TextExpanderText.parse(text, sample).entries) + assertEquals(listOf("neu"), diff.added) + assertEquals(listOf(";cic"), diff.changed) + assertEquals(listOf("mfg", "regex: \\d+eur"), diff.removed) + } + + @Test fun `errors are reported with line numbers`() { + val text = "vorher\n=== ===\nx\n=== a b ===\nx\n=== leer ===\n\n=== d ===\n1\n=== d ===\n2\n=== regex: ( ===\nx" + val errors = TextExpanderText.parse(text, emptyMap()).errors + assertEquals(listOf( + TextExpanderText.Error(1, ErrorKind.TEXT_BEFORE_FIRST), + TextExpanderText.Error(2, ErrorKind.EMPTY_SHORTCUT), + TextExpanderText.Error(4, ErrorKind.SPACE_IN_SHORTCUT, "a b"), + TextExpanderText.Error(6, ErrorKind.EMPTY_TEXT, "leer"), + TextExpanderText.Error(10, ErrorKind.DUPLICATE, "d"), + TextExpanderText.Error(12, ErrorKind.INVALID_REGEX, "("), + ), errors) + } + + @Test fun `headers give names and line offsets`() { + val text = "=== a ===\nx\n=== b ===\ny" + assertEquals(listOf("a" to 0, "b" to 12), TextExpanderText.headers(text)) + } + + @Test fun `empty text means no shortcuts`() { + val result = TextExpanderText.parse(" \n", sample) + assertTrue(result.entries.isEmpty()) + assertEquals(3, TextExpanderText.diff(sample, result.entries).removed.size) + } +}