Compare commits
55 Commits
ac2f9cf85d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e1d94a4ea | ||
|
|
f7735431a8 | ||
|
|
f8693f4be0 | ||
|
|
4d875e12d6 | ||
| b496e607fc | |||
|
|
1f49a7dcbe | ||
|
|
a155c15e17 | ||
|
|
d2ccbc94b5 | ||
|
|
5e69d99996 | ||
|
|
af3112530e | ||
|
|
f8de444f8f | ||
|
|
64a944ab10 | ||
|
|
96ea5fe6f6 | ||
|
|
e82e69c5f3 | ||
|
|
a3059f25fc | ||
|
|
389d615c8b | ||
|
|
8a27b692b8 | ||
|
|
f5c0f0929d | ||
|
|
a5cd9b1d03 | ||
|
|
b7ae15ae87 | ||
|
|
199fc2d043 | ||
|
|
d87500a7dc | ||
|
|
3de93ac61d | ||
|
|
ee9a7c018e | ||
|
|
89bcecebab | ||
|
|
baadaf77a3 | ||
|
|
20f06c8440 | ||
|
|
18f1564d3b | ||
|
|
7c5ffbf69b | ||
|
|
15e2801b87 | ||
|
|
5c52693d4d | ||
|
|
2a0cb2d648 | ||
|
|
f61179e568 | ||
|
|
09430e8e66 | ||
|
|
140c761850 | ||
|
|
f7d5c129fc | ||
|
|
be4a4c3a5f | ||
|
|
fc0f98f45a | ||
|
|
ea8a425c21 | ||
|
|
ab72611d7b | ||
|
|
18423a7633 | ||
|
|
38cd72416c | ||
|
|
38fd37bee8 | ||
|
|
26b1de1cbc | ||
|
|
abe35f47d6 | ||
|
|
65efe708ee | ||
|
|
f796dabcff | ||
|
|
544fcd442c | ||
|
|
476ed9b2a1 | ||
|
|
d2ff1d2f2f | ||
|
|
57d5288b54 | ||
|
|
525e6557c8 | ||
|
|
9f0a10d228 | ||
|
|
db5092ce02 | ||
|
|
bc16ee986d |
2
.env.sample
Normal file
2
.env.sample
Normal file
@@ -0,0 +1,2 @@
|
||||
SENTRY_DSN=
|
||||
ENABLE_SENTRY=false
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ Pods/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
autoresearch-results.tsv
|
||||
|
||||
30
.metadata
Normal file
30
.metadata
Normal file
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
- platform: android
|
||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
14
.runner
Normal file
14
.runner
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.",
|
||||
"id": 3,
|
||||
"uuid": "68105c19-5047-4a36-9342-2877162f169e",
|
||||
"name": "marks-runner",
|
||||
"token": "1e23b24d76fc51bd9b442349a780c38fceced821",
|
||||
"address": "https://git.magicive.com",
|
||||
"labels": [
|
||||
"macos:host",
|
||||
"arm64:host",
|
||||
"ios:host",
|
||||
"xcode:host"
|
||||
]
|
||||
}
|
||||
10
AGENTS.md
Normal file
10
AGENTS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## OpenWiki
|
||||
|
||||
This repository has documentation located in the /openwiki directory.
|
||||
|
||||
Start here:
|
||||
- [OpenWiki quickstart](openwiki/quickstart.md)
|
||||
|
||||
OpenWiki includes repository overview, architecture notes, workflows, domain concepts, operations, integrations, testing guidance, and source maps.
|
||||
|
||||
When working in this repository, read the OpenWiki quickstart first, then follow its links to the relevant architecture, workflow, domain, operation, and testing notes.
|
||||
@@ -7,29 +7,82 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
031BC9E7B0107CD02F9BA846 /* SourceSpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */; };
|
||||
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; };
|
||||
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
|
||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; };
|
||||
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
|
||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
|
||||
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; };
|
||||
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
||||
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; };
|
||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
|
||||
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
|
||||
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
|
||||
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E6702DE1210AC38ED16D1 /* AskView.swift */; };
|
||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
|
||||
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
|
||||
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
|
||||
70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */; };
|
||||
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
|
||||
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */; };
|
||||
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; };
|
||||
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
|
||||
81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */; };
|
||||
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */; };
|
||||
83CC9BA3176B05358A307373 /* IngestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */; };
|
||||
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B7A85A23A13D754F6A75E4D /* ShareView.swift */; };
|
||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
|
||||
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; };
|
||||
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
|
||||
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
|
||||
8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
|
||||
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
|
||||
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
|
||||
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
|
||||
95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB1DA808EE8041C4546DAAB /* SourcesView.swift */; };
|
||||
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
|
||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
|
||||
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
|
||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
|
||||
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; };
|
||||
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; };
|
||||
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
|
||||
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
|
||||
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; };
|
||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; };
|
||||
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
|
||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
|
||||
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
|
||||
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; };
|
||||
D00878A046AF53CBC130388E /* TagSuggester.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6905CD5B1864895E2F84C7DF /* TagSuggester.swift */; };
|
||||
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */; };
|
||||
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; };
|
||||
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
|
||||
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */; };
|
||||
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */; };
|
||||
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
|
||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
|
||||
FA1B2C3D4E5F6A7B8C9D0E1F /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */; };
|
||||
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; };
|
||||
F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
|
||||
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */; };
|
||||
FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7623601C25E481DF58371F2A /* AppIntentsTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 61046BF39C48393DD96687A0;
|
||||
remoteInfo = MarksWidget;
|
||||
};
|
||||
BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
|
||||
@@ -37,6 +90,13 @@
|
||||
remoteGlobalIDString = CF31B2655F8F30CF6DF3C26F;
|
||||
remoteInfo = ShareExtension;
|
||||
};
|
||||
F63E42D8413C0F6ED8BB78CF /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 76C272776D8EBCCD3FB6715A;
|
||||
remoteInfo = Marks;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@@ -47,6 +107,7 @@
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -55,32 +116,93 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
03D800669E343FF305468424 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||
0552C13335034219DECF4F62 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
|
||||
093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestPayload.swift; sourceTree = "<group>"; };
|
||||
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
|
||||
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
|
||||
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLibrary.swift; sourceTree = "<group>"; };
|
||||
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; };
|
||||
18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSourceStore.swift; sourceTree = "<group>"; };
|
||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
|
||||
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
|
||||
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; };
|
||||
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; };
|
||||
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
|
||||
49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentBookmarksWidget.swift; sourceTree = "<group>"; };
|
||||
55D369E6D2B8B036685BBD13 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
|
||||
629C41E0BC28EB6359D50CFD /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = "<group>"; };
|
||||
64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = "<group>"; };
|
||||
6905CD5B1864895E2F84C7DF /* TagSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggester.swift; sourceTree = "<group>"; };
|
||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
|
||||
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
|
||||
7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = "<group>"; };
|
||||
7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSource.swift; sourceTree = "<group>"; };
|
||||
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
|
||||
8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkEntity.swift; sourceTree = "<group>"; };
|
||||
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MarksWidget.entitlements; sourceTree = "<group>"; };
|
||||
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = "<group>"; };
|
||||
9B7A85A23A13D754F6A75E4D /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||
9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = "<group>"; };
|
||||
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; };
|
||||
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
|
||||
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = "<group>"; };
|
||||
B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = "<group>"; };
|
||||
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = "<group>"; };
|
||||
C8B10AC011AF8F242E525440 /* MarksTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MarksTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
|
||||
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
|
||||
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; };
|
||||
CDB1DA808EE8041C4546DAAB /* SourcesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourcesView.swift; sourceTree = "<group>"; };
|
||||
D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = "<group>"; };
|
||||
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRequests.swift; sourceTree = "<group>"; };
|
||||
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryStore.swift; sourceTree = "<group>"; };
|
||||
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
|
||||
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; };
|
||||
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
|
||||
FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = "<group>"; };
|
||||
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
|
||||
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = "<group>"; };
|
||||
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = "<group>"; };
|
||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
|
||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
|
||||
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; };
|
||||
FB7728D15C17219ABFF3EFFE /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
|
||||
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
6B2547BA0E17C2564677B34A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */,
|
||||
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
A1E820C4B9604F8F05055D3E /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */,
|
||||
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
3952A081972B444674BEB51B /* Marks */ = {
|
||||
isa = PBXGroup;
|
||||
@@ -89,6 +211,7 @@
|
||||
86B2DE40098D2B39FFE1AC36 /* Info.plist */,
|
||||
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */,
|
||||
D92575C7C710347F226EC74A /* MarksApp.swift */,
|
||||
58E8E316BE3F10C5149AADC3 /* Intents */,
|
||||
3EF3094F53F0378D1BF18508 /* Models */,
|
||||
7ABBFDF43A1EB773E0A49CFE /* Services */,
|
||||
AF9CABF75E77168EF1D60F29 /* Views */,
|
||||
@@ -100,18 +223,42 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8CA428181B35885F7D9F4D55 /* Bookmark.swift */,
|
||||
7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */,
|
||||
0A7D8DBB58B2F2EC835C9457 /* IngestPayload.swift */,
|
||||
07C21567B95F5069BA946252 /* ServerConfig.swift */,
|
||||
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3F5B1508DEFC894F33CBD16C /* MarksTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7623601C25E481DF58371F2A /* AppIntentsTests.swift */,
|
||||
);
|
||||
path = MarksTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
58E8E316BE3F10C5149AADC3 /* Intents */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8D650D0784B847BFEDCB3141 /* BookmarkEntity.swift */,
|
||||
D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */,
|
||||
9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */,
|
||||
D6ACABF0CA940312B4195456 /* IntentSupport.swift */,
|
||||
64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */,
|
||||
);
|
||||
path = Intents;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E895C34E4D2A1C4709B25FF1 /* Info.plist */,
|
||||
03D800669E343FF305468424 /* ShareExtension.entitlements */,
|
||||
9B7A85A23A13D754F6A75E4D /* ShareView.swift */,
|
||||
ADEAC824576633CC77370262 /* ShareViewController.swift */,
|
||||
6905CD5B1864895E2F84C7DF /* TagSuggester.swift */,
|
||||
);
|
||||
path = ShareExtension;
|
||||
sourceTree = "<group>";
|
||||
@@ -119,25 +266,57 @@
|
||||
7ABBFDF43A1EB773E0A49CFE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */,
|
||||
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
|
||||
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
|
||||
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
|
||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
||||
18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */,
|
||||
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
|
||||
FB7728D15C17219ABFF3EFFE /* Log.swift */,
|
||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
||||
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */,
|
||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
||||
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */,
|
||||
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
|
||||
F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */,
|
||||
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
|
||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
85E717A515682EBF67DD199A /* MarksWidget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
55D369E6D2B8B036685BBD13 /* Info.plist */,
|
||||
969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */,
|
||||
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */,
|
||||
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */,
|
||||
49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */,
|
||||
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */,
|
||||
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */,
|
||||
);
|
||||
path = MarksWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
AF9CABF75E77168EF1D60F29 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
|
||||
217E6702DE1210AC38ED16D1 /* AskView.swift */,
|
||||
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
|
||||
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
|
||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
||||
FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */,
|
||||
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
|
||||
629C41E0BC28EB6359D50CFD /* BrowserView.swift */,
|
||||
FE19F7619214271A8C12EEEB /* CollectionsView.swift */,
|
||||
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */,
|
||||
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */,
|
||||
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */,
|
||||
BCC3BB2525F0F63445D419B9 /* SearchView.swift */,
|
||||
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
||||
CDB1DA808EE8041C4546DAAB /* SourcesView.swift */,
|
||||
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
@@ -147,7 +326,10 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3952A081972B444674BEB51B /* Marks */,
|
||||
3F5B1508DEFC894F33CBD16C /* MarksTests */,
|
||||
85E717A515682EBF67DD199A /* MarksWidget */,
|
||||
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */,
|
||||
F5B3C85424137FA0CE2467B1 /* Frameworks */,
|
||||
C6978BB2E0A71E9A349D5EB6 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -156,26 +338,58 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
AB6C53AB14A38FCD4CC7628D /* Marks.app */,
|
||||
C8B10AC011AF8F242E525440 /* MarksTests.xctest */,
|
||||
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */,
|
||||
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F5B3C85424137FA0CE2467B1 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */,
|
||||
093402014727A338745A06DA /* SwiftUI.framework */,
|
||||
0552C13335034219DECF4F62 /* WidgetKit.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
61046BF39C48393DD96687A0 /* MarksWidget */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */;
|
||||
buildPhases = (
|
||||
2B3C202ACE4B7248E24601BF /* Sources */,
|
||||
A1E820C4B9604F8F05055D3E /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = MarksWidget;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = MarksWidget;
|
||||
productReference = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
76C272776D8EBCCD3FB6715A /* Marks */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */;
|
||||
buildPhases = (
|
||||
81AF93AC59791A6311B9055B /* Sources */,
|
||||
D56AC7DEA48961F856A6CEF6 /* Resources */,
|
||||
6B2547BA0E17C2564677B34A /* Frameworks */,
|
||||
93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */,
|
||||
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Marks;
|
||||
packageProductDependencies = (
|
||||
@@ -201,6 +415,24 @@
|
||||
productReference = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
F96181AD21F4A6755214E19B /* MarksTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CE9D24DDB32E8B7EFDDD9D59 /* Build configuration list for PBXNativeTarget "MarksTests" */;
|
||||
buildPhases = (
|
||||
1035354FBD99C8ED4ACA93DC /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
A665E75E77C12B6A3519ABF9 /* PBXTargetDependency */,
|
||||
);
|
||||
name = MarksTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = MarksTests;
|
||||
productReference = C8B10AC011AF8F242E525440 /* MarksTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -210,6 +442,10 @@
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1630;
|
||||
TargetAttributes = {
|
||||
61046BF39C48393DD96687A0 = {
|
||||
DevelopmentTeam = AE5DZKJHGN;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
76C272776D8EBCCD3FB6715A = {
|
||||
DevelopmentTeam = AE5DZKJHGN;
|
||||
ProvisioningStyle = Automatic;
|
||||
@@ -218,6 +454,10 @@
|
||||
DevelopmentTeam = AE5DZKJHGN;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
F96181AD21F4A6755214E19B = {
|
||||
DevelopmentTeam = AE5DZKJHGN;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 4BACCD33EEE63890F06D7057 /* Build configuration list for PBXProject "Marks" */;
|
||||
@@ -235,6 +475,8 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
76C272776D8EBCCD3FB6715A /* Marks */,
|
||||
F96181AD21F4A6755214E19B /* MarksTests */,
|
||||
61046BF39C48393DD96687A0 /* MarksWidget */,
|
||||
CF31B2655F8F30CF6DF3C26F /* ShareExtension */,
|
||||
);
|
||||
};
|
||||
@@ -252,11 +494,40 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
1035354FBD99C8ED4ACA93DC /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
2210C849B430CC6609106C62 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
|
||||
70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */,
|
||||
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
|
||||
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
|
||||
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
|
||||
8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */,
|
||||
F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */,
|
||||
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */,
|
||||
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */,
|
||||
D00878A046AF53CBC130388E /* TagSuggester.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
2B3C202ACE4B7248E24601BF /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */,
|
||||
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */,
|
||||
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */,
|
||||
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */,
|
||||
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -264,29 +535,65 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */,
|
||||
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
|
||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
|
||||
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
|
||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
|
||||
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */,
|
||||
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
|
||||
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
|
||||
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */,
|
||||
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
|
||||
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */,
|
||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
|
||||
FA1B2C3D4E5F6A7B8C9D0E1F /* BrowserView.swift in Sources */,
|
||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
||||
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
|
||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
||||
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
|
||||
83CC9BA3176B05358A307373 /* IngestPayload.swift in Sources */,
|
||||
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */,
|
||||
81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */,
|
||||
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
|
||||
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
|
||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||
212F713DCC289C48087B79AE /* Log.swift in Sources */,
|
||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
|
||||
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
|
||||
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
|
||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
||||
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
|
||||
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
|
||||
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */,
|
||||
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */,
|
||||
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */,
|
||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
|
||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
||||
031BC9E7B0107CD02F9BA846 /* SourceSpotlightIndexer.swift in Sources */,
|
||||
95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */,
|
||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */,
|
||||
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */,
|
||||
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
|
||||
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
|
||||
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 61046BF39C48393DD96687A0 /* MarksWidget */;
|
||||
targetProxy = 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */;
|
||||
};
|
||||
A665E75E77C12B6A3519ABF9 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 76C272776D8EBCCD3FB6715A /* Marks */;
|
||||
targetProxy = F63E42D8413C0F6ED8BB78CF /* PBXContainerItemProxy */;
|
||||
};
|
||||
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */;
|
||||
@@ -406,6 +713,40 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
68D4EEAD1A7AA570E88EF498 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksTests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Marks.app/Marks";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
6CDF13C33FDD1141CFE677AD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements;
|
||||
INFOPLIST_FILE = MarksWidget/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
7AD80830605CAD3492E03039 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -490,6 +831,40 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D8AB62E7173CC10C6EB15373 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements;
|
||||
INFOPLIST_FILE = MarksWidget/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget;
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
E92B937142E9625F95B51699 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksTests;
|
||||
SDKROOT = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Marks.app/Marks";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -520,6 +895,24 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
6CDF13C33FDD1141CFE677AD /* Debug */,
|
||||
D8AB62E7173CC10C6EB15373 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
CE9D24DDB32E8B7EFDDD9D59 /* Build configuration list for PBXNativeTarget "MarksTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
68D4EEAD1A7AA570E88EF498 /* Debug */,
|
||||
E92B937142E9625F95B51699 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */;
|
||||
|
||||
@@ -21,6 +21,20 @@
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "61046BF39C48393DD96687A0"
|
||||
BuildableName = "MarksWidget.appex"
|
||||
BlueprintName = "MarksWidget"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
@@ -53,6 +67,17 @@
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F96181AD21F4A6755214E19B"
|
||||
BuildableName = "MarksTests.xctest"
|
||||
BlueprintName = "MarksTests"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
|
||||
105
Marks.xcodeproj/xcshareddata/xcschemes/MarksWidget.xcscheme
Normal file
105
Marks.xcodeproj/xcshareddata/xcschemes/MarksWidget.xcscheme
Normal file
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1630"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||
BuildableName = "Marks.app"
|
||||
BlueprintName = "Marks"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "61046BF39C48393DD96687A0"
|
||||
BuildableName = "MarksWidget.appex"
|
||||
BlueprintName = "MarksWidget"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||
BuildableName = "Marks.app"
|
||||
BlueprintName = "Marks"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||
BuildableName = "Marks.app"
|
||||
BlueprintName = "Marks"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||
BuildableName = "Marks.app"
|
||||
BlueprintName = "Marks"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
105
Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
Normal file
105
Marks.xcodeproj/xcshareddata/xcschemes/ShareExtension.xcscheme
Normal file
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1630"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||
BuildableName = "Marks.app"
|
||||
BlueprintName = "Marks"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
|
||||
BuildableName = "ShareExtension.appex"
|
||||
BlueprintName = "ShareExtension"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||
BuildableName = "Marks.app"
|
||||
BlueprintName = "Marks"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||
BuildableName = "Marks.app"
|
||||
BlueprintName = "Marks"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||
BuildableName = "Marks.app"
|
||||
BlueprintName = "Marks"
|
||||
ReferencedContainer = "container:Marks.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -24,5 +24,18 @@
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>marks</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
122
Marks/Intents/BookmarkEntity.swift
Normal file
122
Marks/Intents/BookmarkEntity.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
import Foundation
|
||||
import AppIntents
|
||||
import CoreSpotlight
|
||||
|
||||
/// A Linkding bookmark exposed to Siri, Spotlight, Shortcuts, and the
|
||||
/// Apple-Intelligence "Use Model" action.
|
||||
///
|
||||
/// Conforms to `IndexedEntity` so its title / description / tags become
|
||||
/// Spotlight-searchable, which is what lets Siri answer "find my bookmark
|
||||
/// about Swift concurrency". The identifier is the Linkding server id (`Int`),
|
||||
/// which is stable across devices.
|
||||
struct BookmarkEntity: AppEntity, IndexedEntity {
|
||||
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Bookmark")
|
||||
|
||||
let id: Int
|
||||
|
||||
@Property(title: "Title")
|
||||
var title: String
|
||||
|
||||
@Property(title: "URL")
|
||||
var url: URL
|
||||
|
||||
@Property(title: "Website")
|
||||
var host: String
|
||||
|
||||
@Property(title: "Description")
|
||||
var details: String
|
||||
|
||||
@Property(title: "Tags")
|
||||
var tags: [String]
|
||||
|
||||
@Property(title: "Unread")
|
||||
var unread: Bool
|
||||
|
||||
/// AI-generated summary (when one has been produced). Exposed so the
|
||||
/// Shortcuts "Use Model" action and Siri can reason over it.
|
||||
@Property(title: "Summary")
|
||||
var summary: String?
|
||||
|
||||
static let defaultQuery = BookmarkQuery()
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(
|
||||
title: "\(title)",
|
||||
subtitle: "\(host)",
|
||||
image: .init(systemName: "bookmark.fill")
|
||||
)
|
||||
}
|
||||
|
||||
/// Rich Spotlight attributes so semantic search has real content to match.
|
||||
var attributeSet: CSSearchableItemAttributeSet {
|
||||
let attrs = CSSearchableItemAttributeSet(contentType: .url)
|
||||
attrs.title = title
|
||||
attrs.contentDescription = details.isEmpty ? summary : details
|
||||
attrs.keywords = tags
|
||||
attrs.url = url
|
||||
return attrs
|
||||
}
|
||||
}
|
||||
|
||||
extension BookmarkEntity {
|
||||
init(from b: Bookmark) {
|
||||
self.id = b.id
|
||||
self.title = b.displayTitle
|
||||
self.url = URL(string: b.url) ?? URL(string: "https://example.invalid")!
|
||||
self.host = b.domain
|
||||
self.details = b.contentExcerpt ?? ""
|
||||
self.tags = b.tagNames
|
||||
self.unread = b.unread
|
||||
self.summary = b.aiSummary
|
||||
}
|
||||
|
||||
/// Reconstruct a minimal `Bookmark` from the entity — enough for the
|
||||
/// AI enrichment call, which only reads url/title/tags.
|
||||
func makeBookmark() -> Bookmark {
|
||||
Bookmark(
|
||||
id: id,
|
||||
url: url.absoluteString,
|
||||
title: title,
|
||||
description: details,
|
||||
tagNames: tags,
|
||||
dateAdded: Date(),
|
||||
dateModified: Date(),
|
||||
isArchived: false,
|
||||
unread: unread,
|
||||
shared: false,
|
||||
websiteTitle: nil,
|
||||
websiteDescription: nil,
|
||||
faviconUrl: nil,
|
||||
previewImageUrl: nil,
|
||||
aiSummary: summary,
|
||||
aiTags: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks bookmarks up by id and resolves free-text queries by hitting the
|
||||
/// Linkding server live — the recommended path for data that lives on a
|
||||
/// server and changes too often to pre-index.
|
||||
struct BookmarkQuery: EntityStringQuery {
|
||||
func entities(for identifiers: [Int]) async throws -> [BookmarkEntity] {
|
||||
let api = try MarksIntent.api()
|
||||
// Linkding has no batch-by-id endpoint; pull a page and filter.
|
||||
let response = try await api.fetchBookmarks(limit: 200)
|
||||
let wanted = Set(identifiers)
|
||||
return response.results
|
||||
.filter { wanted.contains($0.id) }
|
||||
.map(BookmarkEntity.init(from:))
|
||||
}
|
||||
|
||||
func entities(matching string: String) async throws -> [BookmarkEntity] {
|
||||
let api = try MarksIntent.api()
|
||||
let response = try await api.fetchBookmarks(search: string, limit: 25)
|
||||
return response.results.map(BookmarkEntity.init(from:))
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [BookmarkEntity] {
|
||||
let api = try MarksIntent.api()
|
||||
let response = try await api.fetchBookmarks(limit: 10)
|
||||
return response.results.map(BookmarkEntity.init(from:))
|
||||
}
|
||||
}
|
||||
19
Marks/Intents/BookmarkOnscreen.swift
Normal file
19
Marks/Intents/BookmarkOnscreen.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
import AppIntents
|
||||
|
||||
extension View {
|
||||
/// Marks `bookmark` as the on-screen entity via an `NSUserActivity`, so
|
||||
/// Siri / Apple Intelligence can resolve references like "summarize this"
|
||||
/// or "open this one" while the bookmark is being viewed.
|
||||
///
|
||||
/// iOS 26.0 ships no SwiftUI `.appEntityIdentifier` modifier, so this uses
|
||||
/// the supported path: the standard `.userActivity` modifier plus
|
||||
/// `NSUserActivity.appEntityIdentifier` (from `AppEntityAnnotatable`).
|
||||
func bookmarkOnscreen(_ bookmark: Bookmark) -> some View {
|
||||
userActivity("com.magicive.marks.viewBookmark", element: bookmark.id) { id, activity in
|
||||
activity.title = bookmark.displayTitle
|
||||
activity.appEntityIdentifier = EntityIdentifier(for: BookmarkEntity.self, identifier: id)
|
||||
activity.isEligibleForSearch = true
|
||||
}
|
||||
}
|
||||
}
|
||||
53
Marks/Intents/IntentSnippetViews.swift
Normal file
53
Marks/Intents/IntentSnippetViews.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Compact card Siri shows after saving / opening a bookmark.
|
||||
struct BookmarkSnippetView: View {
|
||||
let entity: BookmarkEntity
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "bookmark.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(entity.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
Text(entity.host)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if !entity.tags.isEmpty {
|
||||
Text(entity.tags.map { "#\($0)" }.joined(separator: " "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
/// Card Siri shows for an AI summary result.
|
||||
struct SummarySnippetView: View {
|
||||
let title: String
|
||||
let host: String
|
||||
let summary: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles").foregroundStyle(.tint)
|
||||
Text(title).font(.headline).lineLimit(2)
|
||||
}
|
||||
Text(host)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(summary)
|
||||
.font(.body)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
66
Marks/Intents/IntentSupport.swift
Normal file
66
Marks/Intents/IntentSupport.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import AppIntents
|
||||
|
||||
/// Which top-level tab the app is showing. Used so an intent can switch tabs.
|
||||
enum AppTab: Hashable {
|
||||
case bookmarks, tags, sources, podcasts, search
|
||||
}
|
||||
|
||||
/// Bridges App Intents (which run in the main app process, since there is no
|
||||
/// separate AppIntents extension target) to the live SwiftUI scene.
|
||||
///
|
||||
/// Intents mutate this shared, observable singleton; `MainContainer` and
|
||||
/// `SearchView` observe it and react (present a browser, switch to the search
|
||||
/// tab, etc.). This is the standard "intent → in-app navigation" pattern when
|
||||
/// perform() runs in-process.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class IntentRouter {
|
||||
static let shared = IntentRouter()
|
||||
private init() {}
|
||||
|
||||
/// A bookmark URL an intent asked to open in the in-app browser.
|
||||
var openBookmarkURL: String?
|
||||
|
||||
/// A query an intent asked the app to search for.
|
||||
var searchRequest: String?
|
||||
|
||||
func openBookmark(url: String) { openBookmarkURL = url }
|
||||
func search(_ query: String) { searchRequest = query }
|
||||
}
|
||||
|
||||
/// Errors surfaced to Siri / Shortcuts when an intent can't run.
|
||||
enum MarksIntentError: Error, CustomLocalizedStringResourceConvertible {
|
||||
case notConfigured
|
||||
case invalidURL
|
||||
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
switch self {
|
||||
case .notConfigured: "Open Marks and connect to your Linkding server first."
|
||||
case .invalidURL: "That doesn't look like a valid link."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared plumbing for the intents: build an API client from saved config and
|
||||
/// keep the widgets in sync after mutations.
|
||||
enum MarksIntent {
|
||||
/// Builds a `LinkdingAPI` from the saved server config, or throws a
|
||||
/// user-facing error if the app has never been connected.
|
||||
static func api() throws -> LinkdingAPI {
|
||||
guard let config = ServerConfig.load() else { throw MarksIntentError.notConfigured }
|
||||
return LinkdingAPI(config: config)
|
||||
}
|
||||
|
||||
/// Prepend a freshly-saved bookmark into the widget cache so the Recent
|
||||
/// widget reflects it immediately (mirrors `BookmarksViewModel.load`).
|
||||
static func refreshWidgets(adding b: Bookmark) {
|
||||
var items = WidgetDataStore.loadBookmarks()
|
||||
items.removeAll { $0.url == b.url }
|
||||
items.insert(
|
||||
WidgetBookmark(url: b.url, title: b.displayTitle, domain: b.domain, faviconUrl: b.faviconUrl),
|
||||
at: 0
|
||||
)
|
||||
WidgetDataStore.saveBookmarks(Array(items.prefix(20)))
|
||||
}
|
||||
}
|
||||
184
Marks/Intents/MarksAppIntents.swift
Normal file
184
Marks/Intents/MarksAppIntents.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
import Foundation
|
||||
import AppIntents
|
||||
|
||||
// MARK: - Open
|
||||
|
||||
/// Opens a bookmark in the in-app browser. Conforms to `OpenIntent` — the
|
||||
/// system-blessed "open" type Siri understands. (iOS 26 has no separate
|
||||
/// `system.open` App Schema; `OpenIntent` with an `AppEntity` target *is* the
|
||||
/// supported mechanism, so no schema macro is needed here.)
|
||||
struct OpenBookmarkIntent: OpenIntent {
|
||||
static let title: LocalizedStringResource = "Open Bookmark"
|
||||
static let openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Bookmark")
|
||||
var target: BookmarkEntity
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
IntentRouter.shared.openBookmark(url: target.url.absoluteString)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
/// Searches Marks and shows the results in the app's search tab.
|
||||
///
|
||||
/// Conforms to the formal `system.search` App Schema (`ShowInAppSearchResultsIntent`),
|
||||
/// which is what makes it executable by conversational Siri / Apple Intelligence:
|
||||
/// Siri hands over the spoken term as `criteria` and the app re-runs it in its
|
||||
/// own search UI. `searchScopes` defaults to `[.general]` for string criteria.
|
||||
@AppIntent(schema: .system.search)
|
||||
struct SearchMarksIntent {
|
||||
static let title: LocalizedStringResource = "Search Marks"
|
||||
static let description = IntentDescription("Search your Marks bookmarks.")
|
||||
|
||||
@Parameter(title: "Search", requestValueDialog: "What do you want to search for?")
|
||||
var criteria: StringSearchCriteria
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult {
|
||||
IntentRouter.shared.search(criteria.term)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Add
|
||||
|
||||
/// Saves a link to Marks without opening the app. Mirrors the Share Extension's
|
||||
/// headless save, but goes through the typed `LinkdingAPI`.
|
||||
struct AddBookmarkIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Add Bookmark to Marks"
|
||||
static let description = IntentDescription("Save a link to your Marks bookmarks.")
|
||||
static let openAppWhenRun = false
|
||||
|
||||
@Parameter(title: "URL")
|
||||
var url: URL
|
||||
|
||||
@Parameter(title: "Title")
|
||||
var name: String?
|
||||
|
||||
@Parameter(title: "Tags")
|
||||
var tags: [String]?
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Add \(\.$url) to Marks") {
|
||||
\.$name
|
||||
\.$tags
|
||||
}
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<BookmarkEntity> & ProvidesDialog & ShowsSnippetView {
|
||||
let api = try MarksIntent.api()
|
||||
let create = BookmarkCreate(
|
||||
url: url.absoluteString,
|
||||
title: name ?? "",
|
||||
tagNames: tags ?? [],
|
||||
isArchived: false,
|
||||
unread: true,
|
||||
shared: false
|
||||
)
|
||||
let bookmark = try await api.createBookmark(create)
|
||||
let entity = BookmarkEntity(from: bookmark)
|
||||
MarksIntent.refreshWidgets(adding: bookmark)
|
||||
return .result(
|
||||
value: entity,
|
||||
dialog: IntentDialog("Saved \(entity.title) to Marks."),
|
||||
view: BookmarkSnippetView(entity: entity)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Show unread
|
||||
|
||||
/// Returns the user's unread bookmarks — "what's unread in Marks?"
|
||||
struct ShowUnreadIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Show Unread Bookmarks"
|
||||
static let description = IntentDescription("List the bookmarks you haven't read yet.")
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<[BookmarkEntity]> & ProvidesDialog {
|
||||
let api = try MarksIntent.api()
|
||||
let response = try await api.fetchBookmarks(limit: 10, unread: true)
|
||||
let entities = response.results.map(BookmarkEntity.init(from:))
|
||||
let dialog: IntentDialog = entities.isEmpty
|
||||
? "You have no unread bookmarks."
|
||||
: "You have \(entities.count) unread bookmark\(entities.count == 1 ? "" : "s")."
|
||||
return .result(value: entities, dialog: dialog)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Summarize (AI)
|
||||
|
||||
/// Produces an AI summary of a bookmark via the bundled ClaudeService.
|
||||
struct SummarizeBookmarkIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Summarize Bookmark"
|
||||
static let description = IntentDescription("Get an AI summary of a saved bookmark.")
|
||||
|
||||
@Parameter(title: "Bookmark")
|
||||
var target: BookmarkEntity
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Summarize \(\.$target)")
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog & ShowsSnippetView {
|
||||
let claude = ClaudeService()
|
||||
let (summary, _) = try await claude.enrich(bookmark: target.makeBookmark())
|
||||
return .result(
|
||||
value: summary,
|
||||
dialog: IntentDialog("\(summary)"),
|
||||
view: SummarySnippetView(title: target.title, host: target.host, summary: summary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: there is intentionally no "Ask" AppIntent. Apple Intelligence already
|
||||
// answers free-form questions over the user's bookmarks by querying the
|
||||
// Spotlight index of `BookmarkEntity` (an `IndexedEntity`) directly — verified
|
||||
// on device (Campo → CoreSpotlight → on-device model). A named intent would be
|
||||
// redundant, and "Ask <app>" collides with Siri's built-in "Ask". The in-app
|
||||
// `AskView` remains as a manual on-device-RAG surface.
|
||||
|
||||
// MARK: - App Shortcuts
|
||||
|
||||
struct MarksShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: AddBookmarkIntent(),
|
||||
phrases: [
|
||||
"Add a bookmark to \(.applicationName)",
|
||||
"Save this link to \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Add Bookmark",
|
||||
systemImageName: "bookmark"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: SearchMarksIntent(),
|
||||
phrases: [
|
||||
"Search \(.applicationName)",
|
||||
"Search my bookmarks in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Search",
|
||||
systemImageName: "magnifyingglass"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: ShowUnreadIntent(),
|
||||
phrases: [
|
||||
"Show unread in \(.applicationName)",
|
||||
"What's unread in \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Unread",
|
||||
systemImageName: "envelope.badge"
|
||||
)
|
||||
AppShortcut(
|
||||
intent: SummarizeBookmarkIntent(),
|
||||
phrases: [
|
||||
"Summarize a bookmark in \(.applicationName)",
|
||||
"Summarize this with \(.applicationName)"
|
||||
],
|
||||
shortTitle: "Summarize",
|
||||
systemImageName: "sparkles"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,158 @@
|
||||
import SwiftUI
|
||||
|
||||
private let defaultConfig = ServerConfig(
|
||||
host: "linkding-production-f7e0.up.railway.app",
|
||||
port: nil,
|
||||
path: "",
|
||||
token: "04c3388f543a6f4401ae41958b4a459a0125c4bf",
|
||||
useHttps: true
|
||||
)
|
||||
|
||||
@main
|
||||
struct MarksApp: App {
|
||||
@State private var serverConfig: ServerConfig? = ServerConfig.load()
|
||||
@State private var serverConfig: ServerConfig = ServerConfig.load() ?? defaultConfig
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
if let config = serverConfig {
|
||||
MainContainer(config: config) {
|
||||
config.delete()
|
||||
serverConfig = nil
|
||||
}
|
||||
} else {
|
||||
OnboardingView { newConfig in
|
||||
newConfig.save()
|
||||
serverConfig = newConfig
|
||||
}
|
||||
MainContainer(config: serverConfig) {
|
||||
defaultConfig.save()
|
||||
serverConfig = defaultConfig
|
||||
}
|
||||
.task { serverConfig.save() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IdentifiableURL: Identifiable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
}
|
||||
|
||||
// Holds the API + ViewModel so they survive re-renders
|
||||
struct MainContainer: View {
|
||||
let config: ServerConfig
|
||||
let onDisconnect: () -> Void
|
||||
|
||||
@State private var viewModel: BookmarksViewModel
|
||||
@State private var deepLinkBrowser: IdentifiableURL?
|
||||
@State private var showDeepLinkPlayer = false
|
||||
@State private var selectedTab: AppTab = .bookmarks
|
||||
@State private var router = IntentRouter.shared
|
||||
@State private var library = PodcastLibrary.shared
|
||||
@State private var sourceLibrary = IngestedSourceLibrary()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
||||
self.config = config
|
||||
self.onDisconnect = onDisconnect
|
||||
let api = LinkdingAPI(config: config)
|
||||
let claude = ClaudeService.load()
|
||||
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, claude: claude, cacheKey: config.host))
|
||||
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, cacheKey: config.host))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
Tab("Bookmarks", systemImage: "bookmark") {
|
||||
TabView(selection: $selectedTab) {
|
||||
Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) {
|
||||
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
|
||||
}
|
||||
Tab("Tags", systemImage: "tag") {
|
||||
Tab("Tags", systemImage: "tag", value: AppTab.tags) {
|
||||
TagsView(viewModel: viewModel)
|
||||
}
|
||||
Tab(role: .search) {
|
||||
Tab("Sources", systemImage: "tray.full", value: AppTab.sources) {
|
||||
SourcesView(
|
||||
library: sourceLibrary,
|
||||
podcastPlayer: viewModel.podcastPlayer,
|
||||
podcastGenerator: viewModel.podcastGenerator,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
}
|
||||
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
|
||||
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
|
||||
}
|
||||
.badge(library.unplayedCount)
|
||||
Tab(value: AppTab.search, role: .search) {
|
||||
SearchView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleDeepLink(url)
|
||||
}
|
||||
.task {
|
||||
applyPendingIntent()
|
||||
viewModel.processPendingPodcastRequests()
|
||||
viewModel.podcastPlayer.restoreSession(claude: viewModel.claude)
|
||||
}
|
||||
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
|
||||
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
|
||||
.onChange(of: scenePhase) { old, new in
|
||||
// Refresh when returning to the foreground (cold launch is already
|
||||
// covered by BookmarksView's .task, so only react to a real re-entry).
|
||||
if new == .active && old != .active {
|
||||
viewModel.processPendingPodcastRequests()
|
||||
Task { await viewModel.load() }
|
||||
}
|
||||
// Persist playback position + session when leaving the foreground so a
|
||||
// relaunch can resume where you left off.
|
||||
if new == .background {
|
||||
viewModel.podcastPlayer.saveProgress()
|
||||
}
|
||||
}
|
||||
.sheet(item: $deepLinkBrowser) { item in
|
||||
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)
|
||||
}
|
||||
.sheet(isPresented: $showDeepLinkPlayer) {
|
||||
PodcastPlayerView(
|
||||
vm: viewModel.podcastPlayer,
|
||||
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
|
||||
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
|
||||
claude: viewModel.claude,
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reacts to requests an App Intent placed on `IntentRouter` (opening a
|
||||
/// bookmark, or searching). Also runs once at launch so cold-starts via an
|
||||
/// intent are honored.
|
||||
private func applyPendingIntent() {
|
||||
if let urlString = router.openBookmarkURL {
|
||||
router.openBookmarkURL = nil
|
||||
if let url = URL(string: urlString) {
|
||||
deepLinkBrowser = IdentifiableURL(url: url)
|
||||
}
|
||||
}
|
||||
if let query = router.searchRequest {
|
||||
selectedTab = .search
|
||||
Task {
|
||||
viewModel.searchQuery = query
|
||||
await viewModel.search()
|
||||
}
|
||||
// SearchView consumes the text from the router on appear; leave it
|
||||
// set until then, then SearchView clears it.
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
guard url.scheme == "marks",
|
||||
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let articleUrl = comps.queryItems?.first(where: { $0.name == "url" })?.value
|
||||
else { return }
|
||||
|
||||
switch url.host {
|
||||
case "bookmark":
|
||||
if let browserURL = URL(string: articleUrl) {
|
||||
deepLinkBrowser = IdentifiableURL(url: browserURL)
|
||||
}
|
||||
case "podcast":
|
||||
let podcasts = WidgetDataStore.loadPodcasts()
|
||||
let found = podcasts.first { $0.articleUrl == articleUrl }
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: articleUrl,
|
||||
articleTitle: found?.title ?? "",
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showDeepLinkPlayer = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ struct Bookmark: Codable, Identifiable, Sendable, Hashable {
|
||||
var domain: String {
|
||||
URL(string: url)?.host ?? url
|
||||
}
|
||||
|
||||
/// A short content snippet for list previews: the page's scraped meta
|
||||
/// description, falling back to the user's own note. nil when neither exists.
|
||||
var contentExcerpt: String? {
|
||||
for candidate in [websiteDescription, description] {
|
||||
if let c = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarkResponse: Codable, Sendable {
|
||||
@@ -50,19 +61,56 @@ struct BookmarkResponse: Codable, Sendable {
|
||||
let results: [Bookmark]
|
||||
}
|
||||
|
||||
/// Response of linkding's `GET /api/bookmarks/check/?url=` — tells us whether the
|
||||
/// URL is already saved, plus scraped metadata and suggested tags for new saves.
|
||||
struct BookmarkCheck: Codable, Sendable {
|
||||
let bookmark: Bookmark?
|
||||
let metadata: Metadata?
|
||||
let autoTags: [String]?
|
||||
|
||||
struct Metadata: Codable, Sendable {
|
||||
let title: String?
|
||||
let description: String?
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case bookmark, metadata
|
||||
case autoTags = "auto_tags"
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarkCreate: Codable, Sendable {
|
||||
let url: String
|
||||
let title: String
|
||||
let description: String
|
||||
let tagNames: [String]
|
||||
let isArchived: Bool
|
||||
let unread: Bool
|
||||
let shared: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url, title, unread, shared
|
||||
case url, title, description, unread, shared
|
||||
case tagNames = "tag_names"
|
||||
case isArchived = "is_archived"
|
||||
}
|
||||
|
||||
init(
|
||||
url: String,
|
||||
title: String,
|
||||
description: String = "",
|
||||
tagNames: [String],
|
||||
isArchived: Bool,
|
||||
unread: Bool,
|
||||
shared: Bool
|
||||
) {
|
||||
self.url = url
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.tagNames = tagNames
|
||||
self.isArchived = isArchived
|
||||
self.unread = unread
|
||||
self.shared = shared
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarkUpdate: Codable, Sendable {
|
||||
@@ -79,6 +127,14 @@ struct BookmarkUpdate: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct LinkdingTag: Codable, Sendable {
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct TagResponse: Codable, Sendable {
|
||||
let results: [LinkdingTag]
|
||||
}
|
||||
|
||||
struct SmartCollection: Identifiable, Sendable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
|
||||
95
Marks/Models/IngestPayload.swift
Normal file
95
Marks/Models/IngestPayload.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
struct IngestPayload: Equatable, Sendable {
|
||||
let url: String
|
||||
let title: String?
|
||||
let notes: String
|
||||
}
|
||||
|
||||
enum IngestPayloadParser {
|
||||
static func parse(item: Any?, typeIdentifier: String? = nil) -> IngestPayload? {
|
||||
if let url = item as? URL {
|
||||
return payload(from: url.absoluteString, notes: "")
|
||||
}
|
||||
|
||||
if let attributed = item as? NSAttributedString {
|
||||
return payload(from: attributed.string, notes: attributed.string)
|
||||
}
|
||||
|
||||
if let string = item as? String {
|
||||
return payload(from: string, notes: shouldKeepNotes(for: typeIdentifier) ? string : "")
|
||||
}
|
||||
|
||||
if let data = item as? Data,
|
||||
let string = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .ascii) {
|
||||
return payload(from: string, notes: shouldKeepNotes(for: typeIdentifier) ? string : "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func payload(from text: String, notes: String) -> IngestPayload? {
|
||||
guard let url = firstWebURL(in: text) else { return nil }
|
||||
return IngestPayload(url: url, title: nil, notes: cleanedNotes(notes, excluding: url))
|
||||
}
|
||||
|
||||
static func firstWebURL(in text: String) -> String? {
|
||||
if let direct = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
isBookmarkable(direct) {
|
||||
return direct.absoluteString
|
||||
}
|
||||
|
||||
let decoded = decodeHTML(text)
|
||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||
let range = NSRange(decoded.startIndex..<decoded.endIndex, in: decoded)
|
||||
let match = detector?.matches(in: decoded, range: range).first { result in
|
||||
guard let url = result.url else { return false }
|
||||
return isBookmarkable(url)
|
||||
}
|
||||
|
||||
guard let matchRange = match?.range,
|
||||
let swiftRange = Range(matchRange, in: decoded) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let candidate = String(decoded[swiftRange])
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: " \n\t\r<>\"'.,;:)]】」"))
|
||||
|
||||
guard let url = URL(string: candidate), isBookmarkable(url) else { return nil }
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
private static func isBookmarkable(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https",
|
||||
url.host?.isEmpty == false else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private static func cleanedNotes(_ notes: String, excluding url: String) -> String {
|
||||
let trimmed = decodeHTML(notes)
|
||||
.replacingOccurrences(of: url, with: "")
|
||||
.components(separatedBy: .newlines)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
|
||||
return String(trimmed.prefix(1800))
|
||||
}
|
||||
|
||||
private static func decodeHTML(_ text: String) -> String {
|
||||
text
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
|
||||
private static func shouldKeepNotes(for typeIdentifier: String?) -> Bool {
|
||||
guard let typeIdentifier else { return true }
|
||||
return !typeIdentifier.lowercased().contains("url")
|
||||
}
|
||||
}
|
||||
69
Marks/Models/IngestedSource.swift
Normal file
69
Marks/Models/IngestedSource.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
enum IngestedSourceKind: String, Codable, Sendable, CaseIterable {
|
||||
case text
|
||||
case pdf
|
||||
case file
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .text: "Text"
|
||||
case .pdf: "PDF"
|
||||
case .file: "File"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IngestedSource: Identifiable, Codable, Sendable, Hashable {
|
||||
let id: UUID
|
||||
var kind: IngestedSourceKind
|
||||
var title: String
|
||||
var bodyText: String
|
||||
var originalFilename: String?
|
||||
var localFilename: String?
|
||||
var tags: [String]
|
||||
var createdAt: Date
|
||||
var modifiedAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
kind: IngestedSourceKind,
|
||||
title: String,
|
||||
bodyText: String,
|
||||
originalFilename: String? = nil,
|
||||
localFilename: String? = nil,
|
||||
tags: [String] = [],
|
||||
createdAt: Date = Date(),
|
||||
modifiedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
self.bodyText = bodyText
|
||||
self.originalFilename = originalFilename
|
||||
self.localFilename = localFilename
|
||||
self.tags = tags
|
||||
self.createdAt = createdAt
|
||||
self.modifiedAt = modifiedAt
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
return originalFilename ?? kind.label
|
||||
}
|
||||
|
||||
var excerpt: String {
|
||||
bodyText
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
}
|
||||
|
||||
var podcastURL: String {
|
||||
"marks-source://\(id.uuidString)"
|
||||
}
|
||||
|
||||
var podcastText: String {
|
||||
String(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).prefix(100_000))
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,13 @@ extension ServerConfig {
|
||||
return try? JSONDecoder().decode(ServerConfig.self, from: data)
|
||||
}
|
||||
|
||||
/// Loads config from the shared app group — used by extensions, which have no
|
||||
/// access to the main app's `UserDefaults.standard`.
|
||||
static func loadShared() -> ServerConfig? {
|
||||
guard let data = UserDefaults(suiteName: appGroupId)?.data(forKey: key) else { return nil }
|
||||
return try? JSONDecoder().decode(ServerConfig.self, from: data)
|
||||
}
|
||||
|
||||
func save() {
|
||||
guard let data = try? JSONEncoder().encode(self) else { return }
|
||||
UserDefaults.standard.set(data, forKey: ServerConfig.key)
|
||||
|
||||
27
Marks/Services/AISummaryStore.swift
Normal file
27
Marks/Services/AISummaryStore.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// URL-keyed cache of AI summaries so views that only know a URL — e.g. the
|
||||
/// deliberately-decoupled Podcasts tab — can show a bookmark's summary without
|
||||
/// holding the full `Bookmark`. Written by `BookmarksViewModel` as it enriches
|
||||
/// and loads bookmarks; read by the episode detail sheet.
|
||||
enum AISummaryStore {
|
||||
private static let key = "aiSummaryByURL"
|
||||
|
||||
static func summary(for url: String) -> String? {
|
||||
let store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:]
|
||||
let value = store[url]
|
||||
return (value?.isEmpty ?? true) ? nil : value
|
||||
}
|
||||
|
||||
static func set(_ summary: String?, for url: String) {
|
||||
var store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:]
|
||||
if let summary, !summary.isEmpty {
|
||||
guard store[url] != summary else { return } // no-op if unchanged
|
||||
store[url] = summary
|
||||
} else {
|
||||
guard store[url] != nil else { return }
|
||||
store.removeValue(forKey: url)
|
||||
}
|
||||
UserDefaults.standard.set(store, forKey: key)
|
||||
}
|
||||
}
|
||||
28
Marks/Services/AnalyticsService.swift
Normal file
28
Marks/Services/AnalyticsService.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
enum Analytics {
|
||||
static func track(_ event: String, _ properties: [String: Any] = [:]) {
|
||||
// Serialize before crossing the task boundary — Data is Sendable, [String: Any] is not
|
||||
var body: [String: Any] = ["event": event]
|
||||
if !properties.isEmpty { body["properties"] = properties }
|
||||
guard let payload = try? JSONSerialization.data(withJSONObject: body) else { return }
|
||||
Task.detached {
|
||||
do {
|
||||
let token = try await MarksAuth.validToken()
|
||||
guard let url = URL(string: MarksAuth.baseURL + "/v1/marks/events") else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = payload
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status < 200 || status > 299 {
|
||||
Log.analytics.error("\(event, privacy: .public) → HTTP \(status, privacy: .public)")
|
||||
}
|
||||
} catch {
|
||||
Log.analytics.error("\(event, privacy: .public) failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
Marks/Services/BookmarkAssistant.swift
Normal file
81
Marks/Services/BookmarkAssistant.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import FoundationModels
|
||||
|
||||
/// Generation half of on-device RAG. Wraps a `LanguageModelSession` configured
|
||||
/// with `BookmarkSearchTool`, so every question is answered by Apple's
|
||||
/// on-device model grounded in the user's own bookmarks — no network, no
|
||||
/// third-party LLM. Mirrors the `ClaudeService` role, but fully on-device.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BookmarkAssistant {
|
||||
enum Status: Equatable {
|
||||
case checking
|
||||
case ready
|
||||
case unavailable(String)
|
||||
}
|
||||
|
||||
private(set) var status: Status = .checking
|
||||
private var session: LanguageModelSession?
|
||||
|
||||
init() { configure() }
|
||||
|
||||
private func configure() {
|
||||
switch SystemLanguageModel.default.availability {
|
||||
case .available:
|
||||
session = LanguageModelSession(
|
||||
tools: [BookmarkSearchTool()],
|
||||
instructions: """
|
||||
You are an assistant inside a bookmarks app. The user's saved bookmarks \
|
||||
are your only source of truth. To answer any question, call the \
|
||||
searchBookmarks tool to look things up — never rely on outside knowledge \
|
||||
or invent links. Ground every answer in the returned bookmarks, refer to \
|
||||
them by title, and include their URLs when relevant. If nothing relevant \
|
||||
is found, say so plainly. Keep answers concise.
|
||||
"""
|
||||
)
|
||||
status = .ready
|
||||
Log.assistant.info("On-device model available; session ready")
|
||||
case .unavailable(let reason):
|
||||
status = .unavailable(Self.message(for: reason))
|
||||
Log.assistant.notice("On-device model unavailable: \(String(describing: reason), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask a question; the model retrieves from Spotlight via the tool as needed.
|
||||
func ask(_ question: String) async throws -> String {
|
||||
guard let session else {
|
||||
Log.assistant.error("ask() with no session (model unavailable)")
|
||||
throw AssistantError.unavailable
|
||||
}
|
||||
Log.assistant.info("Ask: \(question, privacy: .public)")
|
||||
let start = Date()
|
||||
do {
|
||||
let answer = try await session.respond(to: question).content
|
||||
let ms = Int(Date().timeIntervalSince(start) * 1000)
|
||||
Log.assistant.info("Answered in \(ms, privacy: .public)ms, \(answer.count, privacy: .public) chars")
|
||||
return answer
|
||||
} catch {
|
||||
Log.assistant.error("Ask failed: \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func message(for reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
|
||||
switch reason {
|
||||
case .deviceNotEligible:
|
||||
return "This device doesn't support Apple Intelligence."
|
||||
case .appleIntelligenceNotEnabled:
|
||||
return "Turn on Apple Intelligence in Settings to ask your bookmarks."
|
||||
case .modelNotReady:
|
||||
return "The on-device model is still downloading. Try again shortly."
|
||||
@unknown default:
|
||||
return "On-device intelligence isn't available right now."
|
||||
}
|
||||
}
|
||||
|
||||
enum AssistantError: LocalizedError {
|
||||
case unavailable
|
||||
var errorDescription: String? { "On-device intelligence is unavailable." }
|
||||
}
|
||||
}
|
||||
37
Marks/Services/BookmarkSearchTool.swift
Normal file
37
Marks/Services/BookmarkSearchTool.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
|
||||
/// The tool the on-device model calls to look things up in the user's library.
|
||||
/// It is the bridge between `LanguageModelSession` (generation) and the
|
||||
/// Spotlight index (retrieval) — together they form on-device RAG over the
|
||||
/// private bookmark corpus.
|
||||
struct BookmarkSearchTool: Tool {
|
||||
let name = "searchBookmarks"
|
||||
let description = """
|
||||
Search the user's saved bookmarks. Returns the most relevant bookmarks with \
|
||||
their title, website, description, and URL. Call this for any question about \
|
||||
what the user has saved, read, or wants to find again.
|
||||
"""
|
||||
|
||||
@Generable
|
||||
struct Arguments {
|
||||
@Guide(description: "Keywords or a short phrase describing what to look for in the saved bookmarks")
|
||||
var query: String
|
||||
}
|
||||
|
||||
func call(arguments: Arguments) async throws -> String {
|
||||
Log.tool.info("searchBookmarks invoked query=\(arguments.query, privacy: .public)")
|
||||
let hits = await SpotlightBookmarkSearch.run(query: arguments.query, limit: 8)
|
||||
Log.tool.info("searchBookmarks returned \(hits.count, privacy: .public) bookmarks")
|
||||
guard !hits.isEmpty else {
|
||||
return "No bookmarks matched \"\(arguments.query)\"."
|
||||
}
|
||||
return hits.enumerated().map { idx, b in
|
||||
var line = "\(idx + 1). \(b.title)"
|
||||
if !b.host.isEmpty { line += " — \(b.host)" }
|
||||
if !b.description.isEmpty { line += "\n \(b.description)" }
|
||||
if !b.url.isEmpty { line += "\n \(b.url)" }
|
||||
return line
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -1,147 +1,130 @@
|
||||
import Foundation
|
||||
|
||||
struct PodcastStatus: Decodable {
|
||||
let status: String
|
||||
let progress: Int
|
||||
let title: String?
|
||||
let error: String?
|
||||
|
||||
var isDone: Bool { status == "done" }
|
||||
var isFailed: Bool { status == "error" }
|
||||
}
|
||||
|
||||
struct ClaudeService: Sendable {
|
||||
let apiKey: String
|
||||
static let defaultsKey = "openRouterApiKey"
|
||||
// Cheap, fast model — change to any OpenRouter model slug
|
||||
private static let model = "google/gemini-2.0-flash-lite-001"
|
||||
|
||||
static func load() -> ClaudeService? {
|
||||
guard let key = UserDefaults.standard.string(forKey: defaultsKey), !key.isEmpty else { return nil }
|
||||
return ClaudeService(apiKey: key)
|
||||
}
|
||||
|
||||
static func save(apiKey: String) {
|
||||
UserDefaults.standard.set(apiKey, forKey: defaultsKey)
|
||||
}
|
||||
// MARK: - Bookmark AI
|
||||
|
||||
func enrich(bookmark: Bookmark) async throws -> (summary: String, tags: [String]) {
|
||||
let prompt = """
|
||||
Analyze this bookmark:
|
||||
URL: \(bookmark.url)
|
||||
Title: \(bookmark.displayTitle)
|
||||
Existing tags: \(bookmark.tagNames.joined(separator: ", "))
|
||||
|
||||
Respond with ONLY valid JSON, no markdown:
|
||||
{"summary": "One or two sentence summary of what this page is about.", "tags": ["tag1", "tag2", "tag3"]}
|
||||
|
||||
Rules: summary under 120 chars, 3-5 tags, lowercase, no # symbol, no existing tags duplicated.
|
||||
"""
|
||||
let text = try await callModel(prompt: prompt, maxTokens: 256)
|
||||
return parseEnrichResponse(text)
|
||||
struct Response: Decodable { let summary: String; let tags: [String] }
|
||||
let body: [String: Any] = [
|
||||
"url": bookmark.url,
|
||||
"title": bookmark.displayTitle,
|
||||
"tags": bookmark.tagNames,
|
||||
]
|
||||
let data = try await post("/v1/marks/enrich", body: body)
|
||||
let decoded = try JSONDecoder().decode(Response.self, from: data)
|
||||
return (decoded.summary, decoded.tags)
|
||||
}
|
||||
|
||||
func semanticSearch(query: String, in bookmarks: [Bookmark]) async throws -> [Bookmark] {
|
||||
guard !bookmarks.isEmpty else { return [] }
|
||||
let list = bookmarks.enumerated().map { i, b in
|
||||
"[\(i)] \(b.displayTitle) — \(b.domain)\(b.aiSummary.map { " — \($0)" } ?? "")"
|
||||
}.joined(separator: "\n")
|
||||
let prompt = """
|
||||
Search query: "\(query)"
|
||||
|
||||
Find relevant bookmarks from the list. Return ONLY a JSON array of indices in relevance order. Example: [3, 0, 7]
|
||||
Return [] if nothing matches.
|
||||
|
||||
Bookmarks:
|
||||
\(list)
|
||||
"""
|
||||
let text = try await callModel(prompt: prompt, maxTokens: 256)
|
||||
let indices = parseIntArray(text)
|
||||
return indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil }
|
||||
struct Response: Decodable { let indices: [Int] }
|
||||
let list: [[String: Any]] = bookmarks.map { b in
|
||||
var d: [String: Any] = ["id": b.id, "title": b.displayTitle, "domain": b.domain]
|
||||
if let s = b.aiSummary { d["summary"] = s }
|
||||
return d
|
||||
}
|
||||
let data = try await post("/v1/marks/search", body: ["query": query, "bookmarks": list])
|
||||
let decoded = try JSONDecoder().decode(Response.self, from: data)
|
||||
return decoded.indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil }
|
||||
}
|
||||
|
||||
func generateCollections(from bookmarks: [Bookmark]) async throws -> [SmartCollection] {
|
||||
guard !bookmarks.isEmpty else { return [] }
|
||||
let list = bookmarks.prefix(150).enumerated().map { i, b in
|
||||
"[\(i)] \(b.displayTitle) [\(b.tagNames.joined(separator: ","))]"
|
||||
}.joined(separator: "\n")
|
||||
let prompt = """
|
||||
Group these bookmarks into 3-6 meaningful themed collections.
|
||||
Respond with ONLY valid JSON, no markdown:
|
||||
[{"name": "Collection Name", "description": "Brief description", "indices": [0, 1, 2]}]
|
||||
|
||||
Bookmarks:
|
||||
\(list)
|
||||
"""
|
||||
let text = try await callModel(prompt: prompt, maxTokens: 1024)
|
||||
return parseCollections(text, bookmarks: Array(bookmarks.prefix(150)))
|
||||
struct Item: Decodable { let name: String; let description: String; let bookmarkIds: [Int] }
|
||||
struct Response: Decodable { let collections: [Item] }
|
||||
let capped = Array(bookmarks.prefix(150))
|
||||
let list: [[String: Any]] = capped.map { b in
|
||||
["id": b.id, "title": b.displayTitle, "tags": b.tagNames]
|
||||
}
|
||||
let data = try await post("/v1/marks/collections", body: ["bookmarks": list])
|
||||
let decoded = try JSONDecoder().decode(Response.self, from: data)
|
||||
return decoded.collections.map {
|
||||
SmartCollection(name: $0.name, description: $0.description, bookmarkIds: $0.bookmarkIds)
|
||||
}
|
||||
}
|
||||
|
||||
private func callModel(prompt: String, maxTokens: Int) async throws -> String {
|
||||
let url = URL(string: "https://openrouter.ai/api/v1/chat/completions")!
|
||||
// MARK: - Podcast
|
||||
|
||||
func generatePodcast(url: String, title: String? = nil, sourceText: String? = nil, sourceKind: String? = nil) async throws -> String {
|
||||
struct Response: Decodable { let job_id: String }
|
||||
var body: [String: Any] = ["url": url]
|
||||
if let title, !title.isEmpty { body["title"] = title }
|
||||
if let sourceText, !sourceText.isEmpty { body["text"] = sourceText }
|
||||
if let sourceKind, !sourceKind.isEmpty { body["source_kind"] = sourceKind }
|
||||
let data = try await post("/v1/podcast/generate", body: body)
|
||||
return try JSONDecoder().decode(Response.self, from: data).job_id
|
||||
}
|
||||
|
||||
func podcastStatus(jobId: String) async throws -> PodcastStatus {
|
||||
let data = try await get("/v1/podcast/status/\(jobId)")
|
||||
return try JSONDecoder().decode(PodcastStatus.self, from: data)
|
||||
}
|
||||
|
||||
func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil, parentBookmarkUrl: String? = nil) async throws -> URL {
|
||||
let dest = ClaudeService.cachedPodcastURL(for: articleUrl)
|
||||
try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
let data = try await get("/v1/podcast/audio/\(jobId)")
|
||||
try data.write(to: dest, options: .atomic)
|
||||
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
return dest
|
||||
}
|
||||
|
||||
static func cachedPodcastURL(for articleUrl: String) -> URL {
|
||||
// djb2 hash — stable, no CryptoKit needed
|
||||
let hash = articleUrl.utf8.reduce(UInt64(5381)) { ($0 &* 31) &+ UInt64($1) }
|
||||
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("podcasts")
|
||||
return dir.appendingPathComponent("\(hash).mp3")
|
||||
}
|
||||
|
||||
// MARK: - HTTP primitives
|
||||
|
||||
private func post(_ path: String, body: [String: Any]) async throws -> Data {
|
||||
let token = try await MarksAuth.validToken()
|
||||
guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("https://marks.app", forHTTPHeaderField: "HTTP-Referer")
|
||||
request.setValue("Marks", forHTTPHeaderField: "X-Title")
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": Self.model,
|
||||
"max_tokens": maxTokens,
|
||||
"messages": [["role": "user", "content": prompt]]
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
print("[AI] callModel: POST openrouter model=\(Self.model) maxTokens=\(maxTokens)")
|
||||
Log.network.debug("POST \(path, privacy: .public)")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
print("[AI] callModel: status=\(status) bytes=\(data.count)")
|
||||
guard status == 200 else {
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
print("[AI] callModel: error body=\(body.prefix(200))")
|
||||
Log.network.info("POST \(path, privacy: .public) → \(status, privacy: .public)")
|
||||
guard (200...299).contains(status) else {
|
||||
let bodyText = String(data: data, encoding: .utf8)?.prefix(400) ?? ""
|
||||
Log.network.error("POST \(path, privacy: .public) → \(status, privacy: .public) body=\(bodyText, privacy: .public)")
|
||||
throw APIError.badStatus(status)
|
||||
}
|
||||
|
||||
struct Choice: Codable {
|
||||
struct Message: Codable { let content: String }
|
||||
let message: Message
|
||||
}
|
||||
struct Response: Codable { let choices: [Choice] }
|
||||
let content = (try JSONDecoder().decode(Response.self, from: data)).choices.first?.message.content ?? ""
|
||||
print("[AI] callModel: response=\(content.prefix(100))")
|
||||
return content
|
||||
return data
|
||||
}
|
||||
|
||||
private func parseEnrichResponse(_ text: String) -> (summary: String, tags: [String]) {
|
||||
let cleaned = extractJSON(from: text)
|
||||
guard let data = cleaned.data(using: .utf8),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let summary = json["summary"] as? String,
|
||||
let tags = json["tags"] as? [String] else { return (text, []) }
|
||||
return (summary, tags)
|
||||
private func get(_ path: String) async throws -> Data {
|
||||
let token = try await MarksAuth.validToken()
|
||||
guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl }
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
Log.network.debug("GET \(path, privacy: .public)")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
Log.network.info("GET \(path, privacy: .public) → \(status, privacy: .public)")
|
||||
guard (200...299).contains(status) else {
|
||||
let bodyText = String(data: data, encoding: .utf8)?.prefix(400) ?? ""
|
||||
Log.network.error("GET \(path, privacy: .public) → \(status, privacy: .public) body=\(bodyText, privacy: .public)")
|
||||
throw APIError.badStatus(status)
|
||||
}
|
||||
|
||||
private func parseIntArray(_ text: String) -> [Int] {
|
||||
let cleaned = extractJSON(from: text)
|
||||
guard let data = cleaned.data(using: .utf8),
|
||||
let arr = try? JSONSerialization.jsonObject(with: data) as? [Int] else { return [] }
|
||||
return arr
|
||||
}
|
||||
|
||||
private func parseCollections(_ text: String, bookmarks: [Bookmark]) -> [SmartCollection] {
|
||||
let cleaned = extractJSON(from: text)
|
||||
guard let data = cleaned.data(using: .utf8),
|
||||
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
|
||||
return arr.compactMap { dict in
|
||||
guard let name = dict["name"] as? String,
|
||||
let indices = dict["indices"] as? [Int] else { return nil }
|
||||
let desc = dict["description"] as? String ?? ""
|
||||
let ids = indices.compactMap { i -> Int? in i < bookmarks.count ? bookmarks[i].id : nil }
|
||||
return SmartCollection(name: name, description: desc, bookmarkIds: ids)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractJSON(from text: String) -> String {
|
||||
if let start = text.range(of: "```json\n"), let end = text.range(of: "\n```") {
|
||||
return String(text[start.upperBound..<end.lowerBound])
|
||||
}
|
||||
if let start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}") {
|
||||
return String(text[start...end])
|
||||
}
|
||||
if let start = text.firstIndex(of: "["), let end = text.lastIndex(of: "]") {
|
||||
return String(text[start...end])
|
||||
}
|
||||
return text
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
212
Marks/Services/IngestedSourceStore.swift
Normal file
212
Marks/Services/IngestedSourceStore.swift
Normal file
@@ -0,0 +1,212 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import PDFKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class IngestedSourceLibrary {
|
||||
private(set) var sources: [IngestedSource] = []
|
||||
var error: String?
|
||||
|
||||
private let store: IngestedSourceStore
|
||||
|
||||
init(store: IngestedSourceStore = IngestedSourceStore()) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func load() {
|
||||
do {
|
||||
sources = try store.load()
|
||||
Task { await SourceSpotlightIndexer.index(sources) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func addText(title: String, body: String, tags: [String]) {
|
||||
let source = IngestedSource(
|
||||
kind: .text,
|
||||
title: title,
|
||||
bodyText: body.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
tags: tags
|
||||
)
|
||||
upsert(source)
|
||||
}
|
||||
|
||||
func importFile(from url: URL, tags: [String]) {
|
||||
do {
|
||||
let source = try store.importFile(from: url, tags: tags)
|
||||
upsert(source)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func update(_ source: IngestedSource, title: String, bodyText: String, tags: [String]) {
|
||||
var updated = source
|
||||
updated.title = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
updated.bodyText = bodyText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
updated.tags = tags
|
||||
updated.modifiedAt = Date()
|
||||
upsert(updated)
|
||||
}
|
||||
|
||||
func delete(_ source: IngestedSource) {
|
||||
do {
|
||||
try store.delete(source)
|
||||
sources.removeAll { $0.id == source.id }
|
||||
Task { await SourceSpotlightIndexer.remove(ids: [source.id]) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func fileURL(for source: IngestedSource) -> URL? {
|
||||
store.fileURL(for: source)
|
||||
}
|
||||
|
||||
func search(_ query: String) -> [IngestedSource] {
|
||||
let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !q.isEmpty else { return sources }
|
||||
return sources.filter { source in
|
||||
source.displayTitle.lowercased().contains(q) ||
|
||||
source.bodyText.lowercased().contains(q) ||
|
||||
source.tags.contains { $0.lowercased().contains(q) } ||
|
||||
(source.originalFilename ?? "").lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
private func upsert(_ source: IngestedSource) {
|
||||
sources.removeAll { $0.id == source.id }
|
||||
sources.insert(source, at: 0)
|
||||
do {
|
||||
try store.save(sources)
|
||||
Task { await SourceSpotlightIndexer.index([source]) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct IngestedSourceStore {
|
||||
private let rootURL: URL
|
||||
private let metadataURL: URL
|
||||
private let filesURL: URL
|
||||
|
||||
init(rootURL: URL? = nil) {
|
||||
let base = rootURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
self.rootURL = base.appendingPathComponent("Sources", isDirectory: true)
|
||||
self.metadataURL = self.rootURL.appendingPathComponent("sources.json")
|
||||
self.filesURL = self.rootURL.appendingPathComponent("files", isDirectory: true)
|
||||
}
|
||||
|
||||
func load() throws -> [IngestedSource] {
|
||||
guard FileManager.default.fileExists(atPath: metadataURL.path) else { return [] }
|
||||
let data = try Data(contentsOf: metadataURL)
|
||||
return try JSONDecoder.sourceDecoder.decode([IngestedSource].self, from: data)
|
||||
.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
func save(_ sources: [IngestedSource]) throws {
|
||||
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder.sourceEncoder.encode(sources)
|
||||
try data.write(to: metadataURL, options: [.atomic])
|
||||
}
|
||||
|
||||
func importFile(from url: URL, tags: [String]) throws -> IngestedSource {
|
||||
try FileManager.default.createDirectory(at: filesURL, withIntermediateDirectories: true)
|
||||
|
||||
let accessed = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessed { url.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let originalFilename = url.lastPathComponent
|
||||
let ext = url.pathExtension.isEmpty ? "dat" : url.pathExtension
|
||||
let localFilename = "\(id.uuidString).\(ext)"
|
||||
let destination = filesURL.appendingPathComponent(localFilename)
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
try FileManager.default.copyItem(at: url, to: destination)
|
||||
|
||||
let kind = url.pathExtension.lowercased() == "pdf" ? IngestedSourceKind.pdf : .file
|
||||
let bodyText = kind == .pdf
|
||||
? PDFTextExtractor.extractText(from: destination)
|
||||
: PlainTextExtractor.extractText(from: destination)
|
||||
|
||||
return IngestedSource(
|
||||
id: id,
|
||||
kind: kind,
|
||||
title: originalFilename.removingFileExtension,
|
||||
bodyText: bodyText,
|
||||
originalFilename: originalFilename,
|
||||
localFilename: localFilename,
|
||||
tags: tags
|
||||
)
|
||||
}
|
||||
|
||||
func delete(_ source: IngestedSource) throws {
|
||||
if let url = fileURL(for: source), FileManager.default.fileExists(atPath: url.path) {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
}
|
||||
var loaded = try load()
|
||||
loaded.removeAll { $0.id == source.id }
|
||||
try save(loaded)
|
||||
}
|
||||
|
||||
func fileURL(for source: IngestedSource) -> URL? {
|
||||
guard let localFilename = source.localFilename else { return nil }
|
||||
return filesURL.appendingPathComponent(localFilename)
|
||||
}
|
||||
}
|
||||
|
||||
private enum PDFTextExtractor {
|
||||
static func extractText(from url: URL) -> String {
|
||||
guard let document = PDFDocument(url: url) else { return "" }
|
||||
var pages: [String] = []
|
||||
for index in 0..<document.pageCount {
|
||||
if let text = document.page(at: index)?.string?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty {
|
||||
pages.append(text)
|
||||
}
|
||||
}
|
||||
return pages.joined(separator: "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
private enum PlainTextExtractor {
|
||||
static func extractText(from url: URL) -> String {
|
||||
guard let data = try? Data(contentsOf: url) else { return "" }
|
||||
return String(data: data, encoding: .utf8)
|
||||
?? String(data: data, encoding: .ascii)
|
||||
?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var removingFileExtension: String {
|
||||
let ns = self as NSString
|
||||
let name = ns.deletingPathExtension
|
||||
return name.isEmpty ? self : name
|
||||
}
|
||||
}
|
||||
|
||||
private extension JSONEncoder {
|
||||
static var sourceEncoder: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
|
||||
private extension JSONDecoder {
|
||||
static var sourceDecoder: JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return decoder
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,28 @@ actor LinkdingAPI {
|
||||
return try decoder.decode(BookmarkResponse.self, from: data)
|
||||
}
|
||||
|
||||
/// Checks whether `url` is already bookmarked. Returns the existing bookmark
|
||||
/// (or nil), scraped metadata, and suggested tags for a fresh save.
|
||||
func checkBookmark(url urlString: String) async throws -> BookmarkCheck {
|
||||
let url = try makeUrl(path: "/api/bookmarks/check/", queryItems: [.init(name: "url", value: urlString)])
|
||||
let (data, response) = try await session.data(for: authorizedRequest(url: url))
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
return try decoder.decode(BookmarkCheck.self, from: data)
|
||||
}
|
||||
|
||||
/// All tag names the user already uses — the existing vocabulary we bias
|
||||
/// AI tag suggestions toward.
|
||||
func fetchTags(limit: Int = 1000) async throws -> [String] {
|
||||
let url = try makeUrl(path: "/api/tags/", queryItems: [.init(name: "limit", value: "\(limit)")])
|
||||
let (data, response) = try await session.data(for: authorizedRequest(url: url))
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
return try decoder.decode(TagResponse.self, from: data).results.map(\.name)
|
||||
}
|
||||
|
||||
func createBookmark(_ create: BookmarkCreate) async throws -> Bookmark {
|
||||
let body = try JSONEncoder().encode(create)
|
||||
let url = try makeUrl(path: "/api/bookmarks/")
|
||||
|
||||
43
Marks/Services/Log.swift
Normal file
43
Marks/Services/Log.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Organized, category-based logging for Marks, backed by `os.Logger`.
|
||||
///
|
||||
/// Unlike `print()` — which only reaches stdout and is invisible to
|
||||
/// `log collect` / Console.app — these entries are captured under the
|
||||
/// `com.magicive.marks` subsystem and can be filtered by category off-device.
|
||||
///
|
||||
/// Pull logs from a connected device:
|
||||
/// ```
|
||||
/// log collect --device-udid <UDID> --last 5m --output marks.logarchive
|
||||
/// log show marks.logarchive --info --debug \
|
||||
/// --predicate 'subsystem == "com.magicive.marks"'
|
||||
/// # one category:
|
||||
/// log show marks.logarchive --predicate \
|
||||
/// 'subsystem == "com.magicive.marks" AND category == "assistant"'
|
||||
/// ```
|
||||
///
|
||||
/// Privacy: `os.Logger` redacts interpolations to `<private>` by default. The
|
||||
/// on-device RAG path (`assistant`, `tool`, `spotlight`) logs queries/counts as
|
||||
/// `.public` so the feature is debuggable; bookmark *content* in the cloud
|
||||
/// enrichment path stays default-private.
|
||||
enum Log {
|
||||
private static let subsystem = "com.magicive.marks"
|
||||
|
||||
/// On-device RAG: model availability, questions asked, answers produced.
|
||||
static let assistant = Logger(subsystem: subsystem, category: "assistant")
|
||||
/// The `searchBookmarks` tool the on-device model calls.
|
||||
static let tool = Logger(subsystem: subsystem, category: "tool")
|
||||
/// Spotlight retrieval (`CSSearchQuery`) and indexing (`indexAppEntities`).
|
||||
static let spotlight = Logger(subsystem: subsystem, category: "spotlight")
|
||||
/// Cloud AI enrichment (summaries, tags) via the backend.
|
||||
static let ai = Logger(subsystem: subsystem, category: "ai")
|
||||
/// Bookmark sync / disk cache.
|
||||
static let sync = Logger(subsystem: subsystem, category: "sync")
|
||||
/// Backend HTTP primitives (requests, status codes, error bodies).
|
||||
static let network = Logger(subsystem: subsystem, category: "network")
|
||||
/// Anonymous device auth / token registration.
|
||||
static let auth = Logger(subsystem: subsystem, category: "auth")
|
||||
/// Product analytics event delivery.
|
||||
static let analytics = Logger(subsystem: subsystem, category: "analytics")
|
||||
}
|
||||
52
Marks/Services/MarksAuth.swift
Normal file
52
Marks/Services/MarksAuth.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
private let backendBaseURL = "https://chatai-realtime-proxy-production.up.railway.app"
|
||||
private let deviceIdKey = "marksDeviceId"
|
||||
private let tokenKey = "marksJWT"
|
||||
private let tokenExpiryKey = "marksJWTExpiry"
|
||||
|
||||
enum MarksAuth {
|
||||
static var baseURL: String { backendBaseURL }
|
||||
|
||||
/// Returns a valid JWT, registering if missing or within 1 hour of expiry.
|
||||
static func validToken() async throws -> String {
|
||||
if let token = cached(), !token.isEmpty { return token }
|
||||
return try await register()
|
||||
}
|
||||
|
||||
private static func deviceId() -> String {
|
||||
if let existing = UserDefaults.standard.string(forKey: deviceIdKey) { return existing }
|
||||
let new = UUID().uuidString
|
||||
UserDefaults.standard.set(new, forKey: deviceIdKey)
|
||||
return new
|
||||
}
|
||||
|
||||
private static func cached() -> String? {
|
||||
guard
|
||||
let token = UserDefaults.standard.string(forKey: tokenKey),
|
||||
let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date,
|
||||
expiry > Date().addingTimeInterval(3600)
|
||||
else { return nil }
|
||||
return token
|
||||
}
|
||||
|
||||
private static func register() async throws -> String {
|
||||
guard let url = URL(string: "\(backendBaseURL)/api/auth/register") else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
let id = deviceId()
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: ["device_id": id])
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
guard (200...299).contains(status) else { throw APIError.badStatus(status) }
|
||||
struct Resp: Decodable { let access_token: String; let expires_in: Int }
|
||||
let resp = try JSONDecoder().decode(Resp.self, from: data)
|
||||
UserDefaults.standard.set(resp.access_token, forKey: tokenKey)
|
||||
UserDefaults.standard.set(Date().addingTimeInterval(TimeInterval(resp.expires_in)), forKey: tokenExpiryKey)
|
||||
Log.auth.info("Registered device \(id.prefix(8), privacy: .public)…")
|
||||
return resp.access_token
|
||||
}
|
||||
}
|
||||
127
Marks/Services/PodcastGenerationManager.swift
Normal file
127
Marks/Services/PodcastGenerationManager.swift
Normal file
@@ -0,0 +1,127 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Runs podcast generation (generate → poll → download) **off** the player, so a
|
||||
/// new episode can be produced in the background while another one keeps playing.
|
||||
/// The finished audio is written to the on-disk cache and recorded in
|
||||
/// `PodcastIndex`; the optional `onReady` callback lets a caller start playback
|
||||
/// only when nothing else is playing.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class PodcastGenerationManager {
|
||||
struct Job: Identifiable {
|
||||
let id: String // articleUrl
|
||||
var articleUrl: String
|
||||
var title: String
|
||||
var progress: Double // 0…1
|
||||
var label: String
|
||||
var failed: Bool = false
|
||||
var error: String?
|
||||
}
|
||||
|
||||
private(set) var jobs: [Job] = []
|
||||
|
||||
private var tasks: [String: Task<Void, Never>] = [:]
|
||||
private let claude = ClaudeService()
|
||||
|
||||
/// Jobs still working (not failed). Drives the "Generating…" banner.
|
||||
var activeJobs: [Job] { jobs.filter { !$0.failed } }
|
||||
var hasActive: Bool { !activeJobs.isEmpty }
|
||||
|
||||
func isGenerating(_ articleUrl: String) -> Bool { tasks[articleUrl] != nil }
|
||||
func job(for articleUrl: String) -> Job? { jobs.first { $0.articleUrl == articleUrl } }
|
||||
|
||||
/// Enqueue a background generation for `articleUrl`. Idempotent per URL.
|
||||
/// If audio is already cached, `onReady` fires immediately and nothing runs.
|
||||
/// `onReady` is invoked on the main actor once audio is available.
|
||||
func enqueue(articleUrl: String,
|
||||
title: String,
|
||||
parentBookmarkUrl: String? = nil,
|
||||
sourceText: String? = nil,
|
||||
sourceKind: String? = nil,
|
||||
onReady: ((URL, String) -> Void)? = nil) {
|
||||
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
|
||||
if FileManager.default.fileExists(atPath: cached.path) {
|
||||
onReady?(cached, title)
|
||||
return
|
||||
}
|
||||
if tasks[articleUrl] != nil { return } // already in flight
|
||||
|
||||
jobs.insert(Job(id: articleUrl, articleUrl: articleUrl, title: title,
|
||||
progress: 0, label: "Starting…"), at: 0)
|
||||
Analytics.track("podcast.bg_started", ["url": articleUrl])
|
||||
|
||||
let task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let jobId = try await claude.generatePodcast(
|
||||
url: articleUrl,
|
||||
title: title,
|
||||
sourceText: sourceText,
|
||||
sourceKind: sourceKind
|
||||
)
|
||||
while !Task.isCancelled {
|
||||
let status = try await claude.podcastStatus(jobId: jobId)
|
||||
update(articleUrl) {
|
||||
$0.progress = Double(status.progress) / 100
|
||||
$0.label = PodcastPlayerViewModel.statusLabel(status.status)
|
||||
}
|
||||
if status.isDone {
|
||||
update(articleUrl) { $0.label = "Preparing audio…"; $0.progress = 1 }
|
||||
let url = try await claude.downloadPodcastAudio(
|
||||
jobId: jobId, articleUrl: articleUrl,
|
||||
title: status.title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
let finalTitle = status.title ?? title
|
||||
finish(articleUrl)
|
||||
PodcastLibrary.shared.reload() // new episode → refresh list + badge
|
||||
Analytics.track("podcast.bg_completed", ["url": articleUrl])
|
||||
onReady?(url, finalTitle)
|
||||
return
|
||||
}
|
||||
if status.isFailed {
|
||||
fail(articleUrl, status.error ?? "Generation failed")
|
||||
return
|
||||
}
|
||||
try await Task.sleep(for: .seconds(3))
|
||||
}
|
||||
} catch is CancellationError {
|
||||
finish(articleUrl)
|
||||
} catch {
|
||||
fail(articleUrl, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
tasks[articleUrl] = task
|
||||
}
|
||||
|
||||
/// Cancel an in-flight job and drop it from the list.
|
||||
func cancel(_ articleUrl: String) {
|
||||
tasks[articleUrl]?.cancel()
|
||||
tasks[articleUrl] = nil
|
||||
jobs.removeAll { $0.articleUrl == articleUrl }
|
||||
}
|
||||
|
||||
func dismiss(_ articleUrl: String) { jobs.removeAll { $0.articleUrl == articleUrl } }
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
private func update(_ url: String, _ mutate: (inout Job) -> Void) {
|
||||
guard let i = jobs.firstIndex(where: { $0.articleUrl == url }) else { return }
|
||||
mutate(&jobs[i])
|
||||
}
|
||||
|
||||
private func finish(_ url: String) {
|
||||
tasks[url] = nil
|
||||
jobs.removeAll { $0.articleUrl == url }
|
||||
}
|
||||
|
||||
private func fail(_ url: String, _ message: String) {
|
||||
tasks[url] = nil
|
||||
update(url) { $0.failed = true; $0.error = message; $0.label = message }
|
||||
Analytics.track("podcast.bg_failed", ["url": url, "error": message])
|
||||
// Failed jobs linger briefly so the user sees them, then self-clear.
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
self?.dismiss(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
82
Marks/Services/PodcastIndex.swift
Normal file
82
Marks/Services/PodcastIndex.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
struct PodcastEntry: Codable, Identifiable {
|
||||
var id: String { articleUrl }
|
||||
let articleUrl: String
|
||||
let filename: String
|
||||
var title: String?
|
||||
let createdAt: Date
|
||||
var parentBookmarkUrl: String?
|
||||
/// When the episode was finished. `nil` means unplayed/new. Absent in
|
||||
/// pre-existing index files, which decode as `nil` (i.e. unplayed).
|
||||
var playedAt: Date? = nil
|
||||
|
||||
var isPlayed: Bool { playedAt != nil }
|
||||
}
|
||||
|
||||
enum PodcastIndex {
|
||||
private static let indexURL: URL = {
|
||||
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("podcasts")
|
||||
return dir.appendingPathComponent("index.json")
|
||||
}()
|
||||
|
||||
private static let encoder: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.dateEncodingStrategy = .iso8601
|
||||
e.outputFormatting = .prettyPrinted
|
||||
return e
|
||||
}()
|
||||
|
||||
private static let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .iso8601
|
||||
return d
|
||||
}()
|
||||
|
||||
static func all() -> [PodcastEntry] {
|
||||
guard let data = try? Data(contentsOf: indexURL),
|
||||
let entries = try? decoder.decode([PodcastEntry].self, from: data)
|
||||
else { return [] }
|
||||
return entries.sorted { $0.createdAt > $1.createdAt }
|
||||
}
|
||||
|
||||
static func upsert(articleUrl: String, filename: String, title: String?, parentBookmarkUrl: String? = nil) {
|
||||
var entries = all()
|
||||
entries.removeAll { $0.articleUrl == articleUrl }
|
||||
entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename,
|
||||
title: title, createdAt: Date(),
|
||||
parentBookmarkUrl: parentBookmarkUrl), at: 0)
|
||||
save(entries)
|
||||
}
|
||||
|
||||
static func find(for bookmarkUrl: String) -> [PodcastEntry] {
|
||||
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
|
||||
}
|
||||
|
||||
static func isPlayed(articleUrl: String) -> Bool {
|
||||
all().first { $0.articleUrl == articleUrl }?.isPlayed ?? false
|
||||
}
|
||||
|
||||
/// Mark an episode played (default) or back to unplayed. No-op if unknown.
|
||||
static func setPlayed(articleUrl: String, _ played: Bool = true) {
|
||||
var entries = all()
|
||||
guard let i = entries.firstIndex(where: { $0.articleUrl == articleUrl }) else { return }
|
||||
entries[i].playedAt = played ? Date() : nil
|
||||
save(entries)
|
||||
}
|
||||
|
||||
static func remove(articleUrl: String) {
|
||||
var entries = all()
|
||||
entries.removeAll { $0.articleUrl == articleUrl }
|
||||
save(entries)
|
||||
}
|
||||
|
||||
private static func save(_ entries: [PodcastEntry]) {
|
||||
guard let data = try? encoder.encode(entries) else { return }
|
||||
try? data.write(to: indexURL, options: .atomic)
|
||||
WidgetDataStore.savePodcasts(entries.prefix(10).map {
|
||||
WidgetPodcast(articleUrl: $0.articleUrl, title: $0.title ?? $0.articleUrl, createdAt: $0.createdAt)
|
||||
})
|
||||
}
|
||||
}
|
||||
31
Marks/Services/PodcastLibrary.swift
Normal file
31
Marks/Services/PodcastLibrary.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// Observable, in-memory mirror of `PodcastIndex` so SwiftUI updates immediately
|
||||
/// on changes — the Podcasts tab badge and the library list both read from here.
|
||||
/// Route podcast mutations through this store so the unplayed badge stays in sync.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class PodcastLibrary {
|
||||
static let shared = PodcastLibrary()
|
||||
|
||||
private(set) var entries: [PodcastEntry] = []
|
||||
|
||||
var unplayed: [PodcastEntry] { entries.filter { !$0.isPlayed } }
|
||||
var played: [PodcastEntry] { entries.filter { $0.isPlayed } }
|
||||
var unplayedCount: Int { entries.reduce(0) { $0 + ($1.isPlayed ? 0 : 1) } }
|
||||
|
||||
private init() { entries = PodcastIndex.all() }
|
||||
|
||||
func reload() { entries = PodcastIndex.all() }
|
||||
|
||||
func setPlayed(_ articleUrl: String, _ played: Bool) {
|
||||
PodcastIndex.setPlayed(articleUrl: articleUrl, played)
|
||||
reload()
|
||||
}
|
||||
|
||||
func remove(_ articleUrl: String) {
|
||||
PodcastIndex.remove(articleUrl: articleUrl)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
61
Marks/Services/PodcastRequests.swift
Normal file
61
Marks/Services/PodcastRequests.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
/// Cross-process (main app ⇆ share extension) queue of podcast-generation
|
||||
/// requests, plus the user's "auto-generate for these tags" rule. Lives in the
|
||||
/// shared App Group so the share extension can enqueue work the main app runs —
|
||||
/// the extension itself is too short-lived to generate audio.
|
||||
enum PodcastRequests {
|
||||
private static let appGroupId = "group.com.magicive.marks"
|
||||
private static let pendingKey = "pendingPodcastRequests"
|
||||
private static let autoTagsKey = "podcastAutoTags"
|
||||
|
||||
private static var defaults: UserDefaults? { UserDefaults(suiteName: appGroupId) }
|
||||
|
||||
struct Request: Codable {
|
||||
let url: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
// MARK: - Pending queue
|
||||
|
||||
/// Enqueue a request. De-duplicates on URL so repeated saves don't pile up.
|
||||
static func enqueue(url: String, title: String) {
|
||||
var list = pending()
|
||||
guard !list.contains(where: { $0.url == url }) else { return }
|
||||
list.append(Request(url: url, title: title))
|
||||
save(list)
|
||||
}
|
||||
|
||||
static func pending() -> [Request] {
|
||||
guard let data = defaults?.data(forKey: pendingKey),
|
||||
let list = try? JSONDecoder().decode([Request].self, from: data) else { return [] }
|
||||
return list
|
||||
}
|
||||
|
||||
/// Return everything pending and clear the queue.
|
||||
static func drain() -> [Request] {
|
||||
let list = pending()
|
||||
defaults?.removeObject(forKey: pendingKey)
|
||||
return list
|
||||
}
|
||||
|
||||
private static func save(_ list: [Request]) {
|
||||
guard let data = try? JSONEncoder().encode(list) else { return }
|
||||
defaults?.set(data, forKey: pendingKey)
|
||||
}
|
||||
|
||||
// MARK: - Auto-tag rule
|
||||
|
||||
/// Tags that auto-trigger podcast generation when a bookmark is saved.
|
||||
static var autoTags: [String] {
|
||||
get { defaults?.stringArray(forKey: autoTagsKey) ?? [] }
|
||||
set { defaults?.set(newValue, forKey: autoTagsKey) }
|
||||
}
|
||||
|
||||
/// Whether saving a bookmark with `tags` should auto-generate a podcast.
|
||||
static func matchesAutoTag(_ tags: [String]) -> Bool {
|
||||
let auto = Set(autoTags.map { $0.lowercased() })
|
||||
guard !auto.isEmpty else { return false }
|
||||
return tags.contains { auto.contains($0.lowercased()) }
|
||||
}
|
||||
}
|
||||
40
Marks/Services/SourceSpotlightIndexer.swift
Normal file
40
Marks/Services/SourceSpotlightIndexer.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import CoreSpotlight
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum SourceSpotlightIndexer {
|
||||
private static let domainIdentifier = "com.magicive.marks.sources"
|
||||
|
||||
static func index(_ sources: [IngestedSource]) async {
|
||||
guard CSSearchableIndex.isIndexingAvailable(), !sources.isEmpty else { return }
|
||||
let items = sources.map { source in
|
||||
let attributes = CSSearchableItemAttributeSet(contentType: source.kind == .pdf ? .pdf : .text)
|
||||
attributes.title = source.displayTitle
|
||||
attributes.contentDescription = source.bodyText
|
||||
attributes.keywords = source.tags + [source.kind.label, source.originalFilename].compactMap { $0 }
|
||||
attributes.displayName = source.originalFilename ?? source.displayTitle
|
||||
return CSSearchableItem(
|
||||
uniqueIdentifier: source.id.uuidString,
|
||||
domainIdentifier: domainIdentifier,
|
||||
attributeSet: attributes
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
try await CSSearchableIndex.default().indexSearchableItems(items)
|
||||
Log.spotlight.info("Indexed \(sources.count, privacy: .public) sources")
|
||||
} catch {
|
||||
Log.spotlight.error("Source index failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
static func remove(ids: [UUID]) async {
|
||||
guard CSSearchableIndex.isIndexingAvailable(), !ids.isEmpty else { return }
|
||||
do {
|
||||
try await CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids.map(\.uuidString))
|
||||
Log.spotlight.info("Removed \(ids.count, privacy: .public) sources")
|
||||
} catch {
|
||||
Log.spotlight.error("Source delete failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
70
Marks/Services/SpotlightBookmarkSearch.swift
Normal file
70
Marks/Services/SpotlightBookmarkSearch.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import CoreSpotlight
|
||||
|
||||
/// A bookmark retrieved from the Spotlight index, reduced to the fields the
|
||||
/// on-device model needs to ground an answer.
|
||||
struct RetrievedBookmark: Sendable {
|
||||
let title: String
|
||||
let host: String
|
||||
let description: String
|
||||
let url: String
|
||||
}
|
||||
|
||||
/// Retrieval half of on-device RAG: queries the Spotlight index that
|
||||
/// `SpotlightIndexer` populated and returns the best-matching bookmarks. The
|
||||
/// search runs entirely on-device against the app's own indexed entities.
|
||||
enum SpotlightBookmarkSearch {
|
||||
static func run(query rawQuery: String, limit: Int) async -> [RetrievedBookmark] {
|
||||
guard CSSearchableIndex.isIndexingAvailable() else {
|
||||
Log.spotlight.notice("Search skipped: indexing unavailable")
|
||||
return []
|
||||
}
|
||||
let queryString = makeQueryString(from: rawQuery)
|
||||
guard !queryString.isEmpty else { return [] }
|
||||
Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)")
|
||||
|
||||
let context = CSSearchQueryContext()
|
||||
context.fetchAttributes = ["title", "contentDescription", "keywords", "url"]
|
||||
let query = CSSearchQuery(queryString: queryString, queryContext: context)
|
||||
|
||||
var out: [RetrievedBookmark] = []
|
||||
do {
|
||||
for try await result in query.results {
|
||||
let a = result.item.attributeSet
|
||||
out.append(RetrievedBookmark(
|
||||
title: a.title ?? "Untitled",
|
||||
host: a.url?.host() ?? "",
|
||||
description: a.contentDescription ?? "",
|
||||
url: a.url?.absoluteString ?? ""
|
||||
))
|
||||
if out.count >= limit { break }
|
||||
}
|
||||
Log.spotlight.info("Search \"\(rawQuery, privacy: .public)\" → \(out.count, privacy: .public) hits")
|
||||
} catch {
|
||||
Log.spotlight.error("Search failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
query.cancel()
|
||||
return out
|
||||
}
|
||||
|
||||
/// Build a CoreSpotlight query string that OR-matches each significant token
|
||||
/// across title / description / keywords, case- and diacritic-insensitive.
|
||||
private static func makeQueryString(from raw: String) -> String {
|
||||
let tokens = raw
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.map { $0.lowercased() }
|
||||
.filter { $0.count >= 3 }
|
||||
let terms = (tokens.isEmpty ? [raw] : tokens).map(sanitize).filter { !$0.isEmpty }
|
||||
let fields = ["title", "contentDescription", "keywords"]
|
||||
let clauses = terms.flatMap { term in
|
||||
fields.map { "\($0) == \"*\(term)*\"cd" }
|
||||
}
|
||||
guard !clauses.isEmpty else { return "" }
|
||||
return "(" + clauses.joined(separator: " || ") + ")"
|
||||
}
|
||||
|
||||
/// Keep only alphanumerics so a token can't break the query string syntax.
|
||||
private static func sanitize(_ s: String) -> String {
|
||||
String(String.UnicodeScalarView(s.unicodeScalars.filter(CharacterSet.alphanumerics.contains)))
|
||||
}
|
||||
}
|
||||
40
Marks/Services/SpotlightIndexer.swift
Normal file
40
Marks/Services/SpotlightIndexer.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import CoreSpotlight
|
||||
import AppIntents
|
||||
|
||||
/// Pushes Linkding bookmarks into the system Spotlight index via the
|
||||
/// `IndexedEntity` conformance on `BookmarkEntity`.
|
||||
///
|
||||
/// This is the step that actually makes bookmarks discoverable: conforming to
|
||||
/// `IndexedEntity` only describes *how* an entity would be indexed (its
|
||||
/// `attributeSet`) — nothing reaches Spotlight until the entities are handed to
|
||||
/// `CSSearchableIndex`. Once indexed, bookmarks show up in Spotlight search and
|
||||
/// are reachable by Siri / Apple Intelligence.
|
||||
///
|
||||
/// All calls are best-effort: failures are logged, never surfaced. If indexing
|
||||
/// is unavailable or fails, search degrades but nothing else breaks.
|
||||
enum SpotlightIndexer {
|
||||
/// Insert or update the given bookmarks in the Spotlight index.
|
||||
static func index(_ bookmarks: [Bookmark]) async {
|
||||
guard CSSearchableIndex.isIndexingAvailable(), !bookmarks.isEmpty else { return }
|
||||
do {
|
||||
try await CSSearchableIndex.default()
|
||||
.indexAppEntities(bookmarks.map(BookmarkEntity.init(from:)))
|
||||
Log.spotlight.info("Indexed \(bookmarks.count, privacy: .public) bookmarks")
|
||||
} catch {
|
||||
Log.spotlight.error("Index failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove bookmarks from the index by id (e.g. after delete / archive).
|
||||
static func remove(ids: [Int]) async {
|
||||
guard CSSearchableIndex.isIndexingAvailable(), !ids.isEmpty else { return }
|
||||
do {
|
||||
try await CSSearchableIndex.default()
|
||||
.deleteAppEntities(identifiedBy: ids, ofType: BookmarkEntity.self)
|
||||
Log.spotlight.info("Removed \(ids.count, privacy: .public) bookmarks")
|
||||
} catch {
|
||||
Log.spotlight.error("Delete failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,44 @@ struct AddBookmarkView: View {
|
||||
|
||||
@State private var url = ""
|
||||
@State private var title = ""
|
||||
@State private var description = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var importText = ""
|
||||
@State private var isSaving = false
|
||||
@State private var error: String?
|
||||
@State private var importMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextEditor(text: $importText)
|
||||
.frame(minHeight: 96)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
extractImportText()
|
||||
} label: {
|
||||
Label("Extract Link", systemImage: "link.badge.plus")
|
||||
}
|
||||
.disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let importMessage {
|
||||
Text(importMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Import Text")
|
||||
} footer: {
|
||||
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("https://", text: $url)
|
||||
.keyboardType(.URL)
|
||||
@@ -28,6 +59,13 @@ struct AddBookmarkView: View {
|
||||
Text("Title")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Optional", text: $description, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
} header: {
|
||||
Text("Notes")
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -63,6 +101,23 @@ struct AddBookmarkView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func extractImportText() {
|
||||
guard let payload = IngestPayloadParser.parse(item: importText, typeIdentifier: "public.plain-text") else {
|
||||
importMessage = "No link found"
|
||||
return
|
||||
}
|
||||
|
||||
url = payload.url
|
||||
if let extractedTitle = payload.title, title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
title = extractedTitle
|
||||
}
|
||||
if description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
description = payload.notes
|
||||
}
|
||||
importMessage = "Link extracted"
|
||||
error = nil
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let urlString = url.normalizedURL
|
||||
guard URL(string: urlString) != nil else {
|
||||
@@ -74,7 +129,12 @@ struct AddBookmarkView: View {
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.addBookmark(url: urlString, title: title.trimmingCharacters(in: .whitespaces), tags: tags)
|
||||
try await viewModel.addBookmark(
|
||||
url: urlString,
|
||||
title: title.trimmingCharacters(in: .whitespaces),
|
||||
description: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
tags: tags
|
||||
)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
|
||||
152
Marks/Views/AskView.swift
Normal file
152
Marks/Views/AskView.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
|
||||
/// "Ask Your Bookmarks" — the on-device RAG surface. Questions are answered by
|
||||
/// Apple's on-device model, grounded in the user's bookmarks via Spotlight.
|
||||
struct AskView: View {
|
||||
@State private var assistant = BookmarkAssistant()
|
||||
@State private var question = ""
|
||||
@State private var answer = ""
|
||||
@State private var isLoading = false
|
||||
@State private var errorText: String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch assistant.status {
|
||||
case .checking:
|
||||
ProgressView()
|
||||
case .unavailable(let message):
|
||||
ContentUnavailableView(
|
||||
"Unavailable",
|
||||
systemImage: "sparkles.slash",
|
||||
description: Text(message)
|
||||
)
|
||||
case .ready:
|
||||
ready
|
||||
}
|
||||
}
|
||||
.navigationTitle("Ask Your Bookmarks")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ready: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if answer.isEmpty && !isLoading && errorText == nil {
|
||||
ContentUnavailableView {
|
||||
Label("Ask anything", systemImage: "sparkles")
|
||||
} description: {
|
||||
Text("Answers come from your saved bookmarks, generated on-device.")
|
||||
}
|
||||
.padding(.top, 40)
|
||||
}
|
||||
if isLoading {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Searching your bookmarks…").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if !answer.isEmpty {
|
||||
MarkdownAnswer(text: answer)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let errorText {
|
||||
Text(errorText).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
TextField("Ask about your bookmarks…", text: $question, axis: .vertical)
|
||||
.lineLimit(1...4)
|
||||
.focused($focused)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(send)
|
||||
Button(action: send) {
|
||||
Image(systemName: "arrow.up.circle.fill").font(.title2)
|
||||
}
|
||||
.disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)
|
||||
}
|
||||
.padding()
|
||||
.background(.bar)
|
||||
}
|
||||
.onAppear { focused = true }
|
||||
}
|
||||
|
||||
private func send() {
|
||||
let q = question.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !q.isEmpty, !isLoading else { return }
|
||||
question = ""
|
||||
answer = ""
|
||||
errorText = nil
|
||||
isLoading = true
|
||||
Task {
|
||||
do {
|
||||
answer = try await assistant.ask(q)
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the model's answer as lightweight Markdown. SwiftUI's `Text` only
|
||||
/// parses *inline* Markdown (bold/italic/code/links), so we split into block
|
||||
/// elements — headings, bullet/numbered lists, paragraphs — and lay them out,
|
||||
/// applying inline parsing per line.
|
||||
private struct MarkdownAnswer: View {
|
||||
let text: String
|
||||
|
||||
private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
|
||||
switch block {
|
||||
case .heading(let line):
|
||||
inline(line).font(.headline)
|
||||
case .bullet(let line):
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
inline(line)
|
||||
}
|
||||
case .paragraph(let line):
|
||||
inline(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var blocks: [Block] {
|
||||
text.split(separator: "\n", omittingEmptySubsequences: true).map { raw in
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
if let r = line.range(of: "^#{1,6}\\s+", options: .regularExpression) {
|
||||
return .heading(String(line[r.upperBound...]))
|
||||
}
|
||||
if let r = line.range(of: "^([-*+]|\\d+\\.)\\s+", options: .regularExpression) {
|
||||
return .bullet(String(line[r.upperBound...]))
|
||||
}
|
||||
return .paragraph(line)
|
||||
}
|
||||
}
|
||||
|
||||
private func inline(_ s: String) -> Text {
|
||||
let opts = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
if let attr = try? AttributedString(markdown: s, options: opts) {
|
||||
return Text(attr)
|
||||
}
|
||||
return Text(s)
|
||||
}
|
||||
}
|
||||
107
Marks/Views/BookmarkListRow.swift
Normal file
107
Marks/Views/BookmarkListRow.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
|
||||
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
|
||||
struct BookmarkListRow: View {
|
||||
let bookmark: Bookmark
|
||||
let viewModel: BookmarksViewModel
|
||||
var readingProgress: Double = 0
|
||||
let onOpen: () -> Void
|
||||
var onEdit: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var showFullPlayer = false
|
||||
@State private var episodePickerBookmark: Bookmark?
|
||||
|
||||
var body: some View {
|
||||
BookmarkRow(
|
||||
bookmark: bookmark,
|
||||
readingProgress: readingProgress,
|
||||
onPodcast: handlePodcast
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onOpen() }
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
.listRowSeparator(.visible)
|
||||
// Delete is destructive and not undoable — require an explicit tap on the
|
||||
// revealed button rather than letting a single full swipe delete instantly.
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
if let onEdit {
|
||||
Button { onEdit() } label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if let onEdit {
|
||||
Button { onEdit() } label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
Button { onOpen() } label: {
|
||||
Label("Open", systemImage: "globe")
|
||||
}
|
||||
Button {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
} label: {
|
||||
Label("Open in Safari", systemImage: "safari")
|
||||
}
|
||||
Button { handlePodcast() } label: {
|
||||
Label("Convert to Podcast", systemImage: "headphones")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFullPlayer) {
|
||||
PodcastPlayerView(
|
||||
vm: viewModel.podcastPlayer,
|
||||
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
|
||||
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
|
||||
claude: viewModel.claude,
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
.sheet(item: $episodePickerBookmark) { b in
|
||||
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePodcast() {
|
||||
let episodes = PodcastIndex.find(for: bookmark.url)
|
||||
if episodes.count >= 2 {
|
||||
episodePickerBookmark = bookmark
|
||||
} else if let ep = episodes.first {
|
||||
viewModel.podcastPlayer.start(
|
||||
articleUrl: ep.articleUrl,
|
||||
articleTitle: ep.title ?? bookmark.displayTitle,
|
||||
claude: viewModel.claude
|
||||
)
|
||||
showFullPlayer = true
|
||||
} else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) {
|
||||
showFullPlayer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,20 @@ import SwiftUI
|
||||
|
||||
struct BookmarkRow: View {
|
||||
let bookmark: Bookmark
|
||||
var readingProgress: Double = 0
|
||||
var onPodcast: (() -> Void)? = nil
|
||||
|
||||
@State private var podcastTapCount = 0
|
||||
@State private var podcastCached = false
|
||||
|
||||
/// Compact, static relative date ("6 min ago"). Using a formatter instead of
|
||||
/// `Text(_, style: .relative)` avoids the live per-second ticking timer.
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .abbreviated
|
||||
f.dateTimeStyle = .named
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -19,44 +33,99 @@ struct BookmarkRow: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(bookmark.displayTitle)
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(bookmark.domain)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("·")
|
||||
Text(Self.relativeFormatter.localizedString(for: bookmark.dateAdded, relativeTo: Date()))
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let onPodcast {
|
||||
Button {
|
||||
podcastTapCount += 1
|
||||
onPodcast()
|
||||
} label: {
|
||||
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 1)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
|
||||
}
|
||||
}
|
||||
|
||||
if let summary = bookmark.aiSummary {
|
||||
Text(summary)
|
||||
.font(.system(size: 15))
|
||||
if let excerpt = rowExcerpt {
|
||||
Text(excerpt.text)
|
||||
.font(.subheadline)
|
||||
.italic(excerpt.isAI)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.padding(.leading, 40)
|
||||
.padding(.leading, 44)
|
||||
}
|
||||
|
||||
if !effectiveTags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
GlassEffectContainer(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(effectiveTags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.glassEffect(in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 40)
|
||||
}
|
||||
.padding(.leading, 44)
|
||||
}
|
||||
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.contentShape(Rectangle())
|
||||
.overlay(alignment: .bottom) {
|
||||
if readingProgress > 0.02 {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Rectangle()
|
||||
.fill(Color(.systemGray5))
|
||||
Rectangle()
|
||||
.fill(Color(.systemGreen))
|
||||
.frame(width: geo.size.width * min(readingProgress, 1))
|
||||
}
|
||||
}
|
||||
.frame(height: 2)
|
||||
}
|
||||
}
|
||||
.task(id: bookmark.url) {
|
||||
// Stat the podcast cache off the render path: once per appearance
|
||||
// (and when the URL changes), not on every `body` recomputation.
|
||||
let path = ClaudeService.cachedPodcastURL(for: bookmark.url).path
|
||||
podcastCached = await Task.detached { FileManager.default.fileExists(atPath: path) }.value
|
||||
}
|
||||
}
|
||||
|
||||
/// Excerpt shown under the title: prefer the AI summary (italic), else the
|
||||
/// page's scraped description / user note. nil hides the line entirely.
|
||||
private var rowExcerpt: (text: String, isAI: Bool)? {
|
||||
if let s = bookmark.aiSummary?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty {
|
||||
return (s, true)
|
||||
}
|
||||
if let e = bookmark.contentExcerpt {
|
||||
return (e, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var effectiveTags: [String] {
|
||||
@@ -85,16 +154,16 @@ struct FaviconView: View {
|
||||
placeholder
|
||||
}
|
||||
}
|
||||
.frame(width: 26, height: 26)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color(.systemGray5))
|
||||
.overlay {
|
||||
Image(systemName: "bookmark")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color(.systemGray2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,85 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Skeleton loading row
|
||||
|
||||
private struct SkeletonRow: View {
|
||||
var delay: Double = 0
|
||||
@State private var dimmed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 11) {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 32, height: 32)
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Capsule()
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 13)
|
||||
Capsule()
|
||||
.fill(Color(.systemGray6))
|
||||
.frame(width: 140, height: 10)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.opacity(dimmed ? 0.45 : 1)
|
||||
.onAppear {
|
||||
withAnimation(
|
||||
.easeInOut(duration: 0.9)
|
||||
.repeatForever(autoreverses: true)
|
||||
.delay(delay)
|
||||
) { dimmed = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row press ButtonStyle
|
||||
|
||||
struct RowPressStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.opacity(configuration.isPressed ? 0.75 : 1)
|
||||
.animation(.spring(duration: 0.18, bounce: 0), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BookmarksView
|
||||
|
||||
struct BookmarksView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
let onDisconnect: () -> Void
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var showSettings = false
|
||||
@State private var showCollections = false
|
||||
@State private var showAddBookmark = false
|
||||
@State private var editingBookmark: Bookmark?
|
||||
@State private var browsingBookmark: Bookmark?
|
||||
@State private var showFullPlayer = false
|
||||
@State private var showAsk = false
|
||||
@State private var readingProgress: [String: Double] = ReadingProgress.all()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(viewModel.bookmarks) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.onTapGesture {
|
||||
browsingBookmark = bookmark
|
||||
}
|
||||
.onAppear { maybeLoadMore(bookmark) }
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
SkeletonRow(delay: Double(i) * 0.13)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
Button {
|
||||
editingBookmark = bookmark
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
editingBookmark = bookmark
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
Button {
|
||||
browsingBookmark = bookmark
|
||||
} label: {
|
||||
Label("Open", systemImage: "globe")
|
||||
}
|
||||
Button {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
} label: {
|
||||
Label("Open in Safari", systemImage: "safari")
|
||||
}
|
||||
Divider()
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} else {
|
||||
ForEach(viewModel.bookmarks) { bookmark in
|
||||
BookmarkListRow(
|
||||
bookmark: bookmark,
|
||||
viewModel: viewModel,
|
||||
readingProgress: readingProgress[bookmark.url] ?? 0,
|
||||
onOpen: { browsingBookmark = bookmark },
|
||||
onEdit: { editingBookmark = bookmark }
|
||||
)
|
||||
.onAppear { maybeLoadMore(bookmark) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,12 +89,17 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
|
||||
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if viewModel.claude != nil {
|
||||
Menu {
|
||||
Button {
|
||||
showAsk = true
|
||||
} label: {
|
||||
Label("Ask Your Bookmarks", systemImage: "bubble.left.and.text.bubble.right")
|
||||
}
|
||||
Button {
|
||||
Task { await viewModel.generateSmartCollections() }
|
||||
showCollections = true
|
||||
@@ -100,7 +115,6 @@ struct BookmarksView: View {
|
||||
Image(systemName: "sparkles")
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
Button { showAddBookmark = true } label: {
|
||||
@@ -112,6 +126,7 @@ struct BookmarksView: View {
|
||||
Image(systemName: viewModel.unreadFilter
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
Button { showSettings = true } label: {
|
||||
Image(systemName: "gearshape")
|
||||
@@ -120,18 +135,36 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ProgressView()
|
||||
}
|
||||
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||
ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here."))
|
||||
ContentUnavailableView(
|
||||
"No Bookmarks",
|
||||
systemImage: "bookmark",
|
||||
description: Text("Bookmarks you save will appear here.")
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
VStack(spacing: 8) {
|
||||
if viewModel.podcastGenerator.hasActive {
|
||||
podcastGeneratingBanner
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
|
||||
MiniPlayerView(vm: viewModel.podcastPlayer) {
|
||||
showFullPlayer = true
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
if viewModel.enrichmentProgress > 0 {
|
||||
enrichmentBanner
|
||||
}
|
||||
}
|
||||
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
|
||||
.animation(.spring(duration: 0.3), value: viewModel.podcastGenerator.hasActive)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
|
||||
@@ -139,6 +172,9 @@ struct BookmarksView: View {
|
||||
.sheet(isPresented: $showCollections) {
|
||||
CollectionsView(viewModel: viewModel)
|
||||
}
|
||||
.sheet(isPresented: $showAsk) {
|
||||
AskView()
|
||||
}
|
||||
.sheet(isPresented: $showAddBookmark) {
|
||||
AddBookmarkView(viewModel: viewModel)
|
||||
}
|
||||
@@ -147,8 +183,23 @@ struct BookmarksView: View {
|
||||
}
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(url: url, title: bookmark.displayTitle)
|
||||
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
.bookmarkOnscreen(bookmark)
|
||||
}
|
||||
}
|
||||
.onChange(of: browsingBookmark) { _, new in
|
||||
if new == nil { readingProgress = ReadingProgress.all() }
|
||||
}
|
||||
.sheet(isPresented: $showFullPlayer) {
|
||||
PodcastPlayerView(
|
||||
vm: viewModel.podcastPlayer,
|
||||
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
|
||||
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
|
||||
claude: viewModel.claude,
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load()
|
||||
@@ -182,7 +233,40 @@ struct BookmarksView: View {
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private var podcastGeneratingBanner: some View {
|
||||
let jobs = viewModel.podcastGenerator.activeJobs
|
||||
return HStack(spacing: 10) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.symbolEffect(.variableColor.iterative, isActive: true)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
if let first = jobs.first {
|
||||
Text(first.title.isEmpty ? first.label : first.title)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.blue)
|
||||
.frame(width: 44)
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func maybeLoadMore(_ bookmark: Bookmark) {
|
||||
|
||||
@@ -16,21 +16,44 @@ final class BookmarksViewModel {
|
||||
var unreadFilter = false
|
||||
|
||||
private let api: LinkdingAPI
|
||||
private(set) var claude: ClaudeService?
|
||||
let claude = ClaudeService()
|
||||
let podcastPlayer = PodcastPlayerViewModel()
|
||||
let podcastGenerator = PodcastGenerationManager()
|
||||
private var enrichTask: Task<Void, Never>?
|
||||
private let cacheFileUrl: URL
|
||||
|
||||
init(api: LinkdingAPI, claude: ClaudeService?, cacheKey: String = "") {
|
||||
init(api: LinkdingAPI, cacheKey: String = "") {
|
||||
self.api = api
|
||||
self.claude = claude
|
||||
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
let safe = cacheKey.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_")
|
||||
let name = safe.isEmpty ? "bookmarks_cache" : "bookmarks_\(safe)"
|
||||
self.cacheFileUrl = dir.appendingPathComponent("\(name).json")
|
||||
}
|
||||
|
||||
func updateClaude(_ service: ClaudeService?) {
|
||||
claude = service
|
||||
/// True while the player is showing an episode (playing or generating one).
|
||||
var isPlayerBusy: Bool { !podcastPlayer.currentArticleUrl.isEmpty }
|
||||
|
||||
/// Handle a headphone tap without ever interrupting current playback.
|
||||
/// If the player is idle, generate-and-play in the foreground and return
|
||||
/// `true` (caller should present the full player). If the player is busy,
|
||||
/// generate in the background — the finished episode lands in the library —
|
||||
/// and return `false`.
|
||||
@discardableResult
|
||||
func playOrGeneratePodcast(articleUrl: String, title: String, parentBookmarkUrl: String? = nil) -> Bool {
|
||||
if isPlayerBusy {
|
||||
podcastGenerator.enqueue(articleUrl: articleUrl, title: title, parentBookmarkUrl: parentBookmarkUrl)
|
||||
return false
|
||||
}
|
||||
podcastPlayer.start(articleUrl: articleUrl, articleTitle: title, claude: claude, parentBookmarkUrl: parentBookmarkUrl)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Drain podcast requests queued by the share extension and start generating
|
||||
/// them in the background. Safe to call repeatedly (the queue is cleared).
|
||||
func processPendingPodcastRequests() {
|
||||
for req in PodcastRequests.drain() {
|
||||
podcastGenerator.enqueue(articleUrl: req.url, title: req.title)
|
||||
}
|
||||
}
|
||||
|
||||
func load(useCache: Bool = true) async {
|
||||
@@ -43,6 +66,10 @@ final class BookmarksViewModel {
|
||||
nextPageUrl = response.next
|
||||
restoreAIData()
|
||||
saveToCache(bookmarks)
|
||||
WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map {
|
||||
WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl)
|
||||
})
|
||||
Task { await SpotlightIndexer.index(bookmarks) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -58,6 +85,8 @@ final class BookmarksViewModel {
|
||||
bookmarks.append(contentsOf: response.results)
|
||||
nextPageUrl = response.next
|
||||
restoreAIData()
|
||||
let added = response.results
|
||||
Task { await SpotlightIndexer.index(added) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -83,7 +112,7 @@ final class BookmarksViewModel {
|
||||
}
|
||||
|
||||
func semanticSearch() async {
|
||||
guard let claude, !searchQuery.isEmpty else { return }
|
||||
guard !searchQuery.isEmpty else { return }
|
||||
isLoading = true
|
||||
do {
|
||||
let allResponse = try await api.fetchBookmarks(limit: 200, unread: unreadFilter)
|
||||
@@ -92,32 +121,43 @@ final class BookmarksViewModel {
|
||||
restoreAIData(into: &all)
|
||||
bookmarks = try await claude.semanticSearch(query: searchQuery, in: all)
|
||||
nextPageUrl = nil
|
||||
Analytics.track("search.semantic", ["query": searchQuery, "resultCount": bookmarks.count])
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func addBookmark(url: String, title: String, tags: [String]) async throws {
|
||||
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
|
||||
func addBookmark(url: String, title: String, description: String = "", tags: [String]) async throws {
|
||||
let create = BookmarkCreate(
|
||||
url: url,
|
||||
title: title,
|
||||
description: description,
|
||||
tagNames: tags,
|
||||
isArchived: false,
|
||||
unread: false,
|
||||
shared: false
|
||||
)
|
||||
let bookmark = try await api.createBookmark(create)
|
||||
bookmarks.insert(bookmark, at: 0)
|
||||
print("[AI] addBookmark: saved id=\(bookmark.id) claude=\(claude != nil ? "present" : "nil")")
|
||||
guard let claude else { return }
|
||||
Task { await SpotlightIndexer.index([bookmark]) }
|
||||
Log.ai.info("addBookmark saved id=\(bookmark.id, privacy: .public)")
|
||||
Task {
|
||||
print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)")
|
||||
Log.ai.info("addBookmark enrich start id=\(bookmark.id, privacy: .public)")
|
||||
do {
|
||||
let (summary, aiTags) = try await claude.enrich(bookmark: bookmark)
|
||||
print("[AI] addBookmark: enrich success summary=\(summary.prefix(60)) tags=\(aiTags)")
|
||||
Log.ai.info("addBookmark enrich ok id=\(bookmark.id, privacy: .public) tags=\(aiTags.count, privacy: .public)")
|
||||
guard let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else {
|
||||
print("[AI] addBookmark: bookmark id=\(bookmark.id) not found in list after enrich")
|
||||
Log.ai.notice("addBookmark id=\(bookmark.id, privacy: .public) gone before enrich applied")
|
||||
return
|
||||
}
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = aiTags
|
||||
saveAIData(for: bookmarks[i])
|
||||
let enriched = bookmarks[i]
|
||||
Task { await SpotlightIndexer.index([enriched]) }
|
||||
} catch {
|
||||
print("[AI] addBookmark: enrich FAILED \(error)")
|
||||
Log.ai.error("addBookmark enrich failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.error = "AI enrichment failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
@@ -127,6 +167,7 @@ final class BookmarksViewModel {
|
||||
do {
|
||||
try await api.deleteBookmark(id: bookmark.id)
|
||||
bookmarks.removeAll { $0.id == bookmark.id }
|
||||
Task { await SpotlightIndexer.remove(ids: [bookmark.id]) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -136,6 +177,7 @@ final class BookmarksViewModel {
|
||||
do {
|
||||
try await api.archiveBookmark(id: bookmark.id)
|
||||
bookmarks.removeAll { $0.id == bookmark.id }
|
||||
Task { await SpotlightIndexer.remove(ids: [bookmark.id]) }
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -159,11 +201,12 @@ final class BookmarksViewModel {
|
||||
}
|
||||
|
||||
func generateSmartCollections() async {
|
||||
guard let claude, !bookmarks.isEmpty else { return }
|
||||
guard !bookmarks.isEmpty else { return }
|
||||
isGeneratingCollections = true
|
||||
do {
|
||||
let allResponse = try await api.fetchBookmarks(limit: 200)
|
||||
smartCollections = try await claude.generateCollections(from: allResponse.results)
|
||||
Analytics.track("collections.generated", ["collectionCount": smartCollections.count])
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
@@ -171,7 +214,6 @@ final class BookmarksViewModel {
|
||||
}
|
||||
|
||||
func enrichAll() async {
|
||||
guard let claude else { return }
|
||||
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }
|
||||
guard !toEnrich.isEmpty else { return }
|
||||
enrichmentProgress = 0
|
||||
@@ -181,41 +223,43 @@ final class BookmarksViewModel {
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = tags
|
||||
saveAIData(for: bookmarks[i])
|
||||
let enriched = bookmarks[i]
|
||||
Task { await SpotlightIndexer.index([enriched]) }
|
||||
enrichmentProgress = Double(done + 1) / Double(toEnrich.count)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
let enriched = toEnrich.count - bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.count
|
||||
if enriched > 0 { Analytics.track("enrich.completed", ["count": enriched]) }
|
||||
enrichmentProgress = 0
|
||||
}
|
||||
|
||||
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
|
||||
private func startEnrichment() {
|
||||
guard claude != nil else {
|
||||
print("[AI] startEnrichment: skipped (no claude)")
|
||||
return
|
||||
}
|
||||
enrichTask?.cancel()
|
||||
enrichTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) }
|
||||
print("[AI] startEnrichment: \(indices.count) bookmarks to enrich")
|
||||
Log.ai.info("startEnrichment: \(indices.count, privacy: .public) to enrich")
|
||||
for i in indices {
|
||||
guard !Task.isCancelled else { return }
|
||||
guard let claude = await MainActor.run(body: { self.claude }) else { return }
|
||||
let claude = await MainActor.run(body: { self.claude })
|
||||
do {
|
||||
let bm = await MainActor.run { bookmarks[i] }
|
||||
print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)")
|
||||
Log.ai.debug("startEnrichment enriching id=\(bm.id, privacy: .public)")
|
||||
let (summary, tags) = try await claude.enrich(bookmark: bm)
|
||||
print("[AI] startEnrichment: done id=\(bm.id) summary=\(summary.prefix(60))")
|
||||
Log.ai.debug("startEnrichment done id=\(bm.id, privacy: .public)")
|
||||
await MainActor.run {
|
||||
guard i < bookmarks.count else { return }
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = tags
|
||||
saveAIData(for: bookmarks[i])
|
||||
let enriched = bookmarks[i]
|
||||
Task { await SpotlightIndexer.index([enriched]) }
|
||||
}
|
||||
} catch {
|
||||
print("[AI] startEnrichment: enrich FAILED id=\(i) \(error)")
|
||||
Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -242,7 +286,7 @@ final class BookmarksViewModel {
|
||||
let data = try Self.cacheEncoder.encode(list)
|
||||
try data.write(to: cacheFileUrl, options: .atomic)
|
||||
} catch {
|
||||
print("[Cache] saveToCache failed: \(error)")
|
||||
Log.sync.error("saveToCache failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,6 +307,8 @@ final class BookmarksViewModel {
|
||||
"tags": bookmark.aiTags ?? []
|
||||
]
|
||||
UserDefaults.standard.set(store, forKey: "aiData")
|
||||
// Mirror by URL so the decoupled Podcasts tab can show summaries.
|
||||
AISummaryStore.set(bookmark.aiSummary, for: bookmark.url)
|
||||
}
|
||||
|
||||
private func restoreAIData() {
|
||||
@@ -275,6 +321,8 @@ final class BookmarksViewModel {
|
||||
if let d = store["\(list[i].id)"] {
|
||||
list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
list[i].aiTags = d["tags"] as? [String]
|
||||
// Keep the URL-keyed summary cache populated for existing bookmarks.
|
||||
if let summary = list[i].aiSummary { AISummaryStore.set(summary, for: list[i].url) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,92 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
// MARK: - Reading progress persistence
|
||||
|
||||
enum ReadingProgress {
|
||||
private static let key = "readingProgress"
|
||||
|
||||
static func get(url: String) -> Double {
|
||||
(UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0
|
||||
}
|
||||
|
||||
static func set(url: String, progress: Double) {
|
||||
var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
|
||||
if progress >= 0.99 {
|
||||
store.removeValue(forKey: url) // fully read — clean up
|
||||
} else {
|
||||
store[url] = progress
|
||||
}
|
||||
UserDefaults.standard.set(store, forKey: key)
|
||||
}
|
||||
|
||||
static func all() -> [String: Double] {
|
||||
(UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Indeterminate page load bar
|
||||
|
||||
private struct PageLoadBar: View {
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let barWidth = geo.size.width * 0.38
|
||||
Capsule()
|
||||
.fill(.blue.opacity(0.75))
|
||||
.frame(width: barWidth, height: 3)
|
||||
.offset(x: (phase * (geo.size.width + barWidth)) - barWidth)
|
||||
}
|
||||
.frame(height: 3)
|
||||
.clipped()
|
||||
.onAppear {
|
||||
withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BrowserState
|
||||
|
||||
@Observable
|
||||
final class BrowserState: NSObject, WKNavigationDelegate {
|
||||
final class BrowserState: NSObject, WKNavigationDelegate, WKUIDelegate {
|
||||
let webView: WKWebView
|
||||
var isLoading = false
|
||||
var canGoBack = false
|
||||
var canGoForward = false
|
||||
var pageTitle = ""
|
||||
var currentURL: URL?
|
||||
var readingProgress: Double = 0
|
||||
|
||||
// Weak wrapper breaks WKUserContentController's strong retain of the handler
|
||||
private final class ScrollHandler: NSObject, WKScriptMessageHandler {
|
||||
weak var state: BrowserState?
|
||||
init(_ state: BrowserState) { self.state = state }
|
||||
func userContentController(_ c: WKUserContentController, didReceive msg: WKScriptMessage) {
|
||||
if let p = msg.body as? Double { state?.readingProgress = p }
|
||||
}
|
||||
}
|
||||
private var scrollHandler: ScrollHandler?
|
||||
|
||||
init(url: URL) {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.allowsInlineMediaPlayback = true
|
||||
webView = WKWebView(frame: .zero, configuration: config)
|
||||
super.init()
|
||||
let handler = ScrollHandler(self)
|
||||
scrollHandler = handler
|
||||
webView.configuration.userContentController.add(handler, name: "scrollProgress")
|
||||
webView.navigationDelegate = self
|
||||
webView.uiDelegate = self
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
deinit {
|
||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) {
|
||||
isLoading = true
|
||||
}
|
||||
@@ -35,24 +103,55 @@ final class BrowserState: NSObject, WKNavigationDelegate {
|
||||
canGoForward = webView.canGoForward
|
||||
pageTitle = webView.title ?? ""
|
||||
currentURL = webView.url
|
||||
|
||||
// Restore persisted progress, then start tracking scroll
|
||||
if let urlStr = webView.url?.absoluteString {
|
||||
let saved = ReadingProgress.get(url: urlStr)
|
||||
if saved > 0 {
|
||||
let pct = saved * 100
|
||||
webView.evaluateJavaScript("window.scrollTo(0, (document.body.scrollHeight - window.innerHeight) * \(pct) / 100)", completionHandler: nil)
|
||||
}
|
||||
}
|
||||
webView.evaluateJavaScript(scrollTrackingJS, completionHandler: nil)
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) {
|
||||
isLoading = false
|
||||
}
|
||||
func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { isLoading = false }
|
||||
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { isLoading = false }
|
||||
|
||||
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) {
|
||||
isLoading = false
|
||||
// target="_blank" and window.open() — load in the same webview instead of opening Safari
|
||||
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||
if let url = action.request.url {
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func enterReaderMode(completion: @escaping () -> Void) {
|
||||
webView.evaluateJavaScript(readerModeJS) { _, _ in
|
||||
DispatchQueue.main.async { completion() }
|
||||
DispatchQueue.main.async {
|
||||
completion()
|
||||
self.webView.evaluateJavaScript(self.scrollTrackingJS, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exitReaderMode() {
|
||||
webView.reload()
|
||||
func exitReaderMode() { webView.reload() }
|
||||
|
||||
private var scrollTrackingJS: String {
|
||||
"""
|
||||
(function(){
|
||||
function report(){
|
||||
var h=document.body.scrollHeight-window.innerHeight;
|
||||
var p=h>0?window.scrollY/h:0;
|
||||
window.webkit.messageHandlers.scrollProgress.postMessage(Math.min(1,Math.max(0,p)));
|
||||
}
|
||||
window.removeEventListener('scroll',window._marksScrollFn);
|
||||
window._marksScrollFn=report;
|
||||
window.addEventListener('scroll',report,{passive:true});
|
||||
report();
|
||||
})();
|
||||
"""
|
||||
}
|
||||
|
||||
private var readerModeJS: String {
|
||||
@@ -106,24 +205,41 @@ final class BrowserState: NSObject, WKNavigationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WebView representable
|
||||
|
||||
private struct WebViewRepresentable: UIViewRepresentable {
|
||||
let webView: WKWebView
|
||||
func makeUIView(context: Context) -> WKWebView { webView }
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||
}
|
||||
|
||||
// MARK: - BrowserView
|
||||
|
||||
struct BrowserView: View {
|
||||
let initialURL: URL
|
||||
let initialTitle: String
|
||||
let claude: ClaudeService
|
||||
let podcastPlayer: PodcastPlayerViewModel
|
||||
let podcastGenerator: PodcastGenerationManager
|
||||
var onArchive: (() async -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var state: BrowserState
|
||||
@State private var readerMode = false
|
||||
@State private var showPodcast = false
|
||||
@State private var archiveTapCount = 0
|
||||
@State private var navBackCount = 0
|
||||
@State private var navForwardCount = 0
|
||||
@State private var toolbarHidden = false
|
||||
|
||||
init(url: URL, title: String) {
|
||||
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, podcastGenerator: PodcastGenerationManager, onArchive: (() async -> Void)? = nil) {
|
||||
self.initialURL = url
|
||||
self.initialTitle = title
|
||||
self.claude = claude
|
||||
self.podcastPlayer = podcastPlayer
|
||||
self.podcastGenerator = podcastGenerator
|
||||
self.onArchive = onArchive
|
||||
self._state = State(initialValue: BrowserState(url: url))
|
||||
}
|
||||
|
||||
@@ -133,9 +249,21 @@ struct BrowserView: View {
|
||||
WebViewRepresentable(webView: state.webView)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
|
||||
// Reading progress bar
|
||||
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(Color.blue.opacity(0.55))
|
||||
.frame(width: geo.size.width * state.readingProgress, height: 3)
|
||||
}
|
||||
.frame(height: 3)
|
||||
.animation(.linear(duration: 0.1), value: state.readingProgress)
|
||||
}
|
||||
|
||||
// Page loading sweep bar (replaces center spinner)
|
||||
if state.isLoading {
|
||||
ProgressView()
|
||||
.padding(.top, 8)
|
||||
PageLoadBar()
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
|
||||
@@ -148,40 +276,128 @@ struct BrowserView: View {
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
openURL(state.currentURL ?? initialURL)
|
||||
} label: {
|
||||
Button { openURL(state.currentURL ?? initialURL) } label: {
|
||||
Image(systemName: "safari")
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
bottomBar
|
||||
.offset(y: toolbarHidden ? 88 : 0)
|
||||
.animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden)
|
||||
}
|
||||
.onChange(of: state.readingProgress) { old, new in
|
||||
let delta = new - old
|
||||
withAnimation(.spring(duration: 0.3, bounce: 0)) {
|
||||
if delta > 0.012 && new > 0.07 {
|
||||
toolbarHidden = true
|
||||
} else if delta < -0.005 || new < 0.05 {
|
||||
toolbarHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: state.isLoading) { _, loading in
|
||||
if loading {
|
||||
withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false }
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
let urlStr = (state.currentURL ?? initialURL).absoluteString
|
||||
let p = state.readingProgress
|
||||
if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) }
|
||||
}
|
||||
.sheet(isPresented: $showPodcast) {
|
||||
PodcastPlayerView(
|
||||
vm: podcastPlayer,
|
||||
articleUrl: (state.currentURL ?? initialURL).absoluteString,
|
||||
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
|
||||
claude: claude,
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack {
|
||||
Button {
|
||||
navBackCount += 1
|
||||
state.webView.goBack()
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(!state.canGoBack)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: navBackCount)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
navForwardCount += 1
|
||||
state.webView.goForward()
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(!state.canGoForward)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: navForwardCount)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
if readerMode {
|
||||
state.exitReaderMode()
|
||||
readerMode = false
|
||||
} else {
|
||||
state.enterReaderMode { readerMode = true }
|
||||
}
|
||||
if readerMode { state.exitReaderMode(); readerMode = false }
|
||||
else { state.enterReaderMode { readerMode = true } }
|
||||
} label: {
|
||||
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(state.isLoading)
|
||||
|
||||
if onArchive != nil { Spacer() }
|
||||
|
||||
if let onArchive {
|
||||
Button {
|
||||
archiveTapCount += 1
|
||||
Task { await onArchive() }
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "archivebox")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
let currentUrl = (state.currentURL ?? initialURL).absoluteString
|
||||
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
|
||||
// Never interrupt current playback: play-and-show only when idle,
|
||||
// otherwise generate in the background.
|
||||
if podcastPlayer.currentArticleUrl.isEmpty {
|
||||
podcastPlayer.start(
|
||||
articleUrl: currentUrl,
|
||||
articleTitle: currentTitle,
|
||||
claude: claude,
|
||||
parentBookmarkUrl: initialURL.absoluteString
|
||||
)
|
||||
showPodcast = true
|
||||
} else {
|
||||
podcastGenerator.enqueue(
|
||||
articleUrl: currentUrl,
|
||||
title: currentTitle,
|
||||
parentBookmarkUrl: initialURL.absoluteString
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "headphones")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.disabled(state.isLoading)
|
||||
|
||||
@@ -189,9 +405,15 @@ struct BrowserView: View {
|
||||
|
||||
ShareLink(item: state.currentURL ?? initialURL) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(.bar)
|
||||
.overlay(alignment: .top) { Divider() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,10 +89,9 @@ struct OnboardingView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(canConnect ? Color.primary : Color(.systemGray4))
|
||||
.foregroundStyle(canConnect ? Color(UIColor.systemBackground) : Color(.systemGray2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.buttonBorderShape(.roundedRectangle(radius: 14))
|
||||
.disabled(!canConnect || isConnecting)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 24)
|
||||
|
||||
1191
Marks/Views/PodcastPlayerView.swift
Normal file
1191
Marks/Views/PodcastPlayerView.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,36 +2,27 @@ import SwiftUI
|
||||
|
||||
struct SearchView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var searchText = ""
|
||||
@State private var useSemanticSearch = false
|
||||
@State private var semanticResults: [Bookmark]? = nil
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
@State private var browsingBookmark: Bookmark?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(results) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.onTapGesture {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
BookmarkListRow(
|
||||
bookmark: bookmark,
|
||||
viewModel: viewModel,
|
||||
onOpen: { browsingBookmark = bookmark }
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Search")
|
||||
.toolbar {
|
||||
if viewModel.claude != nil {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Toggle(isOn: $useSemanticSearch) {
|
||||
Label("Semantic", systemImage: "sparkles")
|
||||
@@ -40,7 +31,6 @@ struct SearchView: View {
|
||||
.onChange(of: useSemanticSearch) { _, _ in scheduleSearch() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if searchText.isEmpty {
|
||||
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
|
||||
@@ -51,9 +41,33 @@ struct SearchView: View {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(
|
||||
url: url,
|
||||
title: bookmark.displayTitle,
|
||||
claude: viewModel.claude,
|
||||
podcastPlayer: viewModel.podcastPlayer,
|
||||
podcastGenerator: viewModel.podcastGenerator
|
||||
) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
.bookmarkOnscreen(bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
|
||||
.onChange(of: searchText) { _, _ in scheduleSearch() }
|
||||
.onAppear { consumeIntentSearch() }
|
||||
.onChange(of: IntentRouter.shared.searchRequest) { _, _ in consumeIntentSearch() }
|
||||
}
|
||||
|
||||
/// Pulls a query handed over by `SearchMarksIntent` into the search field.
|
||||
private func consumeIntentSearch() {
|
||||
guard let query = IntentRouter.shared.searchRequest else { return }
|
||||
searchText = query
|
||||
IntentRouter.shared.searchRequest = nil
|
||||
}
|
||||
|
||||
private var results: [Bookmark] {
|
||||
@@ -72,7 +86,8 @@ struct SearchView: View {
|
||||
private func scheduleSearch() {
|
||||
semanticResults = nil
|
||||
searchTask?.cancel()
|
||||
guard !searchText.isEmpty, useSemanticSearch, let claude = viewModel.claude else { return }
|
||||
guard !searchText.isEmpty, useSemanticSearch else { return }
|
||||
let claude = viewModel.claude
|
||||
searchTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
@@ -4,37 +4,32 @@ struct SettingsView: View {
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
let onDisconnect: () -> Void
|
||||
|
||||
@State private var claudeKey = UserDefaults.standard.string(forKey: ClaudeService.defaultsKey) ?? ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var autoTagText = PodcastRequests.autoTags.joined(separator: " ")
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("AI Features") {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("OpenRouter API Key")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
SecureField("sk-or-…", text: $claudeKey)
|
||||
.textContentType(.password)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.onChange(of: claudeKey) { _, new in
|
||||
ClaudeService.save(apiKey: new)
|
||||
viewModel.updateClaude(new.isEmpty ? nil : ClaudeService(apiKey: new))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if viewModel.claude != nil {
|
||||
Label("AI enabled — semantic search, auto-tagging, and smart collections active.", systemImage: "sparkles")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Add an OpenRouter API key to enable AI features. Uses Gemini 2.0 Flash Lite — very cheap.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
Section {
|
||||
TextField("e.g. listen podcast", text: $autoTagText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: autoTagText) { _, new in
|
||||
PodcastRequests.autoTags = new
|
||||
.split(whereSeparator: { $0 == " " || $0 == "," })
|
||||
.map(String.init)
|
||||
}
|
||||
} header: {
|
||||
Text("Auto-Podcast Tags")
|
||||
} footer: {
|
||||
Text("Saving a bookmark with any of these tags automatically generates a podcast for it.")
|
||||
}
|
||||
|
||||
Section("Server") {
|
||||
|
||||
440
Marks/Views/SourcesView.swift
Normal file
440
Marks/Views/SourcesView.swift
Normal file
@@ -0,0 +1,440 @@
|
||||
import QuickLook
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SourcesView: View {
|
||||
@Bindable var library: IngestedSourceLibrary
|
||||
let podcastPlayer: PodcastPlayerViewModel
|
||||
let podcastGenerator: PodcastGenerationManager
|
||||
let claude: ClaudeService
|
||||
|
||||
@State private var searchText = ""
|
||||
@State private var showTextImport = false
|
||||
@State private var showFileImporter = false
|
||||
@State private var selectedSource: IngestedSource?
|
||||
@State private var editingSource: IngestedSource?
|
||||
@State private var showFullPlayer = false
|
||||
|
||||
private var results: [IngestedSource] {
|
||||
library.search(searchText)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(results) { source in
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
selectedSource = source
|
||||
} label: {
|
||||
SourceRow(source: source)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
playOrGeneratePodcast(for: source)
|
||||
} label: {
|
||||
Image(systemName: podcastIcon(for: source))
|
||||
.font(.title3)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(source.podcastText.isEmpty)
|
||||
}
|
||||
.swipeActions {
|
||||
Button {
|
||||
playOrGeneratePodcast(for: source)
|
||||
} label: {
|
||||
Label("Podcast", systemImage: "headphones")
|
||||
}
|
||||
.tint(.blue)
|
||||
|
||||
Button(role: .destructive) {
|
||||
library.delete(source)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Sources")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
showTextImport = true
|
||||
} label: {
|
||||
Label("Import Text", systemImage: "doc.text")
|
||||
}
|
||||
Button {
|
||||
showFileImporter = true
|
||||
} label: {
|
||||
Label("Import File", systemImage: "doc.badge.plus")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if results.isEmpty {
|
||||
ContentUnavailableView(
|
||||
searchText.isEmpty ? "No Sources" : "No Results",
|
||||
systemImage: searchText.isEmpty ? "tray" : "magnifyingglass",
|
||||
description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search sources")
|
||||
}
|
||||
.task { library.load() }
|
||||
.sheet(isPresented: $showTextImport) {
|
||||
ImportTextSourceView(library: library)
|
||||
}
|
||||
.sheet(item: $selectedSource) { source in
|
||||
SourceDetailView(
|
||||
source: source,
|
||||
fileURL: library.fileURL(for: source),
|
||||
podcastCached: podcastCached(for: source),
|
||||
podcastGenerating: podcastGenerator.isGenerating(source.podcastURL),
|
||||
onEdit: {
|
||||
selectedSource = nil
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
editingSource = source
|
||||
}
|
||||
},
|
||||
onPodcast: {
|
||||
selectedSource = nil
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
playOrGeneratePodcast(for: source)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(item: $editingSource) { source in
|
||||
EditSourceView(source: source, library: library)
|
||||
}
|
||||
.sheet(isPresented: $showFullPlayer) {
|
||||
PodcastPlayerView(
|
||||
vm: podcastPlayer,
|
||||
articleUrl: podcastPlayer.currentArticleUrl,
|
||||
articleTitle: podcastPlayer.currentArticleTitle,
|
||||
claude: claude,
|
||||
stopOnDismiss: false
|
||||
)
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showFileImporter,
|
||||
allowedContentTypes: [.pdf, .plainText, .text, .data],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
for url in urls {
|
||||
library.importFile(from: url, tags: [])
|
||||
}
|
||||
case .failure(let error):
|
||||
library.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.alert("Source Error", isPresented: Binding(
|
||||
get: { library.error != nil },
|
||||
set: { if !$0 { library.error = nil } }
|
||||
)) {
|
||||
Button("OK") { library.error = nil }
|
||||
} message: {
|
||||
Text(library.error ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func playOrGeneratePodcast(for source: IngestedSource) {
|
||||
let podcastURL = source.podcastURL
|
||||
let title = source.displayTitle
|
||||
let existing = PodcastIndex.find(for: podcastURL).first
|
||||
|
||||
if let existing {
|
||||
podcastPlayer.start(articleUrl: existing.articleUrl, articleTitle: existing.title ?? title, claude: claude)
|
||||
showFullPlayer = true
|
||||
return
|
||||
}
|
||||
|
||||
if podcastPlayer.currentArticleUrl.isEmpty {
|
||||
podcastPlayer.start(
|
||||
articleUrl: podcastURL,
|
||||
articleTitle: title,
|
||||
claude: claude,
|
||||
sourceText: source.podcastText,
|
||||
sourceKind: source.kind.rawValue
|
||||
)
|
||||
showFullPlayer = true
|
||||
} else {
|
||||
podcastGenerator.enqueue(
|
||||
articleUrl: podcastURL,
|
||||
title: title,
|
||||
sourceText: source.podcastText,
|
||||
sourceKind: source.kind.rawValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func podcastCached(for source: IngestedSource) -> Bool {
|
||||
FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: source.podcastURL).path)
|
||||
}
|
||||
|
||||
private func podcastIcon(for source: IngestedSource) -> String {
|
||||
if podcastGenerator.isGenerating(source.podcastURL) { return "waveform.circle.fill" }
|
||||
if podcastCached(for: source) { return "headphones.circle.fill" }
|
||||
return "headphones.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceRow: View {
|
||||
let source: IngestedSource
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(source.displayTitle)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if !source.excerpt.isEmpty {
|
||||
Text(source.excerpt)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(source.kind.label)
|
||||
Text(source.createdAt, style: .date)
|
||||
if !source.tags.isEmpty {
|
||||
Text(source.tags.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch source.kind {
|
||||
case .text: "doc.text"
|
||||
case .pdf: "doc.richtext"
|
||||
case .file: "doc"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ImportTextSourceView: View {
|
||||
@Bindable var library: IngestedSourceLibrary
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var title = ""
|
||||
@State private var bodyText = ""
|
||||
@State private var tagsText = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Title") {
|
||||
TextField("Optional", text: $title)
|
||||
}
|
||||
Section("Text") {
|
||||
TextEditor(text: $bodyText)
|
||||
.frame(minHeight: 180)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
}
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("Tags")
|
||||
} footer: {
|
||||
Text("Separate tags with commas")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Import Text")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
library.addText(title: title, body: bodyText, tags: tagsText.parsedTags)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EditSourceView: View {
|
||||
let source: IngestedSource
|
||||
@Bindable var library: IngestedSourceLibrary
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var title: String
|
||||
@State private var bodyText: String
|
||||
@State private var tagsText: String
|
||||
|
||||
init(source: IngestedSource, library: IngestedSourceLibrary) {
|
||||
self.source = source
|
||||
self.library = library
|
||||
_title = State(initialValue: source.title)
|
||||
_bodyText = State(initialValue: source.bodyText)
|
||||
_tagsText = State(initialValue: source.tags.joined(separator: ", "))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Title") {
|
||||
TextField("Optional", text: $title)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextEditor(text: $bodyText)
|
||||
.frame(minHeight: 220)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
} header: {
|
||||
Text(source.kind == .text ? "Text" : "Extracted Text")
|
||||
} footer: {
|
||||
if source.kind != .text {
|
||||
Text("Editing extracted text does not modify the original file.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("Tags")
|
||||
} footer: {
|
||||
Text("Separate tags with commas")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
library.update(source, title: title, bodyText: bodyText, tags: tagsText.parsedTags)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(bodyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceDetailView: View {
|
||||
let source: IngestedSource
|
||||
let fileURL: URL?
|
||||
let podcastCached: Bool
|
||||
let podcastGenerating: Bool
|
||||
let onEdit: () -> Void
|
||||
let onPodcast: () -> Void
|
||||
|
||||
@State private var previewURL: URL?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(source.displayTitle)
|
||||
.font(.title2.weight(.semibold))
|
||||
Text(source.kind.label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !source.tags.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(source.tags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.blue.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let fileURL {
|
||||
HStack {
|
||||
Button {
|
||||
previewURL = fileURL
|
||||
} label: {
|
||||
Label("Open Original", systemImage: "doc.viewfinder")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
onPodcast()
|
||||
} label: {
|
||||
Label(podcastTitle, systemImage: "headphones")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(source.podcastText.isEmpty || podcastGenerating)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
onPodcast()
|
||||
} label: {
|
||||
Label(podcastTitle, systemImage: "headphones")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(source.podcastText.isEmpty || podcastGenerating)
|
||||
}
|
||||
|
||||
Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText)
|
||||
.font(.body)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
onEdit()
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.quickLookPreview($previewURL)
|
||||
}
|
||||
|
||||
private var podcastTitle: String {
|
||||
if podcastGenerating { return "Generating" }
|
||||
if podcastCached { return "Play Podcast" }
|
||||
return "Create Podcast"
|
||||
}
|
||||
}
|
||||
@@ -48,32 +48,17 @@ struct TagsView: View {
|
||||
struct TagBookmarksView: View {
|
||||
let tag: String
|
||||
@Bindable var viewModel: BookmarksViewModel
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@State private var browsingBookmark: Bookmark?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(filteredBookmarks) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
.onTapGesture {
|
||||
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowSeparator(.visible)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button(role: .destructive) {
|
||||
Task { await viewModel.delete(bookmark) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
Task { await viewModel.archive(bookmark) }
|
||||
} label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}
|
||||
.tint(.orange)
|
||||
}
|
||||
BookmarkListRow(
|
||||
bookmark: bookmark,
|
||||
viewModel: viewModel,
|
||||
onOpen: { browsingBookmark = bookmark }
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
@@ -84,6 +69,20 @@ struct TagBookmarksView: View {
|
||||
ContentUnavailableView("No Bookmarks", systemImage: "tag")
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
||||
.sheet(item: $browsingBookmark) { bookmark in
|
||||
if let url = URL(string: bookmark.url) {
|
||||
BrowserView(
|
||||
url: url,
|
||||
title: bookmark.displayTitle,
|
||||
claude: viewModel.claude,
|
||||
podcastPlayer: viewModel.podcastPlayer,
|
||||
podcastGenerator: viewModel.podcastGenerator
|
||||
) {
|
||||
await viewModel.archive(bookmark)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredBookmarks: [Bookmark] {
|
||||
|
||||
201
MarksTests/AppIntentsTests.swift
Normal file
201
MarksTests/AppIntentsTests.swift
Normal file
@@ -0,0 +1,201 @@
|
||||
import XCTest
|
||||
import AppIntents
|
||||
@testable import Marks
|
||||
|
||||
/// Tests for the App Intents layer.
|
||||
///
|
||||
/// Note: WWDC26's dedicated `AppIntentsTesting` framework (out-of-process
|
||||
/// `IntentDefinitions(bundleIdentifier:)` / `.run()`) is not present in this
|
||||
/// Xcode, so these are standard XCTest cases. They work because Marks has no
|
||||
/// AppIntents extension — intents run in the app process, so `perform()` can be
|
||||
/// called directly and asserted against `IntentRouter`.
|
||||
final class AppIntentsTests: XCTestCase {
|
||||
|
||||
private func sampleBookmark(
|
||||
id: Int = 42,
|
||||
url: String = "https://example.com/article",
|
||||
title: String = "Concurrency in Swift",
|
||||
tags: [String] = ["swift", "concurrency"],
|
||||
unread: Bool = true,
|
||||
aiSummary: String? = "A short summary."
|
||||
) -> Bookmark {
|
||||
Bookmark(
|
||||
id: id, url: url, title: title, description: "desc",
|
||||
tagNames: tags, dateAdded: Date(), dateModified: Date(),
|
||||
isArchived: false, unread: unread, shared: false,
|
||||
websiteTitle: nil, websiteDescription: nil,
|
||||
faviconUrl: nil, previewImageUrl: nil,
|
||||
aiSummary: aiSummary, aiTags: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Entity mapping
|
||||
|
||||
func testEntityMapsFromBookmark() {
|
||||
let entity = BookmarkEntity(from: sampleBookmark())
|
||||
XCTAssertEqual(entity.id, 42)
|
||||
XCTAssertEqual(entity.title, "Concurrency in Swift")
|
||||
XCTAssertEqual(entity.url, URL(string: "https://example.com/article"))
|
||||
XCTAssertEqual(entity.host, "example.com")
|
||||
XCTAssertEqual(entity.tags, ["swift", "concurrency"])
|
||||
XCTAssertTrue(entity.unread)
|
||||
XCTAssertEqual(entity.summary, "A short summary.")
|
||||
}
|
||||
|
||||
func testEntityUsesDisplayTitleFallback() {
|
||||
// empty title → falls back to url (Bookmark.displayTitle)
|
||||
let entity = BookmarkEntity(from: sampleBookmark(title: ""))
|
||||
XCTAssertFalse(entity.title.isEmpty)
|
||||
}
|
||||
|
||||
func testMakeBookmarkRoundTripsCoreFields() {
|
||||
let entity = BookmarkEntity(from: sampleBookmark())
|
||||
let back = entity.makeBookmark()
|
||||
XCTAssertEqual(back.id, 42)
|
||||
XCTAssertEqual(back.url, "https://example.com/article")
|
||||
XCTAssertEqual(back.tagNames, ["swift", "concurrency"])
|
||||
}
|
||||
|
||||
func testEntityIdentifierEncodesServerId() {
|
||||
let entity = BookmarkEntity(from: sampleBookmark(id: 7))
|
||||
let eid = EntityIdentifier(for: entity)
|
||||
XCTAssertEqual(eid.identifier, "7")
|
||||
XCTAssertTrue(eid.entityType == BookmarkEntity.self)
|
||||
}
|
||||
|
||||
// MARK: - Router
|
||||
|
||||
@MainActor
|
||||
func testRouterSearch() {
|
||||
IntentRouter.shared.searchRequest = nil
|
||||
IntentRouter.shared.search("hello")
|
||||
XCTAssertEqual(IntentRouter.shared.searchRequest, "hello")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testRouterOpenBookmark() {
|
||||
IntentRouter.shared.openBookmarkURL = nil
|
||||
IntentRouter.shared.openBookmark(url: "https://example.com")
|
||||
XCTAssertEqual(IntentRouter.shared.openBookmarkURL, "https://example.com")
|
||||
}
|
||||
|
||||
// MARK: - Intent perform() wiring (no network)
|
||||
|
||||
@MainActor
|
||||
func testSearchIntentRoutesCriteriaTerm() async throws {
|
||||
IntentRouter.shared.searchRequest = nil
|
||||
let intent = SearchMarksIntent()
|
||||
intent.criteria = StringSearchCriteria(term: "graphql")
|
||||
_ = try await intent.perform()
|
||||
XCTAssertEqual(IntentRouter.shared.searchRequest, "graphql")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testOpenIntentRoutesURL() async throws {
|
||||
IntentRouter.shared.openBookmarkURL = nil
|
||||
let intent = OpenBookmarkIntent()
|
||||
intent.target = BookmarkEntity(from: sampleBookmark())
|
||||
_ = try await intent.perform()
|
||||
XCTAssertEqual(IntentRouter.shared.openBookmarkURL, "https://example.com/article")
|
||||
}
|
||||
|
||||
// MARK: - App Shortcuts
|
||||
|
||||
func testAppShortcutsAreRegistered() {
|
||||
XCTAssertGreaterThanOrEqual(MarksShortcuts.appShortcuts.count, 4)
|
||||
}
|
||||
|
||||
// MARK: - Ingest payload parsing
|
||||
|
||||
func testIngestParsesDirectURL() {
|
||||
let payload = IngestPayloadParser.parse(item: URL(string: "https://example.com/article")!)
|
||||
XCTAssertEqual(payload?.url, "https://example.com/article")
|
||||
XCTAssertEqual(payload?.notes, "")
|
||||
}
|
||||
|
||||
func testIngestFindsURLInSharedEmailText() {
|
||||
let text = """
|
||||
Thought you might want to save this:
|
||||
|
||||
https://example.com/story?utm_source=newsletter
|
||||
|
||||
Sent from Spark
|
||||
"""
|
||||
|
||||
let payload = IngestPayloadParser.parse(item: text, typeIdentifier: "public.plain-text")
|
||||
XCTAssertEqual(payload?.url, "https://example.com/story?utm_source=newsletter")
|
||||
XCTAssertTrue(payload?.notes.contains("Sent from Spark") == true)
|
||||
}
|
||||
|
||||
func testIngestIgnoresMailLinksAndUsesWebURL() {
|
||||
let text = "mailto:friend@example.com\nRead https://example.com/read-later."
|
||||
let payload = IngestPayloadParser.parse(item: text, typeIdentifier: "public.plain-text")
|
||||
XCTAssertEqual(payload?.url, "https://example.com/read-later")
|
||||
}
|
||||
|
||||
func testIngestDecodesHTMLLinksFromEmailClients() {
|
||||
let html = "<p>Save this: <a href=\"https://example.com/a?x=1&y=2\">article</a></p>"
|
||||
let payload = IngestPayloadParser.parse(item: html, typeIdentifier: "public.html")
|
||||
XCTAssertEqual(payload?.url, "https://example.com/a?x=1&y=2")
|
||||
}
|
||||
|
||||
// MARK: - Local sources
|
||||
|
||||
func testSourceStoreSavesAndLoadsMetadata() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let store = IngestedSourceStore(rootURL: root)
|
||||
let source = IngestedSource(kind: .text, title: "Meeting Notes", bodyText: "Follow up on paper receipts.", tags: ["receipts"])
|
||||
try store.save([source])
|
||||
|
||||
let loaded = try store.load()
|
||||
XCTAssertEqual(loaded.count, 1)
|
||||
XCTAssertEqual(loaded.first?.id, source.id)
|
||||
XCTAssertEqual(loaded.first?.kind, .text)
|
||||
XCTAssertEqual(loaded.first?.title, "Meeting Notes")
|
||||
XCTAssertEqual(loaded.first?.bodyText, "Follow up on paper receipts.")
|
||||
XCTAssertEqual(loaded.first?.tags, ["receipts"])
|
||||
}
|
||||
|
||||
func testSourceStoreImportsPlainTextFile() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let input = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
try? FileManager.default.removeItem(at: input)
|
||||
}
|
||||
|
||||
try Data("Offline clipping text".utf8).write(to: input)
|
||||
|
||||
let store = IngestedSourceStore(rootURL: root)
|
||||
let source = try store.importFile(from: input, tags: ["offline"])
|
||||
|
||||
XCTAssertEqual(source.kind, .file)
|
||||
XCTAssertEqual(source.bodyText, "Offline clipping text")
|
||||
XCTAssertEqual(source.tags, ["offline"])
|
||||
XCTAssertNotNil(store.fileURL(for: source))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSourceLibraryUpdatesMetadataAndSearch() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
|
||||
let library = IngestedSourceLibrary(store: IngestedSourceStore(rootURL: root))
|
||||
library.addText(title: "Draft", body: "Original extracted text", tags: ["raw"])
|
||||
|
||||
let source = try XCTUnwrap(library.sources.first)
|
||||
library.update(source, title: "Cleaned Notes", bodyText: "Cleaned source body", tags: ["clean", "notes"])
|
||||
|
||||
let updated = try XCTUnwrap(library.sources.first)
|
||||
XCTAssertEqual(updated.title, "Cleaned Notes")
|
||||
XCTAssertEqual(updated.bodyText, "Cleaned source body")
|
||||
XCTAssertEqual(updated.tags, ["clean", "notes"])
|
||||
XCTAssertGreaterThanOrEqual(updated.modifiedAt, source.modifiedAt)
|
||||
XCTAssertEqual(library.search("clean").first?.id, source.id)
|
||||
|
||||
let reloaded = IngestedSourceStore(rootURL: root)
|
||||
XCTAssertEqual(try reloaded.load().first?.title, "Cleaned Notes")
|
||||
}
|
||||
}
|
||||
25
MarksWidget/Info.plist
Normal file
25
MarksWidget/Info.plist
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>MarksWidget</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
10
MarksWidget/MarksWidget.entitlements
Normal file
10
MarksWidget/MarksWidget.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.magicive.marks</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
11
MarksWidget/MarksWidgetBundle.swift
Normal file
11
MarksWidget/MarksWidgetBundle.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MarksWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
RecentBookmarksWidget()
|
||||
RandomBookmarkWidget()
|
||||
RecentPodcastsWidget()
|
||||
}
|
||||
}
|
||||
98
MarksWidget/RandomBookmarkWidget.swift
Normal file
98
MarksWidget/RandomBookmarkWidget.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
struct RandomBookmarkEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let bookmark: WidgetBookmark?
|
||||
|
||||
static let placeholder = RandomBookmarkEntry(
|
||||
date: .now,
|
||||
bookmark: WidgetBookmark(url: "https://example.com", title: "A Saved Article Worth Revisiting", domain: "example.com", faviconUrl: nil)
|
||||
)
|
||||
}
|
||||
|
||||
struct RandomBookmarkProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> RandomBookmarkEntry { .placeholder }
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (RandomBookmarkEntry) -> Void) {
|
||||
let bookmarks = WidgetDataStore.loadBookmarks()
|
||||
completion(RandomBookmarkEntry(date: .now, bookmark: bookmarks.randomElement()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<RandomBookmarkEntry>) -> Void) {
|
||||
let bookmarks = WidgetDataStore.loadBookmarks()
|
||||
let entry = RandomBookmarkEntry(date: .now, bookmark: bookmarks.randomElement())
|
||||
let next = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
struct RandomBookmarkWidgetView: View {
|
||||
let entry: RandomBookmarkEntry
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let bookmark = entry.bookmark {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Rediscover")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
Spacer()
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
.frame(width: 38, height: 38)
|
||||
.overlay {
|
||||
Image(systemName: "bookmark.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
Spacer().frame(height: 10)
|
||||
Text(bookmark.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(3)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(bookmark.domain)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.widgetURL(.marksBookmark(bookmark.url))
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "bookmark")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Open Marks to\nload bookmarks.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
struct RandomBookmarkWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(
|
||||
kind: "com.magicive.marks.RandomBookmark",
|
||||
provider: RandomBookmarkProvider()
|
||||
) { entry in
|
||||
RandomBookmarkWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Rediscover")
|
||||
.description("A random saved bookmark to revisit.")
|
||||
.supportedFamilies([.systemSmall])
|
||||
}
|
||||
}
|
||||
108
MarksWidget/RecentBookmarksWidget.swift
Normal file
108
MarksWidget/RecentBookmarksWidget.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
struct RecentBookmarksEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let bookmarks: [WidgetBookmark]
|
||||
|
||||
static let placeholder = RecentBookmarksEntry(date: .now, bookmarks: [
|
||||
WidgetBookmark(url: "https://example.com/a", title: "The Future of AI in 2025", domain: "example.com", faviconUrl: nil),
|
||||
WidgetBookmark(url: "https://news.com/b", title: "Why Swift Concurrency Matters", domain: "news.com", faviconUrl: nil),
|
||||
WidgetBookmark(url: "https://blog.dev/c", title: "Building Great iOS Apps", domain: "blog.dev", faviconUrl: nil),
|
||||
])
|
||||
}
|
||||
|
||||
struct RecentBookmarksProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> RecentBookmarksEntry { .placeholder }
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (RecentBookmarksEntry) -> Void) {
|
||||
let bookmarks = WidgetDataStore.loadBookmarks()
|
||||
completion(RecentBookmarksEntry(date: .now, bookmarks: Array(bookmarks.prefix(3))))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<RecentBookmarksEntry>) -> Void) {
|
||||
let bookmarks = WidgetDataStore.loadBookmarks()
|
||||
let entry = RecentBookmarksEntry(date: .now, bookmarks: Array(bookmarks.prefix(3)))
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentBookmarksWidgetView: View {
|
||||
let entry: RecentBookmarksEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Recent")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Image(systemName: "bookmark.fill")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if entry.bookmarks.isEmpty {
|
||||
Spacer()
|
||||
Text("Save bookmarks to see them here.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
} else {
|
||||
ForEach(Array(entry.bookmarks.enumerated()), id: \.element.id) { idx, bookmark in
|
||||
if idx > 0 { Divider().padding(.vertical, 5) }
|
||||
Link(destination: .marksBookmark(bookmark.url)) {
|
||||
WidgetBookmarkRow(bookmark: bookmark)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetBookmarkRow: View {
|
||||
let bookmark: WidgetBookmark
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 26, height: 26)
|
||||
.overlay {
|
||||
Image(systemName: "bookmark")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(Color(.systemGray2))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(bookmark.title)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text(bookmark.domain)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentBookmarksWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(
|
||||
kind: "com.magicive.marks.RecentBookmarks",
|
||||
provider: RecentBookmarksProvider()
|
||||
) { entry in
|
||||
RecentBookmarksWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Recent Bookmarks")
|
||||
.description("Your latest saved bookmarks.")
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
111
MarksWidget/RecentPodcastsWidget.swift
Normal file
111
MarksWidget/RecentPodcastsWidget.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
struct RecentPodcastsEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let podcasts: [WidgetPodcast]
|
||||
|
||||
static let placeholder = RecentPodcastsEntry(date: .now, podcasts: [
|
||||
WidgetPodcast(articleUrl: "https://example.com/a", title: "The Future of AI", createdAt: .now),
|
||||
WidgetPodcast(articleUrl: "https://news.com/b", title: "Swift Concurrency Deep Dive", createdAt: .now.addingTimeInterval(-86400)),
|
||||
WidgetPodcast(articleUrl: "https://blog.dev/c", title: "Building Great iOS Apps", createdAt: .now.addingTimeInterval(-172800)),
|
||||
])
|
||||
}
|
||||
|
||||
struct RecentPodcastsProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> RecentPodcastsEntry { .placeholder }
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (RecentPodcastsEntry) -> Void) {
|
||||
let podcasts = WidgetDataStore.loadPodcasts()
|
||||
completion(RecentPodcastsEntry(date: .now, podcasts: Array(podcasts.prefix(3))))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<RecentPodcastsEntry>) -> Void) {
|
||||
let podcasts = WidgetDataStore.loadPodcasts()
|
||||
let entry = RecentPodcastsEntry(date: .now, podcasts: Array(podcasts.prefix(3)))
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentPodcastsWidgetView: View {
|
||||
let entry: RecentPodcastsEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Text("Podcasts")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Image(systemName: "headphones")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if entry.podcasts.isEmpty {
|
||||
Spacer()
|
||||
Text("Generate podcasts from your bookmarks to see them here.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
} else {
|
||||
ForEach(Array(entry.podcasts.enumerated()), id: \.element.id) { idx, podcast in
|
||||
if idx > 0 { Divider().padding(.vertical, 5) }
|
||||
Link(destination: .marksPodcast(podcast.articleUrl)) {
|
||||
WidgetPodcastRow(podcast: podcast)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetPodcastRow: View {
|
||||
let podcast: WidgetPodcast
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
.frame(width: 26, height: 26)
|
||||
.overlay {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(podcast.title)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
Text(podcast.createdAt, style: .relative)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.blue.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentPodcastsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(
|
||||
kind: "com.magicive.marks.RecentPodcasts",
|
||||
provider: RecentPodcastsProvider()
|
||||
) { entry in
|
||||
RecentPodcastsWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Recent Podcasts")
|
||||
.description("Tap to play recently generated podcasts.")
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
117
MarksWidget/WidgetDataStore.swift
Normal file
117
MarksWidget/WidgetDataStore.swift
Normal file
@@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
#if canImport(WidgetKit)
|
||||
import WidgetKit
|
||||
#endif
|
||||
|
||||
private let log = Logger(subsystem: "com.magicive.marks", category: "WidgetDataStore")
|
||||
|
||||
struct WidgetBookmark: Codable, Identifiable, Sendable {
|
||||
var id: String { url }
|
||||
let url: String
|
||||
let title: String
|
||||
let domain: String
|
||||
let faviconUrl: String?
|
||||
}
|
||||
|
||||
struct WidgetPodcast: Codable, Identifiable, Sendable {
|
||||
var id: String { articleUrl }
|
||||
let articleUrl: String
|
||||
let title: String
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
enum WidgetDataStore {
|
||||
static let groupID = "group.com.magicive.marks"
|
||||
|
||||
private static var containerURL: URL? {
|
||||
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID)
|
||||
if url == nil { log.error("App Group container not found — group ID: \(groupID)") }
|
||||
return url
|
||||
}
|
||||
|
||||
private static func fileURL(_ name: String) -> URL? {
|
||||
containerURL?.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
private static let encoder: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.dateEncodingStrategy = .iso8601
|
||||
return e
|
||||
}()
|
||||
|
||||
private static let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .iso8601
|
||||
return d
|
||||
}()
|
||||
|
||||
// MARK: - Bookmarks
|
||||
|
||||
static func saveBookmarks(_ bookmarks: [WidgetBookmark]) {
|
||||
guard let url = fileURL("widget_bookmarks.json"),
|
||||
let data = try? encoder.encode(bookmarks) else { return }
|
||||
try? data.write(to: url, options: .atomic)
|
||||
reloadTimelines()
|
||||
}
|
||||
|
||||
static func loadBookmarks() -> [WidgetBookmark] {
|
||||
guard let url = fileURL("widget_bookmarks.json") else {
|
||||
log.error("loadBookmarks: no container URL")
|
||||
return []
|
||||
}
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
log.warning("loadBookmarks: file not found at \(url.path)")
|
||||
return []
|
||||
}
|
||||
guard let items = try? decoder.decode([WidgetBookmark].self, from: data) else {
|
||||
log.error("loadBookmarks: decode failed")
|
||||
return []
|
||||
}
|
||||
log.info("loadBookmarks: loaded \(items.count) bookmarks")
|
||||
return items
|
||||
}
|
||||
|
||||
// MARK: - Podcasts
|
||||
|
||||
static func savePodcasts(_ podcasts: [WidgetPodcast]) {
|
||||
guard let url = fileURL("widget_podcasts.json"),
|
||||
let data = try? encoder.encode(podcasts) else { return }
|
||||
try? data.write(to: url, options: .atomic)
|
||||
reloadTimelines()
|
||||
}
|
||||
|
||||
static func loadPodcasts() -> [WidgetPodcast] {
|
||||
guard let url = fileURL("widget_podcasts.json"),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let items = try? decoder.decode([WidgetPodcast].self, from: data)
|
||||
else { return [] }
|
||||
return items
|
||||
}
|
||||
|
||||
private static func reloadTimelines() {
|
||||
#if canImport(WidgetKit)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Deep link URL helpers
|
||||
|
||||
extension URL {
|
||||
static func marksBookmark(_ url: String) -> URL {
|
||||
var c = URLComponents()
|
||||
c.scheme = "marks"
|
||||
c.host = "bookmark"
|
||||
c.queryItems = [URLQueryItem(name: "url", value: url)]
|
||||
return c.url ?? URL(string: "marks://bookmark")!
|
||||
}
|
||||
|
||||
static func marksPodcast(_ url: String) -> URL {
|
||||
var c = URLComponents()
|
||||
c.scheme = "marks"
|
||||
c.host = "podcast"
|
||||
c.queryItems = [URLQueryItem(name: "url", value: url)]
|
||||
return c.url ?? URL(string: "marks://podcast")!
|
||||
}
|
||||
}
|
||||
338
ShareExtension/ShareView.swift
Normal file
338
ShareExtension/ShareView.swift
Normal file
@@ -0,0 +1,338 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Interactive save card shown by the share extension. On open it checks whether
|
||||
/// the URL is already bookmarked, then lets the user set/edit tags, notes, and a
|
||||
/// read-later flag before saving (new) or updating (existing) — never a duplicate.
|
||||
struct ShareView: View {
|
||||
let url: String
|
||||
let initialTitle: String?
|
||||
let initialNotes: String
|
||||
/// Called when the extension should dismiss. `saved` is true after a successful
|
||||
/// write, false on cancel.
|
||||
let onClose: (_ saved: Bool) -> Void
|
||||
|
||||
@State private var phase: Phase = .loading
|
||||
@State private var existing: Bookmark?
|
||||
@State private var title = ""
|
||||
@State private var tagText = ""
|
||||
@State private var notes = ""
|
||||
@State private var readLater = false
|
||||
@State private var createPodcast = false
|
||||
@State private var suggestedTags: [String] = [] // linkding auto_tags
|
||||
@State private var knownTags: Set<String> = [] // user's existing vocabulary (lowercased)
|
||||
@State private var aiSuggestions: [String] = [] // net-new AI ideas (not in vocabulary)
|
||||
@State private var suggesting = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
@FocusState private var tagsFocused: Bool
|
||||
|
||||
private let api: LinkdingAPI?
|
||||
|
||||
init(url: String, initialTitle: String? = nil, initialNotes: String = "", onClose: @escaping (_ saved: Bool) -> Void) {
|
||||
self.url = url
|
||||
self.initialTitle = initialTitle
|
||||
self.initialNotes = initialNotes
|
||||
self.onClose = onClose
|
||||
self.api = ServerConfig.loadShared().map(LinkdingAPI.init)
|
||||
_title = State(initialValue: initialTitle ?? "")
|
||||
_notes = State(initialValue: initialNotes)
|
||||
}
|
||||
|
||||
private enum Phase { case loading, editing, saving, done, failed }
|
||||
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .abbreviated
|
||||
f.dateTimeStyle = .named
|
||||
return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
card
|
||||
.frame(maxWidth: 460)
|
||||
.padding(.horizontal, 20)
|
||||
Spacer()
|
||||
}
|
||||
.task { await check() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var card: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
switch phase {
|
||||
case .loading:
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Checking…").foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 24)
|
||||
|
||||
case .editing, .saving:
|
||||
header
|
||||
editor
|
||||
actions
|
||||
|
||||
case .done:
|
||||
statusCard(icon: "checkmark.circle.fill", tint: .green,
|
||||
text: existing != nil ? "Updated" : "Saved")
|
||||
|
||||
case .failed:
|
||||
statusCard(icon: "xmark.circle.fill", tint: .red,
|
||||
text: errorMessage.isEmpty ? "Something went wrong" : errorMessage)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.15), radius: 24, y: 8)
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let existing {
|
||||
Label {
|
||||
Text(existing.dateAdded == .distantPast
|
||||
? "Already saved"
|
||||
: "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))")
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
} else {
|
||||
Text("Save bookmark").font(.headline)
|
||||
}
|
||||
|
||||
Text(title.isEmpty ? url : title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(2)
|
||||
Text(domain)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var editor: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
TextField("Tags (space separated)", text: $tagText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.focused($tagsFocused)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
if suggesting && aiSuggestions.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Suggesting tags…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !suggestionChips.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(suggestionChips, id: \.tag) { item in
|
||||
Button { addTag(item.tag) } label: {
|
||||
Text(item.isAI ? "✨ \(item.tag)" : "+ \(item.tag)")
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule())
|
||||
.foregroundStyle(item.isAI ? Color.purple : .blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField("Notes", text: $notes, axis: .vertical)
|
||||
.lineLimit(1...3)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Toggle("Read later", isOn: $readLater)
|
||||
.font(.subheadline)
|
||||
|
||||
Toggle(isOn: $createPodcast) {
|
||||
Label("Create podcast", systemImage: "headphones")
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
|
||||
private var actions: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") { onClose(false) }
|
||||
.buttonStyle(.bordered)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Button {
|
||||
Task { await commit() }
|
||||
} label: {
|
||||
Group {
|
||||
if phase == .saving { ProgressView().tint(.white) }
|
||||
else { Text(existing != nil ? "Update" : "Save") }
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(phase == .saving)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private func statusCard(icon: String, tint: Color, text: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon).font(.title3).foregroundStyle(tint)
|
||||
Text(text).font(.headline)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
|
||||
// MARK: - Derived
|
||||
|
||||
private var domain: String { URL(string: url)?.host ?? url }
|
||||
|
||||
/// Tappable suggestion chips: net-new AI ideas (✨) followed by linkding's
|
||||
/// auto_tags (+), excluding anything already in the field, de-duplicated.
|
||||
private var suggestionChips: [(tag: String, isAI: Bool)] {
|
||||
let current = Set(parsedTags.map { $0.lowercased() })
|
||||
var seen = Set<String>()
|
||||
var out: [(tag: String, isAI: Bool)] = []
|
||||
for tag in aiSuggestions + suggestedTags {
|
||||
let key = tag.lowercased()
|
||||
let isAI = aiSuggestions.contains { $0.lowercased() == key }
|
||||
if !current.contains(key) && seen.insert(key).inserted {
|
||||
out.append((tag, isAI))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private var parsedTags: [String] {
|
||||
tagText.split(whereSeparator: { $0 == " " || $0 == "," })
|
||||
.map { String($0) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func addTag(_ tag: String) {
|
||||
let trimmed = tagText.trimmingCharacters(in: .whitespaces)
|
||||
tagText = trimmed.isEmpty ? tag : trimmed + " " + tag
|
||||
tagText += " "
|
||||
}
|
||||
|
||||
// MARK: - Networking
|
||||
|
||||
private func check() async {
|
||||
guard let parsed = URL(string: url),
|
||||
let scheme = parsed.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https",
|
||||
parsed.host?.isEmpty == false else {
|
||||
errorMessage = "No bookmarkable link found"
|
||||
phase = .failed
|
||||
return
|
||||
}
|
||||
guard let api else {
|
||||
errorMessage = "Open Marks first"
|
||||
phase = .failed
|
||||
return
|
||||
}
|
||||
do {
|
||||
let result = try await api.checkBookmark(url: url)
|
||||
if let bookmark = result.bookmark {
|
||||
existing = bookmark
|
||||
title = bookmark.displayTitle
|
||||
tagText = bookmark.tagNames.joined(separator: " ")
|
||||
notes = bookmark.description
|
||||
readLater = bookmark.unread
|
||||
} else {
|
||||
title = initialTitle ?? result.metadata?.title ?? ""
|
||||
suggestedTags = result.autoTags ?? []
|
||||
if notes.isEmpty {
|
||||
notes = result.metadata?.description ?? initialNotes
|
||||
}
|
||||
}
|
||||
// Pre-enable the toggle when the current tags match the auto-rule.
|
||||
createPodcast = PodcastRequests.matchesAutoTag(parsedTags)
|
||||
phase = .editing
|
||||
await loadSuggestions()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
phase = .failed
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the user's existing tag vocabulary and AI tag ideas in parallel,
|
||||
/// then applies the hybrid behavior: auto-fill suggestions that match the
|
||||
/// existing vocabulary (new bookmarks only), surface the rest as chips.
|
||||
/// Best-effort — never throws, never blocks saving.
|
||||
private func loadSuggestions() async {
|
||||
guard let api else { return }
|
||||
suggesting = true
|
||||
defer { suggesting = false }
|
||||
|
||||
async let vocabTask = api.fetchTags()
|
||||
let ai = await TagSuggester.suggest(url: url, title: title)
|
||||
let vocab = (try? await vocabTask) ?? []
|
||||
knownTags = Set(vocab.map { $0.lowercased() })
|
||||
|
||||
if existing == nil {
|
||||
// Auto-fill AI tags the user already uses; high confidence, low surprise.
|
||||
let current = Set(parsedTags.map { $0.lowercased() })
|
||||
for tag in ai where knownTags.contains(tag.lowercased()) && !current.contains(tag.lowercased()) {
|
||||
addTag(tag)
|
||||
}
|
||||
}
|
||||
// Everything not in the vocabulary becomes a tappable ✨ chip.
|
||||
aiSuggestions = ai.filter { !knownTags.contains($0.lowercased()) }
|
||||
}
|
||||
|
||||
private func commit() async {
|
||||
guard let api else { return }
|
||||
phase = .saving
|
||||
do {
|
||||
if let existing {
|
||||
let update = BookmarkUpdate(
|
||||
url: existing.url,
|
||||
title: existing.title,
|
||||
description: notes,
|
||||
tagNames: parsedTags,
|
||||
unread: readLater,
|
||||
shared: existing.shared
|
||||
)
|
||||
_ = try await api.updateBookmark(id: existing.id, update: update)
|
||||
} else {
|
||||
let create = BookmarkCreate(
|
||||
url: url,
|
||||
title: "",
|
||||
description: notes,
|
||||
tagNames: parsedTags,
|
||||
isArchived: false,
|
||||
unread: readLater,
|
||||
shared: false
|
||||
)
|
||||
_ = try await api.createBookmark(create)
|
||||
}
|
||||
// Hand off podcast generation to the main app (the extension is too
|
||||
// short-lived to generate audio itself).
|
||||
if createPodcast || PodcastRequests.matchesAutoTag(parsedTags) {
|
||||
PodcastRequests.enqueue(url: url, title: title.isEmpty ? url : title)
|
||||
}
|
||||
phase = .done
|
||||
try? await Task.sleep(nanoseconds: 700_000_000)
|
||||
onClose(true)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
phase = .failed
|
||||
try? await Task.sleep(nanoseconds: 1_600_000_000)
|
||||
onClose(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@objc(ShareViewController)
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
private let appGroupId = "group.com.magicive.marks"
|
||||
private let configKey = "serverConfig"
|
||||
private let supportedTypeIdentifiers = [
|
||||
UTType.url.identifier,
|
||||
UTType.html.identifier,
|
||||
UTType.rtf.identifier,
|
||||
UTType.plainText.identifier,
|
||||
UTType.text.identifier
|
||||
]
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
@@ -13,117 +18,66 @@ class ShareViewController: UIViewController {
|
||||
blur.frame = view.bounds
|
||||
blur.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(blur)
|
||||
extractURLAndSave()
|
||||
extractPayload()
|
||||
}
|
||||
|
||||
private func extractURLAndSave() {
|
||||
private func extractPayload() {
|
||||
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||
let attachments = item.attachments else {
|
||||
showResult(success: false, message: "Nothing to save")
|
||||
present(payload: nil)
|
||||
return
|
||||
}
|
||||
|
||||
func load(typeId: String) -> Bool {
|
||||
guard let attachment = attachments.first(where: { $0.hasItemConformingToTypeIdentifier(typeId) }) else {
|
||||
return false
|
||||
}
|
||||
attachment.loadItem(forTypeIdentifier: typeId, options: nil) { [weak self] item, _ in
|
||||
let urlString = typeId == UTType.url.identifier
|
||||
? ((item as? URL)?.absoluteString ?? (item as? String))
|
||||
: (item as? String)
|
||||
DispatchQueue.main.async {
|
||||
if let url = urlString { self?.save(url: url) }
|
||||
else { self?.showResult(success: false, message: "No URL found") }
|
||||
}
|
||||
}
|
||||
return true
|
||||
let candidates = attachments.flatMap { attachment in
|
||||
supportedTypeIdentifiers
|
||||
.filter { attachment.hasItemConformingToTypeIdentifier($0) }
|
||||
.map { (attachment, $0) }
|
||||
}
|
||||
|
||||
if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) {
|
||||
showResult(success: false, message: "No URL found")
|
||||
}
|
||||
loadNext(candidates, index: 0)
|
||||
}
|
||||
|
||||
private func save(url: String) {
|
||||
guard let groupDefaults = UserDefaults(suiteName: appGroupId),
|
||||
let data = groupDefaults.data(forKey: configKey),
|
||||
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let host = config["host"] as? String,
|
||||
let token = config["token"] as? String,
|
||||
let useHttps = config["useHttps"] as? Bool else {
|
||||
showResult(success: false, message: "Open Marks first")
|
||||
private func loadNext(_ candidates: [(NSItemProvider, String)], index: Int) {
|
||||
guard index < candidates.count else {
|
||||
present(payload: nil)
|
||||
return
|
||||
}
|
||||
|
||||
var base = (useHttps ? "https" : "http") + "://" + host
|
||||
if let port = config["port"] as? Int { base += ":\(port)" }
|
||||
if let path = config["path"] as? String, !path.isEmpty { base += path }
|
||||
|
||||
guard let apiUrl = URL(string: "\(base)/api/bookmarks/") else {
|
||||
showResult(success: false, message: "Bad server URL")
|
||||
return
|
||||
}
|
||||
|
||||
var req = URLRequest(url: apiUrl)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||
"url": url, "is_archived": false, "unread": false, "shared": false
|
||||
])
|
||||
|
||||
URLSession.shared.dataTask(with: req) { [weak self] _, response, error in
|
||||
DispatchQueue.main.async {
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let ok = error == nil && (code == 200 || code == 201)
|
||||
self?.showResult(success: ok, message: ok ? "Bookmarked" : "Failed to save")
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func showResult(success: Bool, message: String) {
|
||||
let card = UIView()
|
||||
card.backgroundColor = success ? .systemGreen : .systemRed
|
||||
card.layer.cornerRadius = 16
|
||||
card.layer.cornerCurve = .continuous
|
||||
card.translatesAutoresizingMaskIntoConstraints = false
|
||||
card.alpha = 0
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 10
|
||||
stack.alignment = .center
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let icon = UIImageView(image: UIImage(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill"))
|
||||
icon.tintColor = .white
|
||||
icon.preferredSymbolConfiguration = .init(pointSize: 22, weight: .semibold)
|
||||
|
||||
let label = UILabel()
|
||||
label.text = message
|
||||
label.textColor = .white
|
||||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
|
||||
stack.addArrangedSubview(icon)
|
||||
stack.addArrangedSubview(label)
|
||||
card.addSubview(stack)
|
||||
view.addSubview(card)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
|
||||
stack.centerYAnchor.constraint(equalTo: card.centerYAnchor),
|
||||
stack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 24),
|
||||
card.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
card.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
card.heightAnchor.constraint(equalToConstant: 56),
|
||||
card.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
])
|
||||
|
||||
UIView.animate(withDuration: 0.2) { card.alpha = 1 }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + (success ? 0.8 : 2.0)) { [weak self] in
|
||||
UIView.animate(withDuration: 0.15, animations: { card.alpha = 0 }) { _ in
|
||||
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
let (attachment, typeIdentifier) = candidates[index]
|
||||
attachment.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { [weak self] item, _ in
|
||||
guard let self else { return }
|
||||
if let payload = IngestPayloadParser.parse(item: item, typeIdentifier: typeIdentifier) {
|
||||
DispatchQueue.main.async { self.present(payload: payload) }
|
||||
} else {
|
||||
DispatchQueue.main.async { self.loadNext(candidates, index: index + 1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hosts the SwiftUI save card. A nil URL surfaces an error state in the card.
|
||||
private func present(payload: IngestPayload?) {
|
||||
let root = ShareView(
|
||||
url: payload?.url ?? "",
|
||||
initialTitle: payload?.title,
|
||||
initialNotes: payload?.notes ?? ""
|
||||
) { [weak self] _ in
|
||||
self?.close()
|
||||
}
|
||||
let host = UIHostingController(rootView: root)
|
||||
host.view.backgroundColor = .clear
|
||||
addChild(host)
|
||||
host.view.frame = view.bounds
|
||||
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(host.view)
|
||||
host.didMove(toParent: self)
|
||||
|
||||
if payload == nil {
|
||||
// No URL to work with — let the card render its failure state briefly.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in self?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private func close() {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
30
ShareExtension/TagSuggester.swift
Normal file
30
ShareExtension/TagSuggester.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight, extension-local client for AI tag suggestions. Reuses the same
|
||||
/// backend + anonymous device auth as the main app's `ClaudeService.enrich`, but
|
||||
/// without pulling the whole service (and its podcast/collection deps) into the
|
||||
/// extension. The backend fetches the page itself, so suggestions reflect the
|
||||
/// actual web page content — not just the URL.
|
||||
enum TagSuggester {
|
||||
/// Asks the backend for tags for `url`. Returns [] on any failure — tag
|
||||
/// suggestion is best-effort and must never block saving.
|
||||
static func suggest(url: String, title: String) async -> [String] {
|
||||
struct Response: Decodable { let tags: [String] }
|
||||
do {
|
||||
let token = try await MarksAuth.validToken()
|
||||
guard let endpoint = URL(string: MarksAuth.baseURL + "/v1/marks/enrich") else { return [] }
|
||||
var req = URLRequest(url: endpoint)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: [
|
||||
"url": url, "title": title, "tags": [String](),
|
||||
])
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
guard (200...299).contains((response as? HTTPURLResponse)?.statusCode ?? 0) else { return [] }
|
||||
return try JSONDecoder().decode(Response.self, from: data).tags
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
161
docs/design-wwdc26.md
Normal file
161
docs/design-wwdc26.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Marks — WWDC26 Design Brief
|
||||
|
||||
Source: WWDC26 session transcripts (`~/master/scratch/claude-setup/wwdc26/transcripts`).
|
||||
Sessions digested: Principles of Great Design, Communicate Your Brand Identity on iOS,
|
||||
What's New in SwiftUI, Dive into Lazy Stacks and Scrolling, Compose Advanced Graphics
|
||||
Effects, Design Intuitive Search Experiences, WidgetKit Foundations, Create UI Prototypes
|
||||
Using Agents in Xcode.
|
||||
|
||||
Everything below is mapped to Marks (SwiftUI bookmarks / read-later app, iOS 26 target,
|
||||
with a home-screen widget and a share extension).
|
||||
|
||||
---
|
||||
|
||||
## The mental model: two layers (iOS 26 Liquid Glass)
|
||||
|
||||
- **UI layer** — tab bar, toolbars, search field → keep **native**. Don't restyle them.
|
||||
- **Content layer** — the bookmark rows → **this is the canvas.** All editorial brand lives here.
|
||||
|
||||
The editorial-row direction is correct: it's the sanctioned place for personality. Corollary:
|
||||
**move any header/brand color into the scroll content**, not onto the toolbar. The Liquid Glass
|
||||
bar then picks up the tint dynamically as rows scroll under it, and content goes edge-to-edge
|
||||
instead of being letterboxed by a colored bar.
|
||||
|
||||
> "Think of your app as two distinct layers: the UI layer … and the content layer … the content
|
||||
> layer is the best opportunity to express your brand identity."
|
||||
|
||||
---
|
||||
|
||||
## Bookmark list (highest leverage)
|
||||
|
||||
- **Row hierarchy** answers three questions at a glance: *what do I look at, what can I tap, how?*
|
||||
Title most prominent; favicon + domain + reading-time/date as the "context that makes it
|
||||
simpler" (a bare title is *less* usable, not more). Build hierarchy with **order, spacing,
|
||||
contrast** — not by stripping.
|
||||
- **No truncation under Dynamic Type — wrap to multiple lines.** Mandatory to test if shipping
|
||||
any custom font (free only with system fonts). New York (system serif) gives an editorial feel
|
||||
with Dynamic Type for free.
|
||||
- **Swipe actions = forgiveness, not confirmation.** Undo over alerts; consistent positions;
|
||||
standard metaphors only (no creative-liberty trash/archive icons).
|
||||
- **Tint = meaning only:** unread/synced status, selection, primary save. Everything else neutral.
|
||||
- **Wordmark only on the main list, fade on scroll** (NYT Cooking pattern). "People don't need to
|
||||
be reminded which app they're using."
|
||||
|
||||
> "When your hierarchy is strong, the most important item on the screen is always the most obvious one."
|
||||
|
||||
---
|
||||
|
||||
## SwiftUI wins (two are nearly free on iOS 26 once built with Xcode 27)
|
||||
|
||||
1. **`AsyncImage` now HTTP-caches by default** → favicons/thumbnails survive scroll-away/back.
|
||||
Biggest free win for an image-heavy list. Add a sized `URLCache` via `.asyncImageURLSession(_:)`
|
||||
if loading many.
|
||||
2. **`@State` is now a lazy macro** → row/view models stop re-allocating on every parent update
|
||||
(back-ports to iOS 17+). *One source-break to grep for:* a `@State` var with both a default
|
||||
value **and** an `init` assignment now errors.
|
||||
3. `toolbarMinimizeBehavior(.onScrollDown, for: .navigationBar)` to reclaim reading space.
|
||||
4. Drag-to-reorder via `reorderable`/`reorderContainer`; swipe actions work outside `List` via
|
||||
`swipeActionsContainer` if rows move to `LazyVStack`.
|
||||
|
||||
### Scroll-perf traps that bite editorial lists specifically
|
||||
|
||||
- **Don't put conditional/dynamic content in `ForEach` leaf rows** (`if env { Row() }`) — keeps
|
||||
off-screen rows alive and re-evaluating. **Filter at the data level** (SwiftData `#Predicate`
|
||||
on `@Query`).
|
||||
- **Configure rows in their initializer, not `onAppear`** (and `onAppear` isn't guaranteed in lazy
|
||||
stacks — fine for paging, wrong for setup).
|
||||
- Scroll-driven effects: use **scale/opacity**, never a transform that moves the row's frame, or
|
||||
lazy rows vanish early.
|
||||
- Read **`onScrollTargetVisibilityChange`** (visibility), not content-offset, for
|
||||
"jump to top/unread" buttons.
|
||||
|
||||
---
|
||||
|
||||
## Search
|
||||
|
||||
- Put search in the **bottom toolbar** (most reachable; animates up over the keyboard) unless a
|
||||
sheet blocks the bottom. `.searchable(text:placement:prompt:)`.
|
||||
- Add a **scope bar** (`.searchScopes`) for All / Unread / tag; optionally layer **search tokens**
|
||||
on top — but pair tokens with the visible scope bar (tokens alone aren't discoverable).
|
||||
- **`ContentUnavailableView.search(text:)`** for no-results, echoing the query so typos are obvious.
|
||||
|
||||
---
|
||||
|
||||
## Widget (MarksWidget)
|
||||
|
||||
- **`AppIntentConfiguration`** so users pick a tag/collection (1–2 params, sensible default, don't
|
||||
force config). Reuse the existing app group (`group.com.magicive.marks`).
|
||||
- Reload policy **`.never`** + call **`WidgetCenter.shared.reloadTimelines(ofKind:)` when the app
|
||||
*and the share extension* background after a save** — content changes on save, not on a clock.
|
||||
- **`.widgetAccentedRenderingMode(.fullColor)` on favicons/thumbnails** — otherwise they render as
|
||||
white rectangles in tinted/clear Home Screen modes. Test full-color / tinted / clear.
|
||||
- Carry the app's editorial palette for instant recognizability; consider iOS 27's new
|
||||
`.systemExtraLargePortrait` for a richer list. Deep-link rows with `.widgetURL`.
|
||||
|
||||
---
|
||||
|
||||
## Prototyping workflow
|
||||
|
||||
The "create UI prototypes with agents in Xcode" flow fits well: ask for *many named SwiftUI
|
||||
previews* of BookmarkListRow variants against **realistic sample data + edge cases** (long titles,
|
||||
missing thumbnails, hundreds of rows), then remix the ones you like. Output is real native SwiftUI
|
||||
you keep. Best targets: BookmarkListRow layouts, the search results + empty states, the row→detail
|
||||
transition, and the widget's families.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting principles (Principles of Great Design)
|
||||
|
||||
- **Agency / forgiveness:** undo over confirmation for destructive actions; only interrupt for
|
||||
genuinely big mistakes (bulk delete).
|
||||
- **Familiarity:** standard symbols/metaphors; things that look the same behave the same.
|
||||
- **Simplicity = removing friction**, not minimalism. "You'll know you've arrived at simplicity
|
||||
when you have exactly enough."
|
||||
- **Craft = materials:** good fonts, adaptive colors, crisp icons, responsive animation. Watch the
|
||||
anti-patterns: jittery scrolling, misaligned icons, layout breaking on rotation, taps with no
|
||||
immediate response — these are the trust-killers in a scrolling list.
|
||||
- **Dark Mode is non-negotiable.**
|
||||
- **Delight is the result of getting everything else right** — pick the target emotion (calm,
|
||||
editorial, focused) and reinforce it everywhere.
|
||||
|
||||
---
|
||||
|
||||
## Suggested punch-list order
|
||||
|
||||
1. `AsyncImage` caching + `.widgetAccentedRenderingMode(.fullColor)` (free correctness/perf).
|
||||
2. Audit `@State` view models for the lazy-init source-break before bumping SDK.
|
||||
3. Move search to bottom toolbar + scope bar + `ContentUnavailableView.search`.
|
||||
4. Row Dynamic Type wrapping audit; confirm no conditional content in `ForEach` leaves.
|
||||
5. Widget → `AppIntentConfiguration` + `.never` reload + reload-on-save from app & share extension.
|
||||
|
||||
---
|
||||
|
||||
## Codebase audit (current vs. brief)
|
||||
|
||||
### Already compliant
|
||||
- Native UI layer; brand lives in rows; no toolbar color letterboxing (`BookmarksView.swift`).
|
||||
- Standard swipe metaphors — trash/archivebox/pencil (`BookmarkListRow.swift`).
|
||||
- Dark-mode-safe: semantic colors throughout.
|
||||
- `ContentUnavailableView.search(text:)` already used (`SearchView.swift`).
|
||||
- Deep links via `Link(destination: .marksBookmark(...))` (`RecentBookmarksWidget.swift`).
|
||||
- Reload-on-save already wired (`WidgetDataStore.saveBookmarks` → `reloadTimelines`).
|
||||
- `onAppear` paging — the sanctioned lazy-list use (`BookmarksView.swift`).
|
||||
|
||||
### Fixed (this pass)
|
||||
- **P1 Dynamic Type** — `BookmarkRow` converted from fixed `.system(size:)` points to text
|
||||
styles: title `.headline`, domain/date `.footnote`, summary `.subheadline`, tags
|
||||
`.caption`, podcast glyph `.title3`. Title already wraps (`lineLimit(2)` + `fixedSize`).
|
||||
- **P2 disk I/O in `body`** — `podcastCached` was `FileManager.fileExists` evaluated during
|
||||
render (per row, per scroll frame). Moved to `@State` populated by a `.task(id: bookmark.url)`
|
||||
that stats off the main actor — once per appearance, not per render.
|
||||
- **P3 unrecoverable delete** — destructive trailing swipe changed to `allowsFullSwipe: false`
|
||||
so a single fling no longer deletes instantly; requires tapping the revealed button.
|
||||
|
||||
### Still open (optional)
|
||||
- Favicon `URLCache` sizing via `.asyncImageURLSession(_:)` (iOS 26 already caches by default).
|
||||
- True **undo** for delete (snackbar + soft-delete) — current fix only removes the accidental
|
||||
full-swipe; an explicit tap still deletes immediately.
|
||||
- Widget fonts still use fixed sizes (left as-is — widgets clamp Dynamic Type; lower value).
|
||||
- Search scope bar (`.searchScopes`) for All / Unread / tag.
|
||||
- Widget `AppIntentConfiguration`, more families incl. `.systemExtraLargePortrait`, interactive
|
||||
"mark read" `AppIntent`.
|
||||
11
openwiki/.last-update.json
Normal file
11
openwiki/.last-update.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"updatedAt": "2026-07-02T05:28:44Z",
|
||||
"gitHead": "b496e607fc37e795c765355dd99c191518e6f7f2",
|
||||
"mode": "init",
|
||||
"pages": [
|
||||
"openwiki/quickstart.md",
|
||||
"openwiki/architecture/app-architecture.md",
|
||||
"openwiki/workflows/product-workflows.md",
|
||||
"openwiki/operations/development-and-testing.md"
|
||||
]
|
||||
}
|
||||
174
openwiki/architecture/app-architecture.md
Normal file
174
openwiki/architecture/app-architecture.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# App Architecture
|
||||
|
||||
This page explains how the major Marks runtime pieces fit together. For product-level flows, see [Product workflows](../workflows/product-workflows.md). For setup and verification, see [Development and testing](../operations/development-and-testing.md).
|
||||
|
||||
## Targets and source layout
|
||||
|
||||
`/project.yml` is the XcodeGen project definition. It defines four targets:
|
||||
|
||||
- `Marks` — the main iOS app. Sources include `/Marks` plus `/MarksWidget/WidgetDataStore.swift` so the app can write widget data.
|
||||
- `ShareExtension` — app extension with `/ShareExtension` plus selected shared model/service files from `/Marks` (`Bookmark`, `IngestPayload`, `ServerConfig`, `LinkdingAPI`, `MarksAuth`, `Log`, `PodcastRequests`).
|
||||
- `MarksWidget` — WidgetKit extension with `/MarksWidget`.
|
||||
- `MarksTests` — unit-test bundle depending on `Marks`.
|
||||
|
||||
The app is Swift 6.0 and targets iOS 26.0. The main app, share extension, and widget extension share the app group `group.com.magicive.marks` through entitlements and runtime code.
|
||||
|
||||
## App entrypoint and navigation shell
|
||||
|
||||
`/Marks/MarksApp.swift` contains the `@main` app and `MainContainer`.
|
||||
|
||||
At startup:
|
||||
|
||||
1. `MarksApp` loads a saved `ServerConfig` or falls back to a built-in default config.
|
||||
2. `MainContainer` constructs `LinkdingAPI(config:)`.
|
||||
3. `MainContainer` stores `BookmarksViewModel(api:cacheKey:)` in `@State`, keeping the model stable across SwiftUI re-renders.
|
||||
4. `MainContainer` also creates an `IngestedSourceLibrary` for local source storage.
|
||||
5. A top-level `TabView` presents Bookmarks, Tags, Sources, Podcasts, and Search.
|
||||
|
||||
`MainContainer` also handles:
|
||||
|
||||
- custom deep links (`marks://bookmark?...`, `marks://podcast?...`),
|
||||
- App Intent handoff through `IntentRouter.shared`,
|
||||
- pending podcast requests queued by the share extension,
|
||||
- foreground refreshes and background podcast progress persistence.
|
||||
|
||||
## Central state: `BookmarksViewModel`
|
||||
|
||||
`/Marks/Views/BookmarksViewModel.swift` is `@Observable @MainActor` and is the core app state object. It owns:
|
||||
|
||||
- loaded bookmarks, errors, loading flags, pagination URL, search query, and unread filter,
|
||||
- generated smart collections and enrichment progress,
|
||||
- a `LinkdingAPI` actor for server sync,
|
||||
- `ClaudeService` for backend AI/podcast calls,
|
||||
- `PodcastPlayerViewModel` and `PodcastGenerationManager`,
|
||||
- the disk bookmark cache URL.
|
||||
|
||||
Key side effects during bookmark loading and mutations:
|
||||
|
||||
- `load()` optionally restores cached bookmarks from Application Support, fetches linkding bookmarks, restores local AI metadata, saves the full unfiltered cache, writes recent bookmarks to `WidgetDataStore`, and indexes bookmarks into Spotlight.
|
||||
- `loadMore()` follows linkding's `next` URL and indexes newly appended bookmarks.
|
||||
- `delete()` and `archive()` mutate linkding, remove local entries, and remove Spotlight entries.
|
||||
- `addBookmark()` saves to linkding, inserts locally, indexes the bookmark, and starts best-effort AI enrichment.
|
||||
- `enrichAll()` and background `startEnrichment()` call AI enrichment, persist AI metadata, and reindex enriched bookmarks.
|
||||
|
||||
Because one `BookmarksViewModel` instance is shared by all tabs, array mutations and filters have cross-tab effects.
|
||||
|
||||
## Linkding integration
|
||||
|
||||
`/Marks/Services/LinkdingAPI.swift` is an actor wrapping linkding's REST API. It builds URLs from `ServerConfig` (`/Marks/Models/ServerConfig.swift`) and sends `Authorization: Token ...` headers.
|
||||
|
||||
Implemented operations include:
|
||||
|
||||
- `fetchBookmarks(search:offset:limit:unread:)` → `GET /api/bookmarks/`,
|
||||
- `fetchBookmarksFromUrl(_:)` for pagination `next` URLs,
|
||||
- `checkBookmark(url:)` → `GET /api/bookmarks/check/`, used by the share extension,
|
||||
- `fetchTags(limit:)` → `GET /api/tags/`,
|
||||
- `createBookmark(_:)` → `POST /api/bookmarks/`,
|
||||
- `updateBookmark(id:update:)` → `PATCH /api/bookmarks/{id}/`,
|
||||
- `deleteBookmark(id:)` → `DELETE /api/bookmarks/{id}/`,
|
||||
- `archiveBookmark(id:)` → `POST /api/bookmarks/{id}/archive/`,
|
||||
- `verifyConnection()` for configuration checks.
|
||||
|
||||
`Bookmark`, `BookmarkResponse`, create/update payloads, `BookmarkCheck`, tag response models, and `SmartCollection` live in `/Marks/Models/Bookmark.swift`.
|
||||
|
||||
## Local source ingest
|
||||
|
||||
Local source ingest is separate from linkding bookmarks. It is owned by:
|
||||
|
||||
- `/Marks/Models/IngestedSource.swift` — source metadata for text, PDF, and generic file imports.
|
||||
- `/Marks/Services/IngestedSourceStore.swift` — JSON metadata persistence, security-scoped file copying, PDFKit text extraction, plain-text extraction, and local search.
|
||||
- `/Marks/Services/SourceSpotlightIndexer.swift` — Spotlight indexing/removal for source title, body text, tags, kind, and original filename.
|
||||
- `/Marks/Views/SourcesView.swift` — Sources tab list/search, text import, file importer, QuickLook preview, delete, and podcast actions.
|
||||
|
||||
`IngestedSourceStore` writes `Sources/sources.json` and imported originals under `Sources/files/` in Application Support. Text imports store only metadata/body text. File imports copy the selected file into app storage; PDF imports extract text with `PDFDocument`, while other files attempt UTF-8/ASCII extraction.
|
||||
|
||||
Source podcasts use `IngestedSource.podcastURL` (`marks-source://{uuid}`) as the podcast cache/index key and send up to 100,000 characters of `bodyText` to the backend as `text`.
|
||||
|
||||
## Configuration and app-group sharing
|
||||
|
||||
`ServerConfig` contains linkding host, optional port, path, token, and HTTPS flag. Its extension stores config in two places:
|
||||
|
||||
- `UserDefaults.standard` for the main app,
|
||||
- `UserDefaults(suiteName: "group.com.magicive.marks")` for extensions.
|
||||
|
||||
`ServerConfig.loadShared()` is used by the share extension, which cannot read the main app's standard defaults. Avoid changing config keys or app-group IDs without updating all entitlements, `ServerConfig`, `WidgetDataStore`, and `PodcastRequests` together.
|
||||
|
||||
Security note: `/Marks/MarksApp.swift` currently includes a built-in default config with a token-like value. Treat it as sensitive; do not copy it into documentation or generated output.
|
||||
|
||||
## Backend AI, analytics, and podcasts
|
||||
|
||||
`/Marks/Services/MarksAuth.swift` registers an anonymous device with the Marks backend and caches a JWT in `UserDefaults.standard`. `ClaudeService` uses that token for backend calls:
|
||||
|
||||
- bookmark enrichment: `POST /v1/marks/enrich`,
|
||||
- semantic search: `POST /v1/marks/search`,
|
||||
- smart collections: `POST /v1/marks/collections`,
|
||||
- podcast generation: `POST /v1/podcast/generate`,
|
||||
- podcast polling: `GET /v1/podcast/status/{jobId}`,
|
||||
- podcast audio download: `GET /v1/podcast/audio/{jobId}`.
|
||||
|
||||
`/Marks/Services/AnalyticsService.swift` sends events through the backend path used by `ClaudeService`/`MarksAuth`.
|
||||
|
||||
Podcast generation accepts either a web URL or a local source pseudo-URL. `ClaudeService.generatePodcast(url:title:sourceText:sourceKind:)` always sends `url` and may also send `title`, `text`, and `source_kind`. Podcast audio is downloaded to Application Support under a `podcasts` directory using a stable hash of the article/source URL. `PodcastIndex` records metadata in `index.json`; `PodcastLibrary` reads that index for the Podcasts tab and widget metadata.
|
||||
|
||||
## On-device Ask and Spotlight
|
||||
|
||||
The app has two AI paths:
|
||||
|
||||
1. **Backend AI** through `ClaudeService` for enrichment, semantic search, smart collections, and podcasts.
|
||||
2. **On-device RAG** through `/Marks/Services/BookmarkAssistant.swift` for the Ask UI.
|
||||
|
||||
`BookmarkAssistant` uses `FoundationModels.LanguageModelSession` with `BookmarkSearchTool`. Its instructions require answers to be grounded in bookmarks. Retrieval flows through Spotlight:
|
||||
|
||||
- `SpotlightIndexer` indexes bookmarks after loads, load-more operations, add, and enrichment.
|
||||
- `SpotlightBookmarkSearch`/`BookmarkSearchTool` search the index for relevant bookmarks.
|
||||
- `BookmarkEntity` also conforms to indexed App Intents concepts for system-level entity discovery.
|
||||
|
||||
If changing bookmark fields, keep `Bookmark`, `BookmarkEntity`, Spotlight indexing, widget payloads, and row UI in sync. If changing source fields, keep `IngestedSource`, `IngestedSourceStore`, `SourceSpotlightIndexer`, Sources UI, and source podcast generation in sync.
|
||||
|
||||
## Share extension architecture
|
||||
|
||||
The share extension is implemented by `/ShareExtension/ShareViewController.swift` and `/ShareExtension/ShareView.swift`.
|
||||
|
||||
It accepts shared URL, HTML, RTF, plain-text, and text payloads, uses `IngestPayloadParser` to find the first bookmarkable `http`/`https` URL and preserve non-URL notes, creates a SwiftUI save card, loads shared `ServerConfig`, checks linkding for an existing bookmark, and lets the user edit tags, notes, read-later state, and a Create Podcast toggle.
|
||||
|
||||
For suggestions, it combines:
|
||||
|
||||
- linkding `checkBookmark` metadata and `auto_tags`,
|
||||
- the user's existing tag vocabulary from `fetchTags()`,
|
||||
- backend AI tag suggestions through `/ShareExtension/TagSuggester.swift`.
|
||||
|
||||
Existing bookmarks are updated rather than duplicated. New bookmarks are created through `LinkdingAPI`. Podcast generation is not performed inside the extension; it queues a `PodcastRequests` item in the app group for the main app to process later. The same parser is used by in-app Add Bookmark text import.
|
||||
|
||||
## Widget architecture and deep links
|
||||
|
||||
`/MarksWidget/MarksWidgetBundle.swift` registers three widgets:
|
||||
|
||||
- `RecentBookmarksWidget` (`systemMedium`),
|
||||
- `RandomBookmarkWidget` (`systemSmall`),
|
||||
- `RecentPodcastsWidget` (`systemMedium`).
|
||||
|
||||
Widgets read only app-group JSON through `/MarksWidget/WidgetDataStore.swift`:
|
||||
|
||||
- `widget_bookmarks.json`,
|
||||
- `widget_podcasts.json`.
|
||||
|
||||
They do not call network APIs. Main-app writes trigger `WidgetCenter.shared.reloadAllTimelines()` when WidgetKit is available.
|
||||
|
||||
Widget links use custom URLs built by `URL.marksBookmark(_:)` and `URL.marksPodcast(_:)`. `MainContainer.handleDeepLink(_:)` opens bookmark URLs in `BrowserView` or starts podcast playback and shows the player.
|
||||
|
||||
## App Intents and Shortcuts
|
||||
|
||||
`/Marks/Intents/MarksAppIntents.swift` defines:
|
||||
|
||||
- `OpenBookmarkIntent` — opens a selected `BookmarkEntity` in app.
|
||||
- `SearchMarksIntent` — conforms to the system search schema and routes text to the Search tab.
|
||||
- `AddBookmarkIntent` — saves a URL headlessly through linkding and refreshes widget data.
|
||||
- `ShowUnreadIntent` — fetches up to 10 unread bookmarks.
|
||||
- `SummarizeBookmarkIntent` — calls backend AI enrichment for a selected bookmark.
|
||||
- `MarksShortcuts` — registers Add Bookmark, Search, Unread, and Summarize shortcuts.
|
||||
|
||||
`IntentRouter` bridges intents that need to open UI back into `MainContainer`. `BookmarkEntity` maps between linkding `Bookmark` values and App Intents entities.
|
||||
|
||||
## Recent history context
|
||||
|
||||
Recent git history shows major investment in ingest and podcast UX: the latest change added local text/PDF/file sources, richer URL extraction for share/add flows, source Spotlight indexing, and source-backed podcast generation. Earlier podcast and AI changes added played/unplayed queues, background podcast generation, share-sheet audio requests, podcast library/index changes, player improvements, on-device RAG, Spotlight indexing, and unified logging. Use those areas cautiously because they span app state, storage, widgets, share extension, and App Intents rather than a single view.
|
||||
170
openwiki/operations/development-and-testing.md
Normal file
170
openwiki/operations/development-and-testing.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Development and Testing
|
||||
|
||||
This page covers local setup, generated project guidance, runtime configuration/storage, and verification strategy for changes.
|
||||
|
||||
## Local setup
|
||||
|
||||
Requirements from `/README.md` and `/project.yml`:
|
||||
|
||||
- macOS with Xcode capable of building the declared iOS 26 / Swift 6 project.
|
||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen).
|
||||
- A linkding server with API access for real sync testing.
|
||||
- Access to the Marks backend for AI, semantic search, smart collections, analytics, and podcast generation flows.
|
||||
|
||||
Setup:
|
||||
|
||||
```bash
|
||||
brew install xcodegen
|
||||
xcodegen generate
|
||||
open Marks.xcodeproj
|
||||
```
|
||||
|
||||
Build and run the `Marks` scheme in Xcode. `/project.yml` declares schemes for `Marks`, `MarksWidget`, and `ShareExtension`; the `Marks` scheme builds the app, share extension, and widget extension and runs `MarksTests` in the test action.
|
||||
|
||||
## Project generation rules
|
||||
|
||||
Use `/project.yml` as the source of truth for targets, source membership, entitlements, and dependencies. Regenerate the Xcode project after changing target membership:
|
||||
|
||||
```bash
|
||||
xcodegen generate
|
||||
```
|
||||
|
||||
Common target-membership traps:
|
||||
|
||||
- The main app includes `/MarksWidget/WidgetDataStore.swift` directly so it can write widget JSON.
|
||||
- The share extension includes selected shared files from `/Marks/Models` and `/Marks/Services`, including `IngestPayload.swift` for URL extraction; if the extension needs new model/service code, add it in `/project.yml`.
|
||||
- The app, widget, and share extension all depend on matching app-group entitlements.
|
||||
|
||||
## Testing
|
||||
|
||||
Current automated test coverage is concentrated in `/MarksTests/AppIntentsTests.swift`.
|
||||
|
||||
Covered today:
|
||||
|
||||
- `BookmarkEntity` mapping from `Bookmark`, fallback title behavior, and round-trip fields.
|
||||
- `EntityIdentifier` encoding.
|
||||
- `IntentRouter` open/search setters.
|
||||
- Direct no-network `perform()` tests for Search and Open intents.
|
||||
- App Shortcuts registration count.
|
||||
- `IngestPayloadParser` URL extraction from direct URLs, plain text, HTML-ish text, and notes.
|
||||
- `IngestedSourceStore` save/load behavior plus plain-text file import into a temporary root.
|
||||
|
||||
Not currently covered by repository tests:
|
||||
|
||||
- `BookmarksViewModel` load/cache/pagination/mutation flows.
|
||||
- Linkding networking with mocks.
|
||||
- AI enrichment, semantic search, smart collections, or podcast backend calls.
|
||||
- Share extension save/update/tag suggestion flows.
|
||||
- Widget timeline rendering/deep links.
|
||||
- Browser reading progress/reader mode.
|
||||
- Podcast generation, playback, queue, played/unplayed, and background audio behavior.
|
||||
- Sources tab UI, QuickLook presentation, PDF extraction edge cases, and source-backed podcast backend behavior.
|
||||
|
||||
Suggested test command:
|
||||
|
||||
```bash
|
||||
xcodebuild test -scheme Marks -destination 'platform=iOS Simulator,name=iPhone 16'
|
||||
```
|
||||
|
||||
Use an installed simulator name if `iPhone 16` is unavailable.
|
||||
|
||||
## Manual verification checklist by area
|
||||
|
||||
Bookmark sync/list changes:
|
||||
|
||||
- Launch with valid linkding config.
|
||||
- Confirm cached bookmarks render quickly, then network refresh updates the list.
|
||||
- Test pull-to-refresh, pagination, unread filter, add, edit, archive, delete.
|
||||
- Confirm widget bookmark JSON updates and Spotlight indexing still happens.
|
||||
|
||||
Search/tag changes:
|
||||
|
||||
- Test linkding search and semantic search separately.
|
||||
- Verify unread filter interactions.
|
||||
- Confirm Tags tab counts include expected linkding and AI tags.
|
||||
|
||||
AI changes:
|
||||
|
||||
- Test enrichment on add, background enrichment, Enrich All, semantic search, Smart Collections, and Summarize intent if affected.
|
||||
- Test Ask on a device/simulator environment where Apple on-device model availability can be observed; handle unavailable states.
|
||||
- Preserve backend privacy expectations: URLs/titles/tags/summaries may be sent to the Marks backend for backend AI paths.
|
||||
|
||||
Share extension changes:
|
||||
|
||||
- Share a new URL, an already-saved URL, and text/HTML/RTF content that contains a URL plus notes.
|
||||
- Verify duplicate-aware update behavior.
|
||||
- Test linkding auto-tags, AI suggestion chips, tag parsing, notes, read-later, and Create Podcast.
|
||||
- Confirm the extension works when shared config is missing; current UI shows an "Open Marks first" style failure.
|
||||
|
||||
Source ingest changes:
|
||||
|
||||
- Import typed text with title/tags and confirm it appears in Sources search.
|
||||
- Import a text file and a PDF through the file importer; confirm originals copy into Application Support and QuickLook opens imported files.
|
||||
- Confirm PDF text extraction works for text PDFs and handles image-only PDFs gracefully.
|
||||
- Delete a source and confirm metadata, copied file, and Spotlight entry are removed.
|
||||
- Create a podcast from a source and confirm cached playback uses the same `marks-source://` entry.
|
||||
|
||||
Podcast changes:
|
||||
|
||||
- Generate from bookmark row and Browser view.
|
||||
- Start generation while another podcast is playing; confirm background generation does not interrupt playback.
|
||||
- Test queue playback, played/unplayed state, delete, sleep timer, saved speed, background audio, remote controls, now-playing metadata, relaunch restore, and widget recent podcasts.
|
||||
- Share a URL with Create Podcast and confirm the main app drains and starts the queued request.
|
||||
- Generate from a local text/PDF source and confirm backend support for `text` and `source_kind`.
|
||||
|
||||
Widget/deep-link changes:
|
||||
|
||||
- Confirm `widget_bookmarks.json` and `widget_podcasts.json` are refreshed by main-app writes.
|
||||
- Test Recent Bookmarks, Random Bookmark, and Recent Podcasts widgets.
|
||||
- Tap bookmark and podcast widget entries and verify `marks://bookmark` and `marks://podcast` routing in `MainContainer`.
|
||||
|
||||
App Intents changes:
|
||||
|
||||
- Run `MarksTests`.
|
||||
- Test Shortcuts/Siri flows for Add Bookmark, Search, Show Unread, Summarize, and Open Bookmark.
|
||||
- Verify entity queries still fetch/resolve bookmarks correctly with a configured linkding server.
|
||||
|
||||
## Configuration and persistence map
|
||||
|
||||
Do not read or document live secret values.
|
||||
|
||||
Main app / standard defaults:
|
||||
|
||||
- `serverConfig` — linkding config saved by `ServerConfig.save()`.
|
||||
- `marksDeviceId`, `marksJWT`, `marksJWTExpiry` — Marks backend auth state from `MarksAuth`.
|
||||
- `aiData` — AI summaries/tags by bookmark ID.
|
||||
- `aiSummaryByURL` — summary mirror used outside the main bookmark array.
|
||||
- `podcastProgress`, `podcastPlaybackSpeed`, `podcastSession` — podcast playback state.
|
||||
- `readingProgress` — in-browser reading progress.
|
||||
|
||||
App-group defaults/files (`group.com.magicive.marks`):
|
||||
|
||||
- `serverConfig` — shared linkding config for extensions.
|
||||
- `pendingPodcastRequests` — share extension to main app podcast queue.
|
||||
- `podcastAutoTags` — tags that auto-enable podcast creation in the share extension.
|
||||
- `widget_bookmarks.json` — recent bookmark widget cache.
|
||||
- `widget_podcasts.json` — recent podcast widget cache.
|
||||
|
||||
Application Support files:
|
||||
|
||||
- `bookmarks_cache.json` or `bookmarks_{host}.json` — cached bookmarks.
|
||||
- `Sources/sources.json` — local source metadata.
|
||||
- `Sources/files/{uuid}.{ext}` — copied originals for imported PDFs/files.
|
||||
- `podcasts/{hash}.mp3` — generated podcast audio.
|
||||
- `podcasts/index.json` — podcast metadata and played state.
|
||||
|
||||
## Known caveats and stale-doc risks
|
||||
|
||||
- `/Marks/MarksApp.swift` contains a built-in default linkding configuration with a token-like value. Treat it as sensitive and consider replacing it with a safer onboarding/configuration flow before distribution.
|
||||
- `OnboardingView` exists but is not the active first-launch path in `MarksApp` as currently written.
|
||||
- Disconnect behavior currently resets via the default config callback path instead of clearly deleting config and returning to onboarding.
|
||||
- README AI provider text appears older than source: current backend AI/podcast features go through `MarksAuth` + `ClaudeService`, while Ask uses Apple's on-device `FoundationModels`.
|
||||
- `MarksAuth` stores backend JWT/device state in `UserDefaults`, not Keychain.
|
||||
- App-group ID strings are duplicated in code and entitlements; update all occurrences together if changing it.
|
||||
- Share-extension podcast queue draining clears requests before generation success, so crash/retry semantics deserve care.
|
||||
- Source-backed podcast generation sends extracted text to the Marks backend; if the backend only accepts URL fetches, local source podcasts will fail even though bookmark podcasts still work.
|
||||
- Recent git history shows source ingest, podcast, and on-device AI features were added across many files; avoid narrow changes that ignore cross-target data flow.
|
||||
|
||||
## Existing design guidance
|
||||
|
||||
`/docs/design-wwdc26.md` is a useful source for UI direction. It emphasizes native iOS 26 UI/Liquid Glass behavior, editorial bookmark rows as the brand canvas, Dynamic Type-safe row layout, bottom/reachable search patterns, WidgetKit app-group data, and standard platform affordances. Link to it rather than duplicating the full design brief.
|
||||
85
openwiki/quickstart.md
Normal file
85
openwiki/quickstart.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Marks OpenWiki Quickstart
|
||||
|
||||
Marks is a native SwiftUI iOS client for [linkding](https://github.com/sissbruecker/linkding), a self-hosted bookmark manager. The app targets iOS 26 and Swift 6, and includes a main app, share extension, widget extension, App Intents, AI-assisted bookmark features, local source ingest, and podcast generation/playback.
|
||||
|
||||
Start here when changing the repository, then follow the page links below for the area you are touching.
|
||||
|
||||
## What this repository contains
|
||||
|
||||
- **Main iOS app** in `/Marks` with SwiftUI tabs for bookmarks, tags, sources, podcasts, and search. The entrypoint is `/Marks/MarksApp.swift`.
|
||||
- **Models** in `/Marks/Models`, centered on `Bookmark`, linkding response/create/update payloads, tags, smart collections, `ServerConfig`, URL ingest payloads, and locally ingested sources.
|
||||
- **Services** in `/Marks/Services` for linkding networking, AI/backend calls, auth, on-device bookmark Q&A, Spotlight indexing, source storage, widget/podcast data, analytics, and logging.
|
||||
- **Views** in `/Marks/Views` for the primary product surfaces. `BookmarksViewModel` is the central app state object.
|
||||
- **App Intents** in `/Marks/Intents` for opening, searching, adding, listing unread, and summarizing bookmarks.
|
||||
- **Share extension** in `/ShareExtension` for extracting URLs from shared URLs, text, HTML, and RTF, saving to linkding, and queueing podcast generation.
|
||||
- **Widget extension** in `/MarksWidget` for recent bookmarks, random bookmark, and recent podcast widgets.
|
||||
- **Tests** in `/MarksTests`, currently focused on App Intents plus ingest parser/source-store coverage.
|
||||
- **XcodeGen project spec** in `/project.yml`; `Marks.xcodeproj` is generated/checked in but `project.yml` is the project definition to inspect first.
|
||||
- **Design notes** in `/docs/design-wwdc26.md` for iOS 26/Liquid Glass, search, widgets, and row-design guidance.
|
||||
|
||||
## Major documentation pages
|
||||
|
||||
- [Architecture notes](architecture/app-architecture.md) — target layout, state flow, networking, persistence, AI, Spotlight, extensions, widgets, and App Intents.
|
||||
- [Product workflows](workflows/product-workflows.md) — how bookmark browsing, saving, AI enrichment, Ask, smart collections, podcasts, widgets, and intents behave.
|
||||
- [Development and testing](operations/development-and-testing.md) — setup/build/test commands, generated project guidance, storage/config map, verification checklist, and caveats.
|
||||
|
||||
## Build and run
|
||||
|
||||
The README documents the intended local setup:
|
||||
|
||||
```bash
|
||||
brew install xcodegen
|
||||
xcodegen generate
|
||||
open Marks.xcodeproj
|
||||
```
|
||||
|
||||
Then build and run from Xcode using the `Marks` scheme. `/project.yml` defines Swift 6.0, iOS 26.0 deployment target, the `Marks`, `ShareExtension`, `MarksWidget`, and `MarksTests` targets, and the app-group entitlements used by all app surfaces.
|
||||
|
||||
Useful checks before shipping a change:
|
||||
|
||||
```bash
|
||||
xcodegen generate
|
||||
xcodebuild test -scheme Marks -destination 'platform=iOS Simulator,name=iPhone 16'
|
||||
```
|
||||
|
||||
Adjust the simulator name to what is installed locally.
|
||||
|
||||
## Runtime architecture in one minute
|
||||
|
||||
At launch, `MarksApp` loads a `ServerConfig`, constructs `LinkdingAPI`, and stores a shared `BookmarksViewModel` in `MainContainer` so it survives SwiftUI re-renders (`/Marks/MarksApp.swift`, `/Marks/Views/BookmarksViewModel.swift`). The top-level `TabView` has Bookmarks, Tags, Sources, Podcasts, and Search tabs.
|
||||
|
||||
`BookmarksViewModel` fetches paginated bookmarks from linkding through `LinkdingAPI`, restores local AI metadata, caches bookmarks to Application Support, writes recent bookmark metadata for widgets, and indexes bookmarks into Spotlight. It also owns `ClaudeService`, `PodcastPlayerViewModel`, and `PodcastGenerationManager`, so AI, podcast, search, and list state are shared across tabs. `MainContainer` separately owns `IngestedSourceLibrary` for the Sources tab.
|
||||
|
||||
Local sources are stored in Application Support under `Sources/`: `sources.json` stores metadata, `Sources/files/` stores imported files, PDF text is extracted with PDFKit, and sources are indexed into Spotlight through `SourceSpotlightIndexer`.
|
||||
|
||||
Extensions and widgets use the shared app group `group.com.magicive.marks`. `ServerConfig.save()` writes to standard defaults and the app-group defaults; `WidgetDataStore` writes app-group JSON files for widgets; `PodcastRequests` uses app-group defaults to hand podcast requests from the share extension to the main app.
|
||||
|
||||
## External systems and privacy boundaries
|
||||
|
||||
- **linkding** is the source of truth for bookmarks and tags. `LinkdingAPI` uses token auth and calls linkding bookmark, check, tag, create, update, delete, archive, and connection-test endpoints.
|
||||
- **Marks backend** is used by `MarksAuth`/`ClaudeService` for AI enrichment, semantic search, smart collections, analytics events, and podcast generation/audio download.
|
||||
- **Apple on-device intelligence and Spotlight** power the in-app Ask surface via `FoundationModels`, `CoreSpotlight`, and the `BookmarkSearchTool` retrieval path. Spotlight is also used for locally ingested sources.
|
||||
- **WidgetKit, App Intents, AVFoundation, and MediaPlayer** support widgets, Siri/Shortcuts integration, podcast playback, background audio, now-playing metadata, and remote controls.
|
||||
|
||||
Do not document or expose secret values. `/Marks/MarksApp.swift` currently contains a built-in default server configuration with a token-like value; treat it as sensitive source material and avoid copying it into docs, logs, screenshots, or tests.
|
||||
|
||||
## Important caveats for future agents
|
||||
|
||||
- The README says optional AI features use OpenRouter/Gemini directly. Current source routes backend AI/podcast calls through `MarksAuth` + `ClaudeService`; the in-app Ask feature is on-device via `FoundationModels`. Prefer source evidence when changing AI behavior.
|
||||
- `OnboardingView` exists, but current launch flow uses saved-or-default config directly. Do not assume onboarding is active without changing `/Marks/MarksApp.swift`.
|
||||
- Settings' disconnect callback currently resets to the built-in default config path rather than forcing onboarding. Confirm intended product behavior before changing account/config flows.
|
||||
- `BookmarksViewModel` is shared across tabs, so changes to bookmark arrays, filters, search, enrichment, and pagination can affect Bookmarks, Tags, Search, Podcasts, widgets, and Spotlight indexing.
|
||||
- Share-extension podcast requests are queued in shared defaults and drained by the main app on launch/foreground. Be careful with crash/retry semantics when touching that flow.
|
||||
- Local source podcasts use `marks-source://{uuid}` pseudo-URLs plus extracted text sent to `ClaudeService.generatePodcast(...)`. The backend must accept `text` and `source_kind` for non-web podcast generation.
|
||||
- Existing automated tests cover App Intents plus focused ingest parser/source-store behavior. For UI, networking, sync, share extension, widgets, and podcasts, add tests where feasible and perform manual verification.
|
||||
|
||||
## Where to start for common changes
|
||||
|
||||
- Bookmark list, pagination, search, tags, enrichment state: `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Views/BookmarksView.swift`, `/Marks/Services/LinkdingAPI.swift`.
|
||||
- Linkding API changes: `/Marks/Services/LinkdingAPI.swift`, `/Marks/Models/Bookmark.swift`, `/ShareExtension/ShareView.swift`, `/Marks/Intents/IntentSupport.swift`.
|
||||
- AI/backend changes: `/Marks/Services/ClaudeService.swift`, `/Marks/Services/MarksAuth.swift`, `/Marks/Services/BookmarkAssistant.swift`, `/Marks/Services/BookmarkSearchTool.swift`.
|
||||
- Source ingest and local documents: `/Marks/Views/SourcesView.swift`, `/Marks/Models/IngestedSource.swift`, `/Marks/Services/IngestedSourceStore.swift`, `/Marks/Services/SourceSpotlightIndexer.swift`.
|
||||
- Podcast generation/playback: `/Marks/Services/PodcastGenerationManager.swift`, `/Marks/Services/PodcastIndex.swift`, `/Marks/Views/PodcastPlayerView.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Views/SourcesView.swift`.
|
||||
- Share extension and text/link extraction: `/Marks/Models/IngestPayload.swift`, `/ShareExtension/ShareViewController.swift`, `/ShareExtension/ShareView.swift`, `/ShareExtension/TagSuggester.swift`, plus shared files declared in `/project.yml`.
|
||||
- Widgets/deep links: `/MarksWidget/WidgetDataStore.swift`, `/MarksWidget/*Widget.swift`, `/Marks/MarksApp.swift`.
|
||||
- App Intents/Siri/Shortcuts: `/Marks/Intents/*`, `/MarksTests/AppIntentsTests.swift`.
|
||||
174
openwiki/workflows/product-workflows.md
Normal file
174
openwiki/workflows/product-workflows.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Product Workflows
|
||||
|
||||
This page describes the user-facing behavior implemented by the app and the source files that own each flow.
|
||||
|
||||
## Browse bookmarks
|
||||
|
||||
Primary sources: `/Marks/Views/BookmarksView.swift`, `/Marks/Views/BookmarkListRow.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Services/LinkdingAPI.swift`.
|
||||
|
||||
The Bookmarks tab loads linkding bookmarks through `BookmarksViewModel.load()`. The view supports loading skeletons, pull-to-refresh, pagination as the user scrolls, an unread filter, and row actions.
|
||||
|
||||
A bookmark row displays title/domain/date context, unread state, available excerpt/AI summary, tags/AI tags, reading progress, and podcast-cache state. Tapping opens `BrowserView`. Swipe/context actions support archive, delete, edit, Safari open, and podcast conversion.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- Keep row actions aligned across `BookmarksView`, `BookmarkListRow`, `BookmarkRow`, and `BrowserView`.
|
||||
- `BookmarksViewModel.saveToCache(_:)` intentionally skips saving when the unread filter is active, so filtered results do not replace the full cache.
|
||||
- Pagination uses linkding's `next` URL through `fetchBookmarksFromUrl(_:)`; do not rebuild next URLs unless the linkding API contract changes.
|
||||
|
||||
## Add and edit bookmarks in app
|
||||
|
||||
Primary sources: `/Marks/Views/AddBookmarkView.swift`, `/Marks/Views/EditBookmarkView.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Models/Bookmark.swift`.
|
||||
|
||||
The Add view collects URL, optional title, description/notes, and comma-separated tags, normalizes/validates the URL, and calls `BookmarksViewModel.addBookmark(url:title:description:tags:)`. It also has a paste/import text path that uses `IngestPayloadParser` to extract the first `http`/`https` URL from plain text and preserve surrounding notes as the bookmark description. Added bookmarks are inserted locally, indexed into Spotlight, and enriched in a background task.
|
||||
|
||||
The Edit view updates URL, title, description, tags, unread, and shared state through `BookmarkUpdate`. `BookmarksViewModel.updateBookmark(_:)` preserves locally stored AI summary/tags when replacing the bookmark returned from linkding.
|
||||
|
||||
## Search and semantic search
|
||||
|
||||
Primary sources: `/Marks/Views/SearchView.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Services/ClaudeService.swift`.
|
||||
|
||||
The Search tab has two paths:
|
||||
|
||||
- normal search through linkding's bookmark API (`q` parameter),
|
||||
- semantic search through `ClaudeService.semanticSearch(query:in:)`, which fetches up to 200 bookmarks and sends a compact list to the backend for ranking.
|
||||
|
||||
`SearchMarksIntent` routes spoken/system search text into `IntentRouter`, `MainContainer` switches to the Search tab, and `SearchView` consumes the router text into its visible search field.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- Search state lives on the shared `BookmarksViewModel`; search and unread filters can affect list state outside Search.
|
||||
- Backend semantic search sends bookmark titles/domains and optional summaries to the Marks backend. Preserve that privacy boundary in UI copy and docs.
|
||||
|
||||
## Tags
|
||||
|
||||
Primary sources: `/Marks/Views/TagsView.swift`, `/Marks/Models/Bookmark.swift`.
|
||||
|
||||
The Tags tab derives tag counts from both linkding tags (`Bookmark.tagNames`) and local AI tags (`Bookmark.aiTags`), sorted by count/name. Tapping a tag shows matching bookmarks and reuses bookmark row/browser flows.
|
||||
|
||||
If changing tag semantics, check in-app Add/Edit, share extension tag parsing, linkding `fetchTags()`, AI suggestions, Tags tab counts, and App Intents bookmark entity fields.
|
||||
|
||||
## AI enrichment and smart collections
|
||||
|
||||
Primary sources: `/Marks/Services/ClaudeService.swift`, `/Marks/Views/BookmarksViewModel.swift`, `/Marks/Views/CollectionsView.swift`, `/Marks/Services/AISummaryStore.swift`.
|
||||
|
||||
AI enrichment generates a short summary and tags for a bookmark. The app stores AI metadata locally in `UserDefaults.standard` under `aiData`, keyed by bookmark ID, and mirrors summaries by URL through `AISummaryStore` for podcast/list display.
|
||||
|
||||
Enrichment can happen in three ways:
|
||||
|
||||
- after adding a bookmark,
|
||||
- as a silent background pass over up to five loaded bookmarks without summaries,
|
||||
- manually through Enrich All.
|
||||
|
||||
Smart Collections send up to 150 bookmarks to the backend and render the returned collection names, descriptions, and bookmark IDs in `CollectionsView`.
|
||||
|
||||
Caveat: README language mentions OpenRouter/Gemini directly, but current source uses the Marks backend proxy for these calls.
|
||||
|
||||
## Ask your bookmarks
|
||||
|
||||
Primary sources: `/Marks/Views/AskView.swift`, `/Marks/Services/BookmarkAssistant.swift`, `/Marks/Services/BookmarkSearchTool.swift`, `/Marks/Services/SpotlightBookmarkSearch.swift`, `/Marks/Services/SpotlightIndexer.swift`.
|
||||
|
||||
Ask is an on-device RAG workflow. `BookmarkAssistant` creates a `FoundationModels` language model session with a bookmark search tool. The model instructions say bookmarks are the only source of truth and require the tool for lookup.
|
||||
|
||||
The retrieval corpus comes from Spotlight. `BookmarksViewModel` indexes bookmarks after loading, loading more, adding, and enrichment. Deleted/archived bookmarks are removed from Spotlight.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- If Ask gives stale answers, inspect Spotlight indexing and removal paths first.
|
||||
- If changing indexed fields, update `BookmarkEntity`, `SpotlightIndexer`, search tooling, and tests where relevant.
|
||||
- The Ask path is separate from backend semantic search and does not use `ClaudeService`.
|
||||
|
||||
## Share extension save flow
|
||||
|
||||
Primary sources: `/Marks/Models/IngestPayload.swift`, `/ShareExtension/ShareViewController.swift`, `/ShareExtension/ShareView.swift`, `/ShareExtension/TagSuggester.swift`, `/Marks/Services/PodcastRequests.swift`, `/project.yml`.
|
||||
|
||||
The share extension scans URL, HTML, RTF, plain-text, and text attachments, then `IngestPayloadParser` extracts the first bookmarkable web URL. For non-URL text payloads it also preserves cleaned surrounding text as notes. On load it:
|
||||
|
||||
1. reads linkding config from app-group defaults,
|
||||
2. calls `LinkdingAPI.checkBookmark(url:)`,
|
||||
3. pre-fills existing bookmark data or scraped metadata/linkding auto-tags,
|
||||
4. fetches existing tag vocabulary and backend AI tag suggestions,
|
||||
5. lets the user edit tags, notes, read-later state, and Create Podcast.
|
||||
|
||||
On save, existing bookmarks are patched; new bookmarks are created. If Create Podcast is enabled, or if configured auto-tags match, the extension enqueues a `PodcastRequests` item instead of generating audio itself.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- The extension is short-lived. Keep long-running work in the main app.
|
||||
- The extension depends on shared source files listed in `/project.yml`; adding service dependencies may require target membership updates.
|
||||
- `IngestPayloadParser` intentionally ignores non-HTTP(S) URLs and caps preserved notes at 1,800 characters.
|
||||
- `ShareExtension/Info.plist` currently uses a broad activation rule. Narrowing supported payloads is a product decision.
|
||||
|
||||
## Sources
|
||||
|
||||
Primary sources: `/Marks/Views/SourcesView.swift`, `/Marks/Models/IngestedSource.swift`, `/Marks/Services/IngestedSourceStore.swift`, `/Marks/Services/SourceSpotlightIndexer.swift`.
|
||||
|
||||
The Sources tab stores non-web material in Marks. Users can:
|
||||
|
||||
- import typed/pasted text with an optional title and tags,
|
||||
- import PDFs, plain text files, generic text/data files through the document picker,
|
||||
- edit source title, tags, and stored body/extracted text,
|
||||
- search across source title, body text, tags, and original filename,
|
||||
- open imported originals through QuickLook,
|
||||
- delete sources and copied files,
|
||||
- create or play a podcast from extractable source text.
|
||||
|
||||
`IngestedSourceLibrary.load()` reads `Sources/sources.json` from Application Support and indexes sources into Spotlight. Text imports write metadata/body text directly. File imports copy the selected security-scoped file into `Sources/files/`; PDFs extract text with PDFKit, while other files attempt UTF-8/ASCII extraction.
|
||||
Editing an imported file source updates Marks' stored extracted text and metadata, but does not modify the copied original file.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- Empty or image-only PDFs may import with no extractable text; podcast actions are disabled when `podcastText` is empty.
|
||||
- Source podcasts use `marks-source://{uuid}` as the cache/index URL and send extracted text plus `source_kind` to the backend.
|
||||
- If changing source storage, update editing, deletion, Spotlight removal, QuickLook file lookup, and source podcast cache behavior together.
|
||||
|
||||
## Podcasts
|
||||
|
||||
Primary sources: `/Marks/Views/PodcastPlayerView.swift`, `/Marks/Services/PodcastGenerationManager.swift`, `/Marks/Services/PodcastIndex.swift`, `/Marks/Services/PodcastLibrary.swift`, `/Marks/Services/ClaudeService.swift`, `/Marks/Services/PodcastRequests.swift`, `/Marks/Info.plist`.
|
||||
|
||||
Podcast generation converts an article URL, or a locally ingested source with extracted text, into audio through the Marks backend:
|
||||
|
||||
1. create generation job (`/v1/podcast/generate`),
|
||||
2. poll status every few seconds,
|
||||
3. download audio when done,
|
||||
4. save MP3 under Application Support `podcasts/`,
|
||||
5. upsert `PodcastIndex`,
|
||||
6. reload `PodcastLibrary`,
|
||||
7. write recent podcast metadata for widgets.
|
||||
|
||||
`BookmarksViewModel.playOrGeneratePodcast(...)` prevents interrupting current bookmark playback. If the player is idle, it starts foreground generation/playback and callers present the player. If busy, it enqueues background generation through `PodcastGenerationManager`. `SourcesView` follows the same foreground/background pattern for local sources, using `marks-source://{uuid}` plus `sourceText`/`sourceKind`.
|
||||
|
||||
The player uses AVFoundation/MediaPlayer for playback, background audio, progress, saved playback speed, restored paused session, queue playback, now-playing metadata, remote controls, sleep timer, and played/unplayed state.
|
||||
|
||||
Change guidance:
|
||||
|
||||
- The main app plist enables background audio and the custom `marks` URL scheme.
|
||||
- Widget podcast metadata is in the app group, but audio files and `PodcastIndex` are in the main app's Application Support directory.
|
||||
- Share-extension queued podcast requests are drained by `BookmarksViewModel.processPendingPodcastRequests()` on launch/foreground. Consider retry semantics before changing drain behavior.
|
||||
- Source-backed podcast generation depends on backend support for the optional `text` and `source_kind` fields in `POST /v1/podcast/generate`.
|
||||
|
||||
## Widgets and deep links
|
||||
|
||||
Primary sources: `/MarksWidget/WidgetDataStore.swift`, `/MarksWidget/RecentBookmarksWidget.swift`, `/MarksWidget/RandomBookmarkWidget.swift`, `/MarksWidget/RecentPodcastsWidget.swift`, `/Marks/MarksApp.swift`.
|
||||
|
||||
Widgets are offline readers of app-group JSON data:
|
||||
|
||||
- Recent Bookmarks shows up to three recent bookmarks and links rows to `marks://bookmark`.
|
||||
- Random Bookmark chooses a random cached bookmark and links the whole widget to `marks://bookmark`.
|
||||
- Recent Podcasts shows up to three podcasts and links rows to `marks://podcast`.
|
||||
|
||||
The main app handles bookmark links by presenting `BrowserView`; podcast links start playback and show `PodcastPlayerView`.
|
||||
|
||||
## App Intents, Siri, and Shortcuts
|
||||
|
||||
Primary sources: `/Marks/Intents/MarksAppIntents.swift`, `/Marks/Intents/BookmarkEntity.swift`, `/Marks/Intents/IntentSupport.swift`, `/Marks/Intents/BookmarkOnscreen.swift`, `/MarksTests/AppIntentsTests.swift`.
|
||||
|
||||
Implemented intents:
|
||||
|
||||
- Open Bookmark — routes a selected entity URL into the app browser.
|
||||
- Search Marks — system search schema; opens Search tab with criteria text.
|
||||
- Add Bookmark — headless linkding save and widget refresh.
|
||||
- Show Unread Bookmarks — fetches up to ten unread items.
|
||||
- Summarize Bookmark — backend AI summary for a selected bookmark.
|
||||
|
||||
`BookmarkEntity` maps linkding bookmarks into App Intents entities. Browser presentations annotate current bookmarks for on-screen entity references. Tests currently cover entity mapping, router behavior, direct no-network open/search intent perform calls, and shortcut registration.
|
||||
68
project.yml
68
project.yml
@@ -21,10 +21,13 @@ schemes:
|
||||
targets:
|
||||
Marks: all
|
||||
ShareExtension: all
|
||||
MarksWidget: all
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- MarksTests
|
||||
profile:
|
||||
config: Release
|
||||
analyze:
|
||||
@@ -32,12 +35,37 @@ schemes:
|
||||
archive:
|
||||
config: Release
|
||||
|
||||
MarksWidget:
|
||||
build:
|
||||
targets:
|
||||
Marks: all
|
||||
MarksWidget: all
|
||||
run:
|
||||
config: Debug
|
||||
analyze:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
|
||||
ShareExtension:
|
||||
build:
|
||||
targets:
|
||||
Marks: all
|
||||
ShareExtension: all
|
||||
run:
|
||||
config: Debug
|
||||
analyze:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
|
||||
targets:
|
||||
Marks:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Marks
|
||||
- path: MarksWidget/WidgetDataStore.swift
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks
|
||||
@@ -46,12 +74,25 @@ targets:
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
dependencies:
|
||||
- target: ShareExtension
|
||||
- target: MarksWidget
|
||||
- sdk: WidgetKit.framework
|
||||
- sdk: AppIntents.framework
|
||||
|
||||
ShareExtension:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: ShareExtension
|
||||
# Shared with the main app so the extension can talk to linkding + the AI
|
||||
# backend (the save card checks for duplicates and suggests tags).
|
||||
- path: Marks/Models/Bookmark.swift
|
||||
- path: Marks/Models/IngestPayload.swift
|
||||
- path: Marks/Models/ServerConfig.swift
|
||||
- path: Marks/Services/LinkdingAPI.swift
|
||||
- path: Marks/Services/MarksAuth.swift
|
||||
- path: Marks/Services/Log.swift
|
||||
# Cross-process podcast request queue: the extension enqueues, the app runs it.
|
||||
- path: Marks/Services/PodcastRequests.swift
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension
|
||||
@@ -59,3 +100,30 @@ targets:
|
||||
CODE_SIGN_ENTITLEMENTS: ShareExtension/ShareExtension.entitlements
|
||||
SKIP_INSTALL: YES
|
||||
APPLICATION_EXTENSION_API_ONLY: YES
|
||||
|
||||
MarksTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: MarksTests
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.MarksTests
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
dependencies:
|
||||
- target: Marks
|
||||
|
||||
MarksWidget:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: MarksWidget
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.MarksWidget
|
||||
INFOPLIST_FILE: MarksWidget/Info.plist
|
||||
CODE_SIGN_ENTITLEMENTS: MarksWidget/MarksWidget.entitlements
|
||||
SKIP_INSTALL: YES
|
||||
dependencies:
|
||||
- sdk: WidgetKit.framework
|
||||
- sdk: SwiftUI.framework
|
||||
|
||||
Reference in New Issue
Block a user