Follow org links in the GUI, resolving across the org path
Clicking an internal org link now navigates the outline to its target.
The GUI resolves the link via /<doc>/resolve and acts on the result:
external targets open in the OS handler; internal targets expand the
target's ancestors, scroll to it, and flash it.
Resolution spans the whole org path rather than a single document:
- file:DOC::TARGET and file:DOC resolve TARGET within the named
document (a file: link to a document not in the path stays external).
- id:uuid links are globally unique and resolve in whichever document
owns the ID.
- #custom-id, *Headline, and bare fuzzy targets resolve in the current
document first, then fall back across every other document.
The resolve result gains a `doc:` field naming the owning document. When
it differs from the current document, DocumentModel::followLink switches
the model to that document before revealing the section.
DocumentModel gains revealSection() and followLink(); OutlineView wires
link clicks to followLink and animates the revealed row via the new
sectionRevealed signal.
This commit is contained in:
parent
b21b675980
commit
27e63f7a6f
|
|
@ -1860,20 +1860,28 @@ impl OrkState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a link target to a structural (and optionally textual) address
|
/// Resolve a link target to a structural (and optionally textual) address
|
||||||
/// within a document. Mirrors Emacs `org-link-search`'s precedence ladder,
|
/// within the org path. Mirrors Emacs `org-link-search`'s precedence
|
||||||
/// but terminates each branch in a section id + offset rather than moving
|
/// ladder, but terminates each branch in an owning document + section id +
|
||||||
/// point.
|
/// offset rather than moving point.
|
||||||
|
///
|
||||||
|
/// `doc_name` is the document the link originates from; internal targets
|
||||||
|
/// resolve there first and then fall back across every other document in
|
||||||
|
/// the path, so a link can reach a heading in another file.
|
||||||
///
|
///
|
||||||
/// Returns a small structured blob:
|
/// Returns a small structured blob:
|
||||||
/// ```text
|
/// ```text
|
||||||
/// kind: dedicated | fuzzy | external | notfound
|
/// kind: dedicated | fuzzy | external | notfound
|
||||||
/// section: <section-id> # omitted for external/notfound
|
/// doc: <doc-name> # owning document; omitted for external/notfound
|
||||||
|
/// section: <section-id> # omitted for external/notfound/bare file:DOC
|
||||||
/// offset: <n> # byte offset within the section, 0 for dedicated
|
/// offset: <n> # byte offset within the section, 0 for dedicated
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// Dispatch by target syntax:
|
/// Dispatch by target syntax:
|
||||||
|
/// - `file:DOC::TARGET` -> resolve TARGET inside document DOC
|
||||||
|
/// - `file:DOC` -> navigate to document DOC
|
||||||
|
/// - `file:other` (non-doc) -> external (handed to the OS by callers)
|
||||||
/// - `#custom-id` -> dedicated (CUSTOM_ID match)
|
/// - `#custom-id` -> dedicated (CUSTOM_ID match)
|
||||||
/// - `id:uuid` -> dedicated (ID match)
|
/// - `id:uuid` -> dedicated (ID match, searched globally)
|
||||||
/// - `*Headline` -> dedicated (exact headline title)
|
/// - `*Headline` -> dedicated (exact headline title)
|
||||||
/// - external URL schemes -> external (handed to the OS by callers)
|
/// - external URL schemes -> external (handed to the OS by callers)
|
||||||
/// - anything else (fuzzy) -> fuzzy section match (Phase 2: + offset)
|
/// - anything else (fuzzy) -> fuzzy section match (Phase 2: + offset)
|
||||||
|
|
@ -1883,11 +1891,62 @@ impl OrkState {
|
||||||
return Ok("kind: notfound\n".to_string());
|
return Ok("kind: notfound\n".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// External URL schemes are not navigation within the model.
|
// `file:DOC` / `file:DOC::TARGET` links name another document in the org
|
||||||
|
// path explicitly. If DOC is a known document, resolve TARGET within it;
|
||||||
|
// otherwise the link points outside the model and is external.
|
||||||
|
if let Some(rest) = strip_file_prefix(target) {
|
||||||
|
let (file_part, inner) = match rest.split_once("::") {
|
||||||
|
Some((f, t)) => (f, Some(t)),
|
||||||
|
None => (rest, None),
|
||||||
|
};
|
||||||
|
let Some(name) = self.doc_name_for_path(file_part) else {
|
||||||
|
// Not a document we serve — hand it to the OS.
|
||||||
|
return Ok("kind: external\n".to_string());
|
||||||
|
};
|
||||||
|
match inner {
|
||||||
|
// `file:DOC::TARGET` resolves TARGET inside DOC.
|
||||||
|
Some(t) => return self.resolve_in_doc(&name, t.trim()),
|
||||||
|
// `file:DOC` with no target navigates to the document itself.
|
||||||
|
None => return Ok(doc_result(&name)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other external URL schemes are not navigation within the model.
|
||||||
if is_external_target(target) {
|
if is_external_target(target) {
|
||||||
return Ok("kind: external\n".to_string());
|
return Ok("kind: external\n".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `id:` links are globally unique: search the current document first,
|
||||||
|
// then every other document in the path.
|
||||||
|
if let Some(uuid) = target.strip_prefix("id:") {
|
||||||
|
if let Some(name) = self.find_doc_with_section(uuid) {
|
||||||
|
return self.resolve_in_doc(&name, &format!("id:{}", uuid));
|
||||||
|
}
|
||||||
|
return Ok("kind: notfound\n".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal targets (#custom-id, *Headline, bare fuzzy) resolve in the
|
||||||
|
// current document first (Emacs semantics), then fall back across the
|
||||||
|
// whole org path so a link can reach a heading in another file.
|
||||||
|
let local = self.resolve_in_doc(doc_name, target)?;
|
||||||
|
if !local.starts_with("kind: notfound") {
|
||||||
|
return Ok(local);
|
||||||
|
}
|
||||||
|
for name in self.doc_names() {
|
||||||
|
if name == doc_name {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let r = self.resolve_in_doc(&name, target)?;
|
||||||
|
if !r.starts_with("kind: notfound") {
|
||||||
|
return Ok(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok("kind: notfound\n".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve an internal `target` within a single named document. Returns a
|
||||||
|
/// `kind: notfound` result if the document is missing or has no match.
|
||||||
|
fn resolve_in_doc(&self, doc_name: &str, target: &str) -> Result<String> {
|
||||||
let Some(d) = self.get_doc(doc_name) else {
|
let Some(d) = self.get_doc(doc_name) else {
|
||||||
return Ok("kind: notfound\n".to_string());
|
return Ok("kind: notfound\n".to_string());
|
||||||
};
|
};
|
||||||
|
|
@ -1896,13 +1955,13 @@ impl OrkState {
|
||||||
// because section_id() already prefers CUSTOM_ID > ID.
|
// because section_id() already prefers CUSTOM_ID > ID.
|
||||||
if let Some(cid) = target.strip_prefix('#') {
|
if let Some(cid) = target.strip_prefix('#') {
|
||||||
if let Some(section) = self.find_section(&d.doc, cid) {
|
if let Some(section) = self.find_section(&d.doc, cid) {
|
||||||
return Ok(dedicated_result(&self.section_id(section)));
|
return Ok(dedicated_result(doc_name, &self.section_id(section)));
|
||||||
}
|
}
|
||||||
return Ok("kind: notfound\n".to_string());
|
return Ok("kind: notfound\n".to_string());
|
||||||
}
|
}
|
||||||
if let Some(uuid) = target.strip_prefix("id:") {
|
if let Some(uuid) = target.strip_prefix("id:") {
|
||||||
if let Some(section) = self.find_section(&d.doc, uuid) {
|
if let Some(section) = self.find_section(&d.doc, uuid) {
|
||||||
return Ok(dedicated_result(&self.section_id(section)));
|
return Ok(dedicated_result(doc_name, &self.section_id(section)));
|
||||||
}
|
}
|
||||||
return Ok("kind: notfound\n".to_string());
|
return Ok("kind: notfound\n".to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -1913,7 +1972,7 @@ impl OrkState {
|
||||||
if let Some(section) = d.doc.all_sections()
|
if let Some(section) = d.doc.all_sections()
|
||||||
.find(|s| normalize_search(&s.headline.title_text()) == want)
|
.find(|s| normalize_search(&s.headline.title_text()) == want)
|
||||||
{
|
{
|
||||||
return Ok(dedicated_result(&self.section_id(section)));
|
return Ok(dedicated_result(doc_name, &self.section_id(section)));
|
||||||
}
|
}
|
||||||
return Ok("kind: notfound\n".to_string());
|
return Ok("kind: notfound\n".to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -1926,38 +1985,81 @@ impl OrkState {
|
||||||
if let Some(section) = d.doc.all_sections()
|
if let Some(section) = d.doc.all_sections()
|
||||||
.find(|s| normalize_search(&s.headline.title_text()) == want)
|
.find(|s| normalize_search(&s.headline.title_text()) == want)
|
||||||
{
|
{
|
||||||
return Ok(fuzzy_result(&self.section_id(section), 0));
|
return Ok(fuzzy_result(doc_name, &self.section_id(section), 0));
|
||||||
}
|
}
|
||||||
if let Some(section) = self.find_section(&d.doc, target) {
|
if let Some(section) = self.find_section(&d.doc, target) {
|
||||||
return Ok(fuzzy_result(&self.section_id(section), 0));
|
return Ok(fuzzy_result(doc_name, &self.section_id(section), 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok("kind: notfound\n".to_string())
|
Ok("kind: notfound\n".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a `file:` link's file component to a served document name, if any.
|
||||||
|
/// Matches on the file stem (e.g. `notes.org`, `./notes.org`, `notes`
|
||||||
|
/// all map to the document `notes`).
|
||||||
|
fn doc_name_for_path(&self, file_part: &str) -> Option<String> {
|
||||||
|
let stem = std::path::Path::new(file_part.trim())
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())?
|
||||||
|
.to_string();
|
||||||
|
if self.get_doc(&stem).is_some() {
|
||||||
|
Some(stem)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the document whose section id (CUSTOM_ID or ID) matches `id`.
|
||||||
|
/// Used for globally unique `id:` links that may live in another file.
|
||||||
|
fn find_doc_with_section(&self, id: &str) -> Option<String> {
|
||||||
|
self.doc_names().into_iter().find(|name| {
|
||||||
|
self.get_doc(name)
|
||||||
|
.map(|d| self.find_section(&d.doc, id).is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a link target is an external URL scheme (handed to the OS, not
|
/// Whether a link target is an external URL scheme (handed to the OS, not
|
||||||
/// resolved within the document model).
|
/// resolved within the document model). `file:` is handled separately by
|
||||||
|
/// `resolve_target` because it can name a document in the org path.
|
||||||
fn is_external_target(target: &str) -> bool {
|
fn is_external_target(target: &str) -> bool {
|
||||||
const SCHEMES: &[&str] = &["http:", "https:", "mailto:", "ftp:", "file:"];
|
const SCHEMES: &[&str] = &["http:", "https:", "mailto:", "ftp:"];
|
||||||
let lower = target.to_ascii_lowercase();
|
let lower = target.to_ascii_lowercase();
|
||||||
SCHEMES.iter().any(|s| lower.starts_with(s))
|
SCHEMES.iter().any(|s| lower.starts_with(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Strip a leading `file:` scheme (case-insensitive), returning the remainder.
|
||||||
|
/// Handles the `file:path` form; the optional `::target` is split by the caller.
|
||||||
|
fn strip_file_prefix(target: &str) -> Option<&str> {
|
||||||
|
let lower = target.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("file:") {
|
||||||
|
Some(&target["file:".len()..])
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Normalize a search string the way Emacs `org-link-search` does: collapse
|
/// Normalize a search string the way Emacs `org-link-search` does: collapse
|
||||||
/// internal whitespace runs to a single space and trim the ends.
|
/// internal whitespace runs to a single space and trim the ends.
|
||||||
fn normalize_search(s: &str) -> String {
|
fn normalize_search(s: &str) -> String {
|
||||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a resolution result that names only the owning document (used for a
|
||||||
|
/// bare `file:DOC` link with no in-document target).
|
||||||
|
fn doc_result(doc: &str) -> String {
|
||||||
|
format!("kind: dedicated\ndoc: {}\noffset: 0\n", doc)
|
||||||
|
}
|
||||||
|
|
||||||
/// Format a dedicated (exact structural) resolution result.
|
/// Format a dedicated (exact structural) resolution result.
|
||||||
fn dedicated_result(section_id: &str) -> String {
|
fn dedicated_result(doc: &str, section_id: &str) -> String {
|
||||||
format!("kind: dedicated\nsection: {}\noffset: 0\n", section_id)
|
format!("kind: dedicated\ndoc: {}\nsection: {}\noffset: 0\n", doc, section_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a fuzzy resolution result with an intra-section byte offset.
|
/// Format a fuzzy resolution result with an intra-section byte offset.
|
||||||
fn fuzzy_result(section_id: &str, offset: usize) -> String {
|
fn fuzzy_result(doc: &str, section_id: &str, offset: usize) -> String {
|
||||||
format!("kind: fuzzy\nsection: {}\noffset: {}\n", section_id, offset)
|
format!("kind: fuzzy\ndoc: {}\nsection: {}\noffset: {}\n", doc, section_id, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One parsed query term.
|
/// One parsed query term.
|
||||||
|
|
@ -2174,12 +2276,25 @@ mod tests {
|
||||||
state.move_section("w", "three", MoveDir::Down).unwrap();
|
state.move_section("w", "three", MoveDir::Down).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a state from several named documents: (doc_name, content).
|
||||||
|
fn state_with_docs(docs: &[(&str, &str)]) -> (tempfile::TempDir, OrkState) {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
for (name, content) in docs {
|
||||||
|
fs::write(dir.path().join(format!("{}.org", name)), content).unwrap();
|
||||||
|
}
|
||||||
|
let state = OrkState::new(dir.path()).unwrap();
|
||||||
|
(dir, state)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_custom_id() {
|
fn test_resolve_custom_id() {
|
||||||
let (_d, state) = state_with(
|
let (_d, state) = state_with(
|
||||||
"#+TITLE: W\n* Alpha\n:PROPERTIES:\n:CUSTOM_ID: my-anchor\n:END:\n* Beta\n");
|
"#+TITLE: W\n* Alpha\n:PROPERTIES:\n:CUSTOM_ID: my-anchor\n:END:\n* Beta\n");
|
||||||
let r = state.resolve_target("w", "#my-anchor").unwrap();
|
let r = state.resolve_target("w", "#my-anchor").unwrap();
|
||||||
assert!(r.contains("kind: dedicated"), "{}", r);
|
assert!(r.contains("kind: dedicated"), "{}", r);
|
||||||
|
assert!(r.contains("doc: w"), "{}", r);
|
||||||
assert!(r.contains("section: my-anchor"), "{}", r);
|
assert!(r.contains("section: my-anchor"), "{}", r);
|
||||||
assert!(r.contains("offset: 0"), "{}", r);
|
assert!(r.contains("offset: 0"), "{}", r);
|
||||||
}
|
}
|
||||||
|
|
@ -2239,6 +2354,90 @@ mod tests {
|
||||||
assert!(r.contains("kind: notfound"), "{}", r);
|
assert!(r.contains("kind: notfound"), "{}", r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_file_doc_headline() {
|
||||||
|
// file:DOC::*Headline resolves the heading inside the named document.
|
||||||
|
let (_d, state) = state_with_docs(&[
|
||||||
|
("a", "#+TITLE: A\n* Start\n"),
|
||||||
|
("b", "#+TITLE: B\n* Target Heading\n"),
|
||||||
|
]);
|
||||||
|
let r = state.resolve_target("a", "file:b.org::*Target Heading").unwrap();
|
||||||
|
assert!(r.contains("kind: dedicated"), "{}", r);
|
||||||
|
assert!(r.contains("doc: b"), "{}", r);
|
||||||
|
assert!(r.contains("section: target-heading"), "{}", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_file_doc_custom_id() {
|
||||||
|
// file:DOC::#custom-id resolves the anchor inside the named document.
|
||||||
|
let (_d, state) = state_with_docs(&[
|
||||||
|
("a", "#+TITLE: A\n* Start\n"),
|
||||||
|
("b", "#+TITLE: B\n* Beta\n:PROPERTIES:\n:CUSTOM_ID: anchor\n:END:\n"),
|
||||||
|
]);
|
||||||
|
let r = state.resolve_target("a", "file:b.org::#anchor").unwrap();
|
||||||
|
assert!(r.contains("kind: dedicated"), "{}", r);
|
||||||
|
assert!(r.contains("doc: b"), "{}", r);
|
||||||
|
assert!(r.contains("section: anchor"), "{}", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_file_doc_no_target() {
|
||||||
|
// file:DOC with no ::target navigates to the document itself.
|
||||||
|
let (_d, state) = state_with_docs(&[
|
||||||
|
("a", "#+TITLE: A\n* Start\n"),
|
||||||
|
("b", "#+TITLE: B\n* Beta\n"),
|
||||||
|
]);
|
||||||
|
let r = state.resolve_target("a", "file:b.org").unwrap();
|
||||||
|
assert!(r.contains("kind: dedicated"), "{}", r);
|
||||||
|
assert!(r.contains("doc: b"), "{}", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_file_unknown_doc_external() {
|
||||||
|
// file: link to a document not in the org path stays external.
|
||||||
|
let (_d, state) = state_with_docs(&[("a", "#+TITLE: A\n* Start\n")]);
|
||||||
|
let r = state.resolve_target("a", "file:/tmp/elsewhere.org::*X").unwrap();
|
||||||
|
assert!(r.contains("kind: external"), "{}", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_id_cross_document() {
|
||||||
|
// id: links are globally unique and resolve in whichever doc owns them.
|
||||||
|
let (_d, state) = state_with_docs(&[
|
||||||
|
("a", "#+TITLE: A\n* Start\n"),
|
||||||
|
("b", "#+TITLE: B\n* Beta\n:PROPERTIES:\n:ID: uuid-xyz\n:END:\n"),
|
||||||
|
]);
|
||||||
|
let r = state.resolve_target("a", "id:uuid-xyz").unwrap();
|
||||||
|
assert!(r.contains("kind: dedicated"), "{}", r);
|
||||||
|
assert!(r.contains("doc: b"), "{}", r);
|
||||||
|
assert!(r.contains("section: uuid-xyz"), "{}", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_headline_cross_document_fallback() {
|
||||||
|
// A bare/headline target missing in the current doc falls back across
|
||||||
|
// the org path to a matching heading elsewhere.
|
||||||
|
let (_d, state) = state_with_docs(&[
|
||||||
|
("a", "#+TITLE: A\n* Start\n"),
|
||||||
|
("b", "#+TITLE: B\n* Faraway\n"),
|
||||||
|
]);
|
||||||
|
let r = state.resolve_target("a", "*Faraway").unwrap();
|
||||||
|
assert!(r.contains("kind: dedicated"), "{}", r);
|
||||||
|
assert!(r.contains("doc: b"), "{}", r);
|
||||||
|
assert!(r.contains("section: faraway"), "{}", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_prefers_current_document() {
|
||||||
|
// When both documents have a matching heading, the current doc wins.
|
||||||
|
let (_d, state) = state_with_docs(&[
|
||||||
|
("a", "#+TITLE: A\n* Shared\n"),
|
||||||
|
("b", "#+TITLE: B\n* Shared\n"),
|
||||||
|
]);
|
||||||
|
let r = state.resolve_target("a", "*Shared").unwrap();
|
||||||
|
assert!(r.contains("doc: a"), "{}", r);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_relevel_section() {
|
fn test_relevel_section() {
|
||||||
let (_d, state) = state_with("#+TITLE: W\n* Parent\n** Child\n* Other\n");
|
let (_d, state) = state_with("#+TITLE: W\n* Parent\n** Child\n* Other\n");
|
||||||
|
|
|
||||||
|
|
@ -70,16 +70,36 @@ ListView {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open a link target activated from body text. External URL schemes open
|
// Open a link target activated from body text. Resolution and dispatch
|
||||||
// in the system handler; internal org links (id:, #custom, *headline) are
|
// happen in the model (via /<doc>/resolve): external URLs open in the system
|
||||||
// left for a future navigation pass.
|
// handler; internal links reveal the target section in the outline.
|
||||||
function openLink(target) {
|
function openLink(target) {
|
||||||
if (/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.test(target)
|
docModel.followLink(target)
|
||||||
|| /^(mailto:|file:)/.test(target)) {
|
}
|
||||||
Qt.openUrlExternally(target)
|
|
||||||
|
// Row currently flashing from a reveal (−1 = none).
|
||||||
|
property int flashRow: -1
|
||||||
|
|
||||||
|
// When the model reveals a section, select it, scroll it into view, and
|
||||||
|
// flash it briefly.
|
||||||
|
Connections {
|
||||||
|
target: docModel
|
||||||
|
function onSectionRevealed(row) {
|
||||||
|
if (row < 0)
|
||||||
|
return
|
||||||
|
outline.currentIndex = row
|
||||||
|
outline.positionViewAtIndex(row, ListView.Contain)
|
||||||
|
outline.flashRow = row
|
||||||
|
flashTimer.restart()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: flashTimer
|
||||||
|
interval: 900
|
||||||
|
onTriggered: outline.flashRow = -1
|
||||||
|
}
|
||||||
|
|
||||||
delegate: Rectangle {
|
delegate: Rectangle {
|
||||||
id: del
|
id: del
|
||||||
width: outline.width
|
width: outline.width
|
||||||
|
|
@ -92,6 +112,16 @@ ListView {
|
||||||
|
|
||||||
property bool drawerOpen: false
|
property bool drawerOpen: false
|
||||||
|
|
||||||
|
// Transient flash overlay when this row is revealed via a link.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
color: theme.linkColor
|
||||||
|
z: -1
|
||||||
|
opacity: outline.flashRow === del.index ? 0.35 : 0.0
|
||||||
|
Behavior on opacity { NumberAnimation { duration: 300 } }
|
||||||
|
}
|
||||||
|
|
||||||
readonly property string sectionBody: (model.body ?? "").trim()
|
readonly property string sectionBody: (model.body ?? "").trim()
|
||||||
readonly property int sectionLevel: model.level ?? 1
|
readonly property int sectionLevel: model.level ?? 1
|
||||||
readonly property int indent: (sectionLevel - 1) * outline.indentWidth
|
readonly property int indent: (sectionLevel - 1) * outline.indentWidth
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
#include "orkclient.h"
|
#include "orkclient.h"
|
||||||
|
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <QDesktopServices>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
static const QStringList DONE_KEYWORDS = {"DONE", "CANCELLED", "CANCELED"};
|
static const QStringList DONE_KEYWORDS = {"DONE", "CANCELLED", "CANCELED"};
|
||||||
|
|
||||||
|
|
@ -276,6 +278,77 @@ bool DocumentModel::cycleCheckbox(int row, int checkboxOrdinal, bool forward)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int DocumentModel::revealSection(const QString §ionId)
|
||||||
|
{
|
||||||
|
if (sectionId.isEmpty() || !m_sections.contains(sectionId))
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
// Walk up the parent chain and expand every ancestor so the target becomes
|
||||||
|
// visible. (The target itself is not expanded — only revealed.)
|
||||||
|
QString cur = m_sections[sectionId].parentId;
|
||||||
|
bool changed = false;
|
||||||
|
while (!cur.isEmpty() && m_sections.contains(cur)) {
|
||||||
|
if (!m_expanded.contains(cur)) {
|
||||||
|
m_expanded.insert(cur);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
cur = m_sections[cur].parentId;
|
||||||
|
}
|
||||||
|
if (changed)
|
||||||
|
rebuildVisibleRows();
|
||||||
|
|
||||||
|
const int row = m_visibleRows.indexOf(sectionId);
|
||||||
|
if (row >= 0)
|
||||||
|
emit sectionRevealed(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DocumentModel::followLink(const QString &target)
|
||||||
|
{
|
||||||
|
if (!m_client || m_document.isEmpty())
|
||||||
|
return QStringLiteral("error");
|
||||||
|
|
||||||
|
QString doc = m_document;
|
||||||
|
if (doc.startsWith('/'))
|
||||||
|
doc = doc.mid(1);
|
||||||
|
|
||||||
|
// Resolve the target via the document's /resolve file.
|
||||||
|
const QString resp =
|
||||||
|
m_client->rdwr(QStringLiteral("/%1/resolve").arg(doc), target);
|
||||||
|
|
||||||
|
// Parse the small "key: value" blob.
|
||||||
|
QString kind;
|
||||||
|
QString section;
|
||||||
|
QString targetDoc;
|
||||||
|
for (const QString &line : resp.split('\n', Qt::SkipEmptyParts)) {
|
||||||
|
const int colon = line.indexOf(':');
|
||||||
|
if (colon < 0)
|
||||||
|
continue;
|
||||||
|
const QString key = line.left(colon).trimmed();
|
||||||
|
const QString val = line.mid(colon + 1).trimmed();
|
||||||
|
if (key == QLatin1String("kind"))
|
||||||
|
kind = val;
|
||||||
|
else if (key == QLatin1String("section"))
|
||||||
|
section = val;
|
||||||
|
else if (key == QLatin1String("doc"))
|
||||||
|
targetDoc = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind == QLatin1String("external")) {
|
||||||
|
QDesktopServices::openUrl(QUrl(target));
|
||||||
|
} else if (kind == QLatin1String("dedicated") || kind == QLatin1String("fuzzy")) {
|
||||||
|
// The resolver may hand back a target in another document (file: links,
|
||||||
|
// globally unique id: links, cross-file headline fallback). Switch the
|
||||||
|
// model to the owning document before revealing the section.
|
||||||
|
if (!targetDoc.isEmpty() && targetDoc != doc)
|
||||||
|
setDocument(targetDoc);
|
||||||
|
|
||||||
|
if (!section.isEmpty())
|
||||||
|
revealSection(section);
|
||||||
|
}
|
||||||
|
return kind.isEmpty() ? QStringLiteral("notfound") : kind;
|
||||||
|
}
|
||||||
|
|
||||||
void DocumentModel::loadDocument()
|
void DocumentModel::loadDocument()
|
||||||
{
|
{
|
||||||
beginResetModel();
|
beginResetModel();
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,26 @@ public:
|
||||||
// the section body back via /body and refreshes the row. Returns success.
|
// the section body back via /body and refreshes the row. Returns success.
|
||||||
Q_INVOKABLE bool cycleCheckbox(int row, int checkboxOrdinal, bool forward);
|
Q_INVOKABLE bool cycleCheckbox(int row, int checkboxOrdinal, bool forward);
|
||||||
|
|
||||||
|
// Reveal a section by id: expand all its ancestors so it becomes visible,
|
||||||
|
// and return its visible row index (-1 if the id is unknown). Emits
|
||||||
|
// sectionRevealed(row) so the view can scroll to and flash it.
|
||||||
|
Q_INVOKABLE int revealSection(const QString §ionId);
|
||||||
|
|
||||||
|
// Resolve a link target via /<doc>/resolve, then act on it: external
|
||||||
|
// targets open in the OS handler; internal targets are revealed in the
|
||||||
|
// outline. Resolution spans the whole org path — file:/id:/fuzzy links may
|
||||||
|
// resolve into another document, in which case the model switches to the
|
||||||
|
// owning document before revealing. Returns the resolved kind
|
||||||
|
// ("dedicated"/"fuzzy"/"external"/"notfound"), or "error" if not connected.
|
||||||
|
Q_INVOKABLE QString followLink(const QString &target);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void documentChanged();
|
void documentChanged();
|
||||||
|
|
||||||
|
// Emitted when a section has been revealed (ancestors expanded); `row` is
|
||||||
|
// its visible index. The view scrolls to and highlights it.
|
||||||
|
void sectionRevealed(int row);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
struct Section {
|
struct Section {
|
||||||
QString id; // section UUID/custom-id
|
QString id; // section UUID/custom-id
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue