Skip to content

Commit 6e507b9

Browse files
author
CSL
committed
Add sync control and portable image paths
Ensure login release workflow has contents write, add sync button/animation, startup toggle, and store local images by filename for portability.
1 parent 78d73c8 commit 6e507b9

18 files changed

Lines changed: 1037 additions & 133 deletions

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ on:
1111
required: true
1212
default: 'dev'
1313

14+
permissions:
15+
contents: write
16+
1417
jobs:
1518
build:
1619
runs-on: macos-14

IMPLEMENTATION_PLAN.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
## Stage 1: Understand current preview and toolbar flows
2+
**Goal**: Locate bookmark web preview toolbar, OGP preview rendering, and image storage helpers.
3+
**Success Criteria**: Know where to add a snapshot control and how preview images are stored/loaded.
4+
**Tests**: None (discovery only).
5+
**Status**: Complete
6+
7+
## Stage 2: Snapshot capture & persistence
8+
**Goal**: Add a toolbar snapshot mode that lets users drag a rectangle over the web view and save a captured image locally.
9+
**Success Criteria**: Snapshot button toggles selection UI; Save writes PNG to storage and updates bookmark metadata image path; cancels cleanly.
10+
**Tests**: Manual: open bookmark detail → capture snapshot → preview shows saved image.
11+
**Status**: Complete
12+
13+
## Stage 3: Preview area UX improvements
14+
**Goal**: Always render preview area in sidebar, allow external image drop, and support local paths in cards.
15+
**Success Criteria**: Sidebar preview shows placeholder when empty, accepts drag/drop to set preview; cards render local preview paths.
16+
**Tests**: Manual: drag external image into preview area → card shows image; fallback placeholder visible when empty.
17+
**Status**: Complete
18+
19+
## Stage 4: Startup setting discovery
20+
**Goal**: Understand current settings architecture and how preferences are stored to add an auto-launch toggle.
21+
**Success Criteria**: Identify where to place UI and which persistence/manager pattern to follow for startup behavior.
22+
**Tests**: None (discovery only).
23+
**Status**: Complete
24+
25+
## Stage 5: Login item manager
26+
**Goal**: Implement a manager that enables/disables Seahorse at login using ServiceManagement and keeps the preference persisted.
27+
**Success Criteria**: Toggling the setting registers/unregisters the app as a login item without errors; preference is stored.
28+
**Tests**: Manual: toggle on/off and observe no errors in logs.
29+
**Status**: Complete
30+
31+
## Stage 6: UI and localization
32+
**Goal**: Expose the startup toggle in Basic Settings with localized copy and accessibility labels.
33+
**Success Criteria**: Toggle appears in settings, uses localized strings, and updates state reactively.
34+
**Tests**: Manual: open Settings → Basic → toggle is visible and updates.
35+
**Status**: Complete
36+
37+
## Stage 7: Validation
38+
**Goal**: Verify startup behavior and document manual test steps.
39+
**Success Criteria**: Manual check shows Seahorse can be set to launch at login and disabled again; plan file updated.
40+
**Tests**: Manual: enable auto-start, restart login session (simulated), then disable and confirm removal.
41+
**Status**: Not Started
42+
43+
## Stage 8: Image path portability
44+
**Goal**: Store only filenames for local images and resolve paths using the current storage directory to avoid breakage after migrations.
45+
**Success Criteria**: Saved image paths omit absolute directories; UI renders local images via resolved paths; existing remote URLs unaffected.
46+
**Tests**: Manual: add image and snapshot, move storage folder, reopen and confirm images load.
47+
**Status**: In Progress
48+

Seahorse/ContentView.swift

Lines changed: 105 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ struct ContentView: View {
2525
@StateObject private var sortPreferenceManager = SortPreferenceManager.shared
2626
@StateObject private var pasteHandler: PasteHandler
2727
@FocusState private var isSearchFocused: Bool
28+
@State private var isSyncing = false
29+
@State private var syncRotation: Double = 0
30+
@State private var syncStartTime: Date?
2831

2932
@State private var windowDelegate = MainWindowDelegate()
3033

@@ -85,37 +88,53 @@ struct ContentView: View {
8588
if !searchQuery.isEmpty {
8689
let queryLower = searchQuery.lowercased()
8790
items = items.filter { item in
88-
let tagIds: [UUID] = item.asBookmark?.tagIds ??
89-
item.asImageItem?.tagIds ??
90-
item.asTextItem?.tagIds ??
91-
[]
92-
93-
let tagMatch = dataStorage.tags.contains { tag in
94-
tagIds.contains(tag.id) && tag.name.lowercased().contains(queryLower)
95-
}
96-
97-
if let bookmark = item.asBookmark {
98-
return bookmark.title.lowercased().contains(queryLower) ||
99-
bookmark.url.lowercased().contains(queryLower) ||
100-
(bookmark.notes?.lowercased().contains(queryLower) ?? false) ||
101-
tagMatch
102-
} else if let imageItem = item.asImageItem {
103-
return imageItem.imagePath.lowercased().contains(queryLower) ||
104-
(imageItem.notes?.lowercased().contains(queryLower) ?? false) ||
105-
tagMatch
106-
} else if let textItem = item.asTextItem {
107-
return textItem.content.lowercased().contains(queryLower) ||
108-
(textItem.notes?.lowercased().contains(queryLower) ?? false) ||
109-
tagMatch
110-
}
111-
return false
91+
let searchable = searchableStrings(for: item)
92+
return searchable.contains { $0.contains(queryLower) }
11293
}
11394
}
11495

11596
// Apply sorting to all items uniformly
11697
return sortPreferenceManager.sortOption.sort(items)
11798
}
11899

100+
private func tagNames(for item: AnyCollectionItem) -> [String] {
101+
let tagIds: [UUID] = item.asBookmark?.tagIds ??
102+
item.asImageItem?.tagIds ??
103+
item.asTextItem?.tagIds ??
104+
[]
105+
106+
guard !tagIds.isEmpty else { return [] }
107+
108+
return dataStorage.tags
109+
.filter { tagIds.contains($0.id) }
110+
.map { $0.name.lowercased() }
111+
}
112+
113+
private func searchableStrings(for item: AnyCollectionItem) -> [String] {
114+
var fields: [String] = []
115+
116+
if let bookmark = item.asBookmark {
117+
fields.append(bookmark.title.lowercased())
118+
fields.append(bookmark.url.lowercased())
119+
if let notes = bookmark.notes {
120+
fields.append(notes.lowercased())
121+
}
122+
} else if let imageItem = item.asImageItem {
123+
fields.append(imageItem.imagePath.lowercased())
124+
if let notes = imageItem.notes {
125+
fields.append(notes.lowercased())
126+
}
127+
} else if let textItem = item.asTextItem {
128+
fields.append(textItem.content.lowercased())
129+
if let notes = textItem.notes {
130+
fields.append(notes.lowercased())
131+
}
132+
}
133+
134+
fields.append(contentsOf: tagNames(for: item))
135+
return fields
136+
}
137+
119138
var body: some View {
120139
NavigationSplitView {
121140
SidebarView(
@@ -176,6 +195,22 @@ struct ContentView: View {
176195
.padding(.vertical, 5)
177196
.background(Color(NSColor.controlBackgroundColor))
178197
.cornerRadius(6)
198+
199+
// Sync button
200+
Button(action: performManualSync) {
201+
Image(systemName: "arrow.triangle.2.circlepath")
202+
.rotationEffect(.degrees(syncRotation))
203+
.animation(isSyncing
204+
? .linear(duration: 1.0).repeatForever(autoreverses: false)
205+
: .default,
206+
value: isSyncing)
207+
.padding(.horizontal, 10)
208+
.padding(.vertical, 6)
209+
.background(Color(NSColor.controlBackgroundColor))
210+
.cornerRadius(6)
211+
}
212+
.buttonStyle(.borderless)
213+
.help("Sync")
179214
}
180215

181216
ToolbarItemGroup(placement: .automatic) {
@@ -351,6 +386,12 @@ struct ContentView: View {
351386
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowAddText"))) { _ in
352387
showingAddText = true
353388
}
389+
.onReceive(NotificationCenter.default.publisher(for: .autoSyncStarted)) { _ in
390+
startSyncAnimation()
391+
}
392+
.onReceive(NotificationCenter.default.publisher(for: .autoSyncEnded)) { _ in
393+
stopSyncAnimation()
394+
}
354395
.onPasteCommand(of: [.url, .image, .plainText]) { providers in
355396
pasteHandler.handlePaste(providers: providers)
356397
}
@@ -375,6 +416,46 @@ struct ContentView: View {
375416
}
376417
.frame(maxWidth: .infinity, maxHeight: .infinity)
377418
}
419+
420+
private func performManualSync() {
421+
guard !isSyncing else { return }
422+
startSyncAnimation()
423+
424+
Task { @MainActor in
425+
// Force save triggers autoSync notifications; we handle animation stop with minimum duration.
426+
DataStorage.shared.forceSaveAllData()
427+
stopSyncAnimation()
428+
}
429+
}
430+
431+
private func startSyncAnimation() {
432+
guard !isSyncing else { return }
433+
isSyncing = true
434+
syncRotation = 0
435+
syncStartTime = Date()
436+
syncRotation += 360
437+
}
438+
439+
private func stopSyncAnimation() {
440+
guard let start = syncStartTime else {
441+
isSyncing = false
442+
syncRotation = 0
443+
return
444+
}
445+
let elapsed = Date().timeIntervalSince(start)
446+
let minimumDuration: TimeInterval = 1.0
447+
if elapsed >= minimumDuration {
448+
isSyncing = false
449+
syncRotation = 0
450+
} else {
451+
let remaining = minimumDuration - elapsed
452+
Task { @MainActor in
453+
try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
454+
isSyncing = false
455+
syncRotation = 0
456+
}
457+
}
458+
}
378459
}
379460

380461
#Preview {

Seahorse/Database/JSONStorage.swift

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,9 @@ class JSONStorage: DatabaseProtocol {
118118
Log.info(" ✅ Read \(data.count) bytes from items file", category: .database)
119119

120120
let loaded = try JSONDecoder().decode([AnyCollectionItem].self, from: data)
121-
items = loaded
121+
items = loaded.map { normalizeItemPaths($0) }
122+
// Persist normalized paths so future reads stay portable
123+
saveItemsToDisk()
122124
Log.info(" ✓ Loaded \(items.count) items", category: .database)
123125
} catch {
124126
Log.error(" ❌ Failed to load items: \(error)", category: .database)
@@ -189,7 +191,8 @@ class JSONStorage: DatabaseProtocol {
189191
queue.async(flags: .barrier) {
190192
let encoder = JSONEncoder()
191193
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
192-
if let data = try? encoder.encode(self.items) {
194+
let normalized = self.items.map { self.normalizeItemPaths($0) }
195+
if let data = try? encoder.encode(normalized) {
193196
try? data.write(to: self.itemsURL)
194197
}
195198
}
@@ -225,6 +228,49 @@ class JSONStorage: DatabaseProtocol {
225228
}
226229
}
227230

231+
// MARK: - Path Normalization
232+
233+
/// Ensure image-related paths are portable (filenames only) while leaving remote URLs untouched.
234+
private func normalizeItemPaths(_ item: AnyCollectionItem) -> AnyCollectionItem {
235+
func normalizePath(_ path: String?) -> String? {
236+
guard let path = path, !path.isEmpty else { return path }
237+
// Keep remote URLs as-is
238+
if let url = URL(string: path),
239+
let scheme = url.scheme,
240+
(scheme == "http" || scheme == "https") {
241+
return path
242+
}
243+
let resolved = StorageManager.shared.resolveImagePath(path)
244+
return StorageManager.shared.relativeImageFilename(from: resolved)
245+
}
246+
247+
switch item.itemType {
248+
case .bookmark:
249+
if var bookmark = item.asBookmark {
250+
if var metadata = bookmark.metadata {
251+
metadata.imageURL = normalizePath(metadata.imageURL)
252+
bookmark.metadata = metadata
253+
}
254+
return AnyCollectionItem(bookmark)
255+
}
256+
case .image:
257+
if var imageItem = item.asImageItem {
258+
if let normalized = normalizePath(imageItem.imagePath) {
259+
imageItem.imagePath = normalized
260+
}
261+
if let thumb = imageItem.thumbnailPath,
262+
let normalizedThumb = normalizePath(thumb) {
263+
imageItem.thumbnailPath = normalizedThumb
264+
}
265+
return AnyCollectionItem(imageItem)
266+
}
267+
case .text:
268+
return item
269+
}
270+
271+
return item
272+
}
273+
228274
// MARK: - Bookmark Operations
229275

230276
func saveBookmark(_ bookmark: Bookmark) throws {
@@ -295,7 +341,7 @@ class JSONStorage: DatabaseProtocol {
295341
guard !items.contains(where: { $0.id == item.id }) else {
296342
throw DatabaseError.duplicateEntry
297343
}
298-
items.append(item)
344+
items.append(normalizeItemPaths(item))
299345
}
300346
saveItemsToDisk()
301347
}
@@ -305,7 +351,7 @@ class JSONStorage: DatabaseProtocol {
305351
guard let index = items.firstIndex(where: { $0.id == item.id }) else {
306352
throw DatabaseError.notFound
307353
}
308-
items[index] = item
354+
items[index] = normalizeItemPaths(item)
309355
}
310356
saveItemsToDisk()
311357
}

0 commit comments

Comments
 (0)