@shaxpir/duiduidui-models 1.9.10 → 1.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { ShareSync } from '../repo';
4
4
  import { Content, ContentBody, ContentId, ContentMeta } from "./Content";
5
5
  import { ConditionFilters } from './Condition';
6
6
  import { BuiltInDbState, DatabaseVersion } from '../util/Database';
7
+ import { SearchHistory, SearchState } from './SearchState';
7
8
  export interface LastSync {
8
9
  at_utc_time: CompactDateTime | null;
9
10
  }
@@ -37,6 +38,7 @@ export interface DevicePayload {
37
38
  download_queue?: DownloadQueueItem[];
38
39
  upload_queue?: UploadQueueItem[];
39
40
  builtin_db?: BuiltInDbState;
41
+ search_history: SearchHistory;
40
42
  }
41
43
  export interface DeviceBody extends ContentBody {
42
44
  meta: ContentMeta;
@@ -77,5 +79,44 @@ export declare class Device extends Content {
77
79
  updateUploadQueueItem(predicate: (item: UploadQueueItem) => boolean, updates: Partial<BaseQueueItem>): void;
78
80
  addImageToUploadQueue(imageId: ContentId): void;
79
81
  removeImageFromUploadQueue(imageId: ContentId): void;
82
+ /**
83
+ * Gets the current search history.
84
+ */
85
+ get searchHistory(): SearchHistory;
86
+ /**
87
+ * Gets the current search state (at the cursor position), or undefined if history is empty.
88
+ */
89
+ get currentSearchState(): SearchState | undefined;
90
+ /**
91
+ * Returns true if back navigation is possible.
92
+ */
93
+ get canGoBack(): boolean;
94
+ /**
95
+ * Returns true if forward navigation is possible.
96
+ */
97
+ get canGoForward(): boolean;
98
+ /**
99
+ * Pushes a new search state onto the history stack.
100
+ * - Deduplicates: won't push if identical to current state
101
+ * - Discards forward history (entries after cursor)
102
+ * - Respects max entries limit (drops oldest when full)
103
+ *
104
+ * @returns The new search state at the cursor, or undefined if deduplicated
105
+ */
106
+ pushSearchState(state: SearchState): SearchState | undefined;
107
+ /**
108
+ * Navigates back in search history.
109
+ * @returns The search state at the new cursor position, or undefined if can't go back
110
+ */
111
+ goBack(): SearchState | undefined;
112
+ /**
113
+ * Navigates forward in search history.
114
+ * @returns The search state at the new cursor position, or undefined if can't go forward
115
+ */
116
+ goForward(): SearchState | undefined;
117
+ /**
118
+ * Clears the entire search history stack.
119
+ */
120
+ clearSearchHistory(): void;
80
121
  }
81
122
  export {};
@@ -7,6 +7,7 @@ const ArrayView_1 = require("./ArrayView");
7
7
  const Content_1 = require("./Content");
8
8
  const ContentKind_1 = require("./ContentKind");
9
9
  const Operation_1 = require("./Operation");
10
+ const SearchState_1 = require("./SearchState");
10
11
  class Device extends Content_1.Content {
11
12
  static create(userId, deviceId) {
12
13
  const now = shaxpir_common_1.ClockService.getClock().now();
@@ -29,7 +30,8 @@ class Device extends Content_1.Content {
29
30
  download_status: 'none',
30
31
  last_check: null,
31
32
  last_error: null
32
- }
33
+ },
34
+ search_history: (0, SearchState_1.createEmptySearchHistory)()
33
35
  }
34
36
  });
35
37
  }
@@ -244,5 +246,121 @@ class Device extends Content_1.Content {
244
246
  removeImageFromUploadQueue(imageId) {
245
247
  this.removeFromUploadQueue(item => item.type === 'image' && item.image_id === imageId);
246
248
  }
249
+ // Search History Methods
250
+ /**
251
+ * Gets the current search history.
252
+ */
253
+ get searchHistory() {
254
+ this.checkDisposed("Device.searchHistory");
255
+ return this.payload.search_history;
256
+ }
257
+ /**
258
+ * Gets the current search state (at the cursor position), or undefined if history is empty.
259
+ */
260
+ get currentSearchState() {
261
+ this.checkDisposed("Device.currentSearchState");
262
+ const history = this.searchHistory;
263
+ if (history.cursor < 0 || history.cursor >= history.entries.length) {
264
+ return undefined;
265
+ }
266
+ return history.entries[history.cursor];
267
+ }
268
+ /**
269
+ * Returns true if back navigation is possible.
270
+ */
271
+ get canGoBack() {
272
+ this.checkDisposed("Device.canGoBack");
273
+ return this.searchHistory.cursor > 0;
274
+ }
275
+ /**
276
+ * Returns true if forward navigation is possible.
277
+ */
278
+ get canGoForward() {
279
+ this.checkDisposed("Device.canGoForward");
280
+ const history = this.searchHistory;
281
+ return history.cursor < history.entries.length - 1;
282
+ }
283
+ /**
284
+ * Pushes a new search state onto the history stack.
285
+ * - Deduplicates: won't push if identical to current state
286
+ * - Discards forward history (entries after cursor)
287
+ * - Respects max entries limit (drops oldest when full)
288
+ *
289
+ * @returns The new search state at the cursor, or undefined if deduplicated
290
+ */
291
+ pushSearchState(state) {
292
+ this.checkDisposed("Device.pushSearchState");
293
+ const history = this.searchHistory;
294
+ // Deduplicate: don't push if identical to current state
295
+ const currentState = history.cursor >= 0 && history.cursor < history.entries.length
296
+ ? history.entries[history.cursor]
297
+ : undefined;
298
+ if ((0, SearchState_1.areSearchStatesEqual)(state, currentState)) {
299
+ return undefined;
300
+ }
301
+ // Build new entries array
302
+ // Discard any entries after the current cursor (browser-style)
303
+ let newEntries = history.cursor >= 0
304
+ ? history.entries.slice(0, history.cursor + 1)
305
+ : [];
306
+ // Add the new state
307
+ newEntries.push(state);
308
+ // Enforce max entries limit - drop oldest entries
309
+ let newCursor = newEntries.length - 1;
310
+ if (newEntries.length > SearchState_1.SEARCH_HISTORY_MAX_ENTRIES) {
311
+ const overflow = newEntries.length - SearchState_1.SEARCH_HISTORY_MAX_ENTRIES;
312
+ newEntries = newEntries.slice(overflow);
313
+ newCursor = newEntries.length - 1;
314
+ }
315
+ // Commit the update
316
+ const batch = new Operation_1.BatchOperation(this);
317
+ batch.setPathValue(['payload', 'search_history'], {
318
+ entries: newEntries,
319
+ cursor: newCursor
320
+ });
321
+ batch.commit();
322
+ return state;
323
+ }
324
+ /**
325
+ * Navigates back in search history.
326
+ * @returns The search state at the new cursor position, or undefined if can't go back
327
+ */
328
+ goBack() {
329
+ this.checkDisposed("Device.goBack");
330
+ if (!this.canGoBack) {
331
+ return undefined;
332
+ }
333
+ const history = this.searchHistory;
334
+ const newCursor = history.cursor - 1;
335
+ const batch = new Operation_1.BatchOperation(this);
336
+ batch.setPathValue(['payload', 'search_history', 'cursor'], newCursor);
337
+ batch.commit();
338
+ return history.entries[newCursor];
339
+ }
340
+ /**
341
+ * Navigates forward in search history.
342
+ * @returns The search state at the new cursor position, or undefined if can't go forward
343
+ */
344
+ goForward() {
345
+ this.checkDisposed("Device.goForward");
346
+ if (!this.canGoForward) {
347
+ return undefined;
348
+ }
349
+ const history = this.searchHistory;
350
+ const newCursor = history.cursor + 1;
351
+ const batch = new Operation_1.BatchOperation(this);
352
+ batch.setPathValue(['payload', 'search_history', 'cursor'], newCursor);
353
+ batch.commit();
354
+ return history.entries[newCursor];
355
+ }
356
+ /**
357
+ * Clears the entire search history stack.
358
+ */
359
+ clearSearchHistory() {
360
+ this.checkDisposed("Device.clearSearchHistory");
361
+ const batch = new Operation_1.BatchOperation(this);
362
+ batch.setPathValue(['payload', 'search_history'], (0, SearchState_1.createEmptySearchHistory)());
363
+ batch.commit();
364
+ }
247
365
  }
248
366
  exports.Device = Device;
@@ -0,0 +1,50 @@
1
+ import { ConditionFilters } from './Condition';
2
+ /**
3
+ * Represents the data portion of a search state.
4
+ * This is the shareable/persistable part of a search - it excludes
5
+ * transient UI state like SearchReason.
6
+ *
7
+ * Used for:
8
+ * - Search history entries in the Device model
9
+ * - Sharing search states between client and server
10
+ * - Cache keys and comparison
11
+ */
12
+ export interface SearchState {
13
+ query?: string;
14
+ senseRank?: number;
15
+ conditions?: ConditionFilters;
16
+ }
17
+ /**
18
+ * Maximum number of entries in the search history stack.
19
+ * When this limit is reached, the oldest entry is dropped.
20
+ */
21
+ export declare const SEARCH_HISTORY_MAX_ENTRIES = 50;
22
+ /**
23
+ * Search history with browser-style back/forward navigation.
24
+ *
25
+ * - `entries`: Stack of search states
26
+ * - `cursor`: Current position in the stack (0-indexed)
27
+ *
28
+ * Navigation behavior:
29
+ * - Back: decrements cursor (if cursor > 0)
30
+ * - Forward: increments cursor (if cursor < entries.length - 1)
31
+ * - New search: discards entries after cursor, pushes new entry, cursor moves to end
32
+ * - Clear: empties the entire stack
33
+ */
34
+ export interface SearchHistory {
35
+ entries: SearchState[];
36
+ cursor: number;
37
+ }
38
+ /**
39
+ * Creates an empty search history.
40
+ */
41
+ export declare function createEmptySearchHistory(): SearchHistory;
42
+ /**
43
+ * Checks if two SearchState objects are equivalent.
44
+ * Used for deduplication when pushing to history.
45
+ */
46
+ export declare function areSearchStatesEqual(a: SearchState | undefined, b: SearchState | undefined): boolean;
47
+ /**
48
+ * Checks if a SearchState is empty (no query, no conditions).
49
+ */
50
+ export declare function isEmptySearchState(state: SearchState | undefined): boolean;
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SEARCH_HISTORY_MAX_ENTRIES = void 0;
4
+ exports.createEmptySearchHistory = createEmptySearchHistory;
5
+ exports.areSearchStatesEqual = areSearchStatesEqual;
6
+ exports.isEmptySearchState = isEmptySearchState;
7
+ /**
8
+ * Maximum number of entries in the search history stack.
9
+ * When this limit is reached, the oldest entry is dropped.
10
+ */
11
+ exports.SEARCH_HISTORY_MAX_ENTRIES = 50;
12
+ /**
13
+ * Creates an empty search history.
14
+ */
15
+ function createEmptySearchHistory() {
16
+ return {
17
+ entries: [],
18
+ cursor: -1
19
+ };
20
+ }
21
+ /**
22
+ * Checks if two SearchState objects are equivalent.
23
+ * Used for deduplication when pushing to history.
24
+ */
25
+ function areSearchStatesEqual(a, b) {
26
+ if (a === b)
27
+ return true;
28
+ if (!a || !b)
29
+ return false;
30
+ // Compare query (normalize empty string to undefined)
31
+ const queryA = a.query?.trim() || undefined;
32
+ const queryB = b.query?.trim() || undefined;
33
+ if (queryA !== queryB)
34
+ return false;
35
+ // Compare senseRank
36
+ if (a.senseRank !== b.senseRank)
37
+ return false;
38
+ // Compare conditions using JSON serialization (with sorted keys for consistency)
39
+ const conditionsA = a.conditions ? JSON.stringify(sortConditions(a.conditions)) : undefined;
40
+ const conditionsB = b.conditions ? JSON.stringify(sortConditions(b.conditions)) : undefined;
41
+ if (conditionsA !== conditionsB)
42
+ return false;
43
+ return true;
44
+ }
45
+ /**
46
+ * Sorts condition arrays for consistent comparison.
47
+ */
48
+ function sortConditions(conditions) {
49
+ const sorted = {};
50
+ if (conditions.any?.length) {
51
+ sorted.any = [...conditions.any].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
52
+ }
53
+ if (conditions.all?.length) {
54
+ sorted.all = [...conditions.all].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
55
+ }
56
+ if (conditions.none?.length) {
57
+ sorted.none = [...conditions.none].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
58
+ }
59
+ return sorted;
60
+ }
61
+ /**
62
+ * Checks if a SearchState is empty (no query, no conditions).
63
+ */
64
+ function isEmptySearchState(state) {
65
+ if (!state)
66
+ return true;
67
+ return !state.query?.trim() &&
68
+ !state.conditions?.any?.length &&
69
+ !state.conditions?.all?.length &&
70
+ !state.conditions?.none?.length;
71
+ }
@@ -19,6 +19,7 @@ export * from './Phrase';
19
19
  export * from './Profile';
20
20
  export * from './Progress';
21
21
  export * from './Review';
22
+ export * from './SearchState';
22
23
  export * from './Session';
23
24
  export * from './SkillLevel';
24
25
  export * from './Social';
@@ -36,6 +36,7 @@ __exportStar(require("./Phrase"), exports);
36
36
  __exportStar(require("./Profile"), exports);
37
37
  __exportStar(require("./Progress"), exports);
38
38
  __exportStar(require("./Review"), exports);
39
+ __exportStar(require("./SearchState"), exports);
39
40
  __exportStar(require("./Session"), exports);
40
41
  __exportStar(require("./SkillLevel"), exports);
41
42
  __exportStar(require("./Social"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaxpir/duiduidui-models",
3
- "version": "1.9.10",
3
+ "version": "1.9.11",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/shaxpir/duiduidui-models"