Compare commits
3 Commits
b7ef5c2215
...
22cde6d2d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22cde6d2d7 | ||
|
|
bbda33ad33 | ||
|
|
1b40f50aab |
50
.gitea/workflows/ci.yaml
Normal file
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
|
||||||
@@ -22,7 +22,9 @@
|
|||||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
|
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
|
||||||
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; };
|
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; };
|
||||||
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
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 */; };
|
||||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
|
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
|
||||||
|
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
|
||||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
|
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@
|
|||||||
5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; 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>"; };
|
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
|
||||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.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>"; };
|
||||||
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; 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>"; };
|
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
|
||||||
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -70,6 +73,7 @@
|
|||||||
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
|
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>"; };
|
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
|
||||||
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
|
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
|
||||||
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
|
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
|
||||||
@@ -95,6 +99,7 @@
|
|||||||
children = (
|
children = (
|
||||||
8CA428181B35885F7D9F4D55 /* Bookmark.swift */,
|
8CA428181B35885F7D9F4D55 /* Bookmark.swift */,
|
||||||
07C21567B95F5069BA946252 /* ServerConfig.swift */,
|
07C21567B95F5069BA946252 /* ServerConfig.swift */,
|
||||||
|
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -126,6 +131,7 @@
|
|||||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
||||||
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
|
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
|
||||||
FE19F7619214271A8C12EEEB /* CollectionsView.swift */,
|
FE19F7619214271A8C12EEEB /* CollectionsView.swift */,
|
||||||
|
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */,
|
||||||
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */,
|
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */,
|
||||||
BCC3BB2525F0F63445D419B9 /* SearchView.swift */,
|
BCC3BB2525F0F63445D419B9 /* SearchView.swift */,
|
||||||
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
||||||
@@ -262,12 +268,14 @@
|
|||||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
||||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
||||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
||||||
|
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
|
||||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
|
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
|
||||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
||||||
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
||||||
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
|
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
|
||||||
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
||||||
|
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
|
||||||
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
|
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ struct MainContainer: View {
|
|||||||
self.onDisconnect = onDisconnect
|
self.onDisconnect = onDisconnect
|
||||||
let api = LinkdingAPI(config: config)
|
let api = LinkdingAPI(config: config)
|
||||||
let claude = ClaudeService.load()
|
let claude = ClaudeService.load()
|
||||||
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, claude: claude))
|
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, claude: claude, cacheKey: config.host))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ struct BookmarkCreate: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 SmartCollection: Identifiable, Sendable {
|
struct SmartCollection: Identifiable, Sendable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let name: String
|
let name: String
|
||||||
|
|||||||
14
Marks/Models/String+Helpers.swift
Normal file
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,12 +57,13 @@ actor LinkdingAPI {
|
|||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchBookmarks(search: String = "", offset: Int = 0, limit: Int = 50) async throws -> BookmarkResponse {
|
func fetchBookmarks(search: String = "", offset: Int = 0, limit: Int = 50, unread: Bool = false) async throws -> BookmarkResponse {
|
||||||
var items: [URLQueryItem] = [
|
var items: [URLQueryItem] = [
|
||||||
.init(name: "limit", value: "\(limit)"),
|
.init(name: "limit", value: "\(limit)"),
|
||||||
.init(name: "offset", value: "\(offset)")
|
.init(name: "offset", value: "\(offset)")
|
||||||
]
|
]
|
||||||
if !search.isEmpty { items.append(.init(name: "q", value: search)) }
|
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 url = try makeUrl(path: "/api/bookmarks/", queryItems: items)
|
||||||
let (data, response) = try await session.data(for: authorizedRequest(url: url))
|
let (data, response) = try await session.data(for: authorizedRequest(url: url))
|
||||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
@@ -87,8 +88,8 @@ actor LinkdingAPI {
|
|||||||
return try decoder.decode(Bookmark.self, from: data)
|
return try decoder.decode(Bookmark.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateBookmark(id: Int, tagNames: [String]) async throws -> Bookmark {
|
func updateBookmark(id: Int, update: BookmarkUpdate) async throws -> Bookmark {
|
||||||
let body = try JSONEncoder().encode(["tag_names": tagNames])
|
let body = try JSONEncoder().encode(update)
|
||||||
let url = try makeUrl(path: "/api/bookmarks/\(id)/")
|
let url = try makeUrl(path: "/api/bookmarks/\(id)/")
|
||||||
let (data, response) = try await session.data(for: authorizedRequest(url: url, method: "PATCH", body: body))
|
let (data, response) = try await session.data(for: authorizedRequest(url: url, method: "PATCH", body: body))
|
||||||
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
|
|||||||
@@ -64,15 +64,12 @@ struct AddBookmarkView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
var urlString = url.trimmingCharacters(in: .whitespaces)
|
let urlString = url.normalizedURL
|
||||||
if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
|
|
||||||
urlString = "https://" + urlString
|
|
||||||
}
|
|
||||||
guard URL(string: urlString) != nil else {
|
guard URL(string: urlString) != nil else {
|
||||||
error = "Invalid URL"
|
error = "Invalid URL"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let tags = tagsText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
let tags = tagsText.parsedTags
|
||||||
isSaving = true
|
isSaving = true
|
||||||
error = nil
|
error = nil
|
||||||
Task {
|
Task {
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ struct BookmarkRow: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack(alignment: .top, spacing: 11) {
|
HStack(alignment: .top, spacing: 11) {
|
||||||
FaviconView(url: bookmark.faviconUrl)
|
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)
|
.padding(.top, 1)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct BookmarksView: View {
|
|||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var showCollections = false
|
@State private var showCollections = false
|
||||||
@State private var showAddBookmark = false
|
@State private var showAddBookmark = false
|
||||||
|
@State private var editingBookmark: Bookmark?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -34,6 +35,35 @@ struct BookmarksView: View {
|
|||||||
Label("Archive", systemImage: "archivebox")
|
Label("Archive", systemImage: "archivebox")
|
||||||
}
|
}
|
||||||
.tint(.orange)
|
.tint(.orange)
|
||||||
|
Button {
|
||||||
|
editingBookmark = bookmark
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
editingBookmark = bookmark
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||||
|
} label: {
|
||||||
|
Label("Open", systemImage: "safari")
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.archive(bookmark) }
|
||||||
|
} label: {
|
||||||
|
Label("Archive", systemImage: "archivebox")
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await viewModel.delete(bookmark) }
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +73,7 @@ struct BookmarksView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.navigationTitle("Bookmarks")
|
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
@@ -70,6 +100,13 @@ struct BookmarksView: View {
|
|||||||
Button { showAddBookmark = true } label: {
|
Button { showAddBookmark = true } label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
}
|
}
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.toggleUnreadFilter() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewModel.unreadFilter
|
||||||
|
? "line.3.horizontal.decrease.circle.fill"
|
||||||
|
: "line.3.horizontal.decrease.circle")
|
||||||
|
}
|
||||||
Button { showSettings = true } label: {
|
Button { showSettings = true } label: {
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
}
|
}
|
||||||
@@ -99,6 +136,9 @@ struct BookmarksView: View {
|
|||||||
.sheet(isPresented: $showAddBookmark) {
|
.sheet(isPresented: $showAddBookmark) {
|
||||||
AddBookmarkView(viewModel: viewModel)
|
AddBookmarkView(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $editingBookmark) { bookmark in
|
||||||
|
EditBookmarkView(viewModel: viewModel, bookmark: bookmark)
|
||||||
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.load()
|
await viewModel.load()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,28 +13,36 @@ final class BookmarksViewModel {
|
|||||||
var smartCollections: [SmartCollection] = []
|
var smartCollections: [SmartCollection] = []
|
||||||
var isGeneratingCollections = false
|
var isGeneratingCollections = false
|
||||||
var enrichmentProgress: Double = 0
|
var enrichmentProgress: Double = 0
|
||||||
|
var unreadFilter = false
|
||||||
|
|
||||||
private let api: LinkdingAPI
|
private let api: LinkdingAPI
|
||||||
private(set) var claude: ClaudeService?
|
private(set) var claude: ClaudeService?
|
||||||
private var enrichTask: Task<Void, Never>?
|
private var enrichTask: Task<Void, Never>?
|
||||||
|
private let cacheFileUrl: URL
|
||||||
|
|
||||||
init(api: LinkdingAPI, claude: ClaudeService?) {
|
init(api: LinkdingAPI, claude: ClaudeService?, cacheKey: String = "") {
|
||||||
self.api = api
|
self.api = api
|
||||||
self.claude = claude
|
self.claude = claude
|
||||||
|
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
let safe = cacheKey.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_")
|
||||||
|
let name = safe.isEmpty ? "bookmarks_cache" : "bookmarks_\(safe)"
|
||||||
|
self.cacheFileUrl = dir.appendingPathComponent("\(name).json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateClaude(_ service: ClaudeService?) {
|
func updateClaude(_ service: ClaudeService?) {
|
||||||
claude = service
|
claude = service
|
||||||
}
|
}
|
||||||
|
|
||||||
func load() async {
|
func load(useCache: Bool = true) async {
|
||||||
|
if useCache { loadFromCache() }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
do {
|
do {
|
||||||
let response = try await api.fetchBookmarks(search: searchQuery)
|
let response = try await api.fetchBookmarks(search: searchQuery, unread: unreadFilter)
|
||||||
bookmarks = response.results
|
bookmarks = response.results
|
||||||
nextPageUrl = response.next
|
nextPageUrl = response.next
|
||||||
restoreAIData()
|
restoreAIData()
|
||||||
|
saveToCache(bookmarks)
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -59,7 +67,7 @@ final class BookmarksViewModel {
|
|||||||
func search() async {
|
func search() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
do {
|
do {
|
||||||
let response = try await api.fetchBookmarks(search: searchQuery)
|
let response = try await api.fetchBookmarks(search: searchQuery, unread: unreadFilter)
|
||||||
bookmarks = response.results
|
bookmarks = response.results
|
||||||
nextPageUrl = response.next
|
nextPageUrl = response.next
|
||||||
restoreAIData()
|
restoreAIData()
|
||||||
@@ -69,12 +77,16 @@ final class BookmarksViewModel {
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toggleUnreadFilter() async {
|
||||||
|
unreadFilter.toggle()
|
||||||
|
await load(useCache: false)
|
||||||
|
}
|
||||||
|
|
||||||
func semanticSearch() async {
|
func semanticSearch() async {
|
||||||
guard let claude, !searchQuery.isEmpty else { return }
|
guard let claude, !searchQuery.isEmpty else { return }
|
||||||
isLoading = true
|
isLoading = true
|
||||||
do {
|
do {
|
||||||
// Search over all loaded bookmarks semantically
|
let allResponse = try await api.fetchBookmarks(limit: 200, unread: unreadFilter)
|
||||||
let allResponse = try await api.fetchBookmarks(limit: 200)
|
|
||||||
var all = allResponse.results
|
var all = allResponse.results
|
||||||
// Restore AI data so summaries are available for ranking
|
// Restore AI data so summaries are available for ranking
|
||||||
restoreAIData(into: &all)
|
restoreAIData(into: &all)
|
||||||
@@ -129,6 +141,23 @@ final class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func generateSmartCollections() async {
|
||||||
guard let claude, !bookmarks.isEmpty else { return }
|
guard let claude, !bookmarks.isEmpty else { return }
|
||||||
isGeneratingCollections = true
|
isGeneratingCollections = true
|
||||||
@@ -193,6 +222,38 @@ final class BookmarksViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
print("[Cache] saveToCache failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// MARK: AI persistence
|
||||||
|
|
||||||
private func saveAIData(for bookmark: Bookmark) {
|
private func saveAIData(for bookmark: Bookmark) {
|
||||||
|
|||||||
109
Marks/Views/EditBookmarkView.swift
Normal file
109
Marks/Views/EditBookmarkView.swift
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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)
|
||||||
|
_tagsText = State(initialValue: bookmark.tagNames.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,10 +125,7 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func parseConfig() throws -> ServerConfig {
|
private func parseConfig() throws -> ServerConfig {
|
||||||
var raw = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let raw = urlText.normalizedURL
|
||||||
if !raw.hasPrefix("http://") && !raw.hasPrefix("https://") {
|
|
||||||
raw = "https://" + raw
|
|
||||||
}
|
|
||||||
guard let comps = URLComponents(string: raw),
|
guard let comps = URLComponents(string: raw),
|
||||||
let host = comps.host, !host.isEmpty else {
|
let host = comps.host, !host.isEmpty else {
|
||||||
throw URLError(.badURL)
|
throw URLError(.badURL)
|
||||||
|
|||||||
104
scripts/ipa-server/build-and-deploy.sh
Executable file
104
scripts/ipa-server/build-and-deploy.sh
Executable file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build an Ad Hoc IPA and copy to the shared distribution server.
|
||||||
|
# Usage: ./build-and-deploy.sh [branch]
|
||||||
|
# branch defaults to current branch if not specified
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SERVER_BUILDS_DIR="/Volumes/Next2/hermes-home/1ai/builds"
|
||||||
|
BUILD_DIR="$SCRIPT_DIR/.build"
|
||||||
|
ARCHIVE_PATH="$BUILD_DIR/Marks.xcarchive"
|
||||||
|
EXPORT_PATH="$BUILD_DIR/export"
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
SCHEME="${SCHEME:-Marks}"
|
||||||
|
CONFIGURATION="${CONFIGURATION:-Debug}"
|
||||||
|
EXPORT_METHOD="${EXPORT_METHOD:-development}"
|
||||||
|
|
||||||
|
mkdir -p "$SERVER_BUILDS_DIR"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
if [ -n "$CI" ]; then
|
||||||
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
|
echo "📌 CI mode — building commit: $COMMIT (ref: $CURRENT_BRANCH)"
|
||||||
|
elif [ -n "$1" ] && [ "$1" != "$CURRENT_BRANCH" ]; then
|
||||||
|
echo "🔀 Switching from $CURRENT_BRANCH to $1..."
|
||||||
|
git checkout "$1"
|
||||||
|
echo "⬇️ Pulling latest $1..."
|
||||||
|
git pull origin "$1"
|
||||||
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
|
echo "📌 Building commit: $COMMIT on $1"
|
||||||
|
else
|
||||||
|
COMMIT=$(git rev-parse --short HEAD)
|
||||||
|
if git ls-remote --exit-code origin "$CURRENT_BRANCH" &>/dev/null; then
|
||||||
|
echo "⬇️ Pulling latest $CURRENT_BRANCH..."
|
||||||
|
git pull origin "$CURRENT_BRANCH"
|
||||||
|
else
|
||||||
|
echo "⚠️ No remote for $CURRENT_BRANCH — building local HEAD"
|
||||||
|
fi
|
||||||
|
echo "📌 Building commit: $COMMIT on $CURRENT_BRANCH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "⚙️ Generating Xcode project..."
|
||||||
|
xcodegen generate
|
||||||
|
|
||||||
|
echo "🔨 Building $SCHEME ($CONFIGURATION)..."
|
||||||
|
|
||||||
|
xcodebuild archive \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration "$CONFIGURATION" \
|
||||||
|
-archivePath "$ARCHIVE_PATH" \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
2>&1 | tee "$BUILD_DIR/archive.log" | tail -20
|
||||||
|
|
||||||
|
cat > "$BUILD_DIR/ExportOptions.plist" << EOF
|
||||||
|
<?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>method</key>
|
||||||
|
<string>${EXPORT_METHOD}</string>
|
||||||
|
<key>signingStyle</key>
|
||||||
|
<string>automatic</string>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>AE5DZKJHGN</string>
|
||||||
|
<key>compileBitcode</key>
|
||||||
|
<false/>
|
||||||
|
<key>stripSwiftSymbols</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "📦 Exporting IPA..."
|
||||||
|
xcodebuild -exportArchive \
|
||||||
|
-archivePath "$ARCHIVE_PATH" \
|
||||||
|
-exportPath "$EXPORT_PATH" \
|
||||||
|
-exportOptionsPlist "$BUILD_DIR/ExportOptions.plist" \
|
||||||
|
2>&1 | tee "$BUILD_DIR/export.log" | tail -20
|
||||||
|
|
||||||
|
IPA_FILE=$(find "$EXPORT_PATH" -name "*.ipa" | head -1)
|
||||||
|
if [ -n "$IPA_FILE" ]; then
|
||||||
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
APP_PROPS="$ARCHIVE_PATH/Info.plist"
|
||||||
|
VERSION=$(/usr/libexec/PlistBuddy -c "Print :ApplicationProperties:CFBundleShortVersionString" "$APP_PROPS")
|
||||||
|
BUILD=$(/usr/libexec/PlistBuddy -c "Print :ApplicationProperties:CFBundleVersion" "$APP_PROPS")
|
||||||
|
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD)}}"
|
||||||
|
BRANCH=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9._-]/-/g')
|
||||||
|
|
||||||
|
DEST_NAME="Marks-${VERSION}-${BUILD}-${BRANCH}-${TIMESTAMP}.ipa"
|
||||||
|
cp "$IPA_FILE" "$SERVER_BUILDS_DIR/$DEST_NAME"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Build complete!"
|
||||||
|
echo " IPA: $SERVER_BUILDS_DIR/$DEST_NAME"
|
||||||
|
echo " Server: https://krishnas-mac-studio.tail37d544.ts.net:8443"
|
||||||
|
else
|
||||||
|
echo "❌ No IPA file found in export"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user