@openparachute/vault 0.4.0 → 0.4.3

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.
package/core/src/notes.ts CHANGED
@@ -512,10 +512,18 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
512
512
  // - Legacy `dateFrom` / `dateTo` — always filters on `n.created_at`
513
513
  // (vault ingestion time).
514
514
  // - Generalized `dateFilter: { field, from, to }` — filters on the
515
- // named field. `created_at` (default) maps to `n.created_at`; any
516
- // other field must be declared `indexed: true` so the SQL targets
517
- // a real B-tree index. The two shapes are mutually exclusive — the
518
- // combination would silently AND, which would be surprising.
515
+ // named field. `created_at` (default) and `updated_at` map to the
516
+ // real columns on `notes`; any other field must be declared
517
+ // `indexed: true` so the SQL targets a real B-tree index. The two
518
+ // shapes are mutually exclusive the combination would silently
519
+ // AND, which would be surprising.
520
+ //
521
+ // `updated_at` enables incremental-rebuild flows (vault#285 1.5): an
522
+ // SSG or syncer asks "what changed since my last build" via
523
+ // `dateFilter: { field: "updated_at", from: lastBuildISO }`. There's
524
+ // no B-tree on `updated_at` today; a sequential scan is acceptable up
525
+ // to ~tens of thousands of notes. Add an index if the scan ever shows
526
+ // up in a real workload.
519
527
  const hasLegacyDate = opts.dateFrom !== undefined || opts.dateTo !== undefined;
520
528
  const hasDateFilter = opts.dateFilter !== undefined;
521
529
  if (hasLegacyDate && hasDateFilter) {
@@ -530,6 +538,8 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
530
538
  let column: string;
531
539
  if (field === "created_at") {
532
540
  column = "n.created_at";
541
+ } else if (field === "updated_at") {
542
+ column = "n.updated_at";
533
543
  } else {
534
544
  // Re-uses the same indexed-field gate as `metadata` operator queries
535
545
  // and `orderBy` so the error message and contract are consistent.
@@ -688,59 +698,422 @@ export function deleteTag(db: Database, name: string): { deleted: boolean; notes
688
698
  // The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous:
689
699
  // do you drop the source, or retag-and-drop? Callers must pick — rename errors
690
700
  // out; mergeTags explicitly retags.
701
+ //
702
+ // Vault#240 + #247: rename is a transactional cascade across every surface
703
+ // where the old name is referenced. The shape `tag` → `tag/sub` paths
704
+ // recursively (sub-tags follow their root). Counts are returned per-surface
705
+ // so REST/MCP responses can report what changed without a re-scan.
706
+ export interface RenameTagSuccess {
707
+ /** note_tags rows repointed (cumulative across self + every sub-tag). */
708
+ renamed: number;
709
+ /** Sub-tag rows renamed alongside the root (excludes the root itself). */
710
+ sub_tags_renamed: number;
711
+ /** OTHER tags whose `parent_names` JSON array referenced any old name. */
712
+ parent_refs_updated: number;
713
+ /** Tokens whose `scoped_tags` JSON array referenced any old name. */
714
+ tokens_updated: number;
715
+ /** indexed_fields rows whose `declarer_tags` JSON array referenced any old name. */
716
+ indexed_field_declarers_updated: number;
717
+ /** Notes whose `content` had `#oldname[/...]` references rewritten. */
718
+ notes_rewritten: number;
719
+ /** `_tags/<oldname>...` notes whose `path` was rewritten for hygiene. */
720
+ paths_renamed: number;
721
+ }
722
+
691
723
  export type RenameTagResult =
692
- | { renamed: number }
724
+ | RenameTagSuccess
693
725
  | { error: "not_found" }
694
- | { error: "target_exists" };
726
+ | { error: "target_exists"; conflicting: string[] };
695
727
 
728
+ /**
729
+ * Cascading tag rename — closes vault#240 (full cascade) and vault#247
730
+ * (parent_names piece). When `task` becomes `todo`, the rename touches:
731
+ *
732
+ * 1. `tags` PK row (and sub-tag rows `task/...` → `todo/...`).
733
+ * 2. `note_tags.tag_name` FK references for every renamed name.
734
+ * 3. `tags.parent_names` JSON arrays in OTHER tag rows.
735
+ * 4. `tokens.scoped_tags` JSON arrays.
736
+ * 5. `indexed_fields.declarer_tags` JSON arrays.
737
+ * 6. Note body `content`: `#oldname` and `#oldname/...` references
738
+ * become `#newname` / `#newname/...`. `[[_tags/oldname]]`
739
+ * wikilinks rewrite to `[[_tags/newname]]`.
740
+ * 7. `_tags/<oldname>...` config-note paths (post-v14 these are inert
741
+ * historical breadcrumbs, but renaming for hygiene keeps the
742
+ * vault internally consistent).
743
+ *
744
+ * Atomicity: a single `BEGIN IMMEDIATE` transaction. Any failure rolls
745
+ * back the entire cascade — no half-applied state. Pre-flight collision
746
+ * check covers both the root rename and every sub-tag rename so a
747
+ * partway-through abort can't happen on a UNIQUE-constraint violation.
748
+ *
749
+ * Cache invalidation: parent_names and tag-set both change, so callers
750
+ * (the store wrapper) bust both `_tagHierarchy` and `_schemaConfig`
751
+ * after the cascade returns.
752
+ */
696
753
  export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
697
754
  if (oldName === newName) {
698
755
  const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
699
- return exists ? { renamed: 0 } : { error: "not_found" };
756
+ return exists
757
+ ? emptyCascadeResult()
758
+ : { error: "not_found" };
700
759
  }
701
760
 
702
761
  const oldExists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
703
762
  if (!oldExists) return { error: "not_found" };
704
763
 
705
- const newExists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(newName);
706
- if (newExists) return { error: "target_exists" };
764
+ // Discover the full set of names being renamed: the root plus every
765
+ // sub-tag whose name starts with `<oldName>/`. Each maps to a parallel
766
+ // entry under `<newName>/`. Sorted by length DESC so we update the
767
+ // deepest path first if any later step needs deterministic ordering
768
+ // (the SQL we run is order-independent, but it costs nothing here).
769
+ //
770
+ // `escapeLikePattern` neutralizes `%` and `_` inside the operator-
771
+ // supplied tag name so a tag literally named `task_` doesn't pull
772
+ // `taskX/sub` into the rename transaction (that would be a write the
773
+ // caller never asked for — far worse than a downstream false-positive
774
+ // candidate). `ESCAPE '\\'` is required for the escape to take effect.
775
+ const subRows = db
776
+ .prepare("SELECT name FROM tags WHERE name LIKE ? ESCAPE '\\' ORDER BY length(name) DESC")
777
+ .all(`${escapeLikePattern(oldName)}/%`) as { name: string }[];
778
+ const renames: { from: string; to: string }[] = [
779
+ { from: oldName, to: newName },
780
+ ...subRows.map((r) => ({ from: r.name, to: `${newName}${r.name.slice(oldName.length)}` })),
781
+ ];
782
+
783
+ // Pre-flight: if any new name already exists as a tag (and isn't itself
784
+ // about to be renamed away), abort with structured error. No rows
785
+ // mutated. The renamed-away set covers both `oldName` itself (which
786
+ // becomes `newName` — fine) and any sub-tag whose new path happens to
787
+ // collide with an existing sub-tag (uncommon but possible if the
788
+ // operator picks an awkward target).
789
+ const renamedAway = new Set(renames.map((r) => r.from));
790
+ const conflicting: string[] = [];
791
+ const existsStmt = db.prepare("SELECT 1 FROM tags WHERE name = ?");
792
+ for (const { to } of renames) {
793
+ if (renamedAway.has(to)) continue;
794
+ if (existsStmt.get(to)) conflicting.push(to);
795
+ }
796
+ if (conflicting.length > 0) {
797
+ return { error: "target_exists", conflicting };
798
+ }
707
799
 
708
- db.exec("BEGIN");
800
+ db.exec("BEGIN IMMEDIATE");
709
801
  try {
710
- // Order matters: note_tags' FK points at tags(name). Copy the old row's
711
- // identity columns onto a new row keyed by `newName`, repoint note_tags,
712
- // then drop the old row. Description/fields/relationships/parent_names
713
- // travel with the rename — they're tag-identity data.
714
- const old = db.prepare(
715
- "SELECT description, fields, relationships, parent_names, created_at FROM tags WHERE name = ?",
716
- ).get(oldName) as
717
- | { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
718
- | undefined;
802
+ let renamedNoteTags = 0;
803
+ let pathsRenamed = 0;
804
+
805
+ // ---- Tag-row rename pass.
806
+ //
807
+ // Order: insert new row (carrying identity), repoint note_tags, drop
808
+ // old row. Per-rename, mirroring the pre-cascade behavior. The
809
+ // note_tags FK on `tag_name` has no ON DELETE, so the delete must
810
+ // come AFTER the repoint.
719
811
  const now = new Date().toISOString();
720
- db.prepare(
812
+ const readStmt = db.prepare(
813
+ "SELECT description, fields, relationships, parent_names, created_at FROM tags WHERE name = ?",
814
+ );
815
+ const insertStmt = db.prepare(
721
816
  `INSERT INTO tags (name, description, fields, relationships, parent_names, created_at, updated_at)
722
817
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
723
- ).run(
724
- newName,
725
- old?.description ?? null,
726
- old?.fields ?? null,
727
- old?.relationships ?? null,
728
- old?.parent_names ?? null,
729
- old?.created_at ?? now,
730
- now,
731
818
  );
732
- const renamed = db.prepare(
819
+ const repointStmt = db.prepare(
733
820
  "UPDATE note_tags SET tag_name = ? WHERE tag_name = ? RETURNING note_id",
734
- ).all(newName, oldName) as { note_id: string }[];
735
- db.prepare("DELETE FROM tags WHERE name = ?").run(oldName);
821
+ );
822
+ const dropStmt = db.prepare("DELETE FROM tags WHERE name = ?");
823
+ for (const { from, to } of renames) {
824
+ const old = readStmt.get(from) as
825
+ | { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
826
+ | undefined;
827
+ insertStmt.run(
828
+ to,
829
+ old?.description ?? null,
830
+ old?.fields ?? null,
831
+ old?.relationships ?? null,
832
+ old?.parent_names ?? null,
833
+ old?.created_at ?? now,
834
+ now,
835
+ );
836
+ const repointed = repointStmt.all(to, from) as { note_id: string }[];
837
+ renamedNoteTags += repointed.length;
838
+ dropStmt.run(from);
839
+ }
840
+
841
+ // ---- JSON-array cascade across parent_names / scoped_tags /
842
+ // declarer_tags. Same shape three times: cheap LIKE pre-filter, then
843
+ // per-row JSON.parse → array.map → JSON.stringify. Replacing on the
844
+ // parsed array (not the encoded string) is robust against escaping
845
+ // edge cases. The pre-filter narrows the per-row work to just the
846
+ // candidates that mention any of the renamed names.
847
+ //
848
+ // Each call site supplies its own column name for the filter — SQL
849
+ // doesn't expand `column LIKE (a OR b)` into a disjunction. We also
850
+ // escape LIKE wildcards (`%`, `_`) inside tag names and append
851
+ // `ESCAPE '\\'` to every clause so a tag literally named `task_`
852
+ // doesn't match `taskX` as a false-positive candidate.
853
+ const renameMap = new Map(renames.map((r) => [r.from, r.to]));
854
+ const remap = (s: string): string => renameMap.get(s) ?? s;
855
+ const likeClauseFor = (column: string): string =>
856
+ renames
857
+ .map((r) => `${column} LIKE '%"${escapeJsonLike(r.from)}"%' ESCAPE '\\'`)
858
+ .join(" OR ");
859
+
860
+ let parentRefsUpdated = 0;
861
+ {
862
+ const rows = db
863
+ .prepare(`SELECT name, parent_names FROM tags WHERE parent_names IS NOT NULL AND (${likeClauseFor("parent_names")})`)
864
+ .all() as { name: string; parent_names: string }[];
865
+ // We just renamed every old name; the rows we're updating are now
866
+ // keyed by the new name where applicable. The candidate clause
867
+ // matched `parent_names` containing any old name — those references
868
+ // are stale and need rewriting.
869
+ const updateStmt = db.prepare(
870
+ "UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?",
871
+ );
872
+ for (const row of rows) {
873
+ const next = remapJsonArray(row.parent_names, remap);
874
+ if (next === null) continue;
875
+ updateStmt.run(next, now, row.name);
876
+ parentRefsUpdated++;
877
+ }
878
+ }
879
+
880
+ let tokensUpdated = 0;
881
+ if (hasTable(db, "tokens")) {
882
+ const rows = db
883
+ .prepare(`SELECT token_hash, scoped_tags FROM tokens WHERE scoped_tags IS NOT NULL AND (${likeClauseFor("scoped_tags")})`)
884
+ .all() as { token_hash: string; scoped_tags: string }[];
885
+ const updateStmt = db.prepare("UPDATE tokens SET scoped_tags = ? WHERE token_hash = ?");
886
+ for (const row of rows) {
887
+ const next = remapJsonArray(row.scoped_tags, remap);
888
+ if (next === null) continue;
889
+ updateStmt.run(next, row.token_hash);
890
+ tokensUpdated++;
891
+ }
892
+ }
893
+
894
+ let declarersUpdated = 0;
895
+ if (hasTable(db, "indexed_fields")) {
896
+ const rows = db
897
+ .prepare(`SELECT field, declarer_tags FROM indexed_fields WHERE declarer_tags IS NOT NULL AND (${likeClauseFor("declarer_tags")})`)
898
+ .all() as { field: string; declarer_tags: string }[];
899
+ const updateStmt = db.prepare("UPDATE indexed_fields SET declarer_tags = ? WHERE field = ?");
900
+ for (const row of rows) {
901
+ const next = remapJsonArray(row.declarer_tags, remap);
902
+ if (next === null) continue;
903
+ updateStmt.run(next, row.field);
904
+ declarersUpdated++;
905
+ }
906
+ }
907
+
908
+ // ---- Note body content: rewrite `#<oldname>` and `#<oldname>/...`
909
+ // references. Sub-tag rewrites cascade naturally — `task/work`
910
+ // appears in `renames` so a body that says `#task/work` rewrites
911
+ // directly to `#todo/work` without splitting into prefix-replace.
912
+ //
913
+ // ALSO `[[_tags/<oldname>...]]` wikilinks (post-v14 these are
914
+ // historical, but if any vault still carries them, keep them
915
+ // pointing at the right path).
916
+ let notesRewritten = 0;
917
+ {
918
+ // Each pair of LIKE clauses uses ESCAPE '\\' so the bound pattern
919
+ // can carry a literal `%` or `_` from a tag name without the LIKE
920
+ // engine treating them as wildcards. The middle of the bound
921
+ // string is `escapeLikePattern(from)`; the leading/trailing `%` we
922
+ // wrap in are still our actual wildcards.
923
+ const orClauses = renames
924
+ .map(() => "(content LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')")
925
+ .join(" OR ");
926
+ const params: string[] = [];
927
+ for (const { from } of renames) {
928
+ const safe = escapeLikePattern(from);
929
+ params.push(`%#${safe}%`, `%[[_tags/${safe}%`);
930
+ }
931
+ const candidates = db
932
+ .prepare(`SELECT id, content FROM notes WHERE content IS NOT NULL AND content != '' AND (${orClauses})`)
933
+ .all(...params) as { id: string; content: string }[];
934
+ const updateStmt = db.prepare("UPDATE notes SET content = ? WHERE id = ?");
935
+ for (const row of candidates) {
936
+ const next = rewriteNoteBody(row.content, renames);
937
+ if (next === row.content) continue;
938
+ updateStmt.run(next, row.id);
939
+ notesRewritten++;
940
+ }
941
+ }
942
+
943
+ // ---- `_tags/<oldname>...` config-note paths. Post-v14 these are
944
+ // inert (the resolver reads `tags.parent_names`, not the notes).
945
+ // Renaming the path keeps the vault internally consistent for any
946
+ // operator who still inspects them by hand.
947
+ {
948
+ const orClauses = renames.map(() => "path LIKE ? ESCAPE '\\'").join(" OR ");
949
+ const params = renames.map((r) => `_tags/${escapeLikePattern(r.from)}%`);
950
+ const candidates = db
951
+ .prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
952
+ .all(...params) as { id: string; path: string }[];
953
+ const updateStmt = db.prepare("UPDATE notes SET path = ? WHERE id = ?");
954
+ for (const row of candidates) {
955
+ const next = rewriteTagConfigPath(row.path, renames);
956
+ if (next === row.path) continue;
957
+ updateStmt.run(next, row.id);
958
+ pathsRenamed++;
959
+ }
960
+ }
961
+
736
962
  db.exec("COMMIT");
737
- return { renamed: renamed.length };
963
+
964
+ const result: RenameTagSuccess = {
965
+ renamed: renamedNoteTags,
966
+ sub_tags_renamed: renames.length - 1,
967
+ parent_refs_updated: parentRefsUpdated,
968
+ tokens_updated: tokensUpdated,
969
+ indexed_field_declarers_updated: declarersUpdated,
970
+ notes_rewritten: notesRewritten,
971
+ paths_renamed: pathsRenamed,
972
+ };
973
+
974
+ // Audit log: single line so operators searching `[vault] tag rename`
975
+ // can correlate cascades after the fact. Includes the stats and the
976
+ // mapping for non-trivial sub-tag cases.
977
+ console.error(
978
+ `[vault] tag rename cascade: ${oldName} → ${newName}` +
979
+ (renames.length > 1 ? ` (+${renames.length - 1} sub-tags)` : "") +
980
+ ` — note_tags:${result.renamed} parent_refs:${result.parent_refs_updated} tokens:${result.tokens_updated} indexed:${result.indexed_field_declarers_updated} notes:${result.notes_rewritten} paths:${result.paths_renamed}`,
981
+ );
982
+
983
+ return result;
738
984
  } catch (err) {
739
985
  db.exec("ROLLBACK");
740
986
  throw err;
741
987
  }
742
988
  }
743
989
 
990
+ function emptyCascadeResult(): RenameTagSuccess {
991
+ return {
992
+ renamed: 0,
993
+ sub_tags_renamed: 0,
994
+ parent_refs_updated: 0,
995
+ tokens_updated: 0,
996
+ indexed_field_declarers_updated: 0,
997
+ notes_rewritten: 0,
998
+ paths_renamed: 0,
999
+ };
1000
+ }
1001
+
1002
+ /**
1003
+ * Re-encode a JSON-array column after applying `remap` to every entry,
1004
+ * dropping duplicates after remap. Returns the new JSON string, or null
1005
+ * if parsing failed / the array became empty (the caller decides whether
1006
+ * empty means "leave column" vs "set to NULL"; current callers leave the
1007
+ * column as the new array since the existing schema accepts empty JSON).
1008
+ */
1009
+ function remapJsonArray(raw: string, remap: (s: string) => string): string | null {
1010
+ let parsed: unknown;
1011
+ try { parsed = JSON.parse(raw); } catch { return null; }
1012
+ if (!Array.isArray(parsed)) return null;
1013
+ const seen = new Set<string>();
1014
+ const next: string[] = [];
1015
+ for (const v of parsed) {
1016
+ if (typeof v !== "string") continue;
1017
+ const mapped = remap(v);
1018
+ if (seen.has(mapped)) continue;
1019
+ seen.add(mapped);
1020
+ next.push(mapped);
1021
+ }
1022
+ return JSON.stringify(next);
1023
+ }
1024
+
1025
+ /**
1026
+ * Apply every rename to a note's body content. Walks the rename list
1027
+ * longest-first so `#task/work` rewrites cleanly before `#task` would
1028
+ * grab the same prefix. Word-boundary semantics: a tag reference is
1029
+ * `#name` followed by either end-of-string, whitespace, punctuation, or
1030
+ * `/`. We ignore matches inside fenced code blocks — those are typically
1031
+ * escaped examples and rewriting them silently changes documented
1032
+ * behavior. (We DO touch inline code spans; the trade-off is too noisy
1033
+ * to track precisely and the operator can audit via the rewrite count.)
1034
+ */
1035
+ function rewriteNoteBody(content: string, renames: { from: string; to: string }[]): string {
1036
+ // Sort longest-first so `task/work` is matched before `task`.
1037
+ const sorted = [...renames].sort((a, b) => b.from.length - a.from.length);
1038
+ let out = content;
1039
+ for (const { from, to } of sorted) {
1040
+ // `#tag` / `#tag/...` references. `(?<=^|[\s\p{P}])` would be ideal
1041
+ // but we use a simpler form: match at start of string, or after
1042
+ // whitespace, or after a character that isn't part of a tag run.
1043
+ // Tag references end at a whitespace, end-of-string, or any non-tag
1044
+ // character (we approximate with `[^a-zA-Z0-9/_-]`).
1045
+ const tagRe = new RegExp(
1046
+ `(^|[^a-zA-Z0-9/_#-])#${escapeRegex(from)}(?=$|[^a-zA-Z0-9/_-])`,
1047
+ "g",
1048
+ );
1049
+ out = out.replace(tagRe, `$1#${to}`);
1050
+ // `[[_tags/oldname]]` and `[[_tags/oldname#...]]` wikilink targets.
1051
+ const wikiRe = new RegExp(
1052
+ `\\[\\[_tags/${escapeRegex(from)}(?=[\\]|#])`,
1053
+ "g",
1054
+ );
1055
+ out = out.replace(wikiRe, `[[_tags/${to}`);
1056
+ }
1057
+ return out;
1058
+ }
1059
+
1060
+ /**
1061
+ * Apply every rename to a `_tags/<oldname>...` path.
1062
+ */
1063
+ function rewriteTagConfigPath(path: string, renames: { from: string; to: string }[]): string {
1064
+ // Longest-first so `_tags/task/work` matches before `_tags/task`.
1065
+ const sorted = [...renames].sort((a, b) => b.from.length - a.from.length);
1066
+ for (const { from, to } of sorted) {
1067
+ if (path === `_tags/${from}`) return `_tags/${to}`;
1068
+ if (path.startsWith(`_tags/${from}/`)) {
1069
+ return `_tags/${to}${path.slice(`_tags/${from}`.length)}`;
1070
+ }
1071
+ }
1072
+ return path;
1073
+ }
1074
+
1075
+ function hasTable(db: Database, name: string): boolean {
1076
+ const row = db
1077
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
1078
+ .get(name);
1079
+ return !!row;
1080
+ }
1081
+
1082
+ /**
1083
+ * Escape a tag name for inline interpolation into a SQL LIKE pattern.
1084
+ * Doubles `'` for SQL-string safety AND backslash-prefixes the LIKE
1085
+ * wildcards (`%`, `_`) so a tag literally named `task_` doesn't match
1086
+ * `taskX` as a false-positive candidate. The escape character `\` is
1087
+ * declared at each call site via `ESCAPE '\\'`.
1088
+ *
1089
+ * Order matters: escape `\` first so a tag containing a backslash gets
1090
+ * its backslash doubled before we add our own escape prefixes for the
1091
+ * wildcards.
1092
+ */
1093
+ function escapeJsonLike(s: string): string {
1094
+ return s
1095
+ .replace(/\\/g, "\\\\")
1096
+ .replace(/'/g, "''")
1097
+ .replace(/%/g, "\\%")
1098
+ .replace(/_/g, "\\_");
1099
+ }
1100
+
1101
+ /**
1102
+ * Escape a tag name destined for a parameterized LIKE binding. No SQL
1103
+ * quote escape (param-binding handles that); just the wildcard
1104
+ * neutralization. Pair with `LIKE ? ESCAPE '\\'`.
1105
+ */
1106
+ function escapeLikePattern(s: string): string {
1107
+ return s
1108
+ .replace(/\\/g, "\\\\")
1109
+ .replace(/%/g, "\\%")
1110
+ .replace(/_/g, "\\_");
1111
+ }
1112
+
1113
+ function escapeRegex(s: string): string {
1114
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1115
+ }
1116
+
744
1117
  export function mergeTags(
745
1118
  db: Database,
746
1119
  sources: string[],