@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/README.md +133 -0
- package/core/src/core.test.ts +1171 -518
- package/core/src/mcp.ts +37 -426
- package/core/src/notes.ts +405 -32
- package/core/src/schema-defaults.ts +214 -170
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +90 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +37 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +313 -206
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +875 -297
- package/core/src/note-schemas.ts +0 -232
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)
|
|
516
|
-
// other field must be declared
|
|
517
|
-
// a real B-tree index. The two
|
|
518
|
-
//
|
|
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
|
-
|
|
|
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
|
|
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
|
-
|
|
706
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
//
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
819
|
+
const repointStmt = db.prepare(
|
|
733
820
|
"UPDATE note_tags SET tag_name = ? WHERE tag_name = ? RETURNING note_id",
|
|
734
|
-
)
|
|
735
|
-
db.prepare("DELETE FROM tags WHERE name = ?")
|
|
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
|
-
|
|
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[],
|