core: extract the store and sync engine into a shared crate (M12 step 1)
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user