entry.go 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package storage // import "miniflux.app/v2/internal/storage"
  4. import (
  5. "database/sql"
  6. "errors"
  7. "fmt"
  8. "log/slog"
  9. "time"
  10. "miniflux.app/v2/internal/crypto"
  11. "miniflux.app/v2/internal/model"
  12. "github.com/lib/pq"
  13. )
  14. // ErrEntryTombstoned is returned when an entry cannot be created because its
  15. // (feed_id, hash) pair has a tombstone recording a prior deletion.
  16. var ErrEntryTombstoned = errors.New("store: entry is tombstoned")
  17. // CountAllEntries returns the number of entries for each status in the database.
  18. func (s *Storage) CountAllEntries() (map[string]int64, error) {
  19. rows, err := s.db.Query(`SELECT status, count(*) FROM entries GROUP BY status`)
  20. if err != nil {
  21. return nil, fmt.Errorf("storage: unable to count entries: %w", err)
  22. }
  23. defer rows.Close()
  24. results := make(map[string]int64)
  25. results[model.EntryStatusUnread] = 0
  26. results[model.EntryStatusRead] = 0
  27. for rows.Next() {
  28. var status string
  29. var count int64
  30. if err := rows.Scan(&status, &count); err != nil {
  31. continue
  32. }
  33. results[status] = count
  34. }
  35. results["total"] = results[model.EntryStatusUnread] + results[model.EntryStatusRead]
  36. return results, nil
  37. }
  38. // UpdateEntryTitleAndContent updates entry title and content.
  39. func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error {
  40. truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
  41. query := `
  42. UPDATE
  43. entries
  44. SET
  45. title=$1,
  46. content=$2,
  47. reading_time=$3,
  48. document_vectors = setweight(to_tsvector($4), 'A') || setweight(to_tsvector($5), 'B')
  49. WHERE
  50. id=$6 AND user_id=$7
  51. `
  52. if _, err := s.db.Exec(
  53. query,
  54. entry.Title,
  55. entry.Content,
  56. entry.ReadingTime,
  57. truncatedTitle,
  58. truncatedContent,
  59. entry.ID,
  60. entry.UserID); err != nil {
  61. return fmt.Errorf(`store: unable to update entry #%d: %v`, entry.ID, err)
  62. }
  63. return nil
  64. }
  65. // createEntry add a new entry.
  66. func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
  67. truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
  68. // The WHERE NOT EXISTS guard makes the tombstone check atomic with the insert, so a
  69. // concurrent archive committing between an earlier existence check and this statement
  70. // cannot bring a deleted entry back as unread.
  71. query := `
  72. INSERT INTO entries
  73. (
  74. title,
  75. hash,
  76. url,
  77. comments_url,
  78. published_at,
  79. content,
  80. author,
  81. user_id,
  82. feed_id,
  83. reading_time,
  84. changed_at,
  85. document_vectors,
  86. tags,
  87. language
  88. )
  89. SELECT
  90. $1,
  91. $2,
  92. $3,
  93. $4,
  94. $5,
  95. $6,
  96. $7,
  97. $8,
  98. $9,
  99. $10,
  100. now(),
  101. setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),
  102. $13,
  103. $14
  104. WHERE NOT EXISTS (
  105. SELECT 1 FROM entry_tombstones WHERE feed_id=$9 AND hash=$2
  106. )
  107. RETURNING
  108. id, status, created_at, changed_at
  109. `
  110. err := tx.QueryRow(
  111. query,
  112. entry.Title,
  113. entry.Hash,
  114. entry.URL,
  115. entry.CommentsURL,
  116. entry.Date,
  117. entry.Content,
  118. entry.Author,
  119. entry.UserID,
  120. entry.FeedID,
  121. entry.ReadingTime,
  122. truncatedTitle,
  123. truncatedContent,
  124. pq.Array(entry.Tags),
  125. entry.Language,
  126. ).Scan(
  127. &entry.ID,
  128. &entry.Status,
  129. &entry.CreatedAt,
  130. &entry.ChangedAt,
  131. )
  132. if errors.Is(err, sql.ErrNoRows) {
  133. return ErrEntryTombstoned
  134. }
  135. if err != nil {
  136. return fmt.Errorf(`store: unable to create entry %q (feed #%d): %v`, entry.URL, entry.FeedID, err)
  137. }
  138. for _, enclosure := range entry.Enclosures {
  139. enclosure.EntryID = entry.ID
  140. enclosure.UserID = entry.UserID
  141. err := s.createEnclosure(tx, enclosure)
  142. if err != nil {
  143. return err
  144. }
  145. }
  146. return nil
  147. }
  148. // updateEntry updates an entry when a feed is refreshed.
  149. // Note: we do not update the published date because some feeds do not contains any date,
  150. // it default to time.Now() which could change the order of items on the history page.
  151. func (s *Storage) updateEntry(tx *sql.Tx, entry *model.Entry) error {
  152. truncatedTitle, truncatedContent := truncateTitleAndContentForTSVectorField(entry.Title, entry.Content)
  153. query := `
  154. UPDATE
  155. entries
  156. SET
  157. title=$1,
  158. url=$2,
  159. comments_url=$3,
  160. content=$4,
  161. author=$5,
  162. reading_time=$6,
  163. document_vectors = setweight(to_tsvector($7), 'A') || setweight(to_tsvector($8), 'B'),
  164. tags=$12,
  165. language=$13
  166. WHERE
  167. user_id=$9 AND feed_id=$10 AND hash=$11
  168. RETURNING
  169. id
  170. `
  171. err := tx.QueryRow(
  172. query,
  173. entry.Title,
  174. entry.URL,
  175. entry.CommentsURL,
  176. entry.Content,
  177. entry.Author,
  178. entry.ReadingTime,
  179. truncatedTitle,
  180. truncatedContent,
  181. entry.UserID,
  182. entry.FeedID,
  183. entry.Hash,
  184. pq.Array(entry.Tags),
  185. entry.Language,
  186. ).Scan(&entry.ID)
  187. if err != nil {
  188. return fmt.Errorf(`store: unable to update entry %q: %v`, entry.URL, err)
  189. }
  190. for _, enclosure := range entry.Enclosures {
  191. enclosure.UserID = entry.UserID
  192. enclosure.EntryID = entry.ID
  193. }
  194. return s.updateEnclosures(tx, entry)
  195. }
  196. // entryExists checks if an entry already exists based on its hash when refreshing a feed.
  197. func (s *Storage) entryExists(tx *sql.Tx, entry *model.Entry) (bool, error) {
  198. var result bool
  199. // Note: This query uses entries_feed_id_hash_key index (filtering on user_id is not necessary).
  200. err := tx.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`, entry.FeedID, entry.Hash).Scan(&result)
  201. if err != nil && err != sql.ErrNoRows {
  202. return result, fmt.Errorf(`store: unable to check if entry exists: %v`, err)
  203. }
  204. return result, nil
  205. }
  206. func (s *Storage) getEntryIDByHash(tx *sql.Tx, feedID int64, entryHash string) (int64, error) {
  207. var entryID int64
  208. err := tx.QueryRow(
  209. `SELECT id FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`,
  210. feedID,
  211. entryHash,
  212. ).Scan(&entryID)
  213. if errors.Is(err, sql.ErrNoRows) {
  214. return 0, nil
  215. }
  216. if err != nil {
  217. return 0, fmt.Errorf(`store: unable to fetch entry ID: %v`, err)
  218. }
  219. return entryID, nil
  220. }
  221. // InsertEntryForFeed inserts a single entry into a feed, optionally updating if it already exists.
  222. // Returns true if a new entry was created, false if an existing one was reused.
  223. func (s *Storage) InsertEntryForFeed(userID, feedID int64, entry *model.Entry) (bool, error) {
  224. entry.UserID = userID
  225. entry.FeedID = feedID
  226. tx, err := s.db.Begin()
  227. if err != nil {
  228. return false, fmt.Errorf("store: unable to start transaction: %v", err)
  229. }
  230. defer tx.Rollback()
  231. entryID, err := s.getEntryIDByHash(tx, entry.FeedID, entry.Hash)
  232. if err != nil {
  233. return false, err
  234. }
  235. alreadyExistingEntry := entryID > 0
  236. if alreadyExistingEntry {
  237. entry.ID = entryID
  238. } else {
  239. if err := s.createEntry(tx, entry); err != nil {
  240. return false, err
  241. }
  242. }
  243. if err := tx.Commit(); err != nil {
  244. return false, err
  245. }
  246. return !alreadyExistingEntry, nil
  247. }
  248. func (s *Storage) IsNewEntry(feedID int64, entryHash string) bool {
  249. // An entry is new only if it is neither stored nor tombstoned; otherwise
  250. // callers (such as the crawler) would do expensive work on every refresh
  251. // for items that will be discarded.
  252. query := `
  253. SELECT
  254. EXISTS (
  255. SELECT 1 FROM entries WHERE feed_id=$1 AND hash=$2
  256. ) OR EXISTS (
  257. SELECT 1 FROM entry_tombstones WHERE feed_id=$1 AND hash=$2
  258. )
  259. `
  260. var known bool
  261. s.db.QueryRow(query, feedID, entryHash).Scan(&known)
  262. return !known
  263. }
  264. func (s *Storage) GetReadTime(feedID int64, entryHash string) int {
  265. var result int
  266. // Note: This query uses entries_feed_id_hash_key index
  267. s.db.QueryRow(
  268. `SELECT
  269. reading_time
  270. FROM
  271. entries
  272. WHERE
  273. feed_id=$1 AND
  274. hash=$2
  275. `,
  276. feedID,
  277. entryHash,
  278. ).Scan(&result)
  279. return result
  280. }
  281. // RefreshFeedEntries updates feed entries while refreshing a feed.
  282. func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries, updateExistingEntries bool) (newEntries model.Entries, err error) {
  283. for _, entry := range entries {
  284. entry.UserID = userID
  285. entry.FeedID = feedID
  286. tx, err := s.db.Begin()
  287. if err != nil {
  288. return nil, fmt.Errorf(`store: unable to start transaction: %v`, err)
  289. }
  290. entryExists, err := s.entryExists(tx, entry)
  291. if err != nil {
  292. if rollbackErr := tx.Rollback(); rollbackErr != nil {
  293. return nil, fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)
  294. }
  295. return nil, err
  296. }
  297. if entryExists {
  298. if updateExistingEntries {
  299. err = s.updateEntry(tx, entry)
  300. }
  301. } else {
  302. err = s.createEntry(tx, entry)
  303. switch {
  304. case errors.Is(err, ErrEntryTombstoned):
  305. err = nil
  306. case err == nil:
  307. newEntries = append(newEntries, entry)
  308. }
  309. }
  310. if err != nil {
  311. if rollbackErr := tx.Rollback(); rollbackErr != nil {
  312. return nil, fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)
  313. }
  314. return nil, err
  315. }
  316. if err := tx.Commit(); err != nil {
  317. return nil, fmt.Errorf(`store: unable to commit transaction: %v`, err)
  318. }
  319. }
  320. return newEntries, nil
  321. }
  322. // ArchiveEntries deletes entries older than the given interval and records tombstones so they are not re-ingested.
  323. func (s *Storage) ArchiveEntries(status string, interval time.Duration, limit int) (int64, error) {
  324. if interval < 0 || limit <= 0 {
  325. return 0, nil
  326. }
  327. query := `
  328. WITH to_delete AS (
  329. SELECT id, feed_id, hash
  330. FROM entries
  331. WHERE
  332. status=$1 AND
  333. starred is false AND
  334. share_code='' AND
  335. created_at < now() - $2::interval
  336. ORDER BY created_at ASC
  337. FOR UPDATE SKIP LOCKED
  338. LIMIT $3
  339. ), deleted AS (
  340. DELETE FROM entries
  341. USING to_delete
  342. WHERE entries.id = to_delete.id
  343. RETURNING entries.feed_id, entries.hash
  344. )
  345. INSERT INTO entry_tombstones (feed_id, hash)
  346. SELECT feed_id, hash FROM deleted WHERE hash <> ''
  347. ON CONFLICT (feed_id, hash) DO NOTHING
  348. `
  349. days := max(int(interval/(24*time.Hour)), 1)
  350. result, err := s.db.Exec(query, status, fmt.Sprintf("%d days", days), limit)
  351. if err != nil {
  352. return 0, fmt.Errorf(`store: unable to archive %s entries: %v`, status, err)
  353. }
  354. count, err := result.RowsAffected()
  355. if err != nil {
  356. return 0, fmt.Errorf(`store: unable to get the number of rows affected: %v`, err)
  357. }
  358. return count, nil
  359. }
  360. // SetEntriesStatus update the status of the given list of entries.
  361. func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string) error {
  362. query := `
  363. UPDATE
  364. entries
  365. SET
  366. status=$1,
  367. changed_at=now()
  368. WHERE
  369. user_id=$2 AND
  370. id=ANY($3)
  371. `
  372. if _, err := s.db.Exec(query, status, userID, pq.Array(entryIDs)); err != nil {
  373. return fmt.Errorf(`store: unable to update entries statuses %v: %v`, entryIDs, err)
  374. }
  375. return nil
  376. }
  377. // SetEntriesStatusAndCountVisible updates the status of the given entries and returns how many are visible in global views.
  378. func (s *Storage) SetEntriesStatusAndCountVisible(userID int64, entryIDs []int64, status string) (int, error) {
  379. query := `
  380. WITH updated AS (
  381. UPDATE entries
  382. SET
  383. status=$1,
  384. changed_at=now()
  385. WHERE
  386. user_id=$2 AND
  387. id=ANY($3)
  388. RETURNING feed_id
  389. )
  390. SELECT count(*)
  391. FROM updated u
  392. JOIN feeds f ON (f.id = u.feed_id)
  393. JOIN categories c ON (c.id = f.category_id)
  394. WHERE NOT f.hide_globally AND NOT c.hide_globally
  395. `
  396. var visible int
  397. if err := s.db.QueryRow(query, status, userID, pq.Array(entryIDs)).Scan(&visible); err != nil {
  398. return 0, fmt.Errorf(`store: unable to update entries status %v: %v`, entryIDs, err)
  399. }
  400. return visible, nil
  401. }
  402. // SetEntriesStarredState updates the starred state for the given list of entries.
  403. func (s *Storage) SetEntriesStarredState(userID int64, entryIDs []int64, starred bool) error {
  404. query := `UPDATE entries SET starred=$1, changed_at=now() WHERE user_id=$2 AND id=ANY($3)`
  405. if _, err := s.db.Exec(query, starred, userID, pq.Array(entryIDs)); err != nil {
  406. return fmt.Errorf(`store: unable to update the starred state %v: %v`, entryIDs, err)
  407. }
  408. return nil
  409. }
  410. // ToggleStarred toggles entry starred value.
  411. func (s *Storage) ToggleStarred(userID int64, entryID int64) error {
  412. query := `UPDATE entries SET starred = NOT starred, changed_at=now() WHERE user_id=$1 AND id=$2`
  413. result, err := s.db.Exec(query, userID, entryID)
  414. if err != nil {
  415. return fmt.Errorf(`store: unable to toggle starred flag for entry #%d: %v`, entryID, err)
  416. }
  417. count, err := result.RowsAffected()
  418. if err != nil {
  419. return fmt.Errorf(`store: unable to toggle starred flag for entry #%d: %v`, entryID, err)
  420. }
  421. if count == 0 {
  422. return errors.New(`store: nothing has been updated`)
  423. }
  424. return nil
  425. }
  426. // FlushHistory deletes all read entries (non-starred, non-shared) and records tombstones to prevent re-ingestion.
  427. func (s *Storage) FlushHistory(userID int64) error {
  428. query := `
  429. WITH deleted AS (
  430. DELETE FROM entries
  431. WHERE user_id=$1 AND status=$2 AND starred is false AND share_code=''
  432. RETURNING feed_id, hash
  433. )
  434. INSERT INTO entry_tombstones (feed_id, hash)
  435. SELECT feed_id, hash FROM deleted WHERE hash <> ''
  436. ON CONFLICT (feed_id, hash) DO NOTHING
  437. `
  438. if _, err := s.db.Exec(query, userID, model.EntryStatusRead); err != nil {
  439. return fmt.Errorf(`store: unable to flush history: %v`, err)
  440. }
  441. return nil
  442. }
  443. // MarkAllAsRead updates all user entries to the read status.
  444. func (s *Storage) MarkAllAsRead(userID int64) error {
  445. query := `UPDATE entries SET status=$1, changed_at=now() WHERE user_id=$2 AND status=$3`
  446. result, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread)
  447. if err != nil {
  448. return fmt.Errorf(`store: unable to mark all entries as read: %v`, err)
  449. }
  450. count, _ := result.RowsAffected()
  451. slog.Debug("Marked all entries as read",
  452. slog.Int64("user_id", userID),
  453. slog.Int64("nb_entries", count),
  454. )
  455. return nil
  456. }
  457. // MarkAllAsReadBeforeDate updates all user entries to the read status before the given date.
  458. func (s *Storage) MarkAllAsReadBeforeDate(userID int64, before time.Time) error {
  459. query := `
  460. UPDATE
  461. entries
  462. SET
  463. status=$1,
  464. changed_at=now()
  465. WHERE
  466. user_id=$2 AND status=$3 AND published_at < $4
  467. `
  468. result, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread, before)
  469. if err != nil {
  470. return fmt.Errorf(`store: unable to mark all entries as read before %s: %v`, before.Format(time.RFC3339), err)
  471. }
  472. count, _ := result.RowsAffected()
  473. slog.Debug("Marked all entries as read before date",
  474. slog.Int64("user_id", userID),
  475. slog.Int64("nb_entries", count),
  476. slog.String("before", before.Format(time.RFC3339)),
  477. )
  478. return nil
  479. }
  480. // MarkGloballyVisibleFeedsAsRead marks as read the unread entries that are
  481. // visible in the global unread view, i.e. those belonging to a feed and a
  482. // category that are both not hidden globally.
  483. func (s *Storage) MarkGloballyVisibleFeedsAsRead(userID int64) error {
  484. query := `
  485. UPDATE
  486. entries
  487. SET
  488. status=$1,
  489. changed_at=now()
  490. FROM
  491. feeds
  492. JOIN categories ON (categories.id = feeds.category_id)
  493. WHERE
  494. entries.feed_id = feeds.id
  495. AND entries.user_id=$2
  496. AND entries.status=$3
  497. AND feeds.hide_globally IS FALSE
  498. AND categories.hide_globally IS FALSE
  499. `
  500. result, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread)
  501. if err != nil {
  502. return fmt.Errorf(`store: unable to mark globally visible feeds as read: %v`, err)
  503. }
  504. count, _ := result.RowsAffected()
  505. slog.Debug("Marked globally visible feed entries as read",
  506. slog.Int64("user_id", userID),
  507. slog.Int64("nb_entries", count),
  508. )
  509. return nil
  510. }
  511. // MarkFeedAsRead updates all feed entries to the read status.
  512. func (s *Storage) MarkFeedAsRead(userID, feedID int64, before time.Time) error {
  513. query := `
  514. UPDATE
  515. entries
  516. SET
  517. status=$1,
  518. changed_at=now()
  519. WHERE
  520. user_id=$2 AND feed_id=$3 AND status=$4 AND published_at < $5
  521. `
  522. result, err := s.db.Exec(query, model.EntryStatusRead, userID, feedID, model.EntryStatusUnread, before)
  523. if err != nil {
  524. return fmt.Errorf(`store: unable to mark feed entries as read: %v`, err)
  525. }
  526. count, _ := result.RowsAffected()
  527. slog.Debug("Marked feed entries as read",
  528. slog.Int64("user_id", userID),
  529. slog.Int64("feed_id", feedID),
  530. slog.Int64("nb_entries", count),
  531. slog.String("before", before.Format(time.RFC3339)),
  532. )
  533. return nil
  534. }
  535. // MarkCategoryAsRead updates all category entries to the read status.
  536. func (s *Storage) MarkCategoryAsRead(userID, categoryID int64, before time.Time) error {
  537. query := `
  538. UPDATE
  539. entries
  540. SET
  541. status=$1,
  542. changed_at=now()
  543. FROM
  544. feeds
  545. WHERE
  546. feed_id=feeds.id
  547. AND
  548. feeds.user_id=$2
  549. AND
  550. status=$3
  551. AND
  552. published_at < $4
  553. AND
  554. feeds.category_id=$5
  555. `
  556. result, err := s.db.Exec(query, model.EntryStatusRead, userID, model.EntryStatusUnread, before, categoryID)
  557. if err != nil {
  558. return fmt.Errorf(`store: unable to mark category entries as read: %v`, err)
  559. }
  560. count, _ := result.RowsAffected()
  561. slog.Debug("Marked category entries as read",
  562. slog.Int64("user_id", userID),
  563. slog.Int64("category_id", categoryID),
  564. slog.Int64("nb_entries", count),
  565. slog.String("before", before.Format(time.RFC3339)),
  566. )
  567. return nil
  568. }
  569. // EntryShareCode returns the share code of the provided entry.
  570. // It generates a new one if not already defined.
  571. func (s *Storage) EntryShareCode(userID int64, entryID int64) (shareCode string, err error) {
  572. query := `SELECT share_code FROM entries WHERE user_id=$1 AND id=$2`
  573. err = s.db.QueryRow(query, userID, entryID).Scan(&shareCode)
  574. if err != nil {
  575. err = fmt.Errorf(`store: unable to get share code for entry #%d: %v`, entryID, err)
  576. return
  577. }
  578. if shareCode == "" {
  579. shareCode = crypto.GenerateRandomStringHex(20)
  580. query = `UPDATE entries SET share_code = $1 WHERE user_id=$2 AND id=$3`
  581. _, err = s.db.Exec(query, shareCode, userID, entryID)
  582. if err != nil {
  583. err = fmt.Errorf(`store: unable to set share code for entry #%d: %v`, entryID, err)
  584. return
  585. }
  586. }
  587. return
  588. }
  589. // UnshareEntry removes the share code for the given entry.
  590. func (s *Storage) UnshareEntry(userID int64, entryID int64) (err error) {
  591. query := `UPDATE entries SET share_code='' WHERE user_id=$1 AND id=$2`
  592. _, err = s.db.Exec(query, userID, entryID)
  593. if err != nil {
  594. err = fmt.Errorf(`store: unable to remove share code for entry #%d: %v`, entryID, err)
  595. }
  596. return
  597. }
  598. func truncateTitleAndContentForTSVectorField(title, content string) (string, string) {
  599. // The length of a tsvector (lexemes + positions) must be less than 1 megabyte.
  600. // We don't need to index the entire content, and we need to keep a buffer for the positions.
  601. return truncateStringForTSVectorField(title, 200000), truncateStringForTSVectorField(content, 500000)
  602. }
  603. // truncateStringForTSVectorField truncates a string and don't break UTF-8 characters.
  604. func truncateStringForTSVectorField(s string, maxSize int) string {
  605. if len(s) < maxSize {
  606. return s
  607. }
  608. // Truncate to fit under the limit, ensuring we don't break UTF-8 characters
  609. truncated := s[:maxSize-1]
  610. // Walk backwards to find the last complete UTF-8 character
  611. for i := len(truncated) - 1; i >= 0; i-- {
  612. if (truncated[i] & 0x80) == 0 {
  613. // ASCII character, we can stop here
  614. return truncated[:i+1]
  615. }
  616. if (truncated[i] & 0xC0) == 0xC0 {
  617. // Start of a multi-byte UTF-8 character
  618. return truncated[:i]
  619. }
  620. }
  621. // Fallback: return empty string if we can't find a valid UTF-8 boundary
  622. return ""
  623. }