Skip to content

Commit dbae49e

Browse files
authored
Merge pull request #937 from multiplex55/codex/refactor-domain-monoliths-into-modules
Refactor linking, note_todo_sync, and settings into focused modules with unit tests
2 parents 4dee336 + bab747c commit dbae49e

14 files changed

Lines changed: 1333 additions & 1369 deletions

File tree

src/linking.rs

Lines changed: 0 additions & 893 deletions
This file was deleted.

src/linking/index.rs

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
use crate::common::entity_ref::EntityKind;
2+
use crate::linking::{LinkRef, LinkTarget};
3+
use crate::plugins::note::Note;
4+
use crate::plugins::todo::TodoEntry;
5+
use std::collections::{BTreeSet, HashMap, HashSet};
6+
7+
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
8+
pub struct EntityKey {
9+
pub entity_type: LinkTarget,
10+
pub entity_id: String,
11+
}
12+
13+
impl EntityKey {
14+
pub fn new(entity_type: LinkTarget, entity_id: impl Into<String>) -> Self {
15+
Self {
16+
entity_type,
17+
entity_id: entity_id.into(),
18+
}
19+
}
20+
}
21+
22+
#[derive(Debug, Clone, Default)]
23+
pub struct LinkIndex {
24+
outgoing: HashMap<EntityKey, Vec<LinkRef>>,
25+
backlinks: HashMap<EntityKey, BTreeSet<EntityKey>>,
26+
}
27+
28+
#[derive(Debug, Clone, Copy, Default)]
29+
pub struct BacklinkFilters {
30+
pub linked_todos: bool,
31+
pub related_notes: bool,
32+
pub mentions: bool,
33+
}
34+
35+
impl LinkIndex {
36+
pub fn set_outgoing_links(&mut self, source: EntityKey, links: Vec<LinkRef>) {
37+
if let Some(prev) = self.outgoing.insert(source.clone(), links.clone()) {
38+
self.remove_reverse_entries(&source, &prev);
39+
}
40+
self.add_reverse_entries(&source, &links);
41+
}
42+
43+
pub fn remove_entity(&mut self, source: &EntityKey) {
44+
if let Some(prev) = self.outgoing.remove(source) {
45+
self.remove_reverse_entries(source, &prev);
46+
}
47+
}
48+
49+
pub fn get_forward_links(&self, source: &EntityKey) -> Vec<LinkRef> {
50+
self.outgoing.get(source).cloned().unwrap_or_default()
51+
}
52+
53+
pub fn get_backlinks(&self, target: &EntityKey, filters: BacklinkFilters) -> Vec<EntityKey> {
54+
let mut entries: Vec<_> = self
55+
.backlinks
56+
.get(target)
57+
.into_iter()
58+
.flat_map(|set| set.iter())
59+
.filter(|source| match source.entity_type {
60+
LinkTarget::Todo => !filters.any_set() || filters.linked_todos,
61+
LinkTarget::Note => !filters.any_set() || filters.related_notes,
62+
LinkTarget::Bookmark | LinkTarget::Layout | LinkTarget::File => {
63+
!filters.any_set() || filters.mentions
64+
}
65+
})
66+
.cloned()
67+
.collect();
68+
entries.sort_by(|a, b| a.entity_id.cmp(&b.entity_id));
69+
entries
70+
}
71+
72+
pub fn search_link_targets(&self, query: &str, scopes: &[LinkTarget]) -> Vec<EntityKey> {
73+
let query = query.trim().to_ascii_lowercase();
74+
let scope_set: HashSet<LinkTarget> = scopes.iter().copied().collect();
75+
let include_all = scope_set.is_empty();
76+
let mut seen = BTreeSet::new();
77+
let mut results = Vec::new();
78+
for links in self.outgoing.values() {
79+
for link in links {
80+
if !include_all && !scope_set.contains(&link.target_type) {
81+
continue;
82+
}
83+
if query.is_empty() || link.target_id.to_ascii_lowercase().contains(&query) {
84+
let key = format!("{}:{}", link.target_type.as_str(), link.target_id);
85+
if seen.insert(key) {
86+
results.push(EntityKey::new(link.target_type, link.target_id.clone()));
87+
}
88+
}
89+
}
90+
}
91+
results
92+
}
93+
94+
fn add_reverse_entries(&mut self, source: &EntityKey, links: &[LinkRef]) {
95+
for link in links {
96+
let target = EntityKey::new(link.target_type, link.target_id.clone());
97+
self.backlinks
98+
.entry(target)
99+
.or_default()
100+
.insert(source.clone());
101+
}
102+
}
103+
104+
fn remove_reverse_entries(&mut self, source: &EntityKey, links: &[LinkRef]) {
105+
for link in links {
106+
let target = EntityKey::new(link.target_type, link.target_id.clone());
107+
if let Some(sources) = self.backlinks.get_mut(&target) {
108+
sources.remove(source);
109+
if sources.is_empty() {
110+
self.backlinks.remove(&target);
111+
}
112+
}
113+
}
114+
}
115+
}
116+
117+
impl BacklinkFilters {
118+
fn any_set(self) -> bool {
119+
self.linked_todos || self.related_notes || self.mentions
120+
}
121+
}
122+
123+
pub fn build_index_from_notes_and_todos(notes: &[Note], todos: &[TodoEntry]) -> LinkIndex {
124+
let mut index = LinkIndex::default();
125+
for note in notes {
126+
let source = EntityKey::new(LinkTarget::Note, note.slug.clone());
127+
index.set_outgoing_links(source, links_from_note(note));
128+
}
129+
for todo in todos {
130+
let source = EntityKey::new(LinkTarget::Todo, todo.id.clone());
131+
index.set_outgoing_links(source, links_from_todo(todo));
132+
}
133+
index
134+
}
135+
136+
pub fn links_from_note(note: &Note) -> Vec<LinkRef> {
137+
let mut links = Vec::new();
138+
for slug in &note.links {
139+
links.push(LinkRef {
140+
target_type: LinkTarget::Note,
141+
target_id: slug.clone(),
142+
anchor: None,
143+
display_text: None,
144+
});
145+
}
146+
for r in &note.entity_refs {
147+
if let Some(target_type) = map_entity_kind(r.kind.clone()) {
148+
links.push(LinkRef {
149+
target_type,
150+
target_id: r.id.clone(),
151+
anchor: None,
152+
display_text: r.title.clone(),
153+
});
154+
}
155+
}
156+
dedupe_links(links)
157+
}
158+
159+
pub fn links_from_todo(todo: &TodoEntry) -> Vec<LinkRef> {
160+
let mut links = Vec::new();
161+
for r in &todo.entity_refs {
162+
if let Some(target_type) = map_entity_kind(r.kind.clone()) {
163+
links.push(LinkRef {
164+
target_type,
165+
target_id: r.id.clone(),
166+
anchor: None,
167+
display_text: r.title.clone(),
168+
});
169+
}
170+
}
171+
dedupe_links(links)
172+
}
173+
174+
fn map_entity_kind(kind: EntityKind) -> Option<LinkTarget> {
175+
match kind {
176+
EntityKind::Note => Some(LinkTarget::Note),
177+
EntityKind::Todo => Some(LinkTarget::Todo),
178+
EntityKind::Event => None,
179+
}
180+
}
181+
182+
pub(crate) fn dedupe_links(mut links: Vec<LinkRef>) -> Vec<LinkRef> {
183+
links.sort_by(|a, b| {
184+
(
185+
&a.target_type.as_str(),
186+
&a.target_id,
187+
&a.anchor,
188+
&a.display_text,
189+
)
190+
.cmp(&(
191+
&b.target_type.as_str(),
192+
&b.target_id,
193+
&b.anchor,
194+
&b.display_text,
195+
))
196+
});
197+
links.dedup();
198+
links
199+
}

src/linking/migrate.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
use crate::linking::{index::dedupe_links, LinkRef, LinkTarget};
2+
3+
pub fn migrate_legacy_links(metadata: &serde_json::Value) -> Vec<LinkRef> {
4+
let mut links = Vec::new();
5+
if let Some(items) = metadata.get("links").and_then(|v| v.as_array()) {
6+
for item in items {
7+
let ty = item
8+
.get("type")
9+
.and_then(|v| v.as_str())
10+
.and_then(LinkTarget::parse);
11+
let id = item.get("id").and_then(|v| v.as_str());
12+
if let (Some(target_type), Some(target_id)) = (ty, id) {
13+
links.push(LinkRef {
14+
target_type,
15+
target_id: target_id.to_string(),
16+
anchor: item
17+
.get("anchor")
18+
.and_then(|v| v.as_str())
19+
.map(|s| s.to_string()),
20+
display_text: item
21+
.get("text")
22+
.and_then(|v| v.as_str())
23+
.map(|s| s.to_string()),
24+
});
25+
}
26+
}
27+
}
28+
if let Some(raw_refs) = metadata
29+
.get("metadata")
30+
.and_then(|v| v.get("refs"))
31+
.and_then(|v| v.as_array())
32+
{
33+
for entry in raw_refs.iter().filter_map(|v| v.as_str()) {
34+
if let Some((kind, id)) = entry.trim_start_matches('@').split_once(':') {
35+
if let Some(target_type) = LinkTarget::parse(kind) {
36+
if !id.trim().is_empty() {
37+
links.push(LinkRef {
38+
target_type,
39+
target_id: id.trim().to_string(),
40+
anchor: None,
41+
display_text: None,
42+
});
43+
}
44+
}
45+
}
46+
}
47+
}
48+
dedupe_links(links)
49+
}
50+
51+
#[cfg(test)]
52+
mod tests {
53+
use super::*;
54+
use crate::linking::LinkRef;
55+
56+
#[test]
57+
fn migrates_legacy_metadata_shapes() {
58+
let blob = serde_json::json!({
59+
"links": [
60+
{"type": "note", "id": "alpha"},
61+
{"type": "todo", "id": "todo-1", "text": "Todo One"}
62+
],
63+
"metadata": {"refs": ["@note:beta", "@layout:daily"]}
64+
});
65+
let links = migrate_legacy_links(&blob);
66+
assert_eq!(
67+
links,
68+
vec![
69+
LinkRef {
70+
target_type: LinkTarget::Layout,
71+
target_id: "daily".into(),
72+
anchor: None,
73+
display_text: None
74+
},
75+
LinkRef {
76+
target_type: LinkTarget::Note,
77+
target_id: "alpha".into(),
78+
anchor: None,
79+
display_text: None
80+
},
81+
LinkRef {
82+
target_type: LinkTarget::Note,
83+
target_id: "beta".into(),
84+
anchor: None,
85+
display_text: None
86+
},
87+
LinkRef {
88+
target_type: LinkTarget::Todo,
89+
target_id: "todo-1".into(),
90+
anchor: None,
91+
display_text: Some("Todo One".into())
92+
},
93+
]
94+
);
95+
}
96+
}

0 commit comments

Comments
 (0)