krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit b98a6509c5

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e

parent: 322a87d475

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 08:10 UTC

Store Link, Store ID Link and Insert Link on iOS, and org-protocol store-link

Layout: unified · split

Sources/OrgCore/Links/LinkStore.swift +9
@@ -9,6 +9,15 @@ public struct StoredLink: Sendable, Equatable, Codable {
9 self.link = link 9 self.link = link
10 self.description = description 10 self.description = description
11 } 11 }
12
13 /// `org-link--add-to-stored-links`: puts `link` first in `links` and says what happened.
14 public static func add(_ link: StoredLink, to links: inout [StoredLink]) -> String {
15 if links.first == link { return "This link has already been stored" }
16 let moved = links.contains(link)
17 links.removeAll { $0 == link }
18 links.insert(link, at: 0)
19 return (moved ? "Link moved to front: " : "Stored: ") + (link.description ?? link.link)
20 }
12} 21}
13 22
14extension Links { 23extension Links {
Sources/Orgstar/OrgstarApp.swift +2 −9
@@ -32,16 +32,9 @@ final class AppCommands {
32 /// `org-stored-links`, most recent first; kept for the session, as in Emacs. 32 /// `org-stored-links`, most recent first; kept for the session, as in Emacs.
33 var storedLinks: [StoredLink] = [] 33 var storedLinks: [StoredLink] = []
34 34
35 /// `org-link--add-to-stored-links`: puts `link` first and says what happened. 35 /// Puts `link` first in `storedLinks` and says what happened.
36 func storeLink(_ link: StoredLink) { 36 func storeLink(_ link: StoredLink) {
37 if storedLinks.first == link { 37 show(StoredLink.add(link, to: &storedLinks))
38 show("This link has already been stored")
39 } else {
40 let moved = storedLinks.contains(link)
41 storedLinks.removeAll { $0 == link }
42 storedLinks.insert(link, at: 0)
43 show((moved ? "Link moved to front: " : "Stored: ") + (link.description ?? link.link))
44 }
45 } 38 }
46 39
47 /// The keys that run `command`, as menus and the palette show them. 40 /// The keys that run `command`, as menus and the palette show them.
Sources/OrgstarMobile/EditorScreen.swift +18
@@ -133,6 +133,22 @@ struct EditorScreen: View {
133 } 133 }
134 } 134 }
135 135
136 /// Store Link, Store ID Link and Insert Link, with the links stored while the app runs.
137 private func link(_ id: String) {
138 guard let editor else { return }
139 switch id {
140 case "org.link.store":
141 message = StoredLink.add(Links.store(in: editor.editContext(), path: Links.abbreviate(path, home: NSHomeDirectory())), to: &model.storedLinks)
142 case "org.id.store-link":
143 if case .commit = editor.perform(CreateID()),
144 let link = Links.storeID(in: editor.editContext(), fileName: (path as NSString).lastPathComponent) {
145 message = StoredLink.add(link, to: &model.storedLinks)
146 }
147 default:
148 _ = editor.perform(InsertLink(stored: model.storedLinks, file: path))
149 }
150 }
151
136 /// Runs a block once it's trusted, asking as `org-confirm-babel-evaluate` does. 152 /// Runs a block once it's trusted, asking as `org-confirm-babel-evaluate` does.
137 private func run(_ job: BabelJob) { 153 private func run(_ job: BabelJob) {
138 let go = { 154 let go = {
@@ -178,6 +194,8 @@ struct EditorScreen: View {
178 editor.onAppCommand = { id in 194 editor.onAppCommand = { id in
179 if id == "app.toggle-markup" { 195 if id == "app.toggle-markup" {
180 model.showMarkup.toggle() 196 model.showMarkup.toggle()
197 } else if MobileEditor.linkCommands.contains(id) {
198 link(id)
181 } else if !MobileClock.run(id, model: model, workspace: workspace, show: { message = $0 }) { 199 } else if !MobileClock.run(id, model: model, workspace: workspace, show: { message = $0 }) {
182 message = "Not available on iOS yet" 200 message = "Not available on iOS yet"
183 } 201 }
Sources/OrgstarMobile/MobileEditor.swift +5 −1
@@ -333,7 +333,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
333 case "edit.undo": textView.undoManager?.undo() 333 case "edit.undo": textView.undoManager?.undo()
334 case "edit.redo": textView.undoManager?.redo() 334 case "edit.redo": textView.undoManager?.redo()
335 case "org.table.toggle-column-width", "org.table.shrink", "org.table.expand": tableColumnWidth(id) 335 case "org.table.toggle-column-width", "org.table.shrink", "org.table.expand": tableColumnWidth(id)
336 case _ where id.hasPrefix("app."): 336 case _ where id.hasPrefix("app.") || Self.linkCommands.contains(id):
337 if let onAppCommand { onAppCommand(id) } else { onMessage?("Not available on iOS yet") } 337 if let onAppCommand { onAppCommand(id) } else { onMessage?("Not available on iOS yet") }
338 default: 338 default:
339 if let command = Commands.command(id) { 339 if let command = Commands.command(id) {
@@ -344,6 +344,9 @@ final class MobileEditor: NSObject, DocumentBuffer {
344 } 344 }
345 } 345 }
346 346
347 /// Link commands that need the stored links or the file, which the screen has.
348 static let linkCommands: Set<String> = ["org.link.store", "org.id.store-link", "org.link.insert"]
349
347 func applies(_ id: String, in context: EditContext) -> Bool { 350 func applies(_ id: String, in context: EditContext) -> Bool {
348 if let command = Commands.command(id) { return command.applies(in: context) } 351 if let command = Commands.command(id) { return command.applies(in: context) }
349 switch id { 352 switch id {
@@ -352,6 +355,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
352 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true 355 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true
353 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.clock.in-recent", "app.clock.in-last", 356 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.clock.in-recent", "app.clock.in-last",
354 "app.clock.goto-recent", "app.clock.default", "app.clock.resolve", "app.toggle-markup": return onAppCommand != nil 357 "app.clock.goto-recent", "app.clock.default", "app.clock.resolve", "app.toggle-markup": return onAppCommand != nil
358 case _ where Self.linkCommands.contains(id): return onAppCommand != nil
355 case "org.link.open": return OpenLinkAtPoint().applies(in: context) 359 case "org.link.open": return OpenLinkAtPoint().applies(in: context)
356 case "org.table.toggle-column-width", "org.table.shrink", "org.table.expand": 360 case "org.table.toggle-column-width", "org.table.shrink", "org.table.expand":
357 return TableShrink.table(at: context.caret, in: context.text) != nil 361 return TableShrink.table(at: context.caret, in: context.text) != nil
Sources/OrgstarMobile/MobileModel.swift +2
@@ -48,6 +48,8 @@ final class MobileModel {
48 private(set) var theme = ThemeSpec.default 48 private(set) var theme = ThemeSpec.default
49 /// `show-markup`, toggled in the editor; the folder's value applies when it changes. 49 /// `show-markup`, toggled in the editor; the folder's value applies when it changes.
50 var showMarkup = false 50 var showMarkup = false
51 /// `org-stored-links`, most recent first; kept while the app runs, as in Emacs.
52 var storedLinks: [StoredLink] = []
51 @ObservationIgnored private var configShowMarkup: Bool? 53 @ObservationIgnored private var configShowMarkup: Bool?
52 @ObservationIgnored private var presenter: FolderPresenter? 54 @ObservationIgnored private var presenter: FolderPresenter?
53 @ObservationIgnored private var sizeObserver: NSObjectProtocol? 55 @ObservationIgnored private var sizeObserver: NSObjectProtocol?
Sources/OrgstarMobile/OrgstarMobileApp.swift +3
@@ -226,6 +226,9 @@ struct RootView: View {
226 open(link) 226 open(link)
227 } else if let request = CaptureRequest(link: url.absoluteString) { 227 } else if let request = CaptureRequest(link: url.absoluteString) {
228 capture = request 228 capture = request
229 } else if case .storeLink(let link, let title)? = OrgProtocol.parse(url.absoluteString) {
230 message = StoredLink.add(StoredLink(link: link, description: title), to: &model.storedLinks)
231 UIPasteboard.general.string = link
229 } 232 }
230 } 233 }
231 // The Live Activity follows the clock, wherever it starts or stops. 234 // The Live Activity follows the clock, wherever it starts or stops.
Tests/OrgCoreTests/LinkStoreTests.swift +11
@@ -219,4 +219,15 @@ struct LinkStoreTests {
219 let id = try #require(text.firstMatch(of: /:ID: +(\S+)/)?.1) 219 let id = try #require(text.firstMatch(of: /:ID: +(\S+)/)?.1)
220 #expect(String(id).range(of: uuid, options: .regularExpression) != nil, "\(text)") 220 #expect(String(id).range(of: uuid, options: .regularExpression) != nil, "\(text)")
221 } 221 }
222
223 @Test func addingMovesALinkToTheFront() {
224 let a = StoredLink(link: "file:a.org", description: "A")
225 let b = StoredLink(link: "file:b.org", description: nil)
226 var links: [StoredLink] = []
227 #expect(StoredLink.add(a, to: &links) == "Stored: A")
228 #expect(StoredLink.add(b, to: &links) == "Stored: file:b.org")
229 #expect(StoredLink.add(b, to: &links) == "This link has already been stored")
230 #expect(StoredLink.add(a, to: &links) == "Link moved to front: A")
231 #expect(links == [a, b])
232 }
222} 233}
docs/manual/guide/08-capture.org +1 −1
@@ -264,7 +264,7 @@ org-protocol://store-link?url=https%3A%2F%2Fexample.com&title=Example
264 264
265This stores the link, so =C-c C-l= in the editor offers it (see [[file:09-links.org][Links]]), and puts the URL on the clipboard. 265This stores the link, so =C-c C-l= in the editor offers it (see [[file:09-links.org][Links]]), and puts the URL on the clipboard.
266 266
267Other handlers, such as =open-source=, report that Orgstar handles only capture and store-link. On iOS only =capture= links are handled. 267Other handlers, such as =open-source=, report that Orgstar handles only capture and store-link.
268 268
269** Bookmarklets 269** Bookmarklets
270 270
docs/manual/guide/09-links.org +5 −2
@@ -338,5 +338,8 @@ Insert Link. Setting up a browser bookmarklet, and =org-protocol://capture=, are
338 338
339The iOS app follows links: tap a link in the reader. Org files open in the app, and 339The iOS app follows links: tap a link in the reader. Org files open in the app, and
340web, mail and other files go to the system. The iOS editor shows inline images as the 340web, mail and other files go to the system. The iOS editor shows inline images as the
341Mac does; the reader doesn't. Storing and inserting links and the backlinks pane are Mac 341Mac does; the reader doesn't. In the editor, Store Link, Store ID Link and Insert Link…
342features. See [[file:14-ios.org][iOS]]. 342are in Commands, and on a hardware keyboard the Emacs keys run them; they work as
343described above, and stored links last until the app quits. =org-protocol://store-link=
344links opened on the device are stored and copied too. The backlinks pane is Mac only.
345See [[file:14-ios.org][iOS]].
docs/manual/guide/14-ios.org +1 −1
@@ -213,7 +213,7 @@ Tap Capture (the pencil icon) in the Agenda or Folders tab.
213 213
214Templates come from =capture.toml= in the configuration folder (see Settings below). Without one, the two default templates apply. A template with =immediate-finish= files as soon as its questions are answered, and one with =jump-to-captured= opens the captured entry. =%^g= offers the target file's tags as choices. 214Templates come from =capture.toml= in the configuration folder (see Settings below). Without one, the two default templates apply. A template with =immediate-finish= files as soon as its questions are answered, and one with =jump-to-captured= opens the captured entry. =%^g= offers the target file's tags as choices.
215 215
216=org-protocol://capture= links opened on the device open the capture sheet with the link's template, URL, title and text. When the link names a template key that =capture.toml= doesn't have, the sheet starts on the first template and shows =No capture template "x"= with the key. 216=org-protocol://store-link= links add the URL to the stored links that Insert Link… offers, and copy it. =org-protocol://capture= links opened on the device open the capture sheet with the link's template, URL, title and text. When the link names a template key that =capture.toml= doesn't have, the sheet starts on the first template and shows =No capture template "x"= with the key.
217 217
218** From the share sheet 218** From the share sheet
219 219
docs/manual/guide/15-alongside-emacs.org +1 −1
@@ -166,7 +166,7 @@ Orgstar declares the =org.orgmode.org= file type for the extensions =.org= and =
166Orgstar registers the =org-protocol:= URL scheme on the Mac and iOS, and handles two sub-protocols, in both the =?key=value= form and the older =:/a/b/c= form: 166Orgstar registers the =org-protocol:= URL scheme on the Mac and iOS, and handles two sub-protocols, in both the =?key=value= form and the older =:/a/b/c= form:
167 167
168- =org-protocol://capture= opens Capture with the link's template, URL, title and body. 168- =org-protocol://capture= opens Capture with the link's template, URL, title and body.
169- =org-protocol://store-link= stores the link for =C-c C-l= and copies it to the clipboard. This is Mac only; the iOS app handles only =capture=. 169- =org-protocol://store-link= stores the link for =C-c C-l= and copies it to the clipboard.
170 170
171Other sub-protocols, such as =open-source=, show "Orgstar handles org-protocol capture and store-link". 171Other sub-protocols, such as =open-source=, show "Orgstar handles org-protocol capture and store-link".
172 172