Compare commits
65 Commits
a4a6491c5e
...
feat/podca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
ac2f9cf85d | ||
|
|
7d1b53bac6 | ||
|
|
22cde6d2d7 | ||
|
|
bbda33ad33 | ||
|
|
1b40f50aab | ||
|
|
b7ef5c2215 | ||
|
|
cd85f9c67f | ||
|
|
582deb6cb1 | ||
|
|
efc03a2e15 | ||
|
|
7af27d361b | ||
|
|
bd1411041e | ||
|
|
06811c50f1 | ||
|
|
4998155415 | ||
|
|
f6d88c05b8 | ||
|
|
d0542fa3f0 |
50
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- swift
|
||||||
|
- 'feat/**'
|
||||||
|
- 'feature/**'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: macos
|
||||||
|
env:
|
||||||
|
DEVELOPER_DIR: /Applications/Xcode-26.3.app/Contents/Developer
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Generate Xcode project
|
||||||
|
run: xcodegen generate
|
||||||
|
|
||||||
|
- name: Build (simulator, compile check)
|
||||||
|
run: |
|
||||||
|
SIM_ID=$(xcrun simctl list devices available -j | python3 -c "
|
||||||
|
import json,sys
|
||||||
|
for rt,devs in json.load(sys.stdin)['devices'].items():
|
||||||
|
if 'iOS' in rt:
|
||||||
|
for d in devs:
|
||||||
|
if 'iPhone' in d['name'] and d['isAvailable']:
|
||||||
|
print(d['udid']); sys.exit()
|
||||||
|
" 2>/dev/null)
|
||||||
|
echo "Using simulator: $SIM_ID"
|
||||||
|
xcodebuild build \
|
||||||
|
-project Marks.xcodeproj \
|
||||||
|
-scheme Marks \
|
||||||
|
-destination "platform=iOS Simulator,id=$SIM_ID" \
|
||||||
|
-configuration Debug \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
DEVELOPMENT_TEAM=""
|
||||||
|
|
||||||
|
- name: Build IPA and deploy to OTA server
|
||||||
|
run: ./scripts/ipa-server/build-and-deploy.sh
|
||||||
|
continue-on-error: true
|
||||||
66
.gitignore
vendored
@@ -1,50 +1,26 @@
|
|||||||
# Miscellaneous
|
# Xcode user data
|
||||||
*.class
|
xcuserdata/
|
||||||
*.log
|
*.xcuserstate
|
||||||
*.pyc
|
|
||||||
*.swp
|
# Build artifacts
|
||||||
.DS_Store
|
DerivedData/
|
||||||
.atom/
|
build/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
.build/
|
.build/
|
||||||
.buildlog/
|
|
||||||
.history
|
|
||||||
.svn/
|
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
migrate_working_dir/
|
|
||||||
|
|
||||||
# IntelliJ related
|
# CocoaPods
|
||||||
*.iml
|
Pods/
|
||||||
*.ipr
|
|
||||||
*.iws
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# The .vscode folder contains launch configuration and tasks you configure in
|
# macOS
|
||||||
# VS Code which you may wish to be included in version control, so this line
|
.DS_Store
|
||||||
# is commented out by default.
|
.DS_Store?
|
||||||
#.vscode/
|
._*
|
||||||
|
|
||||||
# Flutter/Dart/Pub related
|
# VS Code
|
||||||
**/doc/api/
|
.vscode/
|
||||||
**/ios/Flutter/.last_build_id
|
autoresearch-results.tsv
|
||||||
.dart_tool/
|
|
||||||
.flutter-plugins
|
|
||||||
.flutter-plugins-dependencies
|
|
||||||
.pub-cache/
|
|
||||||
.pub/
|
|
||||||
/build/
|
|
||||||
|
|
||||||
# Symbolication related
|
|
||||||
app.*.symbols
|
|
||||||
|
|
||||||
# Obfuscation related
|
|
||||||
app.*.map.json
|
|
||||||
|
|
||||||
# Android Studio will place build artifacts here
|
|
||||||
/android/app/debug
|
|
||||||
/android/app/profile
|
|
||||||
/android/app/release
|
|
||||||
|
|
||||||
# Env file
|
|
||||||
.env
|
|
||||||
android/app/.cxx
|
|
||||||
android/build/reports/problems
|
|
||||||
|
|||||||
25
.metadata
@@ -4,7 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "41456452f29d64e8deb623a3c927524bcf9f111b"
|
revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
@@ -13,26 +13,11 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
- platform: android
|
- platform: android
|
||||||
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
||||||
- platform: ios
|
|
||||||
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
|
||||||
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
|
||||||
- platform: linux
|
|
||||||
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
|
||||||
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
|
||||||
- platform: macos
|
|
||||||
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
|
||||||
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
|
||||||
- platform: web
|
|
||||||
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
|
||||||
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
|
||||||
- platform: windows
|
|
||||||
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
|
||||||
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
|
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
17
.vscode/settings.json
vendored
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.defaultFormatter": "Dart-Code.flutter",
|
|
||||||
"dart.lineLength": 120,
|
|
||||||
"[dart]": {
|
|
||||||
"editor.rulers": [
|
|
||||||
120
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"cSpell.words": [
|
|
||||||
"gstatic",
|
|
||||||
"Linkding",
|
|
||||||
"linkdy",
|
|
||||||
"Linkdy"
|
|
||||||
],
|
|
||||||
"cSpell.language": "en,es,tr",
|
|
||||||
}
|
|
||||||
201
LICENSE.md
@@ -1,201 +0,0 @@
|
|||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2024 JGeek00
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
897
Marks.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,897 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
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 */; };
|
||||||
|
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 */; };
|
||||||
|
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 */; };
|
||||||
|
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.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 */; };
|
||||||
|
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 */; };
|
||||||
|
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 */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = CF31B2655F8F30CF6DF3C26F;
|
||||||
|
remoteInfo = ShareExtension;
|
||||||
|
};
|
||||||
|
F63E42D8413C0F6ED8BB78CF /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 76C272776D8EBCCD3FB6715A;
|
||||||
|
remoteInfo = Marks;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||||
|
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* 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; };
|
||||||
|
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>"; };
|
||||||
|
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>"; };
|
||||||
|
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>"; };
|
||||||
|
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>"; };
|
||||||
|
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>"; };
|
||||||
|
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;
|
||||||
|
children = (
|
||||||
|
57D20912C19182A45914069B /* Assets.xcassets */,
|
||||||
|
86B2DE40098D2B39FFE1AC36 /* Info.plist */,
|
||||||
|
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */,
|
||||||
|
D92575C7C710347F226EC74A /* MarksApp.swift */,
|
||||||
|
58E8E316BE3F10C5149AADC3 /* Intents */,
|
||||||
|
3EF3094F53F0378D1BF18508 /* Models */,
|
||||||
|
7ABBFDF43A1EB773E0A49CFE /* Services */,
|
||||||
|
AF9CABF75E77168EF1D60F29 /* Views */,
|
||||||
|
);
|
||||||
|
path = Marks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
3EF3094F53F0378D1BF18508 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
8CA428181B35885F7D9F4D55 /* Bookmark.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>";
|
||||||
|
};
|
||||||
|
7ABBFDF43A1EB773E0A49CFE /* Services */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */,
|
||||||
|
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
|
||||||
|
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
|
||||||
|
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
|
||||||
|
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
||||||
|
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
|
||||||
|
FB7728D15C17219ABFF3EFFE /* Log.swift */,
|
||||||
|
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
||||||
|
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */,
|
||||||
|
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
||||||
|
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */,
|
||||||
|
D27A97922BAEBDC9C5A7385C /* PodcastRequests.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 */,
|
||||||
|
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
|
||||||
|
629C41E0BC28EB6359D50CFD /* BrowserView.swift */,
|
||||||
|
FE19F7619214271A8C12EEEB /* CollectionsView.swift */,
|
||||||
|
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */,
|
||||||
|
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */,
|
||||||
|
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */,
|
||||||
|
BCC3BB2525F0F63445D419B9 /* SearchView.swift */,
|
||||||
|
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
||||||
|
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BC60767DD65812CCBD41C38F = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3952A081972B444674BEB51B /* Marks */,
|
||||||
|
3F5B1508DEFC894F33CBD16C /* MarksTests */,
|
||||||
|
85E717A515682EBF67DD199A /* MarksWidget */,
|
||||||
|
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */,
|
||||||
|
F5B3C85424137FA0CE2467B1 /* Frameworks */,
|
||||||
|
C6978BB2E0A71E9A349D5EB6 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C6978BB2E0A71E9A349D5EB6 /* Products */ = {
|
||||||
|
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 = (
|
||||||
|
);
|
||||||
|
productName = Marks;
|
||||||
|
productReference = AB6C53AB14A38FCD4CC7628D /* Marks.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
CF31B2655F8F30CF6DF3C26F /* ShareExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 94AD43D2CB52900BE36CD8DA /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
2210C849B430CC6609106C62 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = ShareExtension;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = ShareExtension;
|
||||||
|
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 */
|
||||||
|
0CC2596AE7F05EAF051B2FCD /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1630;
|
||||||
|
TargetAttributes = {
|
||||||
|
61046BF39C48393DD96687A0 = {
|
||||||
|
DevelopmentTeam = AE5DZKJHGN;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
76C272776D8EBCCD3FB6715A = {
|
||||||
|
DevelopmentTeam = AE5DZKJHGN;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
CF31B2655F8F30CF6DF3C26F = {
|
||||||
|
DevelopmentTeam = AE5DZKJHGN;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
F96181AD21F4A6755214E19B = {
|
||||||
|
DevelopmentTeam = AE5DZKJHGN;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 4BACCD33EEE63890F06D7057 /* Build configuration list for PBXProject "Marks" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = BC60767DD65812CCBD41C38F;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
76C272776D8EBCCD3FB6715A /* Marks */,
|
||||||
|
F96181AD21F4A6755214E19B /* MarksTests */,
|
||||||
|
61046BF39C48393DD96687A0 /* MarksWidget */,
|
||||||
|
CF31B2655F8F30CF6DF3C26F /* ShareExtension */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
D56AC7DEA48961F856A6CEF6 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* 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 */,
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
81AF93AC59791A6311B9055B /* Sources */ = {
|
||||||
|
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 */,
|
||||||
|
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 */,
|
||||||
|
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 */,
|
||||||
|
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 */;
|
||||||
|
targetProxy = BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
12B4EAB4496B20EF8B695599 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = AE5DZKJHGN;
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
21D2ADFC1A27251BB64A773D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Marks/Marks.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
INFOPLIST_FILE = Marks/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
46C5C95D9B4F4A1E8ABD1A98 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Marks/Marks.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
INFOPLIST_FILE = Marks/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
5F64C301FBBC85007BB1F0C2 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.ShareExtension;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
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 = {
|
||||||
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.ShareExtension;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
B87C74506F7AEF79B42CC172 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = AE5DZKJHGN;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
};
|
||||||
|
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 */
|
||||||
|
00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
21D2ADFC1A27251BB64A773D /* Debug */,
|
||||||
|
46C5C95D9B4F4A1E8ABD1A98 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
4BACCD33EEE63890F06D7057 /* Build configuration list for PBXProject "Marks" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
B87C74506F7AEF79B42CC172 /* Debug */,
|
||||||
|
12B4EAB4496B20EF8B695599 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
94AD43D2CB52900BE36CD8DA /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7AD80830605CAD3492E03039 /* Debug */,
|
||||||
|
5F64C301FBBC85007BB1F0C2 /* Release */,
|
||||||
|
);
|
||||||
|
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 */;
|
||||||
|
}
|
||||||
134
Marks.xcodeproj/xcshareddata/xcschemes/Marks.xcscheme
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<?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>
|
||||||
|
<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>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "F96181AD21F4A6755214E19B"
|
||||||
|
BuildableName = "MarksTests.xctest"
|
||||||
|
BlueprintName = "MarksTests"
|
||||||
|
ReferencedContainer = "container:Marks.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</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>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1510"
|
LastUpgradeVersion = "1630"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -14,10 +15,24 @@
|
|||||||
buildForAnalyzing = "YES">
|
buildForAnalyzing = "YES">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||||
BuildableName = "Runner.app"
|
BuildableName = "Marks.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Marks"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
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>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
@@ -26,56 +41,45 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||||
BuildableName = "Runner.app"
|
BuildableName = "Marks.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Marks"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Marks.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</MacroExpansion>
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference
|
|
||||||
skipped = "NO"
|
|
||||||
parallelizable = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
|
||||||
BuildableName = "RunnerTests.xctest"
|
|
||||||
BlueprintName = "RunnerTests"
|
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
enableGPUValidationMode = "1"
|
|
||||||
allowLocationSimulation = "YES">
|
allowLocationSimulation = "YES">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||||
BuildableName = "Runner.app"
|
BuildableName = "Marks.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Marks"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Marks.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Profile"
|
buildConfiguration = "Release"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
@@ -84,10 +88,10 @@
|
|||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||||
BuildableName = "Runner.app"
|
BuildableName = "Marks.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Marks"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Marks.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
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>
|
||||||
BIN
Marks/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
14
Marks/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Marks/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Marks/Info.plist
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?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>Marks</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>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<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
@@ -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
@@ -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
@@ -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
@@ -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, 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
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.developer.networking.wifi-info</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.jgeek00.linkdy</string>
|
<string>group.com.magicive.marks</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
149
Marks/MarksApp.swift
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
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() ?? defaultConfig
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
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
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
||||||
|
self.config = config
|
||||||
|
self.onDisconnect = onDisconnect
|
||||||
|
let api = LinkdingAPI(config: config)
|
||||||
|
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, cacheKey: config.host))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
|
Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) {
|
||||||
|
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
|
||||||
|
}
|
||||||
|
Tab("Tags", systemImage: "tag", value: AppTab.tags) {
|
||||||
|
TagsView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
124
Marks/Models/Bookmark.swift
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Bookmark: Codable, Identifiable, Sendable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
var url: String
|
||||||
|
var title: String
|
||||||
|
var description: String
|
||||||
|
var tagNames: [String]
|
||||||
|
var dateAdded: Date
|
||||||
|
var dateModified: Date
|
||||||
|
var isArchived: Bool
|
||||||
|
var unread: Bool
|
||||||
|
var shared: Bool
|
||||||
|
var websiteTitle: String?
|
||||||
|
var websiteDescription: String?
|
||||||
|
var faviconUrl: String?
|
||||||
|
var previewImageUrl: String?
|
||||||
|
|
||||||
|
// AI-enriched fields stored locally
|
||||||
|
var aiSummary: String?
|
||||||
|
var aiTags: [String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, url, title, description, unread, shared
|
||||||
|
case tagNames = "tag_names"
|
||||||
|
case dateAdded = "date_added"
|
||||||
|
case dateModified = "date_modified"
|
||||||
|
case isArchived = "is_archived"
|
||||||
|
case websiteTitle = "website_title"
|
||||||
|
case websiteDescription = "website_description"
|
||||||
|
case faviconUrl = "favicon_url"
|
||||||
|
case previewImageUrl = "preview_image_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayTitle: String {
|
||||||
|
if !title.isEmpty { return title }
|
||||||
|
if let t = websiteTitle, !t.isEmpty { return t }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let count: Int
|
||||||
|
let next: String?
|
||||||
|
let previous: String?
|
||||||
|
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 tagNames: [String]
|
||||||
|
let isArchived: Bool
|
||||||
|
let unread: Bool
|
||||||
|
let shared: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case url, title, unread, shared
|
||||||
|
case tagNames = "tag_names"
|
||||||
|
case isArchived = "is_archived"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BookmarkUpdate: Codable, Sendable {
|
||||||
|
var url: String
|
||||||
|
var title: String
|
||||||
|
var description: String
|
||||||
|
var tagNames: [String]
|
||||||
|
var unread: Bool
|
||||||
|
var shared: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case url, title, description, unread, shared
|
||||||
|
case tagNames = "tag_names"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LinkdingTag: Codable, Sendable {
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TagResponse: Codable, Sendable {
|
||||||
|
let results: [LinkdingTag]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SmartCollection: Identifiable, Sendable {
|
||||||
|
let id = UUID()
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let bookmarkIds: [Int]
|
||||||
|
}
|
||||||
45
Marks/Models/ServerConfig.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ServerConfig: Codable, Sendable {
|
||||||
|
var host: String
|
||||||
|
var port: Int?
|
||||||
|
var path: String
|
||||||
|
var token: String
|
||||||
|
var useHttps: Bool
|
||||||
|
|
||||||
|
var baseUrl: String {
|
||||||
|
var url = useHttps ? "https://" : "http://"
|
||||||
|
url += host
|
||||||
|
if let port { url += ":\(port)" }
|
||||||
|
if !path.isEmpty { url += path }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ServerConfig {
|
||||||
|
private static let key = "serverConfig"
|
||||||
|
private static let appGroupId = "group.com.magicive.marks"
|
||||||
|
|
||||||
|
static func load() -> ServerConfig? {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
|
||||||
|
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)
|
||||||
|
UserDefaults(suiteName: ServerConfig.appGroupId)?.set(data, forKey: ServerConfig.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() {
|
||||||
|
UserDefaults.standard.removeObject(forKey: ServerConfig.key)
|
||||||
|
UserDefaults(suiteName: ServerConfig.appGroupId)?.removeObject(forKey: ServerConfig.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Marks/Models/String+Helpers.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var normalizedURL: String {
|
||||||
|
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return (trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://")) ? trimmed : "https://" + trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsedTags: [String] {
|
||||||
|
split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
126
Marks/Services/ClaudeService.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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 {
|
||||||
|
|
||||||
|
// MARK: - Bookmark AI
|
||||||
|
|
||||||
|
func enrich(bookmark: Bookmark) async throws -> (summary: String, tags: [String]) {
|
||||||
|
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 [] }
|
||||||
|
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 [] }
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Podcast
|
||||||
|
|
||||||
|
func generatePodcast(url: String) async throws -> String {
|
||||||
|
struct Response: Decodable { let job_id: String }
|
||||||
|
let data = try await post("/v1/podcast/generate", body: ["url": url])
|
||||||
|
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 \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
Log.network.debug("POST \(path, privacy: .public)")
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
146
Marks/Services/LinkdingAPI.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum APIError: Error, LocalizedError {
|
||||||
|
case badStatus(Int)
|
||||||
|
case invalidUrl
|
||||||
|
case noData
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .badStatus(let code): "Server returned \(code)"
|
||||||
|
case .invalidUrl: "Invalid server URL"
|
||||||
|
case .noData: "No data received"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor LinkdingAPI {
|
||||||
|
private let config: ServerConfig
|
||||||
|
private let session: URLSession
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
init(config: ServerConfig) {
|
||||||
|
self.config = config
|
||||||
|
self.session = URLSession.shared
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let s = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = f.date(from: s) { return date }
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
if let date = f.date(from: s) { return date }
|
||||||
|
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Bad date: \(s)"))
|
||||||
|
}
|
||||||
|
self.decoder = d
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeUrl(path: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = config.useHttps ? "https" : "http"
|
||||||
|
components.host = config.host
|
||||||
|
components.port = config.port
|
||||||
|
components.path = config.path.isEmpty ? path : config.path + path
|
||||||
|
if !queryItems.isEmpty { components.queryItems = queryItems }
|
||||||
|
guard let url = components.url else { throw APIError.invalidUrl }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func authorizedRequest(url: URL, method: String = "GET", body: Data? = nil) -> URLRequest {
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = method
|
||||||
|
req.setValue("Token \(config.token)", forHTTPHeaderField: "Authorization")
|
||||||
|
if let body {
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = body
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchBookmarks(search: String = "", offset: Int = 0, limit: Int = 50, unread: Bool = false) async throws -> BookmarkResponse {
|
||||||
|
var items: [URLQueryItem] = [
|
||||||
|
.init(name: "limit", value: "\(limit)"),
|
||||||
|
.init(name: "offset", value: "\(offset)")
|
||||||
|
]
|
||||||
|
if !search.isEmpty { items.append(.init(name: "q", value: search)) }
|
||||||
|
if unread { items.append(.init(name: "unread", value: "true")) }
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/", queryItems: items)
|
||||||
|
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(BookmarkResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchBookmarksFromUrl(_ urlString: String) async throws -> BookmarkResponse {
|
||||||
|
guard let url = URL(string: urlString) else { throw APIError.invalidUrl }
|
||||||
|
let (data, _) = try await session.data(for: authorizedRequest(url: url))
|
||||||
|
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/")
|
||||||
|
let (data, response) = try await session.data(for: authorizedRequest(url: url, method: "POST", body: body))
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 201 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
return try decoder.decode(Bookmark.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBookmark(id: Int, update: BookmarkUpdate) async throws -> Bookmark {
|
||||||
|
let body = try JSONEncoder().encode(update)
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/\(id)/")
|
||||||
|
let (data, response) = try await session.data(for: authorizedRequest(url: url, method: "PATCH", body: body))
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
return try decoder.decode(Bookmark.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteBookmark(id: Int) async throws {
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/\(id)/")
|
||||||
|
let (_, response) = try await session.data(for: authorizedRequest(url: url, method: "DELETE"))
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 204 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveBookmark(id: Int) async throws {
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/\(id)/archive/")
|
||||||
|
let (_, response) = try await session.data(for: authorizedRequest(url: url, method: "POST"))
|
||||||
|
guard let code = (response as? HTTPURLResponse)?.statusCode, code == 200 || code == 204 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyConnection() async throws {
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/", queryItems: [.init(name: "limit", value: "1")])
|
||||||
|
let (_, response) = try await session.data(for: authorizedRequest(url: url))
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
120
Marks/Services/PodcastGenerationManager.swift
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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,
|
||||||
|
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)
|
||||||
|
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
@@ -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
@@ -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
@@ -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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Marks/Views/AddBookmarkView.swift
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddBookmarkView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var url = ""
|
||||||
|
@State private var title = ""
|
||||||
|
@State private var tagsText = ""
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("https://", text: $url)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
} header: {
|
||||||
|
Text("URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField("Optional", text: $title)
|
||||||
|
} header: {
|
||||||
|
Text("Title")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField("comma separated", text: $tagsText)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
} header: {
|
||||||
|
Text("Tags")
|
||||||
|
} footer: {
|
||||||
|
Text("Separate tags with commas")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Section {
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add Bookmark")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") { save() }
|
||||||
|
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
|
||||||
|
.overlay {
|
||||||
|
if isSaving { ProgressView().scaleEffect(0.7) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let urlString = url.normalizedURL
|
||||||
|
guard URL(string: urlString) != nil else {
|
||||||
|
error = "Invalid URL"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let tags = tagsText.parsedTags
|
||||||
|
isSaving = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await viewModel.addBookmark(url: urlString, title: title.trimmingCharacters(in: .whitespaces), tags: tags)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
Marks/Views/BookmarkRow.swift
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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) {
|
||||||
|
HStack(alignment: .top, spacing: 11) {
|
||||||
|
FaviconView(url: bookmark.faviconUrl)
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
if bookmark.unread {
|
||||||
|
Circle()
|
||||||
|
.fill(.blue)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
.offset(x: -3, y: -3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 1)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(bookmark.displayTitle)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(bookmark.domain)
|
||||||
|
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 excerpt = rowExcerpt {
|
||||||
|
Text(excerpt.text)
|
||||||
|
.font(.subheadline)
|
||||||
|
.italic(excerpt.isAI)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.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(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.glassEffect(in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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] {
|
||||||
|
let base = bookmark.tagNames
|
||||||
|
let ai = bookmark.aiTags ?? []
|
||||||
|
let extra = ai.filter { !base.contains($0) }.prefix(3)
|
||||||
|
return (base + extra).prefix(6).map { $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FaviconView: View {
|
||||||
|
let url: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let urlString = url, let faviconUrl = URL(string: urlString) {
|
||||||
|
AsyncImage(url: faviconUrl) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image.resizable().scaledToFit()
|
||||||
|
default:
|
||||||
|
placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholder: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "bookmark")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(Color(.systemGray2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
277
Marks/Views/BookmarksView.swift
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
} 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoadingMore {
|
||||||
|
HStack { Spacer(); ProgressView(); Spacer() }
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
|
||||||
|
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
showAsk = true
|
||||||
|
} label: {
|
||||||
|
Label("Ask Your Bookmarks", systemImage: "bubble.left.and.text.bubble.right")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.generateSmartCollections() }
|
||||||
|
showCollections = true
|
||||||
|
} label: {
|
||||||
|
Label("Smart Collections", systemImage: "sparkles")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.enrichAll() }
|
||||||
|
} label: {
|
||||||
|
Label("Enrich All with AI", systemImage: "wand.and.stars")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Button { showAddBookmark = true } label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.toggleUnreadFilter() }
|
||||||
|
} label: {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCollections) {
|
||||||
|
CollectionsView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAsk) {
|
||||||
|
AskView()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddBookmark) {
|
||||||
|
AddBookmarkView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.sheet(item: $editingBookmark) { bookmark in
|
||||||
|
EditBookmarkView(viewModel: viewModel, bookmark: bookmark)
|
||||||
|
}
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if viewModel.bookmarks.isEmpty {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: Binding(
|
||||||
|
get: { viewModel.error != nil },
|
||||||
|
set: { if !$0 { viewModel.error = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { viewModel.error = nil }
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.error ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var enrichmentBanner: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView(value: viewModel.enrichmentProgress)
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
.tint(.primary)
|
||||||
|
Text("Adding AI summaries…")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
||||||
|
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
||||||
|
Task { await viewModel.loadMore() }
|
||||||
|
}
|
||||||
|
}
|
||||||
321
Marks/Views/BookmarksViewModel.swift
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class BookmarksViewModel {
|
||||||
|
var bookmarks: [Bookmark] = []
|
||||||
|
var isLoading = false
|
||||||
|
var isLoadingMore = false
|
||||||
|
var error: String?
|
||||||
|
var searchQuery = ""
|
||||||
|
var nextPageUrl: String?
|
||||||
|
var smartCollections: [SmartCollection] = []
|
||||||
|
var isGeneratingCollections = false
|
||||||
|
var enrichmentProgress: Double = 0
|
||||||
|
var unreadFilter = false
|
||||||
|
|
||||||
|
private let api: LinkdingAPI
|
||||||
|
let claude = ClaudeService()
|
||||||
|
let podcastPlayer = PodcastPlayerViewModel()
|
||||||
|
let podcastGenerator = PodcastGenerationManager()
|
||||||
|
private var enrichTask: Task<Void, Never>?
|
||||||
|
private let cacheFileUrl: URL
|
||||||
|
|
||||||
|
init(api: LinkdingAPI, cacheKey: String = "") {
|
||||||
|
self.api = api
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
if useCache { loadFromCache() }
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let response = try await api.fetchBookmarks(search: searchQuery, unread: unreadFilter)
|
||||||
|
bookmarks = response.results
|
||||||
|
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
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
startEnrichment()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMore() async {
|
||||||
|
guard let next = nextPageUrl, !isLoadingMore else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
do {
|
||||||
|
let response = try await api.fetchBookmarksFromUrl(next)
|
||||||
|
bookmarks.append(contentsOf: response.results)
|
||||||
|
nextPageUrl = response.next
|
||||||
|
restoreAIData()
|
||||||
|
let added = response.results
|
||||||
|
Task { await SpotlightIndexer.index(added) }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoadingMore = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func search() async {
|
||||||
|
isLoading = true
|
||||||
|
do {
|
||||||
|
let response = try await api.fetchBookmarks(search: searchQuery, unread: unreadFilter)
|
||||||
|
bookmarks = response.results
|
||||||
|
nextPageUrl = response.next
|
||||||
|
restoreAIData()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleUnreadFilter() async {
|
||||||
|
unreadFilter.toggle()
|
||||||
|
await load(useCache: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func semanticSearch() async {
|
||||||
|
guard !searchQuery.isEmpty else { return }
|
||||||
|
isLoading = true
|
||||||
|
do {
|
||||||
|
let allResponse = try await api.fetchBookmarks(limit: 200, unread: unreadFilter)
|
||||||
|
var all = allResponse.results
|
||||||
|
// Restore AI data so summaries are available for ranking
|
||||||
|
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)
|
||||||
|
let bookmark = try await api.createBookmark(create)
|
||||||
|
bookmarks.insert(bookmark, at: 0)
|
||||||
|
Task { await SpotlightIndexer.index([bookmark]) }
|
||||||
|
Log.ai.info("addBookmark saved id=\(bookmark.id, privacy: .public)")
|
||||||
|
Task {
|
||||||
|
Log.ai.info("addBookmark enrich start id=\(bookmark.id, privacy: .public)")
|
||||||
|
do {
|
||||||
|
let (summary, aiTags) = try await claude.enrich(bookmark: bookmark)
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
Log.ai.error("addBookmark enrich failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
self.error = "AI enrichment failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(_ bookmark: Bookmark) async {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archive(_ bookmark: Bookmark) async {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBookmark(_ bookmark: Bookmark) async throws {
|
||||||
|
let update = BookmarkUpdate(
|
||||||
|
url: bookmark.url,
|
||||||
|
title: bookmark.title,
|
||||||
|
description: bookmark.description,
|
||||||
|
tagNames: bookmark.tagNames,
|
||||||
|
unread: bookmark.unread,
|
||||||
|
shared: bookmark.shared
|
||||||
|
)
|
||||||
|
var updated = try await api.updateBookmark(id: bookmark.id, update: update)
|
||||||
|
if let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) {
|
||||||
|
updated.aiSummary = bookmarks[i].aiSummary
|
||||||
|
updated.aiTags = bookmarks[i].aiTags
|
||||||
|
bookmarks[i] = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSmartCollections() async {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
isGeneratingCollections = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichAll() async {
|
||||||
|
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }
|
||||||
|
guard !toEnrich.isEmpty else { return }
|
||||||
|
enrichmentProgress = 0
|
||||||
|
for (done, i) in toEnrich.enumerated() {
|
||||||
|
do {
|
||||||
|
let (summary, tags) = try await claude.enrich(bookmark: bookmarks[i])
|
||||||
|
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() {
|
||||||
|
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) }
|
||||||
|
Log.ai.info("startEnrichment: \(indices.count, privacy: .public) to enrich")
|
||||||
|
for i in indices {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
let claude = await MainActor.run(body: { self.claude })
|
||||||
|
do {
|
||||||
|
let bm = await MainActor.run { bookmarks[i] }
|
||||||
|
Log.ai.debug("startEnrichment enriching id=\(bm.id, privacy: .public)")
|
||||||
|
let (summary, tags) = try await claude.enrich(bookmark: bm)
|
||||||
|
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 {
|
||||||
|
Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Disk cache
|
||||||
|
|
||||||
|
private static let cacheEncoder: JSONEncoder = {
|
||||||
|
let e = JSONEncoder()
|
||||||
|
e.dateEncodingStrategy = .iso8601
|
||||||
|
return e
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let cacheDecoder: JSONDecoder = {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .iso8601
|
||||||
|
return d
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func saveToCache(_ list: [Bookmark]) {
|
||||||
|
guard !unreadFilter else { return } // don't overwrite full cache with filtered results
|
||||||
|
do {
|
||||||
|
let data = try Self.cacheEncoder.encode(list)
|
||||||
|
try data.write(to: cacheFileUrl, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
Log.sync.error("saveToCache failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFromCache() {
|
||||||
|
guard bookmarks.isEmpty,
|
||||||
|
let data = try? Data(contentsOf: cacheFileUrl),
|
||||||
|
var cached = try? Self.cacheDecoder.decode([Bookmark].self, from: data) else { return }
|
||||||
|
restoreAIData(into: &cached)
|
||||||
|
bookmarks = cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: AI persistence
|
||||||
|
|
||||||
|
private func saveAIData(for bookmark: Bookmark) {
|
||||||
|
var store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:]
|
||||||
|
store["\(bookmark.id)"] = [
|
||||||
|
"summary": bookmark.aiSummary ?? "",
|
||||||
|
"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() {
|
||||||
|
restoreAIData(into: &bookmarks)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreAIData(into list: inout [Bookmark]) {
|
||||||
|
let store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:]
|
||||||
|
for i in list.indices {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
419
Marks/Views/BrowserView.swift
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
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, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didCommit _: WKNavigation!) {
|
||||||
|
currentURL = webView.url
|
||||||
|
canGoBack = webView.canGoBack
|
||||||
|
canGoForward = webView.canGoForward
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
|
||||||
|
isLoading = false
|
||||||
|
canGoBack = webView.canGoBack
|
||||||
|
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, 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()
|
||||||
|
self.webView.evaluateJavaScript(self.scrollTrackingJS, completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
"""
|
||||||
|
(function() {
|
||||||
|
var title = document.title;
|
||||||
|
var selectors = ['article','[role="article"]','main','[role="main"]',
|
||||||
|
'.post-content','.article-content','.entry-content',
|
||||||
|
'.post-body','.article-body','.story-body','.content','#content'];
|
||||||
|
var content = null;
|
||||||
|
for (var i = 0; i < selectors.length; i++) {
|
||||||
|
var el = document.querySelector(selectors[i]);
|
||||||
|
if (el && (el.innerText||'').trim().length > 300) { content = el; break; }
|
||||||
|
}
|
||||||
|
if (!content) {
|
||||||
|
var best = { el: null, len: 0 };
|
||||||
|
Array.from(document.querySelectorAll('div')).forEach(function(d) {
|
||||||
|
var len = Array.from(d.querySelectorAll('p')).reduce(function(a,p){return a+(p.innerText||'').length;},0);
|
||||||
|
if (len > best.len) best = { el: d, len: len };
|
||||||
|
});
|
||||||
|
content = best.el || document.body;
|
||||||
|
}
|
||||||
|
var clone = content.cloneNode(true);
|
||||||
|
['script','style','nav','header','footer','aside'].forEach(function(tag){
|
||||||
|
clone.querySelectorAll(tag).forEach(function(el){ el.remove(); });
|
||||||
|
});
|
||||||
|
var html = clone.innerHTML;
|
||||||
|
var css = [
|
||||||
|
'body{font-family:-apple-system,Georgia,serif;max-width:680px;margin:0 auto;',
|
||||||
|
'padding:20px 20px 60px;line-height:1.75;font-size:18px;background:#fafafa;color:#1c1c1e}',
|
||||||
|
'h1{font-size:1.6em;line-height:1.3;margin:0 0 .5em}',
|
||||||
|
'h2,h3,h4{line-height:1.35;margin:1.5em 0 .5em}p{margin:0 0 1em}',
|
||||||
|
'img{max-width:100%;height:auto;border-radius:8px;margin:8px 0}',
|
||||||
|
'a{color:#007aff;text-decoration:none}',
|
||||||
|
'blockquote{border-left:3px solid #ccc;margin:1em 0;padding:0 1em;color:#555}',
|
||||||
|
'code,pre{font-family:ui-monospace,monospace;font-size:.9em;background:#f0f0f0;border-radius:4px;padding:2px 4px}',
|
||||||
|
'pre code{padding:0;background:none}pre{padding:12px;overflow-x:auto}',
|
||||||
|
'@media(prefers-color-scheme:dark){body{background:#1c1c1e;color:#e5e5e7}',
|
||||||
|
'blockquote{border-color:#555;color:#aaa}code,pre{background:#2c2c2e}a{color:#0a84ff}}'
|
||||||
|
].join('');
|
||||||
|
document.open();
|
||||||
|
document.write('<!DOCTYPE html><html><head>' +
|
||||||
|
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
||||||
|
'<style>' + css + '</style>' +
|
||||||
|
'<title>' + title + '</title></head><body>' +
|
||||||
|
'<h1>' + title + '</h1>' + html + '</body></html>');
|
||||||
|
document.close();
|
||||||
|
return 'ok';
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
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 {
|
||||||
|
PageLoadBar()
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button { openURL(state.currentURL ?? initialURL) } label: {
|
||||||
|
Image(systemName: "safari")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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 } }
|
||||||
|
} 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)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
66
Marks/Views/CollectionsView.swift
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CollectionsView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isGeneratingCollections {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Building smart collections…")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if viewModel.smartCollections.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Collections Yet",
|
||||||
|
systemImage: "sparkles",
|
||||||
|
description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List(viewModel.smartCollections) { collection in
|
||||||
|
Section {
|
||||||
|
let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) }
|
||||||
|
ForEach(items) { bookmark in
|
||||||
|
BookmarkRow(bookmark: bookmark)
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(collection.name)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if !collection.description.isEmpty {
|
||||||
|
Text(collection.description)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Smart Collections")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.generateSmartCollections() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isGeneratingCollections)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
110
Marks/Views/EditBookmarkView.swift
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct EditBookmarkView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
let bookmark: Bookmark
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var url: String
|
||||||
|
@State private var title: String
|
||||||
|
@State private var description: String
|
||||||
|
@State private var tagsText: String
|
||||||
|
@State private var unread: Bool
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
init(viewModel: BookmarksViewModel, bookmark: Bookmark) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.bookmark = bookmark
|
||||||
|
_url = State(initialValue: bookmark.url)
|
||||||
|
_title = State(initialValue: bookmark.title)
|
||||||
|
_description = State(initialValue: bookmark.description)
|
||||||
|
let effectiveTags = bookmark.tagNames.isEmpty ? (bookmark.aiTags ?? []) : bookmark.tagNames
|
||||||
|
_tagsText = State(initialValue: effectiveTags.joined(separator: ", "))
|
||||||
|
_unread = State(initialValue: bookmark.unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("URL") {
|
||||||
|
TextField("https://", text: $url)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Title") {
|
||||||
|
TextField("Optional", text: $title)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Description") {
|
||||||
|
TextField("Optional", text: $description, axis: .vertical)
|
||||||
|
.lineLimit(3...6)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField("comma separated", text: $tagsText)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
} header: {
|
||||||
|
Text("Tags")
|
||||||
|
} footer: {
|
||||||
|
Text("Separate tags with commas")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle("Mark as unread", isOn: $unread)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Section {
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Edit Bookmark")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") { save() }
|
||||||
|
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
|
||||||
|
.overlay {
|
||||||
|
if isSaving { ProgressView().scaleEffect(0.7) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
let urlString = url.normalizedURL
|
||||||
|
guard URL(string: urlString) != nil else {
|
||||||
|
error = "Invalid URL"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let tags = tagsText.parsedTags
|
||||||
|
var updated = bookmark
|
||||||
|
updated.url = urlString
|
||||||
|
updated.title = title.trimmingCharacters(in: .whitespaces)
|
||||||
|
updated.description = description.trimmingCharacters(in: .whitespaces)
|
||||||
|
updated.tagNames = tags
|
||||||
|
updated.unread = unread
|
||||||
|
isSaving = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await viewModel.updateBookmark(updated)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
140
Marks/Views/OnboardingView.swift
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct OnboardingView: View {
|
||||||
|
let onConnect: (ServerConfig) -> Void
|
||||||
|
|
||||||
|
@State private var urlText = ""
|
||||||
|
@State private var token = ""
|
||||||
|
@State private var isConnecting = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@FocusState private var focused: Field?
|
||||||
|
|
||||||
|
enum Field { case url, token }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Marks")
|
||||||
|
.font(.system(size: 42, weight: .semibold, design: .rounded))
|
||||||
|
Text("Your bookmarks, beautifully.")
|
||||||
|
.font(.system(size: 17))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.padding(.bottom, 48)
|
||||||
|
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Server URL")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
TextField("https://links.example.com", text: $urlText)
|
||||||
|
.textContentType(.URL)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.focused($focused, equals: .url)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { focused = .token }
|
||||||
|
.padding(14)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("API Token")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
SecureField("Paste your token", text: $token)
|
||||||
|
.textContentType(.password)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.focused($focused, equals: .token)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.onSubmit { Task { await connect() } }
|
||||||
|
.padding(14)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
Text("Find your token at Settings → API Token in Linkding.")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
if let err = errorMessage {
|
||||||
|
Text(err)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await connect() }
|
||||||
|
} label: {
|
||||||
|
Group {
|
||||||
|
if isConnecting {
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
} else {
|
||||||
|
Text("Connect")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
}
|
||||||
|
.buttonStyle(.glassProminent)
|
||||||
|
.buttonBorderShape(.roundedRectangle(radius: 14))
|
||||||
|
.disabled(!canConnect || isConnecting)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 24)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canConnect: Bool { !urlText.isEmpty && !token.isEmpty }
|
||||||
|
|
||||||
|
private func connect() async {
|
||||||
|
focused = nil
|
||||||
|
isConnecting = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let config = try parseConfig()
|
||||||
|
let api = LinkdingAPI(config: config)
|
||||||
|
try await api.verifyConnection()
|
||||||
|
onConnect(config)
|
||||||
|
} catch APIError.badStatus(401) {
|
||||||
|
errorMessage = "Invalid token. Check your API token in Linkding settings."
|
||||||
|
} catch APIError.badStatus(let code) {
|
||||||
|
errorMessage = "Server returned \(code). Check the URL."
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Could not connect. Check the URL and try again."
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnecting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseConfig() throws -> ServerConfig {
|
||||||
|
let raw = urlText.normalizedURL
|
||||||
|
guard let comps = URLComponents(string: raw),
|
||||||
|
let host = comps.host, !host.isEmpty else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
return ServerConfig(
|
||||||
|
host: host,
|
||||||
|
port: comps.port,
|
||||||
|
path: comps.path.hasSuffix("/") ? String(comps.path.dropLast()) : comps.path,
|
||||||
|
token: token.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
useHttps: comps.scheme == "https"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
1179
Marks/Views/PodcastPlayerView.swift
Normal file
102
Marks/Views/SearchView.swift
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SearchView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
|
||||||
|
@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
|
||||||
|
BookmarkListRow(
|
||||||
|
bookmark: bookmark,
|
||||||
|
viewModel: viewModel,
|
||||||
|
onOpen: { browsingBookmark = bookmark }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Search")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Toggle(isOn: $useSemanticSearch) {
|
||||||
|
Label("Semantic", systemImage: "sparkles")
|
||||||
|
}
|
||||||
|
.toggleStyle(.button)
|
||||||
|
.onChange(of: useSemanticSearch) { _, _ in scheduleSearch() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
|
||||||
|
} else if results.isEmpty && !viewModel.isLoading {
|
||||||
|
ContentUnavailableView.search(text: searchText)
|
||||||
|
}
|
||||||
|
if viewModel.isLoading {
|
||||||
|
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] {
|
||||||
|
if let semantic = semanticResults { return semantic }
|
||||||
|
guard !searchText.isEmpty else { return [] }
|
||||||
|
let q = searchText.lowercased()
|
||||||
|
return viewModel.bookmarks.filter { b in
|
||||||
|
b.displayTitle.lowercased().contains(q) ||
|
||||||
|
b.url.lowercased().contains(q) ||
|
||||||
|
b.tagNames.contains { $0.lowercased().contains(q) } ||
|
||||||
|
(b.aiTags ?? []).contains { $0.lowercased().contains(q) } ||
|
||||||
|
(b.aiSummary ?? "").lowercased().contains(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleSearch() {
|
||||||
|
semanticResults = nil
|
||||||
|
searchTask?.cancel()
|
||||||
|
guard !searchText.isEmpty, useSemanticSearch else { return }
|
||||||
|
let claude = viewModel.claude
|
||||||
|
searchTask = Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
let q = searchText
|
||||||
|
let all = viewModel.bookmarks
|
||||||
|
do {
|
||||||
|
let ranked = try await claude.semanticSearch(query: q, in: all)
|
||||||
|
await MainActor.run { semanticResults = ranked }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Marks/Views/SettingsView.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
let onDisconnect: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var autoTagText = PodcastRequests.autoTags.joined(separator: " ")
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section("AI Features") {
|
||||||
|
Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles")
|
||||||
|
.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") {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
dismiss()
|
||||||
|
onDisconnect()
|
||||||
|
} label: {
|
||||||
|
Label("Disconnect", systemImage: "person.crop.circle.badge.minus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
Marks/Views/TagsView.swift
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TagsView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(allTags, id: \.tag) { entry in
|
||||||
|
NavigationLink {
|
||||||
|
TagBookmarksView(tag: entry.tag, viewModel: viewModel)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(entry.tag)
|
||||||
|
.font(.system(size: 17))
|
||||||
|
Spacer()
|
||||||
|
Text("\(entry.count)")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Tags")
|
||||||
|
.overlay {
|
||||||
|
if viewModel.bookmarks.isEmpty {
|
||||||
|
ContentUnavailableView("No Tags", systemImage: "tag", description: Text("Tags from your bookmarks will appear here."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var allTags: [(tag: String, count: Int)] {
|
||||||
|
var counts: [String: Int] = [:]
|
||||||
|
for bookmark in viewModel.bookmarks {
|
||||||
|
for tag in bookmark.tagNames {
|
||||||
|
counts[tag, default: 0] += 1
|
||||||
|
}
|
||||||
|
for tag in bookmark.aiTags ?? [] where !bookmark.tagNames.contains(tag) {
|
||||||
|
counts[tag, default: 0] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts.map { (tag: $0.key, count: $0.value) }
|
||||||
|
.sorted { $0.count != $1.count ? $0.count > $1.count : $0.tag < $1.tag }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TagBookmarksView: View {
|
||||||
|
let tag: String
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
|
||||||
|
@State private var browsingBookmark: Bookmark?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(filteredBookmarks) { bookmark in
|
||||||
|
BookmarkListRow(
|
||||||
|
bookmark: bookmark,
|
||||||
|
viewModel: viewModel,
|
||||||
|
onOpen: { browsingBookmark = bookmark }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle(tag)
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.overlay {
|
||||||
|
if filteredBookmarks.isEmpty {
|
||||||
|
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] {
|
||||||
|
viewModel.bookmarks.filter {
|
||||||
|
$0.tagNames.contains(tag) || ($0.aiTags ?? []).contains(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
MarksTests/AppIntentsTests.swift
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>IDEDidComputeMac32BitWarning</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<true/>
|
<array>
|
||||||
|
<string>group.com.magicive.marks</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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")!
|
||||||
|
}
|
||||||
|
}
|
||||||
112
README.md
@@ -1,61 +1,69 @@
|
|||||||
<h1 align="center">
|
# Marks
|
||||||
<img src="assets/other/banner.png" />
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p align="center">
|
A native iOS client for [linkding](https://github.com/sissbruecker/linkding), the self-hosted bookmark manager.
|
||||||
<b>Linkdy</b> is a <a href="https://github.com/sissbruecker/linkding" target="_blank" rel="noopener noreferrer"><b>Linkding</b></a> client created with Flutter. <a href="https://github.com/sissbruecker/linkding" target="_blank" rel="noopener noreferrer">Linkding</a> is a self hosted bookmark manager. The objective of this project is to give all it's users an application to manage the bookmarks from a mobile device.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
Built from scratch in SwiftUI targeting iOS 26. Replaces the previous Flutter-based app.
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.jgeek00.linkdy" target="_blank" rel="noopener noreferrer">
|
|
||||||
<img src="/assets/other/get_google_play.png" width="300px">
|
|
||||||
</a>
|
|
||||||
<a href="https://apps.apple.com/us/app/linkdy/id6479930976" target="_blank" rel="noopener noreferrer">
|
|
||||||
<img src="/assets/other/get-appstore.png" width="300px">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### Main features
|
## Features
|
||||||
<ul>
|
|
||||||
<li>List your bookmarks.</li>
|
|
||||||
<li>Open the links on an internal browser.</li>
|
|
||||||
<li>Search bookmarks.</li>
|
|
||||||
<li>Create new bookmarks.</li>
|
|
||||||
<li>Create new tags.</li>
|
|
||||||
<li>List the bookmarks associated with each tag.</li>
|
|
||||||
<li>Material You interface with dynamic theming (only Android 12+).</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
## Privacy policy
|
- Browse and search your bookmarks
|
||||||
Check the privacy policy [here](https://pastebin.com/raw/Yiw4h6KK).
|
- Swipe to delete or archive
|
||||||
|
- Add bookmarks manually
|
||||||
|
- Share to Marks from any app (share extension)
|
||||||
|
- Tag browser — see all tags sorted by count, tap to filter
|
||||||
|
- Native search tab with instant client-side filtering
|
||||||
|
- AI enrichment via OpenRouter — auto-generates summaries and tags for each bookmark
|
||||||
|
- Semantic search — rank bookmarks by relevance using AI
|
||||||
|
- Smart Collections — AI groups your bookmarks into themed collections
|
||||||
|
- Pagination — loads more as you scroll
|
||||||
|
|
||||||
### Development
|
## Requirements
|
||||||
1. Clone this repository.
|
|
||||||
2. Run ``flutter pub get`` to install all the dependencies.
|
|
||||||
3. Run the application on the desired virtual or physical device.
|
|
||||||
|
|
||||||
<p>
|
- iOS 26+
|
||||||
<b>State manager</b>
|
- A running [linkding](https://github.com/sissbruecker/linkding) instance with API access
|
||||||
<br>
|
- (Optional) An [OpenRouter](https://openrouter.ai) API key for AI features
|
||||||
Linkdy uses Riverpod as the state management system. Files ended on <code>.g.dart</code> are auto generated by Riverpod's code generator and shouldn't be edited manually.
|
|
||||||
The code generator can be started by running <code>dart run build_runner watch</code>. Then, the process will start on the terminal.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>Translations</b>
|
|
||||||
<br>
|
|
||||||
Linkdy uses <a href="https://pub.dev/packages/slang">slang</a> to manage the translations of the application. To add a new translation, create a new file on <code>lib/i18n</code>, with the structure <code>strings_[lang_code].i18n.json</code>. When adding a new string, make sure to add the translation on all <code>.i18n.json</code> files. Then, run <code>dart run slang</code> to generate the translations.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### Android signing
|
## AI Features
|
||||||
1. Clone ``android/key.properties.sample`` and rename it to ``key.properties``.
|
|
||||||
2. Fill the variables with the values.
|
|
||||||
3. Put your ``keystore.jks`` file inside ``android/app/``.
|
|
||||||
4. Run ``flutter build apk --release`` or ``flutter build appbundle --release`` to compile and sign the production build.
|
|
||||||
|
|
||||||
<br>
|
All AI features are optional and powered by [OpenRouter](https://openrouter.ai) using `google/gemini-2.0-flash-lite-001` — one of the cheapest available models (~$0.075/M input tokens).
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
##### Created by JGeek00
|
| Feature | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Auto-enrichment | Generates a one-sentence summary and 3-5 tags for each bookmark |
|
||||||
|
| Semantic search | Ranks your bookmarks by relevance to a natural language query |
|
||||||
|
| Smart Collections | Groups up to 150 bookmarks into 3-6 themed collections |
|
||||||
|
|
||||||
|
Add your OpenRouter API key in Settings to enable these features.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires [xcodegen](https://github.com/yonaskolb/XcodeGen).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install xcodegen
|
||||||
|
xcodegen generate
|
||||||
|
open Marks.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Then build and run in Xcode. The project uses automatic signing — set your development team in `project.yml` under `DEVELOPMENT_TEAM`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **SwiftUI** with `@Observable` and `@MainActor` throughout (Swift 6 strict concurrency)
|
||||||
|
- **LinkdingAPI** — thin wrapper around the linkding REST API
|
||||||
|
- **ClaudeService** — OpenRouter client for AI enrichment, semantic search, and collections
|
||||||
|
- **BookmarksViewModel** — central state: bookmarks list, pagination, search, enrichment progress
|
||||||
|
- **App Groups** — share extension and main app share credentials via `group.com.magicive.marks`
|
||||||
|
- **UserDefaults** — AI summaries and tags cached locally by bookmark ID
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Marks/
|
||||||
|
Models/ Bookmark, ServerConfig, SmartCollection
|
||||||
|
Services/ LinkdingAPI, ClaudeService
|
||||||
|
Views/ BookmarksView, TagsView, SearchView, SettingsView, …
|
||||||
|
Assets.xcassets App icon and assets
|
||||||
|
ShareExtension/ iOS share extension (zero-tap bookmark saving)
|
||||||
|
project.yml xcodegen project spec
|
||||||
|
```
|
||||||
|
|||||||
32
ShareExtension/Info.plist
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?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>Marks</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>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<string>TRUEPREDICATE</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.share-services</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>ShareViewController</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>PreviewsEnabled</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<false/>
|
<array>
|
||||||
|
<string>group.com.magicive.marks</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
320
ShareExtension/ShareView.swift
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
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
|
||||||
|
/// 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, onClose: @escaping (_ saved: Bool) -> Void) {
|
||||||
|
self.url = url
|
||||||
|
self.onClose = onClose
|
||||||
|
self.api = ServerConfig.loadShared().map(LinkdingAPI.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 = result.metadata?.title ?? ""
|
||||||
|
suggestedTags = result.autoTags ?? []
|
||||||
|
}
|
||||||
|
// 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: "",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
ShareExtension/ShareViewController.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import UIKit
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
@objc(ShareViewController)
|
||||||
|
class ShareViewController: UIViewController {
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||||
|
blur.frame = view.bounds
|
||||||
|
blur.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
view.addSubview(blur)
|
||||||
|
extractURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractURL() {
|
||||||
|
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||||
|
let attachments = item.attachments else {
|
||||||
|
present(url: 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 { self?.present(url: urlString) }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) {
|
||||||
|
present(url: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hosts the SwiftUI save card. A nil URL surfaces an error state in the card.
|
||||||
|
private func present(url: String?) {
|
||||||
|
let root = ShareView(url: url ?? "") { [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 url == 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
@@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# This file configures the analyzer, which statically analyzes Dart code to
|
|
||||||
# check for errors, warnings, and lints.
|
|
||||||
#
|
|
||||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
|
||||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
|
||||||
# invoked from the command line by running `flutter analyze`.
|
|
||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
|
||||||
include: package:flutter_lints/flutter.yaml
|
|
||||||
|
|
||||||
linter:
|
|
||||||
# The lint rules applied to this project can be customized in the
|
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
|
||||||
# included above or to enable additional rules. A list of all available lints
|
|
||||||
# and their documentation is published at https://dart.dev/lints.
|
|
||||||
#
|
|
||||||
# Instead of disabling a lint rule for the entire project in the
|
|
||||||
# section below, it can also be suppressed for a single line of code
|
|
||||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
|
||||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
|
||||||
# producing the lint.
|
|
||||||
rules:
|
|
||||||
require_trailing_commas: true
|
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
|
||||||
# https://dart.dev/guides/language/analysis-options
|
|
||||||
13
android/.gitignore
vendored
@@ -1,13 +0,0 @@
|
|||||||
gradle-wrapper.jar
|
|
||||||
/.gradle
|
|
||||||
/captures/
|
|
||||||
/gradlew
|
|
||||||
/gradlew.bat
|
|
||||||
/local.properties
|
|
||||||
GeneratedPluginRegistrant.java
|
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
|
||||||
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
|
|
||||||
key.properties
|
|
||||||
**/*.keystore
|
|
||||||
**/*.jks
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id "com.android.application"
|
|
||||||
id "kotlin-android"
|
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
|
||||||
}
|
|
||||||
|
|
||||||
def localProperties = new Properties()
|
|
||||||
def localPropertiesFile = rootProject.file('local.properties')
|
|
||||||
if (localPropertiesFile.exists()) {
|
|
||||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
|
||||||
localProperties.load(reader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
|
||||||
if (flutterVersionCode == null) {
|
|
||||||
flutterVersionCode = '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
|
||||||
if (flutterVersionName == null) {
|
|
||||||
flutterVersionName = '1.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
def keystoreProperties = new Properties()
|
|
||||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace "com.jgeek00.linkdy"
|
|
||||||
compileSdkVersion 36
|
|
||||||
ndkVersion '27.0.12077973'
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_18
|
|
||||||
targetCompatibility JavaVersion.VERSION_18
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = 18
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "com.jgeek00.linkdy"
|
|
||||||
minSdkVersion 26
|
|
||||||
targetSdkVersion 36
|
|
||||||
versionCode flutterVersionCode.toInteger()
|
|
||||||
versionName flutterVersionName
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
release {
|
|
||||||
keyAlias keystoreProperties['keyAlias']
|
|
||||||
keyPassword keystoreProperties['keyPassword']
|
|
||||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
|
||||||
storePassword keystoreProperties['storePassword']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
signingConfig signingConfigs.release
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flutter {
|
|
||||||
source '../..'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
|
||||||
the Flutter tool needs it to communicate with the running application
|
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
|
||||||
-->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<application
|
|
||||||
android:label="Linkdy"
|
|
||||||
android:name="${applicationName}"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:enableOnBackInvokedCallback="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="flutterEmbedding"
|
|
||||||
android:value="2" />
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:theme="@style/LaunchTheme"
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
|
||||||
android:hardwareAccelerated="true"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
|
||||||
the Android process has started. This theme is visible to the user
|
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
|
||||||
to determine the Window background behind the Flutter UI. -->
|
|
||||||
<meta-data
|
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
|
||||||
android:resource="@style/NormalTheme"
|
|
||||||
/>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="text/*" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data
|
|
||||||
android:scheme="https"
|
|
||||||
android:host="example.com"
|
|
||||||
android:pathPrefix="/invite" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
<!-- Don't delete the meta-data below.
|
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
|
||||||
<meta-data
|
|
||||||
android:name="flutterEmbedding"
|
|
||||||
android:value="2" />
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.jgeek00.linkdy
|
|
||||||
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
|
||||||
import io.flutter.plugins.GeneratedPluginRegistrant
|
|
||||||
import android.os.Bundle
|
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
|
||||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
|
||||||
GeneratedPluginRegistrant.registerWith(flutterEngine);
|
|
||||||
java.lang.Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
if (intent.getIntExtra("org.chromium.chrome.extra.TASK_ID", -1) == this.taskId) {
|
|
||||||
this.finish()
|
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
||||||
startActivity(intent);
|
|
||||||
}
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 69 B |
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
|
||||||
</item>
|
|
||||||
</layer-list>
|
|
||||||
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 69 B |
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
|
||||||
</item>
|
|
||||||
</layer-list>
|
|
||||||
|
Before Width: | Height: | Size: 69 B |
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
|
||||||
</item>
|
|
||||||
</layer-list>
|
|
||||||
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 69 B |