entry.go 19 KB

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