core: extract the store and sync engine into a shared crate (M12 step 1)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 48s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m50s
Desktop (Tauri) / Update manifest (push) Skipped

Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.

This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.

The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.

Two things a workspace changes that are easy to miss, both caught before pushing:

[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.

And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.

Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 23:12:26 -04:00
co-authored by Claude Opus 5
parent c28f2bc00e
commit 0a7480cf9b
75 changed files with 246 additions and 1346 deletions
+122
View File
@@ -0,0 +1,122 @@
//! Deriving `[[wiki-links]]` and `#tags` from a note's body — the local mirror of
//! what the server computes on save. Pure string scanning (no regex dependency),
//! kept in lockstep with the frontend's inline rules (see frontend notes/markdown.ts):
//!
//! - `[[link]]`: `[[` … `]]` with no brackets inside, inner text trimmed. Used to
//! compute backlinks at query time (links are derived, never stored/synced).
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
//! On save these become labels attached with `via_tag = true`.
//!
//! Both dedupe case-insensitively, preserving first-seen order.
/// Extract the trimmed inner text of every `[[wiki-link]]` in `body`.
pub fn extract_links(body: &str) -> Vec<String> {
let bytes = body.as_bytes();
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
if let Some(rel) = body[i + 2..].find("]]") {
let inner = &body[i + 2..i + 2 + rel];
// Mirror the frontend's `[^[\]]+`: no stray brackets inside.
if !inner.contains('[') && !inner.contains(']') {
let t = inner.trim();
if !t.is_empty() {
push_unique(&mut out, t);
}
}
i += 2 + rel + 2;
continue;
}
}
i += 1;
}
out
}
/// Extract every `#tag` name (without the leading `#`) from `body`.
pub fn extract_tags(body: &str) -> Vec<String> {
let chars: Vec<char> = body.chars().collect();
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '#' {
let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#');
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
let mut j = i + 1;
while j < chars.len() && is_tag_char(chars[j]) {
j += 1;
}
let tag: String = chars[i + 1..j].iter().collect();
push_unique(&mut out, &tag);
i = j;
continue;
}
}
i += 1;
}
out
}
fn is_tag_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '-'
}
fn push_unique(out: &mut Vec<String>, candidate: &str) {
if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) {
out.push(candidate.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn links_basic_and_trim() {
assert_eq!(
extract_links("see [[ Alpha ]] and [[Beta]]"),
vec!["Alpha", "Beta"]
);
}
#[test]
fn links_dedupe_case_insensitive_first_seen() {
assert_eq!(extract_links("[[Note]] then [[note]] again"), vec!["Note"]);
}
#[test]
fn links_ignore_malformed_and_nested_brackets() {
assert_eq!(
extract_links("[[a[b]] [[]] [ [x] ] plain"),
Vec::<String>::new()
);
assert_eq!(extract_links("[[ok]] [[a]b]]"), vec!["ok"]);
}
#[test]
fn tags_basic() {
assert_eq!(
extract_tags("a #todo and #Work-item_2 here"),
vec!["todo", "Work-item_2"]
);
}
#[test]
fn tags_require_letter_start_and_boundary() {
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]);
}
#[test]
fn tags_dedupe_case_insensitive() {
assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]);
}
#[test]
fn empty_body() {
assert!(extract_links("").is_empty());
assert!(extract_tags("").is_empty());
}
}