diff --git a/CHANGELOG.ko.md b/CHANGELOG.ko.md index 2d0f788..216d930 100644 --- a/CHANGELOG.ko.md +++ b/CHANGELOG.ko.md @@ -6,6 +6,14 @@ Markleaf의 주요 변경 사항을 기록합니다. 영어판이 기본이며 G v2.15.3 이전 항목은 이 파일에만 한국어로 보존되어 있습니다. +## v2.32.0 - 노트를 정리해 보는 두 가지 방법 (Two ways to arrange your notes) - 2026-07-25 + +두 가지 모두 같은 제보자의 요청이며, 기존 동작을 대체하지 않는 선택지입니다. 직접 바꾸기 전까지 목록의 모양도, 노트의 제목도 그대로입니다. 권한 변경과 저장 형식 변경은 없습니다. + +### Added +- **노트를 바둑판으로 볼 수 있습니다 (#279).** **설정 → 노트 및 검색 → 노트 목록 배치**에 한 열짜리 *목록*과 함께 *바둑판*이 생겼습니다. 기본값은 그대로 *목록*입니다. 바둑판은 같은 노트를 카드로 배치하며 화면 너비가 허용하는 만큼 열을 늘립니다 — 폰에서는 두 열, 태블릿에서는 더 많이, 편집기 옆의 태블릿 목록 창에서는 폭에 맞춰 줄어듭니다. 나머지는 그대로입니다: 오늘 / 어제 / 고정 섹션은 여전히 노트를 묶고, 카드를 길게 누르면 고정 / 보관 / 잠금 / 휴지통이 열리며, *노트 미리보기 표시*가 발췌와 수정 시각의 표시 여부를 계속 결정합니다. +- **노트 제목을 어느 줄에서 가져올지 고를 수 있습니다 (#280).** Markleaf는 노트 *어디에 있든* 첫 Markdown 제목을 노트 제목으로 삼았습니다. 그래서 문단으로 시작하고 아래쪽에 제목이 있는 노트는 그 제목으로 불렸습니다 — 맨 위에서 열 줄 아래의 편집이 노트 이름을 바꾸는 셈이었습니다. 이제 **설정 → 노트 및 검색 → 노트 제목**에서 *첫 줄*을 고를 수 있습니다. 언제나 비어 있지 않은 첫 줄을 쓰고, 그 줄이 제목이면 `#` 표시를 떼어 냅니다. 기본값은 여전히 *첫 제목 줄*이라 바꾸지 않으면 아무 이름도 달라지지 않습니다. 전환하면 이미 가지고 있는 노트의 제목이 다시 계산되며, 수정 시각은 건드리지 않으므로 목록 순서도 그대로입니다. 동기화 폴더의 파일명은 **동기화 센터 → 파일명을 노트 제목으로 정리**로 함께 맞출 수 있습니다. + ## v2.31.0 - 노트 안에서 Markleaf의 기록을 걷어내다 (Your notes, without Markleaf's bookkeeping in them) - 2026-07-24 동기화가 기록을 어디에 두는지에 대한 선택형 변경 하나와, 모든 언어에서 한국어로 나오던 텍스트 수정 하나입니다. 권한 변경은 없으며, 새 옵션을 켜지 않는 한 저장 형식도 그대로입니다. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5fab9..8f908c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to Markleaf are documented in this file. This English editio > 💬 **Questions or feedback?** Start a thread in [GitHub Discussions](https://github.com/jeiel85/markleaf-android/discussions). Bug reports still belong in [Issues](https://github.com/jeiel85/markleaf-android/issues). +## v2.32.0 - Two ways to arrange your notes - 2026-07-25 + +Both additions came from the same reporter, and both are choices rather than replacements: your list keeps its shape and your notes keep their titles until you say otherwise. No permission or storage-format changes. + +### Added +- **See your notes as a grid (#279).** **Settings → Notes & search → Notes list layout** now offers *Grid* alongside the one-column *List*, which stays the default. Grid lays the same notes out as cards, in as many columns as the screen fits — two on a phone, more on a tablet, and it narrows correctly in the tablet's list pane beside the editor. Everything else about the list is unchanged: the Today / Yesterday / Pinned sections still group your notes, long-pressing a card still opens pin / archive / lock / trash, and *Show note previews* still governs whether the excerpt and edit time appear. +- **Choose which line becomes a note's title (#280).** Markleaf took the title from the first Markdown heading *anywhere* in a note, so a note that opened with a paragraph and had a heading further down was titled by that heading — the same note, retitled by an edit ten lines below the top. **Settings → Notes & search → Note title** now offers *First line*, which always uses the first non-empty line and strips the `#` markers if that line happens to be a heading. *First heading* remains the default, so nothing is renamed unless you switch. Switching retitles the notes you already have — leaving their edit times, and therefore their order, untouched — and *Sync Center → Rename files to note titles* brings a sync folder's filenames along. + ## v2.31.0 - Your notes, without Markleaf's bookkeeping in them - 2026-07-24 One opt-in change to how sync stores its bookkeeping, and one fix for text that was Korean in every language. No permission changes, and the storage format is unchanged unless you turn the new option on. diff --git a/HISTORY.md b/HISTORY.md index a2e86c7..99c54df 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,12 @@ +## 2026-07-25 - Notes grid, first-line titles, and v2.32.0 (#279, #280) + +- Trigger: two feature requests filed the same afternoon by one external reporter — a grid view for the notes list (#279), and a title field for each note (#280), the second reported as "what is set as the title is not necessarily the first line". +- Analysis: (#280) `TitleExtractor.titleLineIndex` searched the whole note for the first heading and only fell back to the first non-empty line when there was none, so a note opening with a paragraph took its name from a heading further down. The report is accurate and reproducible. A stored title field — the literal request — was declined in the triage comment: a Markleaf note *is* its `.md` file, so a separate title would have to live in the frontmatter or the sidecar index and would drift the moment the file was edited elsewhere. (#279) the list was a single `LazyColumn` with per-row action lambdas defined inline, so a second layout would have duplicated the lock action's mirror cleanup. +- Contract/scope: two settings under **Notes & search**, both defaulting to today's behaviour. `notesLayout` = LIST | GRID (#279) and `noteTitleSource` = FIRST_HEADING | FIRST_LINE (#280), the latter living in `core.text` beside `TitleExtractor` so `core` keeps not depending on `data`. Switching the title rule runs `NoteRetitler.retitleAll` over the database — titles are derived at save time, so the setting would otherwise appear to do nothing — leaving `updatedAt`, empty notes and conflict copies alone. Mirror filenames follow through the existing Sync Center "tidy filenames" action rather than a new folder pass. +- Implementation: PR #281. Row actions hoisted out of the list body and shared by `NoteRow` and the new `NoteCard`; the long-press menu extracted to `NoteContextMenu`; the shared-bounds transition moved into a `Modifier.noteSharedBounds` helper used by both. `titleSource` threaded through the editor's five derivations, both import passes in `NoteFolderMirror`, and the share-intent note creation — which reads the store directly, since the first composition still holds default settings. +- Verification: `testDebugUnitTest` + `lintRelease` locally; 27 unit tests across `TitleExtractorTest`, `NoteRetitlerTest`, `AppSettingsRepositoryListTest` and `AppSettingsTest`. Device pass on `markleaf-phone-api36`: grid at two columns with section headers spanning and the long-press menu working, then the title rule both ways on a note reading `Plain first line` / `## Heading below` — "Retitled 1 note." and the note kept its place in the list. The `settings_notes_search_tablet` golden was re-anchored on "First line": anchored on "Where I left off" it kept passing while showing none of the new rows. +- Release: versionCode 121→122, versionName 2.31.0→2.32.0, CHANGELOG both editions, six store-locale `122.txt` (414 chars at the longest against the 500 cap), landing x6 + README x6. No hardening issue filed — this is issue-response work, and AGENTS.md keeps that flow from growing the backlog it is meant to spend. + ## 2026-07-24 - Sidecar metadata index, localised sync labels, and v2.31.0 (#216, #255) - Trigger: #216 asked why Markleaf writes its own bookkeeping into the note file itself. #255, the hardening list filed alongside v2.30.0, carried a settings-snapshot coverage item, and covering it is what surfaced the hardcoded Korean relative-time labels. diff --git a/README.de.md b/README.de.md index b987ffd..98fd474 100644 --- a/README.de.md +++ b/README.de.md @@ -55,7 +55,7 @@ **Markleaf** ist eine Android-Markdown-Notiz-App, die bewusst auf Ballast verzichtet, damit du dich auf zwei Dinge konzentrieren kannst: festhalten und ordnen. Deine Daten liegen ausschließlich auf deinem Gerät, und das standardisierte Markdown-Format garantiert volle Eigentümerschaft und Portabilität. Auch die Synchronisierung läuft nur über *einen von dir gewählten Ordner* – Markleaf selbst geht nie online. -[**Branding-Seite ansehen**](https://jeiel85.github.io/markleaf-android/) · [Aktuelle Version: v2.31.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0) · [Datenschutzerklärung](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) +[**Branding-Seite ansehen**](https://jeiel85.github.io/markleaf-android/) · [Aktuelle Version: v2.32.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0) · [Datenschutzerklärung](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) --- @@ -162,7 +162,7 @@ com.markleaf.notes > **Google-Play-Updates sind derzeit ausgesetzt.** Bis eine koreanische Gewerbeanmeldungs-Anforderung für den Einzelentwickler geklärt ist, werden keine neuen Versionen in den Play Store geladen. Hol dir in der Zwischenzeit **die neueste Version über F-Droid, GitHub Releases oder GitLab Releases.** (Wenn du sie bereits aus dem Play Store installiert hast, funktioniert sie weiterhin.) - **F-Droid** *(empfohlen)*: [Markleaf on F-Droid](https://f-droid.org/packages/com.markleaf.notes/) – im F-Droid-Client suchen oder über den Link oben installieren. Es wird derselbe Signaturschlüssel (SHA-256 `0be97352…f91a`) verwendet, sodass Updates auch dann nahtlos weiterlaufen, wenn du ein APK aus den GitHub- oder GitLab-Releases per Sideload installiert hast. -- **Direkte APK-Installation**: lade das APK aus dem [GitHub-v2.31.0-Release](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0) herunter und führe es auf deinem Android-Gerät aus. +- **Direkte APK-Installation**: lade das APK aus dem [GitHub-v2.32.0-Release](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0) herunter und führe es auf deinem Android-Gerät aus. - **Google Play**: [Markleaf on Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) – **Updates sind ausgesetzt** (siehe Hinweis oben). Wenn du die App bereits hast, funktioniert sie weiter, aber die neueste Version gibt es über F-Droid, GitHub oder GitLab. ### Aus dem Quellcode bauen diff --git a/README.es.md b/README.es.md index c9b6ba0..4817bce 100644 --- a/README.es.md +++ b/README.es.md @@ -55,7 +55,7 @@ **Markleaf** es una app de notas Markdown para Android diseñada para eliminar lo superfluo y dejarte concentrar en solo dos cosas: capturar y organizar. Tus datos se guardan únicamente en tu dispositivo, y el formato Markdown estándar garantiza la propiedad total y la portabilidad de tus datos. Incluso la sincronización ocurre solo a través de *una carpeta que tú eliges* — Markleaf en sí nunca se conecta a internet. -[**Ver la página de branding**](https://jeiel85.github.io/markleaf-android/) · [Versión actual: v2.31.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0) · [Política de privacidad](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) +[**Ver la página de branding**](https://jeiel85.github.io/markleaf-android/) · [Versión actual: v2.32.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0) · [Política de privacidad](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) --- @@ -162,7 +162,7 @@ com.markleaf.notes > **Las actualizaciones en Google Play están en pausa por ahora.** No se publicarán nuevas versiones en la Play Store hasta que se resuelva un requisito de política de registro de negocio en Corea para el desarrollador individual. Mientras tanto, **obtén la última versión desde F-Droid, GitHub Releases o GitLab Releases.** (Si ya la instalaste desde la Play Store, seguirá funcionando.) - **F-Droid** *(recomendado)*: [Markleaf en F-Droid](https://f-droid.org/packages/com.markleaf.notes/) — búscalo en el cliente de F-Droid o instálalo con el enlace de arriba. Usa la misma clave de firma (SHA-256 `0be97352…f91a`), así que las actualizaciones continúan sin problemas incluso si instalaste un APK de GitHub o GitLab Releases mediante sideload. -- **Instalación directa del APK**: descarga el APK desde el [release v2.31.0 de GitHub](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0), y ejecútalo en tu dispositivo Android. +- **Instalación directa del APK**: descarga el APK desde el [release v2.32.0 de GitHub](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0), y ejecútalo en tu dispositivo Android. - **Google Play**: [Markleaf en Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) — **las actualizaciones están en pausa** (ver la nota de arriba). Si ya la tienes instalada, seguirá funcionando, pero obtén la última versión desde F-Droid, GitHub o GitLab. ### Compilar desde el código fuente diff --git a/README.fr.md b/README.fr.md index da948db..8460037 100644 --- a/README.fr.md +++ b/README.fr.md @@ -55,7 +55,7 @@ **Markleaf** est une application Android de prise de notes Markdown conçue pour éliminer le superflu afin que vous puissiez vous concentrer sur seulement deux choses : capturer et organiser. Vos données sont stockées uniquement sur votre appareil, et le format Markdown standard garantit une propriété et une portabilité complètes. Même la synchronisation ne passe que par *un dossier que vous choisissez* — Markleaf lui-même ne se connecte jamais à internet. -[**Voir la page de branding**](https://jeiel85.github.io/markleaf-android/) · [Version actuelle : v2.31.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0) · [Politique de confidentialité](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) +[**Voir la page de branding**](https://jeiel85.github.io/markleaf-android/) · [Version actuelle : v2.32.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0) · [Politique de confidentialité](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) --- @@ -162,7 +162,7 @@ com.markleaf.notes > **Les mises à jour sur Google Play sont actuellement en pause.** Aucune nouvelle version ne sera publiée sur le Play Store tant qu'une exigence de politique d'enregistrement d'entreprise en Corée pour le développeur indépendant ne sera pas résolue. En attendant, **obtenez la dernière version via F-Droid, GitHub Releases ou GitLab Releases.** (Si vous l'avez déjà installée depuis le Play Store, elle continue de fonctionner.) - **F-Droid** *(recommandé)* : [Markleaf sur F-Droid](https://f-droid.org/packages/com.markleaf.notes/) — recherchez-le dans le client F-Droid ou installez-le via le lien ci-dessus. Il utilise la même clé de signature (SHA-256 `0be97352…f91a`), donc les mises à jour continuent sans interruption même si vous avez installé un APK via sideload depuis GitHub ou GitLab Releases. -- **Installation directe de l'APK** : téléchargez l'APK depuis la [release GitHub v2.31.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0), puis exécutez-le sur votre appareil Android. +- **Installation directe de l'APK** : téléchargez l'APK depuis la [release GitHub v2.32.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0), puis exécutez-le sur votre appareil Android. - **Google Play** : [Markleaf sur Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) — **les mises à jour sont en pause** (voir la note ci-dessus). Si vous l'avez déjà, elle continue de fonctionner, mais obtenez la dernière version via F-Droid, GitHub ou GitLab. ### Compilation depuis les sources diff --git a/README.ja.md b/README.ja.md index 4c9b71a..cec73e5 100644 --- a/README.ja.md +++ b/README.ja.md @@ -55,7 +55,7 @@ **Markleaf** は、余計なものをそぎ落とし「記録」と「整理」だけに集中できるよう設計された Android 向け Markdown メモアプリです。データは端末内にのみ保存され、標準 Markdown 形式によってデータの所有権と移植性が完全に保証されます。同期も *あなたが選んだフォルダ* を介してのみ行われ、Markleaf 自体はインターネットに接続しません。 -[**ブランディングページを見る**](https://jeiel85.github.io/markleaf-android/) · [現在のバージョン: v2.31.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0) · [プライバシーポリシー](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) +[**ブランディングページを見る**](https://jeiel85.github.io/markleaf-android/) · [現在のバージョン: v2.32.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0) · [プライバシーポリシー](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) --- @@ -162,7 +162,7 @@ com.markleaf.notes > **現在、Google Play での更新は一時保留中です。** 個人開発者の韓国の事業者登録に関するポリシー要件が解決するまで、新しいバージョンは Play ストアに公開しません。その間は **最新版を F-Droid、GitHub Releases、または GitLab Releases から入手してください。**(すでに Play ストアからインストール済みの場合はそのまま使えます。) - **F-Droid** *(推奨)*: [Markleaf on F-Droid](https://f-droid.org/packages/com.markleaf.notes/) — F-Droid クライアントで検索するか、上のリンクから直接インストールできます。同じ署名鍵(SHA-256 `0be97352…f91a`)を使用するため、GitHub/GitLab Releases の APK をサイドロードした場合でも途切れなく更新が続きます。 -- **APK の直接インストール**: [GitHub v2.31.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0) リリースから APK をダウンロードし、Android 端末で実行してインストールします。 +- **APK の直接インストール**: [GitHub v2.32.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0) リリースから APK をダウンロードし、Android 端末で実行してインストールします。 - **Google Play**: [Markleaf on Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) — **更新は一時保留中**です(上の注記を参照)。すでにインストール済みなら引き続き使えますが、最新版は F-Droid・GitHub・GitLab から入手してください。 ### 開発環境の構築 diff --git a/README.ko.md b/README.ko.md index f7ac336..041d5cf 100644 --- a/README.ko.md +++ b/README.ko.md @@ -55,7 +55,7 @@ **Markleaf**는 군더더기를 덜어내고 오직 '기록'과 '정리'에만 집중할 수 있도록 설계된 Android Markdown 메모 앱입니다. 당신의 데이터는 오직 당신의 기기에만 저장되며, 표준 Markdown 형식을 사용하여 데이터의 소유권과 이식성을 완벽히 보장합니다. 동기화도 *당신이 선택한 폴더* 를 통해서만 일어납니다 — Markleaf 자체는 인터넷에 나가지 않습니다. -[**브랜딩 페이지 보기**](https://jeiel85.github.io/markleaf-android/) · [현재 버전: v2.31.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0) · [Privacy Policy](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) +[**브랜딩 페이지 보기**](https://jeiel85.github.io/markleaf-android/) · [현재 버전: v2.32.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0) · [Privacy Policy](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) --- @@ -162,7 +162,7 @@ com.markleaf.notes > **Google Play 업데이트는 현재 잠정 보류 중입니다.** 1인 개발자의 한국 사업자 등록 요건 관련 정책 이슈가 정리될 때까지 새 버전을 Play Store에 올리지 않습니다. 그동안 **최신 버전은 F-Droid, GitHub Releases 또는 GitLab Releases에서 받아 주세요.** (Play Store에 이미 설치돼 있다면 그대로 사용할 수 있습니다.) - **F-Droid** *(권장)*: [Markleaf on F-Droid](https://f-droid.org/packages/com.markleaf.notes/) — F-Droid 클라이언트에서 검색하거나 위 링크로 바로 설치할 수 있습니다. 동일 서명 키(SHA-256 `0be97352…f91a`)를 사용하므로 GitHub/GitLab Releases APK로 사이드로드한 경우에도 끊김 없이 업데이트가 이어집니다. -- **APK 직접 설치**: [GitHub v2.31.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0) 릴리스에서 APK를 다운로드한 뒤 Android 기기에서 실행해 설치합니다. +- **APK 직접 설치**: [GitHub v2.32.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0) 릴리스에서 APK를 다운로드한 뒤 Android 기기에서 실행해 설치합니다. - **Google Play**: [Markleaf on Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) — **업데이트 잠정 보류 중**입니다(위 안내 참고). 이미 설치돼 있으면 계속 쓸 수 있지만, 최신 버전은 F-Droid·GitHub·GitLab에서 받으세요. ### 개발 환경 구축 diff --git a/README.md b/README.md index 4130aaa..b17546d 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ **Markleaf** is an Android Markdown note app designed to strip away the clutter so you can focus on just two things: capturing and organizing. Your data is stored only on your device, and standard Markdown guarantees full ownership and portability. Even sync happens only through *a folder you choose* — Markleaf itself never goes online. -[**View the branding page**](https://jeiel85.github.io/markleaf-android/) · [Current version: v2.31.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0) · [Privacy Policy](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) +[**View the branding page**](https://jeiel85.github.io/markleaf-android/) · [Current version: v2.32.0](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0) · [Privacy Policy](https://jeiel85.github.io/markleaf-android/privacy.html) · [F-Droid](https://f-droid.org/packages/com.markleaf.notes/) · [Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) --- @@ -162,7 +162,7 @@ com.markleaf.notes > **Google Play updates are currently on hold.** New versions won't be pushed to the Play Store until a Korean business-registration policy requirement for the solo developer is resolved. In the meantime, **get the latest version from F-Droid, GitHub Releases, or GitLab Releases.** (If you already installed it from the Play Store, it keeps working.) - **F-Droid** *(recommended)*: [Markleaf on F-Droid](https://f-droid.org/packages/com.markleaf.notes/) — search in the F-Droid client or install via the link above. It uses the same signing key (SHA-256 `0be97352…f91a`), so updates continue seamlessly even if you sideloaded an APK from GitHub or GitLab Releases. -- **Direct APK install**: download the APK from the [GitHub v2.31.0 release](https://github.com/jeiel85/markleaf-android/releases/tag/v2.31.0), then run it on your Android device. +- **Direct APK install**: download the APK from the [GitHub v2.32.0 release](https://github.com/jeiel85/markleaf-android/releases/tag/v2.32.0), then run it on your Android device. - **Google Play**: [Markleaf on Google Play](https://play.google.com/store/apps/details?id=com.markleaf.notes) — **updates are paused** (see the note above). If you already have it, it keeps working, but get the latest version from F-Droid, GitHub, or GitLab. ### Building from source diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 613eb3f..3dbadce 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -62,8 +62,8 @@ android { applicationId = "com.markleaf.notes" minSdk = 26 targetSdk = 35 - versionCode = 121 - versionName = "2.31.0" + versionCode = 122 + versionName = "2.32.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/markleaf/notes/MainActivity.kt b/app/src/main/java/com/markleaf/notes/MainActivity.kt index fc64bb9..fa54e24 100644 --- a/app/src/main/java/com/markleaf/notes/MainActivity.kt +++ b/app/src/main/java/com/markleaf/notes/MainActivity.kt @@ -89,7 +89,8 @@ class MainActivity : FragmentActivity() { existing = notes, applyUpdate = { updated -> importer.update(updated) }, applyCreate = { created -> importer.create(created) }, - metadata = settings.mirrorMetadata() + metadata = settings.mirrorMetadata(), + titleSource = settings.noteTitleSource ) } settingsRepository.setSyncLastSyncedAt(System.currentTimeMillis()) diff --git a/app/src/main/java/com/markleaf/notes/core/text/TitleExtractor.kt b/app/src/main/java/com/markleaf/notes/core/text/TitleExtractor.kt index 561f441..1f26432 100644 --- a/app/src/main/java/com/markleaf/notes/core/text/TitleExtractor.kt +++ b/app/src/main/java/com/markleaf/notes/core/text/TitleExtractor.kt @@ -1,5 +1,28 @@ package com.markleaf.notes.core.text +/** + * Which line of a note becomes its title (#280). + * + * Kept here rather than beside the other settings enums because [TitleExtractor] + * is what has to understand it, and `core` must not depend on `data`. + */ +enum class NoteTitleSource { + /** + * The first Markdown heading anywhere in the note, falling back to the first + * non-empty line when there is no heading at all. The original behaviour and + * the default — which also means a note whose heading sits *below* a + * paragraph is titled by that heading, the inconsistency #280 reported. + */ + FIRST_HEADING, + + /** + * The first non-empty line, heading or not — its `#` markers are stripped + * when that line happens to be a heading. Predictable at the price of + * ignoring a heading further down. + */ + FIRST_LINE +} + object TitleExtractor { private val HEADING_MARKER = Regex("#+\\s*") @@ -9,29 +32,37 @@ object TitleExtractor { trimmed.startsWith("#") && trimmed.length > 1 && (trimmed[1] == ' ' || trimmed[1] == '#') /** - * Index of the line that title extraction consumes — the first Markdown - * heading, otherwise the first non-empty line — or -1 when there is none. - * Shared by [extractTitle] and [generateExcerpt] so the excerpt never - * repeats the line already shown as the note title. + * Index of the line that title extraction consumes, or -1 when there is + * none. Shared by [extractTitle] and [generateExcerpt] so the excerpt never + * repeats the line already shown as the note title — under either [source]. */ - private fun titleLineIndex(lines: List): Int { - val headingIndex = lines.indexOfFirst { isHeadingLine(it.trim()) } - if (headingIndex >= 0) return headingIndex + private fun titleLineIndex(lines: List, source: NoteTitleSource): Int { + if (source == NoteTitleSource.FIRST_HEADING) { + val headingIndex = lines.indexOfFirst { isHeadingLine(it.trim()) } + if (headingIndex >= 0) return headingIndex + } return lines.indexOfFirst { it.trim().isNotEmpty() } } /** - * Extract title from markdown content. - * Priority: - * 1. First Markdown heading - * 2. First non-empty line - * 3. Fallback: "Untitled" + * Extract a title from markdown content, following [source]: + * [NoteTitleSource.FIRST_HEADING] takes the first heading and only falls + * back to the first non-empty line, [NoteTitleSource.FIRST_LINE] always + * takes the first non-empty line. Either way heading markers are stripped, + * the result is capped at 80 characters, and an empty one becomes + * "Untitled". + * + * [source] defaults to the historical rule so a caller with no user setting + * to hand — a starter note, an export preview — behaves as it always did. */ - fun extractTitle(content: String): String { + fun extractTitle( + content: String, + source: NoteTitleSource = NoteTitleSource.FIRST_HEADING + ): String { if (content.isBlank()) return "Untitled" val lines = content.lines() - val index = titleLineIndex(lines) + val index = titleLineIndex(lines, source) if (index < 0) return "Untitled" val line = lines[index].trim() @@ -47,11 +78,15 @@ object TitleExtractor { * skipping the line used as the title so a list row never repeats the * title in its preview. */ - fun generateExcerpt(content: String, maxLength: Int = 100): String { + fun generateExcerpt( + content: String, + source: NoteTitleSource = NoteTitleSource.FIRST_HEADING, + maxLength: Int = 100 + ): String { if (content.isBlank()) return "" val lines = content.lines() - val titleIndex = titleLineIndex(lines) + val titleIndex = titleLineIndex(lines, source) val bodyLines = if (titleIndex >= 0) { lines.filterIndexed { index, _ -> index != titleIndex } } else { diff --git a/app/src/main/java/com/markleaf/notes/data/local/dao/NoteDao.kt b/app/src/main/java/com/markleaf/notes/data/local/dao/NoteDao.kt index a492709..a67a505 100644 --- a/app/src/main/java/com/markleaf/notes/data/local/dao/NoteDao.kt +++ b/app/src/main/java/com/markleaf/notes/data/local/dao/NoteDao.kt @@ -57,6 +57,18 @@ interface NoteDao { @Update suspend fun updateNote(note: NoteEntity) + /** + * Rewrite only the two columns derived from the note body, used by the + * retitle pass behind the "Note title" setting (#280). + * + * Deliberately not `updateNote`: that pass reads every note, computes, and + * writes back — and a whole-row write would restore the body it read at the + * start of the pass, silently undoing an edit or an import that landed in + * between. Room's FTS content triggers fire on this UPDATE the same way. + */ + @Query("UPDATE notes SET title = :title, excerpt = :excerpt WHERE id = :noteId") + suspend fun updateDerivedTitle(noteId: String, title: String, excerpt: String) + // Trashing also clears `locked`: a deleted note leaves the Locked space so it // shows normally in Trash (no leak of a still-locked title) and can never // become an unreachable locked+trashed orphan (#155). diff --git a/app/src/main/java/com/markleaf/notes/data/repository/LocalNoteRepository.kt b/app/src/main/java/com/markleaf/notes/data/repository/LocalNoteRepository.kt index ffb1b26..779ea03 100644 --- a/app/src/main/java/com/markleaf/notes/data/repository/LocalNoteRepository.kt +++ b/app/src/main/java/com/markleaf/notes/data/repository/LocalNoteRepository.kt @@ -35,6 +35,10 @@ class LocalNoteRepository( database.noteDao().updateNote(note.toEntity()) } + override suspend fun updateDerivedTitle(noteId: String, title: String, excerpt: String) { + database.noteDao().updateDerivedTitle(noteId, title, excerpt) + } + override suspend fun moveToTrash(noteId: String) { database.noteDao().moveToTrash(noteId, System.currentTimeMillis()) } diff --git a/app/src/main/java/com/markleaf/notes/data/repository/NoteRetitler.kt b/app/src/main/java/com/markleaf/notes/data/repository/NoteRetitler.kt new file mode 100644 index 0000000..6df71c0 --- /dev/null +++ b/app/src/main/java/com/markleaf/notes/data/repository/NoteRetitler.kt @@ -0,0 +1,45 @@ +package com.markleaf.notes.data.repository + +import com.markleaf.notes.core.text.NoteTitleSource +import com.markleaf.notes.core.text.TitleExtractor +import com.markleaf.notes.domain.repository.NoteRepository + +/** + * Recompute stored titles and excerpts under a new title rule (#280). + * + * Titles are derived once, when a note is saved or imported, and then stored. + * Without this pass, changing the rule would appear to do nothing until every + * note had been edited again — the setting would read as broken. + * + * What it deliberately does *not* touch: + * - Any column but `title` and `excerpt`. The pass reads every note, computes, + * and writes — and writing the whole row back would restore the body it read + * at the start of the pass, undoing an edit or an import that landed while it + * ran. `updateDerivedTitle` rewrites the two derived columns and nothing + * else, so `updatedAt` also stays put: no note jumps to the top of the list, + * and the sync layer does not read a renamed note as an edited one. + * - Notes with no content, whose stored title is the empty string that the + * list renders as "Untitled". Deriving would write the literal word instead. + * - Conflict copies, whose title carries the "conflict copy" suffix that makes + * them findable; re-deriving from the body would erase it. + * + * Mirror filenames follow the title, but only when a note is next written. + * *Sync Center → Tidy filenames* is the existing one-pass way to catch the + * folder up, so this stays a database-only operation. + */ +object NoteRetitler { + + /** Retitle every stored note under [source]; returns how many changed. */ + suspend fun retitleAll(repository: NoteRepository, source: NoteTitleSource): Int { + var changed = 0 + for (note in repository.getAllNotes()) { + if (note.contentMarkdown.isBlank() || note.isConflictCopy) continue + val title = TitleExtractor.extractTitle(note.contentMarkdown, source) + val excerpt = TitleExtractor.generateExcerpt(note.contentMarkdown, source) + if (title == note.title && excerpt == note.excerpt) continue + repository.updateDerivedTitle(note.id, title, excerpt) + changed++ + } + return changed + } +} diff --git a/app/src/main/java/com/markleaf/notes/data/settings/AppSettings.kt b/app/src/main/java/com/markleaf/notes/data/settings/AppSettings.kt index d1a8edb..44d8d18 100644 --- a/app/src/main/java/com/markleaf/notes/data/settings/AppSettings.kt +++ b/app/src/main/java/com/markleaf/notes/data/settings/AppSettings.kt @@ -1,5 +1,7 @@ package com.markleaf.notes.data.settings +import com.markleaf.notes.core.text.NoteTitleSource + data class AppSettings( val markdownSyntaxVisibility: MarkdownSyntaxVisibility = MarkdownSyntaxVisibility.SHOW, val lineWidth: EditorLineWidth = EditorLineWidth.COMFORTABLE, @@ -25,6 +27,12 @@ data class AppSettings( val reopenLastNote: Boolean = false, /** Sort order of the notes list, driven by the top-bar sort menu (#191). */ val notesSortMode: NotesSortMode = NotesSortMode.UPDATED_DESC, + /** How the notes list arranges its rows. Defaults to [NotesLayout.LIST], + * the one-column list Markleaf has always shown (#279). */ + val notesLayout: NotesLayout = NotesLayout.LIST, + /** Which line of a note becomes its title. Defaults to + * [NoteTitleSource.FIRST_HEADING], the original rule (#280). */ + val noteTitleSource: NoteTitleSource = NoteTitleSource.FIRST_HEADING, /** When true the Search screen matches note titles only (the Quick Access * behaviour); full-text search is one toggle away (#193). Persisted so * whichever mode was used last becomes that user's default. */ @@ -100,6 +108,21 @@ enum class OpenNotesAt { LAST_POSITION } +/** + * How the notes list arranges its rows (#279). + * + * Requested by someone who wanted to see more notes at once, so it is a choice + * rather than a replacement: [LIST] is what everyone already has, and the + * "Show note previews" setting keeps applying to both. + */ +enum class NotesLayout { + /** One note per row, full width — the original behaviour and the default. */ + LIST, + + /** Cards in as many columns as the screen width allows. */ + GRID +} + /** Notes-list sort orders offered by the top-bar sort menu (#191). */ enum class NotesSortMode { /** Most recently edited first — the original behaviour and the only mode diff --git a/app/src/main/java/com/markleaf/notes/data/settings/AppSettingsRepository.kt b/app/src/main/java/com/markleaf/notes/data/settings/AppSettingsRepository.kt index b917288..2f29528 100644 --- a/app/src/main/java/com/markleaf/notes/data/settings/AppSettingsRepository.kt +++ b/app/src/main/java/com/markleaf/notes/data/settings/AppSettingsRepository.kt @@ -12,6 +12,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.markleaf.notes.core.security.PasscodeBackoff import com.markleaf.notes.core.security.PasscodeHasher +import com.markleaf.notes.core.text.NoteTitleSource import java.util.UUID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable @@ -71,6 +72,12 @@ class AppSettingsRepository internal constructor( notesSortMode = preferences[NOTES_SORT_MODE] ?.let { value -> enumValueOrDefault(value, NotesSortMode.UPDATED_DESC) } ?: NotesSortMode.UPDATED_DESC, + notesLayout = preferences[NOTES_LAYOUT] + ?.let { value -> enumValueOrDefault(value, NotesLayout.LIST) } + ?: NotesLayout.LIST, + noteTitleSource = preferences[NOTE_TITLE_SOURCE] + ?.let { value -> enumValueOrDefault(value, NoteTitleSource.FIRST_HEADING) } + ?: NoteTitleSource.FIRST_HEADING, searchTitlesOnly = preferences[SEARCH_TITLES_ONLY] ?: false, lastOpenedNoteId = preferences[LAST_OPENED_NOTE_ID], openNotesInPreview = preferences[OPEN_NOTES_IN_PREVIEW] ?: false, @@ -249,6 +256,23 @@ class AppSettingsRepository internal constructor( } } + suspend fun setNotesLayout(layout: NotesLayout) { + persist { preferences -> + preferences[NOTES_LAYOUT] = layout.name + } + } + + /** + * Change which line becomes a note's title (#280). Only the preference is + * written here — stored titles are recomputed by the caller, because the + * notes database is not this repository's to touch. + */ + suspend fun setNoteTitleSource(source: NoteTitleSource) { + persist { preferences -> + preferences[NOTE_TITLE_SOURCE] = source.name + } + } + suspend fun setSearchTitlesOnly(titlesOnly: Boolean) { persist { preferences -> preferences[SEARCH_TITLES_ONLY] = titlesOnly @@ -342,6 +366,8 @@ class AppSettingsRepository internal constructor( val NOTES_SHOW_PREVIEW = booleanPreferencesKey("notes_show_preview") val REOPEN_LAST_NOTE = booleanPreferencesKey("reopen_last_note") val NOTES_SORT_MODE = stringPreferencesKey("notes_sort_mode") + val NOTES_LAYOUT = stringPreferencesKey("notes_layout") + val NOTE_TITLE_SOURCE = stringPreferencesKey("note_title_source") val SEARCH_TITLES_ONLY = booleanPreferencesKey("search_titles_only") val LAST_OPENED_NOTE_ID = stringPreferencesKey("last_opened_note_id") val OPEN_NOTES_IN_PREVIEW = booleanPreferencesKey("open_notes_in_preview") diff --git a/app/src/main/java/com/markleaf/notes/data/sync/NoteFolderMirror.kt b/app/src/main/java/com/markleaf/notes/data/sync/NoteFolderMirror.kt index 2366bea..681ed3c 100644 --- a/app/src/main/java/com/markleaf/notes/data/sync/NoteFolderMirror.kt +++ b/app/src/main/java/com/markleaf/notes/data/sync/NoteFolderMirror.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import androidx.documentfile.provider.DocumentFile import com.markleaf.notes.R +import com.markleaf.notes.core.text.NoteTitleSource import com.markleaf.notes.core.text.TitleExtractor import com.markleaf.notes.data.settings.AppSettings import com.markleaf.notes.data.settings.SyncFileExtension @@ -474,11 +475,14 @@ object NoteFolderMirror { existing: List, applyUpdate: suspend (Note) -> Unit, applyCreate: suspend (Note) -> Unit, - metadata: MirrorMetadata = MirrorMetadata.Frontmatter + metadata: MirrorMetadata = MirrorMetadata.Frontmatter, + titleSource: NoteTitleSource = NoteTitleSource.FIRST_HEADING ): ImportResult { val folder = DocumentFile.fromTreeUri(context, folderUri) ?: return ImportResult(0, 0, 0, 1) - return importChangesFrom(context, folder, existing, applyUpdate, applyCreate, metadata) + return importChangesFrom( + context, folder, existing, applyUpdate, applyCreate, metadata, titleSource + ) } /** @@ -491,12 +495,13 @@ object NoteFolderMirror { existing: List, applyUpdate: suspend (Note) -> Unit, applyCreate: suspend (Note) -> Unit, - metadata: MirrorMetadata = MirrorMetadata.Frontmatter + metadata: MirrorMetadata = MirrorMetadata.Frontmatter, + titleSource: NoteTitleSource = NoteTitleSource.FIRST_HEADING ): ImportResult { if (!folder.canRead()) return ImportResult(0, 0, 0, 1) if (metadata is MirrorMetadata.Sidecar) { return importChangesSidecar( - context, folder, existing, applyUpdate, applyCreate, metadata.deviceId + context, folder, existing, applyUpdate, applyCreate, metadata.deviceId, titleSource ) } @@ -539,9 +544,9 @@ object NoteFolderMirror { val now = Instant.now() val newNote = Note( id = parsed.markleafId ?: UUID.randomUUID().toString(), - title = TitleExtractor.extractTitle(parsed.body), + title = TitleExtractor.extractTitle(parsed.body, titleSource), contentMarkdown = parsed.body, - excerpt = TitleExtractor.generateExcerpt(parsed.body), + excerpt = TitleExtractor.generateExcerpt(parsed.body, titleSource), createdAt = parsed.createdAt ?: now, updatedAt = parsed.updatedAt ?: now, pinned = parsed.pinned ?: false, @@ -567,13 +572,13 @@ object NoteFolderMirror { // Both sides moved since the last sync. Keep the local // note's content untouched and bring the remote in as a // separate note so the user can compare and merge by hand. - val baseTitle = TitleExtractor.extractTitle(parsed.body) + val baseTitle = TitleExtractor.extractTitle(parsed.body, titleSource) val suffix = conflictSuffix(context, Instant.now()) val duplicate = Note( id = UUID.randomUUID().toString(), title = "$baseTitle $suffix", contentMarkdown = parsed.body, - excerpt = TitleExtractor.generateExcerpt(parsed.body), + excerpt = TitleExtractor.generateExcerpt(parsed.body, titleSource), createdAt = parsed.createdAt ?: fileTs, updatedAt = fileTs, pinned = false, @@ -595,9 +600,9 @@ object NoteFolderMirror { Reconcile.Overwrite -> { val note = existingNote!! val merged = note.copy( - title = TitleExtractor.extractTitle(parsed.body), + title = TitleExtractor.extractTitle(parsed.body, titleSource), contentMarkdown = parsed.body, - excerpt = TitleExtractor.generateExcerpt(parsed.body), + excerpt = TitleExtractor.generateExcerpt(parsed.body, titleSource), updatedAt = fileTs, pinned = parsed.pinned ?: note.pinned, archived = parsed.archived ?: note.archived, @@ -640,7 +645,8 @@ object NoteFolderMirror { existing: List, applyUpdate: suspend (Note) -> Unit, applyCreate: suspend (Note) -> Unit, - deviceId: String + deviceId: String, + titleSource: NoteTitleSource ): ImportResult { var updated = 0 var created = 0 @@ -710,9 +716,9 @@ object NoteFolderMirror { // identities across devices. ?: parsed.markleafId ?: UUID.randomUUID().toString(), - title = TitleExtractor.extractTitle(text), + title = TitleExtractor.extractTitle(text, titleSource), contentMarkdown = text, - excerpt = TitleExtractor.generateExcerpt(text), + excerpt = TitleExtractor.generateExcerpt(text, titleSource), createdAt = createdAt, updatedAt = now, pinned = entry?.pinned ?: parsed.pinned ?: false, @@ -736,12 +742,12 @@ object NoteFolderMirror { } Reconcile.Conflict -> { val note = existingNote!! - val baseTitle = TitleExtractor.extractTitle(text) + val baseTitle = TitleExtractor.extractTitle(text, titleSource) val duplicate = Note( id = UUID.randomUUID().toString(), title = "$baseTitle ${conflictSuffix(context, Instant.now())}", contentMarkdown = text, - excerpt = TitleExtractor.generateExcerpt(text), + excerpt = TitleExtractor.generateExcerpt(text, titleSource), createdAt = Instant.now(), updatedAt = Instant.now(), lastImportedAt = Instant.now(), @@ -769,9 +775,9 @@ object NoteFolderMirror { val now = Instant.now() applyUpdate( note.copy( - title = TitleExtractor.extractTitle(text), + title = TitleExtractor.extractTitle(text, titleSource), contentMarkdown = text, - excerpt = TitleExtractor.generateExcerpt(text), + excerpt = TitleExtractor.generateExcerpt(text, titleSource), updatedAt = now, lastImportedAt = now, remoteSeenAt = now diff --git a/app/src/main/java/com/markleaf/notes/domain/repository/NoteRepository.kt b/app/src/main/java/com/markleaf/notes/domain/repository/NoteRepository.kt index 9364222..9434af5 100644 --- a/app/src/main/java/com/markleaf/notes/domain/repository/NoteRepository.kt +++ b/app/src/main/java/com/markleaf/notes/domain/repository/NoteRepository.kt @@ -11,6 +11,11 @@ interface NoteRepository { suspend fun getAllNotes(): List suspend fun createNote(note: Note) suspend fun updateNote(note: Note) + + /** Rewrite only the title and excerpt of one note — see + * [com.markleaf.notes.data.repository.NoteRetitler] for why the retitle + * pass must not write the whole row back (#280). */ + suspend fun updateDerivedTitle(noteId: String, title: String, excerpt: String) suspend fun moveToTrash(noteId: String) suspend fun setPinned(noteId: String, pinned: Boolean) suspend fun setArchived(noteId: String, archived: Boolean) diff --git a/app/src/main/java/com/markleaf/notes/feature/editor/EditorScreen.kt b/app/src/main/java/com/markleaf/notes/feature/editor/EditorScreen.kt index 7919b03..d52b0d2 100644 --- a/app/src/main/java/com/markleaf/notes/feature/editor/EditorScreen.kt +++ b/app/src/main/java/com/markleaf/notes/feature/editor/EditorScreen.kt @@ -155,8 +155,11 @@ fun EditorScreen( if (shouldPreparePreview) SimpleMarkdownPreview.parse(editorState.text) else emptyList() } val tocHeadings = remember(previewLines) { extractHeadings(previewLines) } - val currentTitle = remember(editorState.text, noteId) { - if (noteId == null) "" else TitleExtractor.extractTitle(editorState.text) + // Which line becomes the title is a user setting (#280); it keys every + // derivation below so flipping it re-titles the open note straight away. + val titleSource = appSettings.noteTitleSource + val currentTitle = remember(editorState.text, noteId, titleSource) { + if (noteId == null) "" else TitleExtractor.extractTitle(editorState.text, titleSource) } val backlinksFlow = remember(currentTitle, noteId) { linkRepo.observeBacklinks(currentTitle, noteId.orEmpty()) @@ -380,9 +383,9 @@ fun EditorScreen( if (currentNote != null) { val content = editorState.text val updatedNote = currentNote.copy( - title = TitleExtractor.extractTitle(content), + title = TitleExtractor.extractTitle(content, titleSource), contentMarkdown = content, - excerpt = TitleExtractor.generateExcerpt(content), + excerpt = TitleExtractor.generateExcerpt(content, titleSource), updatedAt = java.time.Instant.now() ) repo.updateNote(updatedNote) @@ -617,9 +620,9 @@ fun EditorScreen( coroutineScope.launch { val current = repo.getNote(noteId) ?: return@launch val live = current.copy( - title = TitleExtractor.extractTitle(editorState.text), + title = TitleExtractor.extractTitle(editorState.text, titleSource), contentMarkdown = editorState.text, - excerpt = TitleExtractor.generateExcerpt(editorState.text) + excerpt = TitleExtractor.generateExcerpt(editorState.text, titleSource) ) ShareNoteUtil.shareNote(context, live) } @@ -632,9 +635,9 @@ fun EditorScreen( coroutineScope.launch { val current = repo.getNote(noteId) ?: return@launch val live = current.copy( - title = TitleExtractor.extractTitle(editorState.text), + title = TitleExtractor.extractTitle(editorState.text, titleSource), contentMarkdown = editorState.text, - excerpt = TitleExtractor.generateExcerpt(editorState.text) + excerpt = TitleExtractor.generateExcerpt(editorState.text, titleSource) ) pendingExport = live exportSingleLauncher.launch(ExportUtil.generateFileName(live)) @@ -648,9 +651,9 @@ fun EditorScreen( coroutineScope.launch { val current = repo.getNote(noteId) ?: return@launch val live = current.copy( - title = TitleExtractor.extractTitle(editorState.text), + title = TitleExtractor.extractTitle(editorState.text, titleSource), contentMarkdown = editorState.text, - excerpt = TitleExtractor.generateExcerpt(editorState.text) + excerpt = TitleExtractor.generateExcerpt(editorState.text, titleSource) ) ExportPdf.export(context, live) } diff --git a/app/src/main/java/com/markleaf/notes/feature/notes/NotesListScreen.kt b/app/src/main/java/com/markleaf/notes/feature/notes/NotesListScreen.kt index 0ba6e20..7c5d86b 100644 --- a/app/src/main/java/com/markleaf/notes/feature/notes/NotesListScreen.kt +++ b/app/src/main/java/com/markleaf/notes/feature/notes/NotesListScreen.kt @@ -3,14 +3,20 @@ package com.markleaf.notes.feature.notes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -74,10 +80,12 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.markleaf.notes.R import com.markleaf.notes.data.settings.AppSettings import com.markleaf.notes.data.settings.AppSettingsRepository +import com.markleaf.notes.data.settings.NotesLayout import com.markleaf.notes.data.settings.NotesSortMode import com.markleaf.notes.data.sync.NoteFolderMirror import com.markleaf.notes.data.sync.syncFolderUriOrNull @@ -410,68 +418,123 @@ fun NotesListScreen( ) } } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - sections.forEach { section -> - if (section.titleResId != null) { - item(key = "header-${section.titleResId}") { - SectionHeader( - stringResource(section.titleResId), - modifier = Modifier.animateItem() + // Both layouts drive the same actions (#279). Defined once here so + // the list and the grid cannot drift apart — in particular the lock + // action, which has to clean up the mirror file as well. + val togglePin: (Note) -> Unit = { note -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + viewModel.setPinned(note.id, !note.pinned) + } + val archive: (Note) -> Unit = { note -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.setArchived(note.id, true) + } + val lock: (Note) -> Unit = { note -> + if (lockPasscodeSet) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.setLocked(note.id, true) + // The export guard only stops *future* saves, so a note + // mirrored before it was locked would leave a readable + // plaintext copy in the sync folder. Remove it now, or + // the Locked space's privacy promise is only skin deep. + // LockedNotesScreen re-mirrors on unlock (#156). + appSettings.syncFolderUriOrNull()?.let { uri -> + scope.launch { + withContext(Dispatchers.IO) { + NoteFolderMirror.deleteNote( + context, + uri, + note.id, + appSettings.mirrorMetadata() + ) + } + } + } + } else { + showSetPasscodePrompt = true + } + } + val moveToTrash: (Note) -> Unit = { note -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + viewModel.moveToTrash(note.id) + } + val longPress: () -> Unit = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + + when (appSettings.notesLayout) { + NotesLayout.LIST -> LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + sections.forEach { section -> + if (section.titleResId != null) { + item(key = "header-${section.titleResId}") { + SectionHeader( + stringResource(section.titleResId), + modifier = Modifier.animateItem() + ) + } + } + items(section.notes, key = { it.id }) { note -> + NoteRow( + note = note, + selected = note.id == selectedNoteId, + showPreview = appSettings.notesShowPreview, + modifier = Modifier.animateItem(), + onClick = { onNoteClick(note.id) }, + onTogglePin = { togglePin(note) }, + onArchive = { archive(note) }, + onLock = { lock(note) }, + onMoveToTrash = { moveToTrash(note) }, + onLongPress = longPress ) } } - items(section.notes, key = { it.id }) { note -> - NoteRow( - note = note, - selected = note.id == selectedNoteId, - showPreview = appSettings.notesShowPreview, - modifier = Modifier.animateItem(), - onClick = { onNoteClick(note.id) }, - onTogglePin = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) - viewModel.setPinned(note.id, !note.pinned) - }, - onArchive = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - viewModel.setArchived(note.id, true) - }, - onLock = { - if (lockPasscodeSet) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - viewModel.setLocked(note.id, true) - // The export guard only stops *future* saves, so a note - // mirrored before it was locked would leave a readable - // plaintext copy in the sync folder. Remove it now, or - // the Locked space's privacy promise is only skin deep. - // LockedNotesScreen re-mirrors on unlock (#156). - appSettings.syncFolderUriOrNull()?.let { uri -> - scope.launch { - withContext(Dispatchers.IO) { - NoteFolderMirror.deleteNote( - context, - uri, - note.id, - appSettings.mirrorMetadata() - ) - } - } - } - } else { - showSetPasscodePrompt = true - } - }, - onMoveToTrash = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - viewModel.moveToTrash(note.id) - }, - onLongPress = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + + NotesLayout.GRID -> LazyVerticalGrid( + // Adaptive, not a fixed column count: this screen is also the + // tablet's narrow list pane beside the editor, where a fixed + // two columns would leave nothing readable in either. + columns = GridCells.Adaptive(minSize = 180.dp), + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + sections.forEach { section -> + if (section.titleResId != null) { + item( + key = "header-${section.titleResId}", + span = { GridItemSpan(maxLineSpan) } + ) { + SectionHeader( + stringResource(section.titleResId), + modifier = Modifier.animateItem(), + // The grid already pads its content by 12dp; + // the list's 24 would double up here. + horizontalPadding = 12.dp + ) } - ) + } + items(section.notes, key = { it.id }) { note -> + NoteCard( + note = note, + selected = note.id == selectedNoteId, + showPreview = appSettings.notesShowPreview, + modifier = Modifier.animateItem(), + onClick = { onNoteClick(note.id) }, + onTogglePin = { togglePin(note) }, + onArchive = { archive(note) }, + onLock = { lock(note) }, + onMoveToTrash = { moveToTrash(note) }, + onLongPress = longPress + ) + } } } } @@ -480,7 +543,11 @@ fun NotesListScreen( } @Composable -private fun SectionHeader(text: String, modifier: Modifier = Modifier) { +private fun SectionHeader( + text: String, + modifier: Modifier = Modifier, + horizontalPadding: Dp = 24.dp +) { Text( text = text, style = MaterialTheme.typography.labelMedium.copy( @@ -489,11 +556,33 @@ private fun SectionHeader(text: String, modifier: Modifier = Modifier) { ), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), modifier = modifier - .padding(start = 24.dp, top = 20.dp, end = 24.dp, bottom = 6.dp) + .padding(start = horizontalPadding, top = 20.dp, end = horizontalPadding, bottom = 6.dp) .semantics { heading() } ) } +/** + * Source half of the card→editor container transform. Only active on the phone + * nav path, where both scopes are published; the tablet in-pane editor provides + * neither, so the item just renders normally. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun Modifier.noteSharedBounds(noteId: String): Modifier { + val sharedScope = LocalSharedTransitionScope.current + val avScope = LocalNavAnimatedVisibilityScope.current + return if (sharedScope != null && avScope != null) { + with(sharedScope) { + this@noteSharedBounds.sharedBounds( + rememberSharedContentState(key = "note-$noteId"), + animatedVisibilityScope = avScope + ) + } + } else { + this + } +} + @OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) @Composable private fun NoteRow( @@ -515,23 +604,7 @@ private fun NoteRow( Color.Transparent } - // Source half of the card->editor container transform. Only active on the - // phone nav path, where both scopes are published; the tablet in-pane editor - // provides neither, so the row just renders normally. - val sharedScope = LocalSharedTransitionScope.current - val avScope = LocalNavAnimatedVisibilityScope.current - val rowModifier = if (sharedScope != null && avScope != null) { - with(sharedScope) { - modifier.sharedBounds( - rememberSharedContentState(key = "note-${note.id}"), - animatedVisibilityScope = avScope - ) - } - } else { - modifier - } - - Box(rowModifier) { + Box(modifier.noteSharedBounds(note.id)) { Column( modifier = Modifier .fillMaxWidth() @@ -594,53 +667,179 @@ private fun NoteRow( } } - DropdownMenu( + NoteContextMenu( + note = note, expanded = menuExpanded, - onDismissRequest = { menuExpanded = false } + onDismiss = { menuExpanded = false }, + onTogglePin = onTogglePin, + onArchive = onArchive, + onLock = onLock, + onMoveToTrash = onMoveToTrash + ) + } +} + +/** + * A note as a grid tile (#279) — the same content and the same long-press menu + * as [NoteRow], laid out for a column narrower than the screen: the title gets + * two lines instead of one and the excerpt four instead of two, because a tile + * has height to spare and width it does not. + */ +@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) +@Composable +private fun NoteCard( + note: Note, + selected: Boolean, + showPreview: Boolean, + onClick: (String) -> Unit, + onTogglePin: () -> Unit, + onArchive: () -> Unit, + onLock: () -> Unit, + onMoveToTrash: () -> Unit, + onLongPress: () -> Unit, + modifier: Modifier = Modifier +) { + var menuExpanded by remember { mutableStateOf(false) } + val itemBackground = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.36f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Box(modifier.noteSharedBounds(note.id)) { + Column( + modifier = Modifier + .fillMaxWidth() + // A title-only tile is otherwise a thin sliver next to a tile + // with an excerpt; the minimum keeps a row of them even. + .heightIn(min = if (showPreview) 120.dp else 64.dp) + .clip(MaterialTheme.shapes.medium) + .background(itemBackground) + .combinedClickable( + onClick = { onClick(note.id) }, + onLongClick = { + onLongPress() + menuExpanded = true + } + ) + .padding(12.dp) + .semantics(mergeDescendants = true) {} ) { - DropdownMenuItem( - leadingIcon = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { + if (note.pinned) { Icon( - imageVector = if (note.pinned) Icons.Outlined.PushPin else Icons.Filled.PushPin, - contentDescription = null + imageVector = Icons.Filled.PushPin, + contentDescription = stringResource(R.string.pinned), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(end = 6.dp, top = 2.dp) + .height(14.dp) ) - }, - text = { + } + Text( + text = note.title.ifEmpty { stringResource(R.string.untitled) }, + style = MaterialTheme.typography.titleSmall, + color = if (selected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.primary + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + if (showPreview) { + if (note.excerpt.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) Text( - if (note.pinned) stringResource(R.string.unpin) - else stringResource(R.string.pin) + text = note.excerpt, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + maxLines = 4, + overflow = TextOverflow.Ellipsis ) - }, - onClick = { - menuExpanded = false - onTogglePin() } - ) - DropdownMenuItem( - leadingIcon = { Icon(Icons.Default.Archive, contentDescription = null) }, - text = { Text(stringResource(R.string.archive_action)) }, - onClick = { - menuExpanded = false - onArchive() - } - ) - DropdownMenuItem( - leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, - text = { Text(stringResource(R.string.move_to_locked)) }, - onClick = { - menuExpanded = false - onLock() - } - ) - DropdownMenuItem( - leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, - text = { Text(stringResource(R.string.move_to_trash)) }, - onClick = { - menuExpanded = false - onMoveToTrash() - } - ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = formatUpdatedTime(LocalContext.current, note.updatedAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } } + + NoteContextMenu( + note = note, + expanded = menuExpanded, + onDismiss = { menuExpanded = false }, + onTogglePin = onTogglePin, + onArchive = onArchive, + onLock = onLock, + onMoveToTrash = onMoveToTrash + ) + } +} + +/** The long-press menu shared by [NoteRow] and [NoteCard]. */ +@Composable +private fun NoteContextMenu( + note: Note, + expanded: Boolean, + onDismiss: () -> Unit, + onTogglePin: () -> Unit, + onArchive: () -> Unit, + onLock: () -> Unit, + onMoveToTrash: () -> Unit +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss + ) { + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = if (note.pinned) Icons.Outlined.PushPin else Icons.Filled.PushPin, + contentDescription = null + ) + }, + text = { + Text( + if (note.pinned) stringResource(R.string.unpin) + else stringResource(R.string.pin) + ) + }, + onClick = { + onDismiss() + onTogglePin() + } + ) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Archive, contentDescription = null) }, + text = { Text(stringResource(R.string.archive_action)) }, + onClick = { + onDismiss() + onArchive() + } + ) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + text = { Text(stringResource(R.string.move_to_locked)) }, + onClick = { + onDismiss() + onLock() + } + ) + DropdownMenuItem( + leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, + text = { Text(stringResource(R.string.move_to_trash)) }, + onClick = { + onDismiss() + onMoveToTrash() + } + ) } } diff --git a/app/src/main/java/com/markleaf/notes/feature/settings/SettingsScreen.kt b/app/src/main/java/com/markleaf/notes/feature/settings/SettingsScreen.kt index adb6434..aefade1 100644 --- a/app/src/main/java/com/markleaf/notes/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/com/markleaf/notes/feature/settings/SettingsScreen.kt @@ -56,13 +56,16 @@ import androidx.compose.ui.unit.dp import com.markleaf.notes.BuildConfig import com.markleaf.notes.R import com.markleaf.notes.data.local.AppDatabase +import com.markleaf.notes.core.text.NoteTitleSource import com.markleaf.notes.data.repository.LocalNoteRepository +import com.markleaf.notes.data.repository.NoteRetitler import com.markleaf.notes.data.settings.AppSettings import com.markleaf.notes.data.settings.AppSettingsRepository import com.markleaf.notes.data.settings.ColorPalette import com.markleaf.notes.data.settings.EditorFont import com.markleaf.notes.data.settings.EditorLineWidth import com.markleaf.notes.data.settings.MarkdownSyntaxVisibility +import com.markleaf.notes.data.settings.NotesLayout import com.markleaf.notes.data.settings.OpenNotesAt import com.markleaf.notes.data.settings.SyncMetadataMode import com.markleaf.notes.data.sync.NoteFolderMirror @@ -75,6 +78,7 @@ import com.markleaf.notes.ui.component.elapsedTimeLabel import com.markleaf.notes.util.ExportAllNotes import com.markleaf.notes.util.HapticFeedback import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -96,6 +100,9 @@ fun SettingsScreen( // control while it runs stops a second switch starting on top of the first, // which would have two passes rewriting the same files. var metadataSwitchBusy by remember { mutableStateOf(false) } + // Same reason for the title rule (#280): two retitle passes running over the + // same rows would leave titles from both rules behind. + var retitleBusy by remember { mutableStateOf(false) } val exportAllLauncher = rememberLauncherForActivityResult( ActivityResultContracts.OpenDocumentTree() ) { folderUri -> @@ -369,6 +376,97 @@ fun SettingsScreen( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.notes_layout), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + NotesLayout.entries.forEach { layout -> + val selected = appSettings.notesLayout == layout + if (selected) { + Button(onClick = {}) { + Text(layout.localizedLabel()) + } + } else { + OutlinedButton( + onClick = { + scope.launch { + settingsRepository.setNotesLayout(layout) + } + } + ) { + Text(layout.localizedLabel()) + } + } + } + } + Spacer(Modifier.height(6.dp)) + Text( + text = stringResource(R.string.notes_layout_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.note_title_source), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + NoteTitleSource.entries.forEach { source -> + val selected = appSettings.noteTitleSource == source + if (selected) { + Button(onClick = {}, enabled = !retitleBusy) { + Text(source.localizedLabel()) + } + } else { + OutlinedButton( + enabled = !retitleBusy, + onClick = { + retitleBusy = true + scope.launch { + settingsRepository.setNoteTitleSource(source) + // Stored titles are derived once, at save + // time, so without this pass the setting + // would appear to do nothing (#280). + // NonCancellable: leaving the screen + // mid-pass would otherwise strand half the + // notes under the old rule. + val changed = withContext( + Dispatchers.IO + NonCancellable + ) { + NoteRetitler.retitleAll(noteRepository, source) + } + retitleBusy = false + Toast.makeText( + context, + context.resources.getQuantityString( + R.plurals.note_title_retitled_format, + changed, + changed + ), + Toast.LENGTH_SHORT + ).show() + } + } + ) { + Text(source.localizedLabel()) + } + } + } + } + Spacer(Modifier.height(6.dp)) + Text( + text = stringResource(R.string.note_title_source_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } SettingsSection(title = stringResource(R.string.settings_privacy)) { @@ -516,7 +614,8 @@ fun SettingsScreen( applyCreate = { created -> noteImporter.create(created) }, - metadata = appSettings.mirrorMetadata() + metadata = appSettings.mirrorMetadata(), + titleSource = appSettings.noteTitleSource ) } settingsRepository.setSyncLastSyncedAt(System.currentTimeMillis()) @@ -1016,6 +1115,22 @@ private fun OpenNotesAt.localizedLabel(): String { } } +@Composable +private fun NotesLayout.localizedLabel(): String { + return when (this) { + NotesLayout.LIST -> stringResource(R.string.notes_layout_list) + NotesLayout.GRID -> stringResource(R.string.notes_layout_grid) + } +} + +@Composable +private fun NoteTitleSource.localizedLabel(): String { + return when (this) { + NoteTitleSource.FIRST_HEADING -> stringResource(R.string.note_title_source_first_heading) + NoteTitleSource.FIRST_LINE -> stringResource(R.string.note_title_source_first_line) + } +} + @Composable private fun ColorPalette.localizedLabel(): String { return when (this) { diff --git a/app/src/main/java/com/markleaf/notes/feature/sync/SyncCenterScreen.kt b/app/src/main/java/com/markleaf/notes/feature/sync/SyncCenterScreen.kt index 5cc6685..1a385df 100644 --- a/app/src/main/java/com/markleaf/notes/feature/sync/SyncCenterScreen.kt +++ b/app/src/main/java/com/markleaf/notes/feature/sync/SyncCenterScreen.kt @@ -230,7 +230,8 @@ fun SyncCenterScreen( applyCreate = { created -> noteImporter.create(created) }, - metadata = appSettings.mirrorMetadata() + metadata = appSettings.mirrorMetadata(), + titleSource = appSettings.noteTitleSource ) } settingsRepository.setSyncLastSyncedAt(System.currentTimeMillis()) diff --git a/app/src/main/java/com/markleaf/notes/navigation/MarkleafNavHost.kt b/app/src/main/java/com/markleaf/notes/navigation/MarkleafNavHost.kt index aaecc0e..3fe4e78 100644 --- a/app/src/main/java/com/markleaf/notes/navigation/MarkleafNavHost.kt +++ b/app/src/main/java/com/markleaf/notes/navigation/MarkleafNavHost.kt @@ -132,7 +132,11 @@ fun MarkleafNavHost( navController.navigateOnMain(NavRoutes.editorRoute(newNote.id)) } !sharedText.isNullOrBlank() -> { - val newNote = intentEntryViewModel.createNote(sharedText) + // Read the title rule from the store rather than the collected + // state: this effect runs on the first composition, where + // `appSettings` is still the initial default (#280). + val titleSource = settingsRepository.settings.first().noteTitleSource + val newNote = intentEntryViewModel.createNote(sharedText, titleSource) navController.navigateOnMain(NavRoutes.editorRoute(newNote.id)) } !openNoteId.isNullOrBlank() -> { diff --git a/app/src/main/java/com/markleaf/notes/ui/viewmodel/NotesViewModel.kt b/app/src/main/java/com/markleaf/notes/ui/viewmodel/NotesViewModel.kt index 5bedc9f..9961982 100644 --- a/app/src/main/java/com/markleaf/notes/ui/viewmodel/NotesViewModel.kt +++ b/app/src/main/java/com/markleaf/notes/ui/viewmodel/NotesViewModel.kt @@ -2,6 +2,7 @@ package com.markleaf.notes.ui.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.markleaf.notes.core.text.NoteTitleSource import com.markleaf.notes.core.text.TitleExtractor import com.markleaf.notes.domain.model.Note import com.markleaf.notes.domain.repository.NoteRepository @@ -53,12 +54,29 @@ class NotesViewModel( } } - suspend fun createNote(initialContent: String = ""): Note { + /** + * Create a note, optionally seeded with [initialContent] (shared text, a + * widget quick note). [titleSource] is the user's title rule (#280) — the + * default matters only for a caller with no settings to hand, since an + * empty note has no title to derive either way. + */ + suspend fun createNote( + initialContent: String = "", + titleSource: NoteTitleSource = NoteTitleSource.FIRST_HEADING + ): Note { val newNote = Note( id = UUID.randomUUID().toString(), - title = if (initialContent.isBlank()) "" else TitleExtractor.extractTitle(initialContent), + title = if (initialContent.isBlank()) { + "" + } else { + TitleExtractor.extractTitle(initialContent, titleSource) + }, contentMarkdown = initialContent, - excerpt = if (initialContent.isBlank()) "" else TitleExtractor.generateExcerpt(initialContent), + excerpt = if (initialContent.isBlank()) { + "" + } else { + TitleExtractor.generateExcerpt(initialContent, titleSource) + }, createdAt = Instant.now(), updatedAt = Instant.now() ) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 258242a..e695325 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -200,6 +200,18 @@ Ende Letzter Stand Wo eine Notiz beim Öffnen landet. „Ende“ passt zu Notizen, an die du nur anfügst. „Letzter Stand“ merkt sich die Position jeder Notiz nur in der App — in deine Dateien wird nichts geschrieben. + Layout der Notizliste + Liste + Raster + Das Raster zeigt Notizen als Karten, in so vielen Spalten wie auf den Bildschirm passen. „Notizvorschau anzeigen“ gilt für beide Layouts. + Notiztitel + Erste Überschrift + Erste Zeile + Aus welcher Zeile der Titel in der Liste stammt. „Erste Überschrift“ nimmt die erste Markdown-Überschrift irgendwo in der Notiz, „Erste Zeile“ immer die zuerst geschriebene Zeile. Eine Änderung betitelt vorhandene Notizen neu; die Dateinamen im Sync-Ordner ziehen über „Dateien nach Notiztitel benennen“ im Synchronisationscenter nach. + + %1$d Notiz neu betitelt. + %1$d Notizen neu betitelt. + Ans Ende springen An den Anfang springen Notizen öffnen jetzt in der Vorschau diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 00c078d..297804d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -200,6 +200,18 @@ Final Donde lo dejé Dónde queda una nota al abrirla. «Final» va bien con notas a las que solo añades. «Donde lo dejé» recuerda la posición de cada nota solo en la app: no se escribe nada en tus archivos. + Diseño de la lista de notas + Lista + Cuadrícula + La cuadrícula muestra las notas como tarjetas, en tantas columnas como quepan en la pantalla. «Mostrar vista previa de las notas» se aplica a ambos diseños. + Título de la nota + Primer encabezado + Primera línea + De qué línea sale el título que ves en la lista. «Primer encabezado» toma el primer encabezado Markdown esté donde esté en la nota; «Primera línea» toma siempre la primera línea que escribiste. Al cambiarlo se retitulan tus notas actuales; los nombres de archivo de la carpeta sincronizada se ajustan con «Renombrar archivos según el título» en el Centro de sincronización. + + %1$d nota retitulada. + %1$d notas retituladas. + Ir al final Ir al principio Las notas ahora se abren en vista previa diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4005dc5..d42a91a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -200,6 +200,18 @@ Fin Là où je me suis arrêté L\'endroit où une note s\'ouvre. « Fin » convient aux notes que vous ne faites que compléter. « Là où je me suis arrêté » retient la position de chaque note dans l\'application uniquement : rien n\'est écrit dans vos fichiers. + Disposition de la liste de notes + Liste + Grille + La grille affiche les notes sous forme de cartes, sur autant de colonnes que l\'écran le permet. « Afficher l\'aperçu des notes » s\'applique aux deux dispositions. + Titre de la note + Premier titre + Première ligne + La ligne dont provient le titre affiché dans la liste. « Premier titre » prend le premier titre Markdown où qu\'il soit dans la note ; « Première ligne » prend toujours la première ligne que vous avez écrite. Ce changement retitre vos notes existantes ; les noms de fichiers du dossier synchronisé suivent via « Renommer les fichiers selon le titre » dans le Centre de synchronisation. + + %1$d note retitrée. + %1$d notes retitrées. + Aller à la fin Aller au début Les notes s\'ouvrent désormais en aperçu diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e11bd2d..c8b30af 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -195,6 +195,17 @@ 末尾 前回の位置 ノートを開いたときに表示する位置です。「末尾」は書き足していくノートに向いています。「前回の位置」はノートごとの位置をアプリ内にのみ記憶し、ファイルには何も書き込みません。 + ノート一覧のレイアウト + リスト + グリッド + グリッドはノートをカードで表示し、画面幅に収まるだけ列を増やします。「ノートのプレビューを表示」はどちらのレイアウトにも適用されます。 + ノートのタイトル + 最初の見出し + 最初の行 + 一覧に表示するタイトルをノートのどの行から取るかを決めます。「最初の見出し」はノート内のどこにあっても最初の Markdown 見出しを使い、「最初の行」は常に書き始めた行を使います。変更すると既存ノートのタイトルが付け直され、同期フォルダのファイル名は同期センターの「ファイル名をノートのタイトルに整える」で揃えられます。 + + %1$d 件のノートのタイトルを付け直しました。 + 末尾へ移動 先頭へ移動 ノートはプレビューで開きます diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f5dbd59..b2ab0d6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -195,6 +195,17 @@ 맨 아래 마지막 위치 노트를 열었을 때 표시할 위치입니다. \'맨 아래\'는 계속 이어 쓰는 노트에 적합합니다. \'마지막 위치\'는 노트별 위치를 앱 안에만 기억하며 파일에는 아무것도 쓰지 않습니다. + 노트 목록 배치 + 목록 + 바둑판 + 바둑판은 노트를 카드로 보여 주며, 화면 너비가 허용하는 만큼 열이 늘어납니다. \'노트 미리보기 표시\'는 두 배치에 모두 적용됩니다. + 노트 제목 + 첫 제목 줄 + 첫 줄 + 목록에 보이는 노트 제목을 어느 줄에서 가져올지 정합니다. \'첫 제목 줄\'은 노트 어디에 있든 첫 Markdown 제목을 쓰고, \'첫 줄\'은 언제나 맨 처음 쓴 줄을 씁니다. 바꾸면 기존 노트의 제목이 다시 계산되며, 동기화 폴더의 파일명은 동기화 센터의 \'파일명을 노트 제목으로 정리\'로 맞출 수 있습니다. + + 노트 %1$d개의 제목을 다시 정했습니다. + 맨 아래로 이동 맨 위로 이동 이제 노트가 미리보기로 열립니다 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf9709a..e0bb3f4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -206,6 +206,18 @@ Bottom Where I left off Where a note lands when you open it. Bottom suits notes you only add to. "Where I left off" remembers each note\'s position in the app only — nothing is written into your files. + Notes list layout + List + Grid + Grid shows notes as cards, in as many columns as the screen fits. "Show note previews" applies to both. + Note title + First heading + First line + Which line of a note becomes its title in the list. "First heading" takes the first Markdown heading anywhere in the note; "First line" always takes the first line you wrote. Changing this retitles your existing notes — in Sync Center, "Rename files to note titles" brings your synced filenames along. + + Retitled %1$d note. + Retitled %1$d notes. + Jump to the end Jump to the start Notes now open in preview diff --git a/app/src/test/java/com/markleaf/notes/core/text/TitleExtractorTest.kt b/app/src/test/java/com/markleaf/notes/core/text/TitleExtractorTest.kt index dc0de5e..1fca933 100644 --- a/app/src/test/java/com/markleaf/notes/core/text/TitleExtractorTest.kt +++ b/app/src/test/java/com/markleaf/notes/core/text/TitleExtractorTest.kt @@ -72,4 +72,65 @@ class TitleExtractorTest { fun `generateExcerpt is empty when the note is only a title`() { assertEquals("", TitleExtractor.generateExcerpt("# Only title")) } + + /** + * The behaviour #280 reported: with the default rule, a heading anywhere in + * the note outranks the line the note actually starts with. + */ + @Test + fun `first heading wins over an earlier plain line by default`() { + val content = "Some plain text\n## Details\nmore" + assertEquals("Details", TitleExtractor.extractTitle(content)) + assertEquals( + "Details", + TitleExtractor.extractTitle(content, NoteTitleSource.FIRST_HEADING) + ) + } + + @Test + fun `first line mode ignores a heading further down`() { + val content = "Some plain text\n## Details\nmore" + assertEquals( + "Some plain text", + TitleExtractor.extractTitle(content, NoteTitleSource.FIRST_LINE) + ) + } + + @Test + fun `first line mode still strips markers when the first line is a heading`() { + val content = "## My Note\nbody" + assertEquals( + "My Note", + TitleExtractor.extractTitle(content, NoteTitleSource.FIRST_LINE) + ) + } + + @Test + fun `first line mode skips leading blank lines`() { + val content = "\n\n Started here\n# Later heading" + assertEquals( + "Started here", + TitleExtractor.extractTitle(content, NoteTitleSource.FIRST_LINE) + ) + } + + @Test + fun `first line mode is Untitled for blank content`() { + assertEquals("Untitled", TitleExtractor.extractTitle("", NoteTitleSource.FIRST_LINE)) + assertEquals("Untitled", TitleExtractor.extractTitle("\n \n", NoteTitleSource.FIRST_LINE)) + } + + /** The excerpt drops whichever line became the title, under either rule. */ + @Test + fun `excerpt skips the line the title came from in first line mode`() { + val content = "Some plain text\n## Details\nmore" + assertEquals( + "Details\nmore", + TitleExtractor.generateExcerpt(content, NoteTitleSource.FIRST_LINE) + ) + assertEquals( + "Some plain text\nmore", + TitleExtractor.generateExcerpt(content, NoteTitleSource.FIRST_HEADING) + ) + } } diff --git a/app/src/test/java/com/markleaf/notes/data/repository/NoteRetitlerTest.kt b/app/src/test/java/com/markleaf/notes/data/repository/NoteRetitlerTest.kt new file mode 100644 index 0000000..8f18e29 --- /dev/null +++ b/app/src/test/java/com/markleaf/notes/data/repository/NoteRetitlerTest.kt @@ -0,0 +1,157 @@ +package com.markleaf.notes.data.repository + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.markleaf.notes.core.text.NoteTitleSource +import com.markleaf.notes.data.local.AppDatabase +import com.markleaf.notes.domain.model.Note +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Instant + +/** + * The retitle pass behind the "Note title" setting (#280) — the part that makes + * flipping the setting visible on notes that already exist. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NoteRetitlerTest { + private lateinit var db: AppDatabase + private lateinit var repository: LocalNoteRepository + + private val created = Instant.ofEpochMilli(1_000L) + private val edited = Instant.ofEpochMilli(2_000L) + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AppDatabase::class.java + ).build() + repository = LocalNoteRepository(db) + } + + @After + fun tearDown() { + db.close() + } + + private fun note( + id: String, + title: String, + content: String, + excerpt: String = "", + isConflictCopy: Boolean = false + ) = Note( + id = id, + title = title, + contentMarkdown = content, + excerpt = excerpt, + createdAt = created, + updatedAt = edited, + isConflictCopy = isConflictCopy + ) + + @Test + fun `switching to first line retitles a note whose heading sits below a paragraph`() = runTest { + repository.createNote(note("1", "Details", "Some plain text\n## Details\nmore")) + + val changed = NoteRetitler.retitleAll(repository, NoteTitleSource.FIRST_LINE) + + assertEquals(1, changed) + val stored = repository.getNote("1")!! + assertEquals("Some plain text", stored.title) + assertEquals("Details\nmore", stored.excerpt) + } + + /** A retitle is not an edit: the note must keep its place in the list. */ + @Test + fun `retitling leaves updatedAt untouched`() = runTest { + repository.createNote(note("1", "Details", "Some plain text\n## Details")) + + NoteRetitler.retitleAll(repository, NoteTitleSource.FIRST_LINE) + + assertEquals(edited, repository.getNote("1")!!.updatedAt) + } + + @Test + fun `an empty note keeps its empty title rather than becoming Untitled`() = runTest { + repository.createNote(note("1", "", "")) + + val changed = NoteRetitler.retitleAll(repository, NoteTitleSource.FIRST_LINE) + + assertEquals(0, changed) + assertEquals("", repository.getNote("1")!!.title) + } + + /** The suffix is what makes a conflict copy findable; re-deriving loses it. */ + @Test + fun `a conflict copy keeps its suffixed title`() = runTest { + repository.createNote( + note( + id = "1", + title = "My Note (copy from another device 12:00)", + content = "Some plain text\n## My Note", + isConflictCopy = true + ) + ) + + val changed = NoteRetitler.retitleAll(repository, NoteTitleSource.FIRST_LINE) + + assertEquals(0, changed) + assertEquals( + "My Note (copy from another device 12:00)", + repository.getNote("1")!!.title + ) + } + + /** + * The pass reads every note, computes, and writes. Writing the whole row + * back would restore the body it read at the start — undoing an edit or an + * import that landed while it ran — so only the derived columns move. + */ + @Test + fun `retitling rewrites the derived columns and nothing else`() = runTest { + repository.createNote( + note("1", "Details", "Some plain text\n## Details", excerpt = "old excerpt") + ) + val before = repository.getNote("1")!! + + // What the pass does to one note, with the row edited underneath it in + // between — the concurrent-edit window. + repository.updateNote(before.copy(contentMarkdown = "edited elsewhere", pinned = true)) + repository.updateDerivedTitle("1", "Some plain text", "Details") + + val after = repository.getNote("1")!! + assertEquals("Some plain text", after.title) + assertEquals("Details", after.excerpt) + assertEquals("edited elsewhere", after.contentMarkdown) + assertEquals(true, after.pinned) + assertEquals(before.updatedAt, after.updatedAt) + assertEquals(before.createdAt, after.createdAt) + } + + @Test + fun `a second pass under the same rule changes nothing`() = runTest { + repository.createNote(note("1", "Details", "Some plain text\n## Details")) + + assertEquals(1, NoteRetitler.retitleAll(repository, NoteTitleSource.FIRST_LINE)) + assertEquals(0, NoteRetitler.retitleAll(repository, NoteTitleSource.FIRST_LINE)) + } + + @Test + fun `switching back restores the heading title`() = runTest { + repository.createNote(note("1", "Details", "Some plain text\n## Details")) + + NoteRetitler.retitleAll(repository, NoteTitleSource.FIRST_LINE) + NoteRetitler.retitleAll(repository, NoteTitleSource.FIRST_HEADING) + + assertEquals("Details", repository.getNote("1")!!.title) + } +} diff --git a/app/src/test/java/com/markleaf/notes/data/settings/AppSettingsRepositoryListTest.kt b/app/src/test/java/com/markleaf/notes/data/settings/AppSettingsRepositoryListTest.kt new file mode 100644 index 0000000..5102f0e --- /dev/null +++ b/app/src/test/java/com/markleaf/notes/data/settings/AppSettingsRepositoryListTest.kt @@ -0,0 +1,76 @@ +package com.markleaf.notes.data.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import com.markleaf.notes.core.text.NoteTitleSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Round-trips the notes-list preferences added for #279 and #280 through the + * repository's own transform logic. See [AppSettingsRepositoryLockTest] for why + * the store is injected rather than the production singleton. + */ +class AppSettingsRepositoryListTest { + + private class InMemoryPreferencesDataStore : DataStore { + private val state = MutableStateFlow(emptyPreferences()) + private val mutex = Mutex() + override val data: Flow = state + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences + ): Preferences = mutex.withLock { + val updated = transform(state.value) + state.value = updated + updated + } + } + + private val store = InMemoryPreferencesDataStore() + private val repo = AppSettingsRepository(store) + + @Test + fun `an untouched store reports the pre-existing behaviour`() = runTest { + val settings = repo.settings.first() + + assertEquals(NotesLayout.LIST, settings.notesLayout) + assertEquals(NoteTitleSource.FIRST_HEADING, settings.noteTitleSource) + } + + @Test + fun `notes layout round-trips`() = runTest { + repo.setNotesLayout(NotesLayout.GRID) + assertEquals(NotesLayout.GRID, repo.settings.first().notesLayout) + + repo.setNotesLayout(NotesLayout.LIST) + assertEquals(NotesLayout.LIST, repo.settings.first().notesLayout) + } + + @Test + fun `note title source round-trips`() = runTest { + repo.setNoteTitleSource(NoteTitleSource.FIRST_LINE) + assertEquals(NoteTitleSource.FIRST_LINE, repo.settings.first().noteTitleSource) + + repo.setNoteTitleSource(NoteTitleSource.FIRST_HEADING) + assertEquals(NoteTitleSource.FIRST_HEADING, repo.settings.first().noteTitleSource) + } + + /** Each preference has its own key — writing one must not disturb the other. */ + @Test + fun `the two settings are stored independently`() = runTest { + repo.setNotesLayout(NotesLayout.GRID) + repo.setNoteTitleSource(NoteTitleSource.FIRST_LINE) + + val settings = repo.settings.first() + assertEquals(NotesLayout.GRID, settings.notesLayout) + assertEquals(NoteTitleSource.FIRST_LINE, settings.noteTitleSource) + } +} diff --git a/app/src/test/java/com/markleaf/notes/data/settings/AppSettingsTest.kt b/app/src/test/java/com/markleaf/notes/data/settings/AppSettingsTest.kt index 944efc5..9b96665 100644 --- a/app/src/test/java/com/markleaf/notes/data/settings/AppSettingsTest.kt +++ b/app/src/test/java/com/markleaf/notes/data/settings/AppSettingsTest.kt @@ -1,5 +1,6 @@ package com.markleaf.notes.data.settings +import com.markleaf.notes.core.text.NoteTitleSource import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -29,6 +30,19 @@ class AppSettingsTest { assertNull(settings.lastOpenedNoteId) } + /** + * The grid (#279) and the title rule (#280) are both choices laid over + * behaviour people already have. A default of anything else would rearrange + * one user's list and rename another user's notes on update. + */ + @Test + fun notesStillRenderAsAListTitledByTheFirstHeading() { + val settings = AppSettings() + + assertEquals(NotesLayout.LIST, settings.notesLayout) + assertEquals(NoteTitleSource.FIRST_HEADING, settings.noteTitleSource) + } + /** * "Open notes at" was asked for by someone who wanted the bottom (#214) — * but it was added as an option precisely so nobody else's habits change. diff --git a/app/src/test/java/com/markleaf/notes/ui/viewmodel/MarkleafViewModelFactoryTest.kt b/app/src/test/java/com/markleaf/notes/ui/viewmodel/MarkleafViewModelFactoryTest.kt index 775bdb5..06c2244 100644 --- a/app/src/test/java/com/markleaf/notes/ui/viewmodel/MarkleafViewModelFactoryTest.kt +++ b/app/src/test/java/com/markleaf/notes/ui/viewmodel/MarkleafViewModelFactoryTest.kt @@ -70,6 +70,11 @@ class MarkleafViewModelFactoryTest { override suspend fun getAllNotes(): List = emptyList() override suspend fun createNote(note: Note) = Unit override suspend fun updateNote(note: Note) = Unit + override suspend fun updateDerivedTitle( + noteId: String, + title: String, + excerpt: String + ) = Unit override suspend fun moveToTrash(noteId: String) = Unit override suspend fun setPinned(noteId: String, pinned: Boolean) = Unit override suspend fun setArchived(noteId: String, archived: Boolean) = Unit diff --git a/app/src/test/snapshots/roborazzi/settings_notes_search_tablet.png b/app/src/test/snapshots/roborazzi/settings_notes_search_tablet.png index 6d7c407..d36c27a 100644 Binary files a/app/src/test/snapshots/roborazzi/settings_notes_search_tablet.png and b/app/src/test/snapshots/roborazzi/settings_notes_search_tablet.png differ diff --git a/app/src/test/snapshots/roborazzi/settings_privacy_tablet.png b/app/src/test/snapshots/roborazzi/settings_privacy_tablet.png index d7c5a5a..7a08dea 100644 Binary files a/app/src/test/snapshots/roborazzi/settings_privacy_tablet.png and b/app/src/test/snapshots/roborazzi/settings_privacy_tablet.png differ diff --git a/app/src/testDebug/java/com/markleaf/notes/feature/settings/SettingsScreenSnapshotTest.kt b/app/src/testDebug/java/com/markleaf/notes/feature/settings/SettingsScreenSnapshotTest.kt index ebda70b..a12b8b8 100644 --- a/app/src/testDebug/java/com/markleaf/notes/feature/settings/SettingsScreenSnapshotTest.kt +++ b/app/src/testDebug/java/com/markleaf/notes/feature/settings/SettingsScreenSnapshotTest.kt @@ -46,9 +46,16 @@ class SettingsScreenSnapshotTest { @Test fun centeredColumnTablet() = capture("settings_centered_tablet") + /** + * Anchored on the section's current last control — "First line", from the + * title rule added in #280. It was "Where I left off" until the layout and + * title options landed below it, at which point the golden kept passing + * while showing none of them: the same blind spot this class was written + * for, one section deeper. + */ @Test fun notesAndSearchSection() = - capture("settings_notes_search_tablet", scrollTo = "Where I left off") + capture("settings_notes_search_tablet", scrollTo = "First line") @Test fun privacySection() = capture("settings_privacy_tablet", scrollTo = "View Privacy Dashboard") diff --git a/docs/index.de.html b/docs/index.de.html index afd950a..f77b712 100644 --- a/docs/index.de.html +++ b/docs/index.de.html @@ -39,7 +39,7 @@ "name": "Markleaf", "applicationCategory": "ProductivityApplication", "operatingSystem": "Android", - "softwareVersion": "2.31.0", + "softwareVersion": "2.32.0", "description": "A lightweight, local-first Markdown note app for Android.", "downloadUrl": "https://f-droid.org/packages/com.markleaf.notes/", "codeRepository": "https://github.com/jeiel85/markleaf-android", @@ -91,7 +91,7 @@

Notizen auf deinem Gerät.
Gedanken in Markdown.Über F-Droid installieren GitHub APK -

Jetzt v2.31.0 · Kostenlos · Apache-2.0

+

Jetzt v2.32.0 · Kostenlos · Apache-2.0

@@ -112,7 +112,7 @@

Notizen auf deinem Gerät.
Gedanken in Markdown.

Aktuelle Version

- v2.31.0 + v2.32.0 Inklusive Quiet Formatting
diff --git a/docs/index.es.html b/docs/index.es.html index cfa4c38..5db6618 100644 --- a/docs/index.es.html +++ b/docs/index.es.html @@ -39,7 +39,7 @@ "name": "Markleaf", "applicationCategory": "ProductivityApplication", "operatingSystem": "Android", - "softwareVersion": "2.31.0", + "softwareVersion": "2.32.0", "description": "A lightweight, local-first Markdown note app for Android.", "downloadUrl": "https://f-droid.org/packages/com.markleaf.notes/", "codeRepository": "https://github.com/jeiel85/markleaf-android", @@ -91,7 +91,7 @@

Notas en tu dispositivo.
Pensamientos en Markdown.< Instalar en F-Droid GitHub APK -

Ahora v2.31.0 · Gratis · Apache-2.0

+

Ahora v2.32.0 · Gratis · Apache-2.0

@@ -112,7 +112,7 @@

Notas en tu dispositivo.
Pensamientos en Markdown.<

Versión actual

- v2.31.0 + v2.32.0 Incluye Quiet Formatting
diff --git a/docs/index.fr.html b/docs/index.fr.html index facf0a6..9a28b19 100644 --- a/docs/index.fr.html +++ b/docs/index.fr.html @@ -39,7 +39,7 @@ "name": "Markleaf", "applicationCategory": "ProductivityApplication", "operatingSystem": "Android", - "softwareVersion": "2.31.0", + "softwareVersion": "2.32.0", "description": "A lightweight, local-first Markdown note app for Android.", "downloadUrl": "https://f-droid.org/packages/com.markleaf.notes/", "codeRepository": "https://github.com/jeiel85/markleaf-android", @@ -91,7 +91,7 @@

Notes sur votre appareil.
Pensées en Markdown.Installer sur F-Droid GitHub APK

-

Maintenant v2.31.0 · Gratuit · Apache-2.0

+

Maintenant v2.32.0 · Gratuit · Apache-2.0

@@ -112,7 +112,7 @@

Notes sur votre appareil.
Pensées en Markdown.

Version actuelle

- v2.31.0 + v2.32.0 Inclut Quiet Formatting
diff --git a/docs/index.html b/docs/index.html index 15e8534..4fa22a5 100644 --- a/docs/index.html +++ b/docs/index.html @@ -39,7 +39,7 @@ "name": "Markleaf", "applicationCategory": "ProductivityApplication", "operatingSystem": "Android", - "softwareVersion": "2.31.0", + "softwareVersion": "2.32.0", "description": "A lightweight, local-first Markdown note app for Android.", "downloadUrl": "https://f-droid.org/packages/com.markleaf.notes/", "codeRepository": "https://github.com/jeiel85/markleaf-android", @@ -91,7 +91,7 @@

Notes on your device.
Thoughts in Markdown.< Install on F-Droid GitHub APK -

Now v2.31.0 · Free · Apache-2.0

+

Now v2.32.0 · Free · Apache-2.0

@@ -112,7 +112,7 @@

Notes on your device.
Thoughts in Markdown.<

Current release

- v2.31.0 + v2.32.0 Includes Quiet Formatting
diff --git a/docs/index.ja.html b/docs/index.ja.html index 768d39e..3e0a576 100644 --- a/docs/index.ja.html +++ b/docs/index.ja.html @@ -39,7 +39,7 @@ "name": "Markleaf", "applicationCategory": "ProductivityApplication", "operatingSystem": "Android", - "softwareVersion": "2.31.0", + "softwareVersion": "2.32.0", "description": "A lightweight, local-first Markdown note app for Android.", "downloadUrl": "https://f-droid.org/packages/com.markleaf.notes/", "codeRepository": "https://github.com/jeiel85/markleaf-android", @@ -91,7 +91,7 @@

ノートは端末に。
思考は Markdown で。< F-Droid でインストール GitHub APK

-

現在 v2.31.0 · 無料 · Apache-2.0

+

現在 v2.32.0 · 無料 · Apache-2.0

@@ -112,7 +112,7 @@

ノートは端末に。
思考は Markdown で。<

現在のリリース

- v2.31.0 + v2.32.0 Quiet Formatting を搭載
diff --git a/docs/index.ko.html b/docs/index.ko.html index 2f635ea..f8ca3b0 100644 --- a/docs/index.ko.html +++ b/docs/index.ko.html @@ -39,7 +39,7 @@ "name": "Markleaf", "applicationCategory": "ProductivityApplication", "operatingSystem": "Android", - "softwareVersion": "2.31.0", + "softwareVersion": "2.32.0", "description": "A lightweight, local-first Markdown note app for Android.", "downloadUrl": "https://f-droid.org/packages/com.markleaf.notes/", "codeRepository": "https://github.com/jeiel85/markleaf-android", @@ -91,7 +91,7 @@

메모는 기기에.
생각은 Markdown으로.F-Droid에서 설치 GitHub APK

-

현재 v2.31.0 · 무료 · Apache-2.0

+

현재 v2.32.0 · 무료 · Apache-2.0

@@ -112,7 +112,7 @@

메모는 기기에.
생각은 Markdown으로.

현재 릴리스

- v2.31.0 + v2.32.0 Quiet Formatting 포함
diff --git a/fastlane/metadata/android/de-DE/changelogs/122.txt b/fastlane/metadata/android/de-DE/changelogs/122.txt new file mode 100644 index 0000000..7e293e0 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/122.txt @@ -0,0 +1 @@ +Zwei Arten, Notizen anzuordnen, beide optional. Einstellungen > Notizen & Suche > Layout der Notizliste bietet jetzt Raster: dieselben Notizen als Karten, in so vielen Spalten wie der Bildschirm fasst. Unter Notiztitel wählen Sie, welche Zeile zum Titel wird - Erste Überschrift (Standard) oder Erste Zeile. Eine Notiz, die mit einem Absatz beginnt, heißt dann nicht mehr nach einer Überschrift weiter unten. Die Reihenfolge bleibt. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/122.txt b/fastlane/metadata/android/en-US/changelogs/122.txt new file mode 100644 index 0000000..5fa9b3b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/122.txt @@ -0,0 +1 @@ +Two ways to arrange your notes, both optional. Settings > Notes & search > Notes list layout adds Grid: the same notes as cards, in as many columns as your screen fits. Note title lets you pick which line becomes the title - First heading (the default) or First line, so a note that opens with a paragraph is no longer named after a heading further down. Switching retitles your notes without changing their order. \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/changelogs/122.txt b/fastlane/metadata/android/es-ES/changelogs/122.txt new file mode 100644 index 0000000..3864722 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/122.txt @@ -0,0 +1 @@ +Dos formas de organizar tus notas, ambas opcionales. Ajustes > Notas y búsqueda > Diseño de la lista añade Cuadrícula: las mismas notas como tarjetas, en tantas columnas como quepan. Título de la nota elige qué línea será el título - Primer encabezado (por defecto) o Primera línea. Una nota que empieza con un párrafo ya no se llama como un encabezado de más abajo. El orden no cambia. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/changelogs/122.txt b/fastlane/metadata/android/fr-FR/changelogs/122.txt new file mode 100644 index 0000000..7fe8845 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/122.txt @@ -0,0 +1 @@ +Deux façons d'organiser vos notes, toutes deux optionnelles. Paramètres > Notes et recherche > Disposition de la liste propose Grille : les mêmes notes en cartes, sur autant de colonnes que l'écran permet. Titre de la note choisit la ligne qui devient le titre - Premier titre (par défaut) ou Première ligne. Une note commençant par un paragraphe ne porte plus le nom d'un titre plus bas. L'ordre ne change pas. \ No newline at end of file diff --git a/fastlane/metadata/android/ja-JP/changelogs/122.txt b/fastlane/metadata/android/ja-JP/changelogs/122.txt new file mode 100644 index 0000000..f65d437 --- /dev/null +++ b/fastlane/metadata/android/ja-JP/changelogs/122.txt @@ -0,0 +1 @@ +ノートの並べ方が二通りになりました。どちらも任意です。設定 > ノートと検索 > ノート一覧のレイアウトにグリッドが加わり、同じノートをカードで、画面に収まるだけの列で表示します。ノートのタイトルでは、どの行をタイトルにするか選べます - 最初の見出し(既定)か最初の行。段落で始まるノートが下の見出しの名前になることはなくなります。切り替えても並び順は変わりません。 \ No newline at end of file diff --git a/fastlane/metadata/android/ko-KR/changelogs/122.txt b/fastlane/metadata/android/ko-KR/changelogs/122.txt new file mode 100644 index 0000000..57cc251 --- /dev/null +++ b/fastlane/metadata/android/ko-KR/changelogs/122.txt @@ -0,0 +1 @@ +노트를 정리해 보는 두 가지 방법이며 둘 다 선택 사항입니다. 설정 > 노트 및 검색 > 노트 목록 배치에 바둑판이 생겼습니다. 같은 노트를 카드로, 화면이 허용하는 만큼 여러 열로 보여 줍니다. 노트 제목에서는 제목을 어느 줄에서 가져올지 고를 수 있습니다 - 첫 제목 줄(기본값) 또는 첫 줄. 문단으로 시작하는 노트가 아래쪽 제목으로 불리는 일이 없어집니다. 전환해도 목록 순서는 그대로입니다. \ No newline at end of file