update search
This commit is contained in:
+232
-183
@@ -1,4 +1,28 @@
|
||||
[
|
||||
{
|
||||
"path": "docs_md/articles/facial-nerve-in-temporal-bone_21dccac8-d73d-4ef3-859b-73e013ec15cc.md",
|
||||
"title": "Facial Nerve in Temporal Bone",
|
||||
"docid": "21dccac8-d73d-4ef3-859b-73e013ec15cc",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Anatomy",
|
||||
"Temporal Bone",
|
||||
"Facial Nerve in Temporal Bone"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "b2e6dabb-ee1c-42a4-a332-9f0814c1c607",
|
||||
"value": "Surjith Vattoth, MD, FRCR"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Anatomy, Temporal Bone, Facial Nerve in Temporal Bone",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/cnvii-facial-nerve_98cb2d45-e64c-4295-9662-3470cd46513a.md",
|
||||
"title": "CNVII (Facial Nerve)",
|
||||
@@ -19,7 +43,13 @@
|
||||
"value": "Philip R. Chapman, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Anatomy, Cranial Nerves, CNVII (Facial Nerve)"
|
||||
"pageKeywords": "Head and Neck, Anatomy, Cranial Nerves, CNVII (Facial Nerve)",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/facial-nerve-cnvii_2f4818dd-6438-405b-8561-5cbbb9c91562.md",
|
||||
@@ -41,55 +71,24 @@
|
||||
"value": "Ryan P. Cabeen, PhD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Brain, Anatomy, Skull Base and Cranial Nerves, Facial Nerve (CNVII)"
|
||||
"pageKeywords": "Brain, Anatomy, Skull Base and Cranial Nerves, Facial Nerve (CNVII)",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/central-skull-base-trigeminal-schwannoma_ba6828c1-f547-4af4-b82c-0711ee7600f5.md",
|
||||
"title": "Central Skull Base Trigeminal Schwannoma",
|
||||
"docid": "ba6828c1-f547-4af4-b82c-0711ee7600f5",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Diagnosis",
|
||||
"Skull Base Lesions",
|
||||
"Sphenoid Bone",
|
||||
"Central Skull Base Trigeminal Schwannoma"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "20008105-51da-4bbf-aba6-e8253b2d9c21",
|
||||
"value": "Daniel E. Meltzer, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Diagnosis, Skull Base Lesions, Sphenoid Bone, Central Skull Base Trigeminal Schwannoma"
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/cnv-trigeminal-nerve_9db87cfa-58ff-45fd-a7fd-fe8c73eb9770.md",
|
||||
"title": "CNV (Trigeminal Nerve)",
|
||||
"docid": "9db87cfa-58ff-45fd-a7fd-fe8c73eb9770",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Anatomy",
|
||||
"Cranial Nerves",
|
||||
"CNV (Trigeminal Nerve) "
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "b2e6dabb-ee1c-42a4-a332-9f0814c1c607",
|
||||
"value": "Surjith Vattoth, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Anatomy, Cranial Nerves, CNV (Trigeminal Nerve) "
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/trigeminal-neuralgia_294f4a67-30d6-4488-bd1a-931d1f8c7609.md",
|
||||
"title": "Trigeminal Neuralgia",
|
||||
"docid": "294f4a67-30d6-4488-bd1a-931d1f8c7609",
|
||||
"path": "docs_md/articles/peripheral-facial-nerve-paralysis_4da52ac4-c03c-4711-ae7e-bb4f2f7c5ab8.md",
|
||||
"title": "Peripheral Facial Nerve Paralysis",
|
||||
"docid": "4da52ac4-c03c-4711-ae7e-bb4f2f7c5ab8",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Differential Diagnosis",
|
||||
"Cranial Nerves and Brainstem",
|
||||
"Temporal Bone",
|
||||
"Clinically Based Differentials",
|
||||
"Trigeminal Neuralgia"
|
||||
"Peripheral Facial Nerve Paralysis"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
@@ -97,181 +96,231 @@
|
||||
"value": "Nicholas A. Koontz, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Differential Diagnosis, Cranial Nerves and Brainstem, Clinically Based Differentials, Trigeminal Neuralgia"
|
||||
"pageKeywords": "Head and Neck, Differential Diagnosis, Temporal Bone, Clinically Based Differentials, Peripheral Facial Nerve Paralysis",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/vestibulocochlear-nerve-cnviii_498e844d-faca-4c6a-bff1-9c6ad4993e62.md",
|
||||
"title": "Vestibulocochlear Nerve (CNVIII)",
|
||||
"docid": "498e844d-faca-4c6a-bff1-9c6ad4993e62",
|
||||
"breadcrumbs": [
|
||||
"Brain",
|
||||
"Anatomy",
|
||||
"Skull Base and Cranial Nerves",
|
||||
"Vestibulocochlear Nerve (CNVIII)"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "94f835c8-fa13-4e8a-995b-53048e6b0605",
|
||||
"value": "Philip R. Chapman, MD"
|
||||
},
|
||||
{
|
||||
"key": "b0a6efa4-ad68-430c-b5da-f5c904adf809",
|
||||
"value": "Ryan P. Cabeen, PhD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Brain, Anatomy, Skull Base and Cranial Nerves, Vestibulocochlear Nerve (CNVIII)"
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/abducens-nerve-cnvi_655e63da-d744-4267-9d90-07f52493bbc0.md",
|
||||
"title": "Abducens Nerve (CNVI)",
|
||||
"docid": "655e63da-d744-4267-9d90-07f52493bbc0",
|
||||
"breadcrumbs": [
|
||||
"Brain",
|
||||
"Anatomy",
|
||||
"Skull Base and Cranial Nerves",
|
||||
"Abducens Nerve (CNVI)"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "94f835c8-fa13-4e8a-995b-53048e6b0605",
|
||||
"value": "Philip R. Chapman, MD"
|
||||
},
|
||||
{
|
||||
"key": "8d5254e9-8dda-478b-8f08-bdee97a32c79",
|
||||
"value": "Karen L. Salzman, MD, FACR"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Brain, Anatomy, Skull Base and Cranial Nerves, Abducens Nerve (CNVI)"
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/cnv3-motor-denervation_84c14a32-d0b3-4e83-bb8f-cb3b9964fb4a.md",
|
||||
"title": "CNV3 Motor Denervation",
|
||||
"docid": "84c14a32-d0b3-4e83-bb8f-cb3b9964fb4a",
|
||||
"path": "docs_md/articles/temporal-bone-facial-nerve-venous-malformation-hemangioma_dcd6a44e-cbe6-457c-9b03-598a2b874ece.md",
|
||||
"title": "Temporal Bone Facial Nerve Venous Malformation (Hemangioma)",
|
||||
"docid": "dcd6a44e-cbe6-457c-9b03-598a2b874ece",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Diagnosis",
|
||||
"Masticator Space",
|
||||
"Pseudolesions",
|
||||
"CNV3 Motor Denervation"
|
||||
"Temporal Bone",
|
||||
"Intratemporal Facial Nerve",
|
||||
"Benign and Malignant Tumors",
|
||||
"Temporal Bone Facial Nerve Venous Malformation (Hemangioma)"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "530afd4d-6f67-4edf-8925-dd23ebf434f3",
|
||||
"value": "Jaclyn E. Thiessen, MD"
|
||||
"key": "07a2c087-6202-49e7-870b-7aa162d18f06",
|
||||
"value": "Bronwyn E. Hamilton, MD"
|
||||
},
|
||||
{
|
||||
"key": "33151213-01b2-4542-9105-342e006b3915",
|
||||
"value": "H. Ric Harnsberger, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Diagnosis, Temporal Bone, Intratemporal Facial Nerve, Benign and Malignant Tumors, Temporal Bone Facial Nerve Venous Malformation (Hemangioma)",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/middle-ear-prolapsing-facial-nerve_61f6bcaa-f073-4385-b895-dc420ddc8a1e.md",
|
||||
"title": "Middle Ear Prolapsing Facial Nerve",
|
||||
"docid": "61f6bcaa-f073-4385-b895-dc420ddc8a1e",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Diagnosis",
|
||||
"Temporal Bone",
|
||||
"Intratemporal Facial Nerve",
|
||||
"Pseudolesions",
|
||||
"Middle Ear Prolapsing Facial Nerve"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "94f835c8-fa13-4e8a-995b-53048e6b0605",
|
||||
"value": "Philip R. Chapman, MD"
|
||||
},
|
||||
{
|
||||
"key": "33151213-01b2-4542-9105-342e006b3915",
|
||||
"value": "H. Ric Harnsberger, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Diagnosis, Temporal Bone, Intratemporal Facial Nerve, Pseudolesions, Middle Ear Prolapsing Facial Nerve",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/facial-muscles-and-superficial-musculoaponeurotic-system_80a3629d-d41e-4efd-9dac-4f19bbdea9db.md",
|
||||
"title": "Facial Muscles and Superficial Musculoaponeurotic System",
|
||||
"docid": "80a3629d-d41e-4efd-9dac-4f19bbdea9db",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Anatomy",
|
||||
"Face",
|
||||
"Facial Muscles and Superficial Musculoaponeurotic System"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "b2e6dabb-ee1c-42a4-a332-9f0814c1c607",
|
||||
"value": "Surjith Vattoth, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Anatomy, Face, Facial Muscles and Superficial Musculoaponeurotic System",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/temporal-bone-facial-nerve-schwannoma_cf2bcc82-4a1b-4989-adeb-f4e82116111b.md",
|
||||
"title": "Temporal Bone Facial Nerve Schwannoma",
|
||||
"docid": "cf2bcc82-4a1b-4989-adeb-f4e82116111b",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Diagnosis",
|
||||
"Temporal Bone",
|
||||
"Intratemporal Facial Nerve",
|
||||
"Benign and Malignant Tumors",
|
||||
"Temporal Bone Facial Nerve Schwannoma"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "07a2c087-6202-49e7-870b-7aa162d18f06",
|
||||
"value": "Bronwyn E. Hamilton, MD"
|
||||
},
|
||||
{
|
||||
"key": "33151213-01b2-4542-9105-342e006b3915",
|
||||
"value": "H. Ric Harnsberger, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Diagnosis, Masticator Space, Pseudolesions, CNV3 Motor Denervation"
|
||||
"pageKeywords": "Head and Neck, Diagnosis, Temporal Bone, Intratemporal Facial Nerve, Benign and Malignant Tumors, Temporal Bone Facial Nerve Schwannoma",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/trigeminal-neuropathy_6d668284-4c8e-4a14-90b6-c7abf86065e6.md",
|
||||
"title": "Trigeminal Neuropathy",
|
||||
"docid": "6d668284-4c8e-4a14-90b6-c7abf86065e6",
|
||||
"path": "docs_md/articles/intratemporal-facial-nerve-enhancement_a3569ec5-a566-411d-877f-41ad832e3fd2.md",
|
||||
"title": "Intratemporal Facial Nerve Enhancement",
|
||||
"docid": "a3569ec5-a566-411d-877f-41ad832e3fd2",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Diagnosis",
|
||||
"Temporal Bone",
|
||||
"Intratemporal Facial Nerve",
|
||||
"Pseudolesions",
|
||||
"Intratemporal Facial Nerve Enhancement"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "94f835c8-fa13-4e8a-995b-53048e6b0605",
|
||||
"value": "Philip R. Chapman, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Diagnosis, Temporal Bone, Intratemporal Facial Nerve, Pseudolesions, Intratemporal Facial Nerve Enhancement",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/hemifacial-spasm_1b390143-1212-4447-beb3-ed9e85ef34e4.md",
|
||||
"title": "Hemifacial Spasm",
|
||||
"docid": "1b390143-1212-4447-beb3-ed9e85ef34e4",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Differential Diagnosis",
|
||||
"Cranial Nerves and Brainstem",
|
||||
"CPA-IAC and Posterior Fossa",
|
||||
"Clinically Based Differentials",
|
||||
"Trigeminal Neuropathy"
|
||||
"Hemifacial Spasm"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "ab4396df-0647-4f6a-b534-995eda06646c",
|
||||
"value": "Nancy J. Fischbein, MD"
|
||||
"key": "eef2f839-5706-47b9-89c3-60d8315b2b3a",
|
||||
"value": "Nicholas A. Koontz, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Differential Diagnosis, Cranial Nerves and Brainstem, Clinically Based Differentials, Trigeminal Neuropathy"
|
||||
"pageKeywords": "Head and Neck, Differential Diagnosis, CPA-IAC and Posterior Fossa, Clinically Based Differentials, Hemifacial Spasm",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/cnviii-vestibulocochlear-nerve_e9917c41-94c9-46aa-b9d8-b196c375d35b.md",
|
||||
"title": "CNVIII (Vestibulocochlear Nerve)",
|
||||
"docid": "e9917c41-94c9-46aa-b9d8-b196c375d35b",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Anatomy",
|
||||
"Cranial Nerves",
|
||||
"CNVIII (Vestibulocochlear Nerve)"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "1fa14dfd-71ea-4960-908e-e720313bc63a",
|
||||
"value": "Santhosh Gaddikeri, MD"
|
||||
},
|
||||
{
|
||||
"key": "94f835c8-fa13-4e8a-995b-53048e6b0605",
|
||||
"value": "Philip R. Chapman, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Anatomy, Cranial Nerves, CNVIII (Vestibulocochlear Nerve)"
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/trigeminal-nerve-cnv_06e99da6-48fc-45f4-a571-97d12a15f365.md",
|
||||
"title": "Trigeminal Nerve (CNV)",
|
||||
"docid": "06e99da6-48fc-45f4-a571-97d12a15f365",
|
||||
"breadcrumbs": [
|
||||
"Brain",
|
||||
"Anatomy",
|
||||
"Skull Base and Cranial Nerves",
|
||||
"Trigeminal Nerve (CNV) "
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "b2e6dabb-ee1c-42a4-a332-9f0814c1c607",
|
||||
"value": "Surjith Vattoth, MD"
|
||||
},
|
||||
{
|
||||
"key": "b0a6efa4-ad68-430c-b5da-f5c904adf809",
|
||||
"value": "Ryan P. Cabeen, PhD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Brain, Anatomy, Skull Base and Cranial Nerves, Trigeminal Nerve (CNV) "
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/cnvi-abducens-nerve_923d7c4a-93a3-42ba-b8db-c7b15348e473.md",
|
||||
"title": "CNVI (Abducens Nerve)",
|
||||
"docid": "923d7c4a-93a3-42ba-b8db-c7b15348e473",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Anatomy",
|
||||
"Cranial Nerves",
|
||||
"CNVI (Abducens Nerve)"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "b2e6dabb-ee1c-42a4-a332-9f0814c1c607",
|
||||
"value": "Surjith Vattoth, MD"
|
||||
},
|
||||
{
|
||||
"key": "94f835c8-fa13-4e8a-995b-53048e6b0605",
|
||||
"value": "Philip R. Chapman, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Anatomy, Cranial Nerves, CNVI (Abducens Nerve)"
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/masticator-space-cnv3-perineural-tumor_164c3453-bb5e-47de-bc50-ad96628e0d57.md",
|
||||
"title": "Masticator Space CNV3 Perineural Tumor",
|
||||
"docid": "164c3453-bb5e-47de-bc50-ad96628e0d57",
|
||||
"path": "docs_md/articles/cpa-iac-facial-nerve-schwannoma_9db01630-23a4-4f42-ad83-0ec399503495.md",
|
||||
"title": "CPA-IAC Facial Nerve Schwannoma",
|
||||
"docid": "9db01630-23a4-4f42-ad83-0ec399503495",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Diagnosis",
|
||||
"Masticator Space",
|
||||
"Malignant Tumors",
|
||||
"Masticator Space CNV3 Perineural Tumor"
|
||||
"CPA-IAC",
|
||||
"Benign and Malignant Tumors",
|
||||
"CPA-IAC Facial Nerve Schwannoma"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "530afd4d-6f67-4edf-8925-dd23ebf434f3",
|
||||
"value": "Jaclyn E. Thiessen, MD"
|
||||
},
|
||||
{
|
||||
"key": "07a2c087-6202-49e7-870b-7aa162d18f06",
|
||||
"value": "Bronwyn E. Hamilton, MD"
|
||||
},
|
||||
{
|
||||
"key": "33151213-01b2-4542-9105-342e006b3915",
|
||||
"value": "H. Ric Harnsberger, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Diagnosis, Masticator Space, Malignant Tumors, Masticator Space CNV3 Perineural Tumor"
|
||||
"pageKeywords": "Head and Neck, Diagnosis, CPA-IAC, Benign and Malignant Tumors, CPA-IAC Facial Nerve Schwannoma",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/facial-nerve-lesion-temporal-bone_1428754b-a8ee-48a0-98f8-4faeebf8dbab.md",
|
||||
"title": "Facial Nerve Lesion, Temporal Bone",
|
||||
"docid": "1428754b-a8ee-48a0-98f8-4faeebf8dbab",
|
||||
"breadcrumbs": [
|
||||
"Head and Neck",
|
||||
"Differential Diagnosis",
|
||||
"Temporal Bone",
|
||||
"Anatomically Based Differentials",
|
||||
"Facial Nerve Lesion, Temporal Bone"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "eef2f839-5706-47b9-89c3-60d8315b2b3a",
|
||||
"value": "Nicholas A. Koontz, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Head and Neck, Differential Diagnosis, Temporal Bone, Anatomically Based Differentials, Facial Nerve Lesion, Temporal Bone",
|
||||
"reasons": [
|
||||
"Breadcrumbs",
|
||||
"Keywords",
|
||||
"Title"
|
||||
],
|
||||
"snippet": ""
|
||||
}
|
||||
]
|
||||
+11319
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,332 @@
|
||||
[
|
||||
{
|
||||
"path": "docs_md/articles/femoral-hernia_45d54e6d-97bd-4dd4-beed-b31f572d7b95.md",
|
||||
"title": "Femoral Hernia",
|
||||
"docid": "45d54e6d-97bd-4dd4-beed-b31f572d7b95",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Diagnosis",
|
||||
"Peritoneum, Mesentery, and Abdominal Wall",
|
||||
"External Hernias",
|
||||
"Femoral Hernia"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "c1df94ab-4a9f-44c4-add7-1f174fb9ac45",
|
||||
"value": "Siva P. Raman, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Diagnosis, Peritoneum, Mesentery, and Abdominal Wall, External Hernias, Femoral Hernia",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...than inguinal hernias - Rarely, inflamed appendix extends into hernia sac (**De Garengeot hernia**) - Morbidity and mortality..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/spigelian-hernia_3bbee7e5-dcd2-423c-a079-ce530cdb08c0.md",
|
||||
"title": "Spigelian Hernia",
|
||||
"docid": "3bbee7e5-dcd2-423c-a079-ce530cdb08c0",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Diagnosis",
|
||||
"Peritoneum, Mesentery, and Abdominal Wall",
|
||||
"External Hernias",
|
||||
"Spigelian Hernia"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "c1df94ab-4a9f-44c4-add7-1f174fb9ac45",
|
||||
"value": "Siva P. Raman, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Diagnosis, Peritoneum, Mesentery, and Abdominal Wall, External Hernias, Spigelian Hernia",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...entum, small bowel, or colon - Rarely can involve appendix, bladder, and other abdominal/pelvic structures - Defect size is usually smal..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/groin-mass_160e727f-dabe-4187-aa46-c29625076cc5.md",
|
||||
"title": "Groin Mass",
|
||||
"docid": "160e727f-dabe-4187-aa46-c29625076cc5",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Differential Diagnosis",
|
||||
"Abdominal Wall",
|
||||
"Anatomically Based Differentials",
|
||||
"Groin Mass"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "c1df94ab-4a9f-44c4-add7-1f174fb9ac45",
|
||||
"value": "Siva P. Raman, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Differential Diagnosis, Abdominal Wall, Anatomically Based Differentials, Groin Mass",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...- Hernia sac can contain omental fat, bowel, bladder, appendix, and other pelvic structures - Usually diagnosed on clinical exam, but..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/solitary-colonic-filling-defect_b0e848cd-1cd1-4b8c-bd5b-45a6fa908329.md",
|
||||
"title": "Solitary Colonic Filling Defect",
|
||||
"docid": "b0e848cd-1cd1-4b8c-bd5b-45a6fa908329",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Differential Diagnosis",
|
||||
"Colon",
|
||||
"Generic Imaging Patterns",
|
||||
"Solitary Colonic Filling Defect"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "6c5a9e0e-9dea-461b-9ad4-c00f5c4c2bbf",
|
||||
"value": "Atif Zaheer, MD, FSAR"
|
||||
},
|
||||
{
|
||||
"key": "e987d3d3-1206-48d6-824b-3347c2968855",
|
||||
"value": "Michael P. Federle, MD, FACR"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Differential Diagnosis, Colon, Generic Imaging Patterns, Solitary Colonic Filling Defect",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...thrombosed - **Diverticulitis** - Perforated appendix, diverticulitis, other - May cause mass effect and spasm simulating col..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/mass-or-inflammation-of-ileocecal-area_5c7d84e9-f14b-4649-9464-a1a9642ddfd6.md",
|
||||
"title": "Mass or Inflammation of Ileocecal Area",
|
||||
"docid": "5c7d84e9-f14b-4649-9464-a1a9642ddfd6",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Differential Diagnosis",
|
||||
"Colon",
|
||||
"Generic Imaging Patterns",
|
||||
"Mass or Inflammation of Ileocecal Area"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "6c5a9e0e-9dea-461b-9ad4-c00f5c4c2bbf",
|
||||
"value": "Atif Zaheer, MD, FSAR"
|
||||
},
|
||||
{
|
||||
"key": "e987d3d3-1206-48d6-824b-3347c2968855",
|
||||
"value": "Michael P. Federle, MD, FACR"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Differential Diagnosis, Colon, Generic Imaging Patterns, Mass or Inflammation of Ileocecal Area",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...terminal ileum may be thickened by inflammation starting in appendix - Appendiceal wall is thickened and lumen usually distended -..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/colonic-wall-thickening_71ca9b30-6186-4129-957b-3b686b91b327.md",
|
||||
"title": "Colonic Wall Thickening",
|
||||
"docid": "71ca9b30-6186-4129-957b-3b686b91b327",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Differential Diagnosis",
|
||||
"Colon",
|
||||
"Generic Imaging Patterns",
|
||||
"Colonic Wall Thickening"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "6c5a9e0e-9dea-461b-9ad4-c00f5c4c2bbf",
|
||||
"value": "Atif Zaheer, MD, FSAR"
|
||||
},
|
||||
{
|
||||
"key": "e987d3d3-1206-48d6-824b-3347c2968855",
|
||||
"value": "Michael P. Federle, MD, FACR"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Differential Diagnosis, Colon, Generic Imaging Patterns, Colonic Wall Thickening",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...c='img/arrows/WS.png'/> that had obstructed the base of the appendix. Transmural invasion <img src='img/arrows/CS.png'/>, lymphadenopathy <img src='im..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/acute-left-abdominal-pain_65c32297-ce9e-41dd-80b5-60fd7160f2a6.md",
|
||||
"title": "Acute Left Abdominal Pain",
|
||||
"docid": "65c32297-ce9e-41dd-80b5-60fd7160f2a6",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Differential Diagnosis",
|
||||
"Colon",
|
||||
"Clinically Based Differentials",
|
||||
"Acute Left Abdominal Pain"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "6c5a9e0e-9dea-461b-9ad4-c00f5c4c2bbf",
|
||||
"value": "Atif Zaheer, MD, FSAR"
|
||||
},
|
||||
{
|
||||
"key": "e987d3d3-1206-48d6-824b-3347c2968855",
|
||||
"value": "Michael P. Federle, MD, FACR"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Differential Diagnosis, Colon, Clinically Based Differentials, Acute Left Abdominal Pain",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...- May mimic mass - **Appendicitis (Rare)** - Appendix may be very long or may arise from malrotated colon; leads to left-sided symptoms..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/acute-right-lower-quadrant-pain_0cf67a29-c8c8-4ffc-83d6-99f0ee0b8dd8.md",
|
||||
"title": "Acute Right Lower Quadrant Pain",
|
||||
"docid": "0cf67a29-c8c8-4ffc-83d6-99f0ee0b8dd8",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Differential Diagnosis",
|
||||
"Colon",
|
||||
"Clinically Based Differentials",
|
||||
"Acute Right Lower Quadrant Pain"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "6c5a9e0e-9dea-461b-9ad4-c00f5c4c2bbf",
|
||||
"value": "Atif Zaheer, MD, FSAR"
|
||||
},
|
||||
{
|
||||
"key": "e987d3d3-1206-48d6-824b-3347c2968855",
|
||||
"value": "Michael P. Federle, MD, FACR"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Differential Diagnosis, Colon, Clinically Based Differentials, Acute Right Lower Quadrant Pain",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...m) nodes in RLQ - Without inflammation of appendix - Common in children and adolescents - ## Helpful Clues for Less Common..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/colonic-ileus-or-dilation_85d23777-3d1b-4f53-8733-e2553061469b.md",
|
||||
"title": "Colonic Ileus or Dilation",
|
||||
"docid": "85d23777-3d1b-4f53-8733-e2553061469b",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Differential Diagnosis",
|
||||
"Colon",
|
||||
"Generic Imaging Patterns",
|
||||
"Colonic Ileus or Dilation"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "6c5a9e0e-9dea-461b-9ad4-c00f5c4c2bbf",
|
||||
"value": "Atif Zaheer, MD, FSAR"
|
||||
},
|
||||
{
|
||||
"key": "e987d3d3-1206-48d6-824b-3347c2968855",
|
||||
"value": "Michael P. Federle, MD, FACR"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Differential Diagnosis, Colon, Generic Imaging Patterns, Colonic Ileus or Dilation",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...had renal transplantation. Note contrast material in normal appendix <img src='img/arrows/WC.png'/>.](images/app.statdx.com_image_thumbnail_6eabbde0-8..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/inguinal-hernia_cf4d4a15-6a85-4aeb-9563-7be7e8131bc7.md",
|
||||
"title": "Inguinal Hernia",
|
||||
"docid": "cf4d4a15-6a85-4aeb-9563-7be7e8131bc7",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Diagnosis",
|
||||
"Peritoneum, Mesentery, and Abdominal Wall",
|
||||
"External Hernias",
|
||||
"Inguinal Hernia"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "c1df94ab-4a9f-44c4-add7-1f174fb9ac45",
|
||||
"value": "Siva P. Raman, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Diagnosis, Peritoneum, Mesentery, and Abdominal Wall, External Hernias, Inguinal Hernia",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...egments may be injured during surgery or trauma - Appendix within hernia sac: Amyand hernia - Meckel diverticulum within hernia sa..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/right-upper-quadrant-pain_33f29d78-0099-4ee9-85da-d0c70c1e0ce6.md",
|
||||
"title": "Right Upper Quadrant Pain",
|
||||
"docid": "33f29d78-0099-4ee9-85da-d0c70c1e0ce6",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Differential Diagnosis",
|
||||
"Gallbladder",
|
||||
"Clinically Based Differentials",
|
||||
"Right Upper Quadrant Pain"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "c1df94ab-4a9f-44c4-add7-1f174fb9ac45",
|
||||
"value": "Siva P. Raman, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Differential Diagnosis, Gallbladder, Clinically Based Differentials, Right Upper Quadrant Pain",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...ten presenting with right lower quadrant (RLQ) pain, tip of appendix may extend upward to RUQ and cause upper abdominal pain - Dilated, thic..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/mass-in-iliopsoas-compartment_e39c8918-27cb-4dd6-9166-2b265b777a35.md",
|
||||
"title": "Mass in Iliopsoas Compartment",
|
||||
"docid": "e39c8918-27cb-4dd6-9166-2b265b777a35",
|
||||
"breadcrumbs": [
|
||||
"Gastrointestinal",
|
||||
"Differential Diagnosis",
|
||||
"Abdominal Wall",
|
||||
"Anatomically Based Differentials",
|
||||
"Mass in Iliopsoas Compartment"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "c1df94ab-4a9f-44c4-add7-1f174fb9ac45",
|
||||
"value": "Siva P. Raman, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Gastrointestinal, Differential Diagnosis, Abdominal Wall, Anatomically Based Differentials, Mass in Iliopsoas Compartment",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...y infectious spread from bone, kidney, and bowel (including appendix) - Paraspinal psoas abscess should prompt careful sea..."
|
||||
},
|
||||
{
|
||||
"path": "docs_md/articles/mesenteric-vessels_79a4117f-773a-40b3-bdb5-b0195f84e087.md",
|
||||
"title": "Mesenteric Vessels",
|
||||
"docid": "79a4117f-773a-40b3-bdb5-b0195f84e087",
|
||||
"breadcrumbs": [
|
||||
"Ultrasound",
|
||||
"Anatomy",
|
||||
"Abdomen",
|
||||
"Mesenteric Vessels"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"key": "738b847a-9cda-4cbd-ad90-2d1fdaecd4ca",
|
||||
"value": "Eric Turner, MD"
|
||||
},
|
||||
{
|
||||
"key": "3c685430-58b7-4676-a18e-dd1c1de70f1a",
|
||||
"value": "Mark E. Lockhart, MD, MPH"
|
||||
},
|
||||
{
|
||||
"key": "0817f9c9-9bce-42a9-aacc-5d18a3cd66be",
|
||||
"value": "Daniel Childers, MD"
|
||||
}
|
||||
],
|
||||
"pageKeywords": "Ultrasound, Anatomy, Abdomen, Mesenteric Vessels",
|
||||
"reasons": [
|
||||
"Content"
|
||||
],
|
||||
"snippet": "...or mesenteric arteries. The SMA supplies the colon from the appendix through the splenic flexure, and the inferior mesenteric artery (IMA) supplies th..."
|
||||
}
|
||||
]
|
||||
+307
-224
@@ -46,6 +46,164 @@ def read_frontmatter(path: str) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def read_md_file(path: str) -> tuple[Dict[str, Any], str]:
|
||||
"""Read frontmatter and content from a markdown file."""
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
text = f.read()
|
||||
except Exception:
|
||||
return {}, ""
|
||||
|
||||
fm = {}
|
||||
content = text
|
||||
if text.startswith('---'):
|
||||
parts = text.split('---', 2)
|
||||
if len(parts) >= 3:
|
||||
fm_text = parts[1]
|
||||
content = parts[2]
|
||||
try:
|
||||
data = yaml.safe_load(fm_text)
|
||||
if isinstance(data, dict):
|
||||
fm = data
|
||||
except Exception:
|
||||
pass
|
||||
return fm, content
|
||||
|
||||
|
||||
import re
|
||||
|
||||
def tokenize(text: str) -> List[str]:
|
||||
"""Tokenize text into lowercase alphanumeric words."""
|
||||
if not text:
|
||||
return []
|
||||
return re.findall(r'\b[a-zA-Z0-9]+\b', text.lower())
|
||||
|
||||
|
||||
def stem_word(word: str) -> str:
|
||||
"""A simple suffix-stripping stemmer for common English and medical terms."""
|
||||
word = word.lower().strip()
|
||||
if len(word) <= 3:
|
||||
return word
|
||||
|
||||
# Standard plural stripping
|
||||
if word.endswith('s') and not word.endswith('ss'):
|
||||
if word.endswith('ies'):
|
||||
word = word[:-3] + 'y'
|
||||
elif word.endswith('es'):
|
||||
if any(word.endswith(x) for x in ['ches', 'shes', 'xes', 'zes']):
|
||||
word = word[:-2]
|
||||
else:
|
||||
word = word[:-1]
|
||||
else:
|
||||
word = word[:-1]
|
||||
|
||||
# Common suffixes
|
||||
suffixes = [
|
||||
('ectomy', 6), ('otomy', 5), ('pathy', 5), ('itis', 4), ('osis', 4),
|
||||
('tion', 4), ('sion', 4), ('ness', 4), ('ment', 4), ('able', 4),
|
||||
('ible', 4), ('ical', 4), ('ing', 3), ('ive', 3), ('est', 3),
|
||||
('ism', 3), ('ity', 3), ('oma', 3), ('ed', 2), ('ly', 2),
|
||||
('al', 2), ('ic', 2), ('er', 2), ('or', 2), ('ia', 2), ('ar', 2), ('y', 1)
|
||||
]
|
||||
|
||||
for suffix, length in suffixes:
|
||||
if word.endswith(suffix) and len(word) - length >= 3:
|
||||
return word[:-length]
|
||||
return word
|
||||
|
||||
|
||||
def stem_text(text: str) -> Set[str]:
|
||||
"""Tokenize and stem all words in a text block."""
|
||||
return {stem_word(w) for w in tokenize(text)}
|
||||
|
||||
|
||||
def match_stemming(query_text: str, target_text: str) -> bool:
|
||||
if not query_text or not target_text:
|
||||
return False
|
||||
q_stems = {stem_word(w) for w in tokenize(query_text)}
|
||||
t_stems = stem_text(target_text)
|
||||
return q_stems.issubset(t_stems)
|
||||
|
||||
|
||||
def edit_distance(s1: str, s2: str) -> int:
|
||||
# Levenshtein distance
|
||||
if len(s1) < len(s2):
|
||||
s1, s2 = s2, s1
|
||||
if len(s2) == 0:
|
||||
return len(s1)
|
||||
|
||||
previous_row = list(range(len(s2) + 1))
|
||||
for i, c1 in enumerate(s1):
|
||||
current_row = [i + 1]
|
||||
for j, c2 in enumerate(s2):
|
||||
insertions = previous_row[j + 1] + 1
|
||||
deletions = current_row[j] + 1
|
||||
substitutions = previous_row[j] + (c1 != c2)
|
||||
current_row.append(min(insertions, deletions, substitutions))
|
||||
previous_row = current_row
|
||||
return previous_row[-1]
|
||||
|
||||
|
||||
def is_fuzzy_match_word(q_word: str, t_word: str) -> bool:
|
||||
if q_word == t_word or q_word in t_word or t_word in q_word:
|
||||
return True
|
||||
len_q, len_t = len(q_word), len(t_word)
|
||||
if abs(len_q - len_t) > 2:
|
||||
return False
|
||||
max_dist = 0
|
||||
if len_q >= 6:
|
||||
max_dist = 2
|
||||
elif len_q >= 4:
|
||||
max_dist = 1
|
||||
return edit_distance(q_word, t_word) <= max_dist
|
||||
|
||||
|
||||
def match_fuzzy(query_text: str, target_text: str) -> bool:
|
||||
if not query_text or not target_text:
|
||||
return False
|
||||
q_words = tokenize(query_text)
|
||||
t_words = set(tokenize(target_text))
|
||||
|
||||
for qw in q_words:
|
||||
found_match = False
|
||||
for tw in t_words:
|
||||
if is_fuzzy_match_word(qw, tw):
|
||||
found_match = True
|
||||
break
|
||||
if not found_match:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_content_snippet(content: str, query: str) -> str:
|
||||
"""Extract a small snippet from content around the first match of query terms."""
|
||||
if not content or not query:
|
||||
return ""
|
||||
q_words = tokenize(query)
|
||||
if not q_words:
|
||||
return content[:150] + "..." if len(content) > 150 else content
|
||||
|
||||
content_lower = content.lower()
|
||||
first_pos = -1
|
||||
for qw in q_words:
|
||||
pos = content_lower.find(qw)
|
||||
if pos != -1:
|
||||
if first_pos == -1 or pos < first_pos:
|
||||
first_pos = pos
|
||||
|
||||
if first_pos == -1:
|
||||
return content[:150] + "..." if len(content) > 150 else content
|
||||
|
||||
start = max(0, first_pos - 60)
|
||||
end = min(len(content), first_pos + 90)
|
||||
snippet = content[start:end]
|
||||
prefix = "..." if start > 0 else ""
|
||||
suffix = "..." if end < len(content) else ""
|
||||
snippet_clean = re.sub(r'\s+', ' ', snippet).strip()
|
||||
return f"{prefix}{snippet_clean}{suffix}"
|
||||
|
||||
|
||||
|
||||
def match_breadcrumbs(bcs: List[str], query: List[str]) -> bool:
|
||||
"""Return True if breadcrumbs list contains the query sequence (in order).
|
||||
|
||||
@@ -91,242 +249,163 @@ def match_author(authors: List[Dict[str, Any]], q: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def run_search(root: str, qkey: str, qval: str) -> List[Dict[str, Any]]:
|
||||
def run_search(root: str, qkey: str, qval: str, mode: str = 'exact', targets: List[str] = None) -> List[Dict[str, Any]]:
|
||||
out = []
|
||||
|
||||
# Helper to test if a string/list/dict matches under the chosen mode
|
||||
def value_matches(query: str, target_val: Any) -> bool:
|
||||
if target_val is None:
|
||||
return False
|
||||
|
||||
if isinstance(target_val, list):
|
||||
for item in target_val:
|
||||
if isinstance(item, dict):
|
||||
for k, v in item.items():
|
||||
if value_matches(query, v):
|
||||
return True
|
||||
else:
|
||||
if value_matches(query, item):
|
||||
return True
|
||||
return False
|
||||
elif isinstance(target_val, dict):
|
||||
for k, v in target_val.items():
|
||||
if value_matches(query, v):
|
||||
return True
|
||||
return False
|
||||
|
||||
s_val = str(target_val).strip()
|
||||
if not s_val:
|
||||
return False
|
||||
|
||||
if mode == 'stemming':
|
||||
return match_stemming(query, s_val)
|
||||
elif mode == 'fuzzy':
|
||||
return match_fuzzy(query, s_val)
|
||||
else:
|
||||
# Exact substring or wildcard match
|
||||
q_lower = query.lower().strip()
|
||||
s_lower = s_val.lower()
|
||||
if '*' in q_lower:
|
||||
return fnmatch.fnmatchcase(s_lower, q_lower)
|
||||
else:
|
||||
return q_lower in s_lower
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
for fn in filenames:
|
||||
if not fn.endswith('.md'):
|
||||
continue
|
||||
path = os.path.join(dirpath, fn)
|
||||
fm = read_frontmatter(path)
|
||||
if not fm:
|
||||
fm, content = read_md_file(path)
|
||||
if not fm and not content:
|
||||
continue
|
||||
|
||||
matched = False
|
||||
# Helper to test non-breadcrumb simple fields using the same semantics
|
||||
def match_field_value(v, qval_local: str) -> bool:
|
||||
if v is None:
|
||||
return False
|
||||
# If v is a list, check each element for a match
|
||||
if isinstance(v, list):
|
||||
for elem in v:
|
||||
s = str(elem)
|
||||
if '*' in qval_local:
|
||||
if fnmatch.fnmatchcase(s.lower(), qval_local.lower()):
|
||||
return True
|
||||
else:
|
||||
if qval_local.strip().lower() in s.lower():
|
||||
return True
|
||||
return False
|
||||
reasons = []
|
||||
snippet = ""
|
||||
|
||||
# Normalise query key. If none or global/all/empty, search all
|
||||
k_lower = (qkey or '').strip().lower()
|
||||
if not k_lower or k_lower in ('global', 'all', 'any'):
|
||||
# Determine which targets to test
|
||||
if targets:
|
||||
target_set = {t.lower() for t in targets}
|
||||
else:
|
||||
s = str(v)
|
||||
if '*' in qval_local:
|
||||
return fnmatch.fnmatchcase(s.lower(), qval_local.lower())
|
||||
else:
|
||||
return qval_local.strip().lower() in s.lower()
|
||||
|
||||
# Helper to evaluate a single clause (key, value) against this file's frontmatter
|
||||
def match_clause(cl_key: str, cl_val: str) -> bool:
|
||||
cl_key = (cl_key or '').strip()
|
||||
cl_val = (cl_val or '').strip()
|
||||
if not cl_key:
|
||||
cl_key = 'global'
|
||||
if cl_key == 'breadcrumbs':
|
||||
query_parts = [p.strip() for p in cl_val.split('>') if p.strip()]
|
||||
bcs = fm.get('breadcrumbs') or []
|
||||
if bcs and isinstance(bcs, list) and bcs and isinstance(bcs[0], dict):
|
||||
bcs_list = [d.get('name') or d.get('slug') or '' for d in bcs]
|
||||
else:
|
||||
bcs_list = bcs
|
||||
return match_breadcrumbs(bcs_list, query_parts)
|
||||
elif cl_key == 'authors':
|
||||
authors = fm.get('authors') or []
|
||||
return match_author(authors, cl_val)
|
||||
elif cl_key == 'pageKeywords':
|
||||
pk = fm.get('pageKeywords') or ''
|
||||
if isinstance(pk, list):
|
||||
tokens = [str(x) for x in pk]
|
||||
else:
|
||||
tokens = [t.strip() for t in str(pk).split(',') if t.strip()]
|
||||
if '*' in cl_val:
|
||||
for t in tokens:
|
||||
if fnmatch.fnmatchcase(t.lower(), cl_val.lower()):
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
for t in tokens:
|
||||
if cl_val.strip().lower() in t.lower():
|
||||
return True
|
||||
return False
|
||||
elif cl_key == 'global':
|
||||
q = cl_val or ''
|
||||
# breadcrumbs
|
||||
target_set = {'title', 'docid', 'breadcrumbs', 'authors', 'keywords', 'category', 'type', 'content'}
|
||||
|
||||
# Single search box mode / global search:
|
||||
# 1. Title
|
||||
if 'title' in target_set:
|
||||
for f in ['title', 'enhancedTitle', 'pageTitle']:
|
||||
if value_matches(qval, fm.get(f)):
|
||||
matched = True
|
||||
reasons.append('Title')
|
||||
break
|
||||
# 2. DocID
|
||||
if 'docid' in target_set:
|
||||
if value_matches(qval, fm.get('docid')):
|
||||
matched = True
|
||||
reasons.append('DocID')
|
||||
# 3. Breadcrumbs
|
||||
if 'breadcrumbs' in target_set:
|
||||
bcs = fm.get('breadcrumbs') or []
|
||||
if isinstance(bcs, list) and bcs and isinstance(bcs[0], dict):
|
||||
bcs_list = [d.get('name') or d.get('slug') or '' for d in bcs]
|
||||
else:
|
||||
bcs_list = bcs
|
||||
if '>' in q:
|
||||
query_parts = [p.strip() for p in q.split('>') if p.strip()]
|
||||
if '>' in qval:
|
||||
query_parts = [p.strip() for p in qval.split('>') if p.strip()]
|
||||
if match_breadcrumbs(bcs_list, query_parts):
|
||||
return True
|
||||
else:
|
||||
for b in bcs_list:
|
||||
try:
|
||||
if q.strip().lower() in str(b).lower():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
# authors
|
||||
if match_author(fm.get('authors') or [], q):
|
||||
return True
|
||||
# pageKeywords
|
||||
pk = fm.get('pageKeywords') or ''
|
||||
if isinstance(pk, list):
|
||||
tokens = [str(x) for x in pk]
|
||||
else:
|
||||
tokens = [t.strip() for t in str(pk).split(',') if t.strip()]
|
||||
if '*' in q:
|
||||
for t in tokens:
|
||||
if fnmatch.fnmatchcase(t.lower(), q.lower()):
|
||||
return True
|
||||
else:
|
||||
for t in tokens:
|
||||
if q.strip().lower() in t.lower():
|
||||
return True
|
||||
# other fields
|
||||
for field in ['category', 'title', 'enhancedTitle', 'type', 'docid']:
|
||||
if match_field_value(fm.get(field), q):
|
||||
return True
|
||||
return False
|
||||
else:
|
||||
return match_field_value(fm.get(cl_key), cl_val)
|
||||
|
||||
if qkey == 'breadcrumbs':
|
||||
# qval format: A>B>C
|
||||
query_parts = [p.strip() for p in qval.split('>') if p.strip()]
|
||||
bcs = fm.get('breadcrumbs') or []
|
||||
# support both list-of-strings and list-of-dicts
|
||||
if bcs and isinstance(bcs, list) and bcs and isinstance(bcs[0], dict):
|
||||
bcs_list = [d.get('name') or d.get('slug') or '' for d in bcs]
|
||||
else:
|
||||
bcs_list = bcs
|
||||
matched = match_breadcrumbs(bcs_list, query_parts)
|
||||
elif qkey == 'or':
|
||||
# qval is a set of clauses separated by '||', '|', or ';' where each clause is key:val
|
||||
# Example: "title:stroke|authors:Smith|global:keyword"
|
||||
sep_norm = (qval or '').replace('||', '|').replace(';', '|')
|
||||
clauses = [c.strip() for c in sep_norm.split('|') if c.strip()]
|
||||
for cl in clauses:
|
||||
if ':' in cl:
|
||||
ck, cv = cl.split(':', 1)
|
||||
else:
|
||||
ck, cv = 'global', cl
|
||||
try:
|
||||
if match_clause(ck, cv):
|
||||
matched = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
elif qkey == 'global':
|
||||
q = qval or ''
|
||||
# breadcrumbs: if query contains '>' treat as sequence, else substring match
|
||||
bcs = fm.get('breadcrumbs') or []
|
||||
if isinstance(bcs, list) and bcs and isinstance(bcs[0], dict):
|
||||
bcs_list = [d.get('name') or d.get('slug') or '' for d in bcs]
|
||||
else:
|
||||
bcs_list = bcs
|
||||
if '>' in q:
|
||||
query_parts = [p.strip() for p in q.split('>') if p.strip()]
|
||||
if match_breadcrumbs(bcs_list, query_parts):
|
||||
reasons.append('Breadcrumbs')
|
||||
else:
|
||||
if value_matches(qval, bcs_list):
|
||||
matched = True
|
||||
reasons.append('Breadcrumbs')
|
||||
# 4. Authors
|
||||
if 'authors' in target_set:
|
||||
if value_matches(qval, fm.get('authors')):
|
||||
matched = True
|
||||
else:
|
||||
# substring match on breadcrumb names
|
||||
for b in bcs_list:
|
||||
try:
|
||||
if q.strip().lower() in str(b).lower():
|
||||
matched = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# authors
|
||||
if not matched:
|
||||
authors = fm.get('authors') or []
|
||||
if match_author(authors, q):
|
||||
reasons.append('Authors')
|
||||
# 5. PageKeywords
|
||||
if 'keywords' in target_set or 'pagekeywords' in target_set:
|
||||
if value_matches(qval, fm.get('pageKeywords')):
|
||||
matched = True
|
||||
|
||||
# pageKeywords
|
||||
if not matched:
|
||||
pk = fm.get('pageKeywords') or ''
|
||||
tokens: List[str]
|
||||
if isinstance(pk, list):
|
||||
tokens = [str(x) for x in pk]
|
||||
else:
|
||||
tokens = [t.strip() for t in str(pk).split(',') if t.strip()]
|
||||
if '*' in q:
|
||||
for t in tokens:
|
||||
if fnmatch.fnmatchcase(t.lower(), q.lower()):
|
||||
matched = True
|
||||
break
|
||||
else:
|
||||
for t in tokens:
|
||||
if q.strip().lower() in t.lower():
|
||||
matched = True
|
||||
break
|
||||
|
||||
# other simple fields
|
||||
if not matched:
|
||||
for field in ['category', 'title', 'enhancedTitle', 'type', 'docid']:
|
||||
if match_field_value(fm.get(field), q):
|
||||
matched = True
|
||||
break
|
||||
elif qkey == 'authors':
|
||||
authors = fm.get('authors') or []
|
||||
matched = match_author(authors, qval)
|
||||
elif qkey == 'pageKeywords':
|
||||
pk = fm.get('pageKeywords') or ''
|
||||
# pageKeywords often is a comma-separated string; split into tokens
|
||||
tokens: List[str]
|
||||
if isinstance(pk, list):
|
||||
tokens = [str(x) for x in pk]
|
||||
else:
|
||||
tokens = [t.strip() for t in str(pk).split(',') if t.strip()]
|
||||
matched = False
|
||||
if '*' in qval:
|
||||
for t in tokens:
|
||||
if fnmatch.fnmatchcase(t.lower(), qval.lower()):
|
||||
matched = True
|
||||
break
|
||||
else:
|
||||
for t in tokens:
|
||||
if qval.strip().lower() in t.lower():
|
||||
matched = True
|
||||
break
|
||||
reasons.append('Keywords')
|
||||
# 6. Category
|
||||
if 'category' in target_set:
|
||||
if value_matches(qval, fm.get('category')):
|
||||
matched = True
|
||||
reasons.append('Category')
|
||||
# 7. Type
|
||||
if 'type' in target_set:
|
||||
if value_matches(qval, fm.get('type')):
|
||||
matched = True
|
||||
reasons.append('Type')
|
||||
# 8. Content
|
||||
if 'content' in target_set:
|
||||
if value_matches(qval, content):
|
||||
matched = True
|
||||
reasons.append('Content')
|
||||
snippet = get_content_snippet(content, qval)
|
||||
else:
|
||||
v = fm.get(qkey)
|
||||
if v is None:
|
||||
matched = False
|
||||
else:
|
||||
# If v is a list, check each element for a match
|
||||
if isinstance(v, list):
|
||||
matched = False
|
||||
for elem in v:
|
||||
s = str(elem)
|
||||
if '*' in qval:
|
||||
if fnmatch.fnmatchcase(s.lower(), qval.lower()):
|
||||
matched = True
|
||||
break
|
||||
else:
|
||||
if qval.strip().lower() in s.lower():
|
||||
matched = True
|
||||
break
|
||||
# Specific key search
|
||||
if k_lower == 'breadcrumbs':
|
||||
bcs = fm.get('breadcrumbs') or []
|
||||
if bcs and isinstance(bcs, list) and bcs and isinstance(bcs[0], dict):
|
||||
bcs_list = [d.get('name') or d.get('slug') or '' for d in bcs]
|
||||
else:
|
||||
s = str(v)
|
||||
if '*' in qval:
|
||||
matched = fnmatch.fnmatchcase(s.lower(), qval.lower())
|
||||
else:
|
||||
matched = qval.strip().lower() in s.lower()
|
||||
|
||||
bcs_list = bcs
|
||||
if '>' in qval:
|
||||
query_parts = [p.strip() for p in qval.split('>') if p.strip()]
|
||||
matched = match_breadcrumbs(bcs_list, query_parts)
|
||||
else:
|
||||
matched = value_matches(qval, bcs_list)
|
||||
if matched:
|
||||
reasons.append('Breadcrumbs')
|
||||
elif k_lower == 'authors':
|
||||
matched = value_matches(qval, fm.get('authors'))
|
||||
if matched:
|
||||
reasons.append('Authors')
|
||||
elif k_lower == 'pagekeywords':
|
||||
matched = value_matches(qval, fm.get('pageKeywords'))
|
||||
if matched:
|
||||
reasons.append('Keywords')
|
||||
elif k_lower == 'content':
|
||||
matched = value_matches(qval, content)
|
||||
if matched:
|
||||
reasons.append('Content')
|
||||
snippet = get_content_snippet(content, qval)
|
||||
elif k_lower in ('title', 'enhancedtitle', 'pagetitle'):
|
||||
for f in ['title', 'enhancedTitle', 'pageTitle']:
|
||||
if value_matches(qval, fm.get(f)):
|
||||
matched = True
|
||||
reasons.append('Title')
|
||||
break
|
||||
else:
|
||||
matched = value_matches(qval, fm.get(qkey))
|
||||
if matched:
|
||||
reasons.append(qkey.capitalize())
|
||||
|
||||
if matched:
|
||||
out.append({
|
||||
'path': path,
|
||||
@@ -335,6 +414,8 @@ def run_search(root: str, qkey: str, qval: str) -> List[Dict[str, Any]]:
|
||||
'breadcrumbs': fm.get('breadcrumbs'),
|
||||
'authors': fm.get('authors'),
|
||||
'pageKeywords': fm.get('pageKeywords'),
|
||||
'reasons': sorted(list(set(reasons))),
|
||||
'snippet': snippet,
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -354,21 +435,23 @@ def write_output(results: List[Dict[str, Any]], out_path: str, fmt: str = 'json'
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--root', default='docs_md/articles', help='Root dir with extracted markdown')
|
||||
p.add_argument('--query', required=True, help='Query in the form key:value where key is breadcrumbs/authors/pageKeywords/...')
|
||||
p.add_argument('--query', required=True, help='Query. Can be key:value or a raw search string')
|
||||
p.add_argument('--mode', choices=['exact', 'fuzzy', 'stemming'], default='exact', help='Search matching mode')
|
||||
p.add_argument('--out', default='results.json', help='Output file')
|
||||
p.add_argument('--format', choices=['json','csv'], default='json')
|
||||
p.add_argument('--copy-to', help='Optional: copy matched markdown files to this dir')
|
||||
p.add_argument("--copy-to-clear", default=True, action="store_true", help="Overwrite files when copying to --copy-to")
|
||||
args = p.parse_args(argv)
|
||||
|
||||
if ':' not in args.query:
|
||||
print('Query must be key:value')
|
||||
return 2
|
||||
key, val = args.query.split(':', 1)
|
||||
key = key.strip()
|
||||
val = val.strip()
|
||||
if ':' in args.query:
|
||||
key, val = args.query.split(':', 1)
|
||||
key = key.strip()
|
||||
val = val.strip()
|
||||
else:
|
||||
key = 'global'
|
||||
val = args.query.strip()
|
||||
|
||||
results = run_search(args.root, key, val)
|
||||
results = run_search(args.root, key, val, args.mode)
|
||||
write_output(results, args.out, args.format)
|
||||
print(f'Found {len(results)} matches. Wrote {args.out}')
|
||||
|
||||
|
||||
+310
-89
@@ -50,102 +50,331 @@ def load_document_to_markdown_module() -> object:
|
||||
document_to_markdown = load_document_to_markdown_module()
|
||||
|
||||
|
||||
def format_result_md(results: List[Dict[str, Any]]) -> str:
|
||||
if not results:
|
||||
return 'No results.'
|
||||
lines = [
|
||||
'| Title | DocID | Path | breadcrumbs | authors | pageKeywords |',
|
||||
'|---|---|---|---|---|---|',
|
||||
]
|
||||
for r in results:
|
||||
title = (r.get('title') or '').replace('|', r'\|')
|
||||
docid = r.get('docid') or ''
|
||||
if docid:
|
||||
doclink = f"[\u2197 {docid}](https://app.statdx.com/document/{docid})"
|
||||
else:
|
||||
doclink = ''
|
||||
path = r.get('path') or ''
|
||||
# file:// link so clicking in a browser attempts to open local file (may be blocked by browser)
|
||||
path_link = f"[{os.path.basename(path)}](file://{path})" if path else ''
|
||||
bcs = json.dumps(r.get('breadcrumbs') or [])
|
||||
authors = json.dumps(r.get('authors') or [])
|
||||
pks = r.get('pageKeywords') or ''
|
||||
lines.append(f'| {title} | {doclink} | {path_link} | {bcs} | {authors} | {pks} |')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
@ui.page('/')
|
||||
def search_page() -> None: # build UI
|
||||
ui.markdown('# Search extracted markdown (search_md)')
|
||||
# Load fonts & modern dark mode stylesheet
|
||||
ui.add_head_html('''
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif !important;
|
||||
background-color: #0f172a !important;
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
.q-page-container {
|
||||
background-color: #0f172a !important;
|
||||
}
|
||||
.q-card {
|
||||
background-color: #1e293b !important;
|
||||
color: #f8fafc !important;
|
||||
border: 1px solid #334155 !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
.q-table__container {
|
||||
background-color: #1e293b !important;
|
||||
color: #f8fafc !important;
|
||||
border: 1px solid #334155 !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
.q-table {
|
||||
background-color: #1e293b !important;
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
.q-table th {
|
||||
font-weight: 600 !important;
|
||||
color: #94a3b8 !important;
|
||||
background-color: #0f172a !important;
|
||||
border-bottom: 2px solid #334155 !important;
|
||||
}
|
||||
.q-table tbody td {
|
||||
color: #e2e8f0 !important;
|
||||
border-bottom: 1px solid #1e293b !important;
|
||||
}
|
||||
.q-table__bottom {
|
||||
background-color: #0f172a !important;
|
||||
color: #94a3b8 !important;
|
||||
border-top: 1px solid #334155 !important;
|
||||
}
|
||||
.q-field__native, .q-field__prefix, .q-field__suffix, .q-field__input {
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
.q-field__label {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
.q-field__marginal {
|
||||
color: #94a3b8 !important;
|
||||
}
|
||||
/* Dropdown popups legibility overrides */
|
||||
.q-menu {
|
||||
background-color: #1e293b !important;
|
||||
border: 1px solid #334155 !important;
|
||||
color: #f8fafc !important;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
|
||||
}
|
||||
.q-item {
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
.q-item:hover, .q-item--active, .q-item--focused {
|
||||
background-color: #334155 !important;
|
||||
color: #f8fafc !important;
|
||||
}
|
||||
.q-item__label {
|
||||
color: inherit !important;
|
||||
}
|
||||
.q-checkbox__bg {
|
||||
border: 2px solid #64748b !important;
|
||||
}
|
||||
.q-item--active .q-checkbox__bg {
|
||||
border: 2px solid #3b82f6 !important;
|
||||
background: #3b82f6 !important;
|
||||
}
|
||||
.gradient-header {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #a855f7 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.badge-reason {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.badge-title {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
color: #93c5fd;
|
||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
.badge-content {
|
||||
background-color: rgba(168, 85, 247, 0.2);
|
||||
color: #e9d5ff;
|
||||
border: 1px solid rgba(168, 85, 247, 0.4);
|
||||
}
|
||||
.badge-other {
|
||||
background-color: rgba(148, 163, 184, 0.2);
|
||||
color: #cbd5e1;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
</style>
|
||||
''')
|
||||
|
||||
with ui.row().style('gap: 12px; align-items: start;'):
|
||||
root_input = ui.input(value='docs_md/articles', label='Root directory', placeholder='docs_md/articles')
|
||||
key_select = ui.select(['global', 'or', 'breadcrumbs', 'authors', 'pageKeywords', 'category', 'title', 'enhancedTitle', 'type'], value='breadcrumbs', label='Query key')
|
||||
qval_input = ui.input(value='', label='Query value', placeholder='e.g. Brain>Diagnosis or Smith or keyword')
|
||||
with ui.column().classes('w-full max-w-7xl mx-auto p-6 gap-6'):
|
||||
# Header / Title Block
|
||||
with ui.row().classes('items-center gap-4 w-full justify-between pb-4 border-b border-slate-800'):
|
||||
with ui.column().classes('gap-1'):
|
||||
ui.html('<h1 class="text-4xl font-extrabold gradient-header m-0">STATdx Article Explorer</h1>')
|
||||
ui.label('Search extracted markdown files with fuzzy matching, stemming, and interactive selection.').classes('text-slate-400 text-base')
|
||||
|
||||
with ui.row().classes('gap-2'):
|
||||
ui.button('Open Output Dir', icon='folder', on_click=lambda: open_output_dir(), color='secondary').props('outline')
|
||||
|
||||
with ui.row().style('gap: 12px; align-items: start; margin-top: 8px;'):
|
||||
fmt_select = ui.select(['json', 'csv'], value='json', label='Output format')
|
||||
out_input = ui.input(value='results.json', label='Output file (server-side)')
|
||||
copy_input = ui.input(value='out', label='Copy matched files to (optional)')
|
||||
copy_clear = ui.checkbox('Clear destination before copy (--copy-to-clear)', value=False)
|
||||
# Main Search Box Card
|
||||
with ui.card().classes('w-full p-6 shadow-xl'):
|
||||
with ui.row().classes('w-full items-center gap-4'):
|
||||
# Single search box
|
||||
qval_input = ui.input(
|
||||
label='Search Query',
|
||||
placeholder='Type search terms...'
|
||||
).classes('flex-grow').props('outlined label-slot')
|
||||
qval_input.add_slot('append', '<q-icon name="search" />')
|
||||
|
||||
# Match mode select
|
||||
mode_select = ui.select(
|
||||
options={'exact': 'Exact Match', 'fuzzy': 'Fuzzy Match', 'stemming': 'Stemming (Related Words)'},
|
||||
value='exact',
|
||||
label='Match Mode'
|
||||
).classes('w-56').props('outlined')
|
||||
|
||||
# Targets select
|
||||
targets_select = ui.select(
|
||||
options={
|
||||
'Title': 'Title',
|
||||
'Content': 'Content',
|
||||
'Breadcrumbs': 'Breadcrumbs',
|
||||
'Authors': 'Authors',
|
||||
'Keywords': 'Keywords',
|
||||
'Category': 'Category',
|
||||
'Type': 'Type'
|
||||
},
|
||||
value=['Title', 'Content', 'Breadcrumbs', 'Keywords', 'Category', 'Type'],
|
||||
label='Search Targets',
|
||||
multiple=True
|
||||
).classes('w-80').props('outlined use-chips')
|
||||
|
||||
# Search button
|
||||
search_btn = ui.button('Search', icon='search', on_click=lambda: asyncio.create_task(run_search_handler())).classes('bg-blue-600 hover:bg-blue-700 text-white font-semibold py-4 px-8 rounded-lg shadow-md transition-all duration-300')
|
||||
|
||||
status = ui.label('')
|
||||
md = ui.markdown('')
|
||||
# Advanced Criteria Expansion Box
|
||||
with ui.expansion('Advanced Configurations', icon='settings').classes('w-full text-slate-300 border border-slate-800 rounded-lg p-2'):
|
||||
with ui.row().classes('w-full gap-4 items-start p-4'):
|
||||
root_input = ui.input(value='docs_md/articles', label='Root directory').classes('w-64').props('outlined dense')
|
||||
key_select = ui.select(['global', 'breadcrumbs', 'authors', 'pageKeywords', 'category', 'title', 'enhancedTitle', 'type', 'content'], value='global', label='Query key').classes('w-64').props('outlined dense')
|
||||
fmt_select = ui.select(['json', 'csv'], value='json', label='Output format').classes('w-40').props('outlined dense')
|
||||
out_input = ui.input(value='results.json', label='Output file (server-side)').classes('w-64').props('outlined dense')
|
||||
copy_input = ui.input(value='out', label='Copy matched files to (optional)').classes('w-64').props('outlined dense')
|
||||
copy_clear = ui.checkbox('Clear destination before copy', value=False).classes('self-center text-slate-300')
|
||||
|
||||
status = ui.label('').classes('text-blue-400 font-semibold text-lg px-2')
|
||||
|
||||
# Action bar for selected rows
|
||||
# Hidden by default, displayed via bind_visibility_from
|
||||
action_bar = ui.row().classes('w-full justify-between items-center gap-4 bg-slate-800 p-4 rounded-lg border border-slate-700')
|
||||
action_bar_text = ui.label('').classes('text-white font-semibold')
|
||||
with action_bar:
|
||||
with ui.row().classes('gap-2'):
|
||||
ui.button('Copy Selected Files', icon='content_copy', on_click=lambda: asyncio.create_task(copy_selected())).classes('bg-indigo-600 hover:bg-indigo-700 text-white font-medium px-4')
|
||||
ui.button('Export Selected', icon='file_download', on_click=lambda: asyncio.create_task(export_selected())).classes('bg-emerald-600 hover:bg-emerald-700 text-white font-medium px-4')
|
||||
ui.button('Deselect All', icon='close', on_click=lambda: table.selected.clear()).classes('bg-slate-700 hover:bg-slate-600 text-white font-medium px-4')
|
||||
|
||||
# Results table filter input
|
||||
filter_card = ui.row().classes('w-full items-center gap-4')
|
||||
with filter_card:
|
||||
table_filter = ui.input(
|
||||
placeholder='Filter results table below (live)...'
|
||||
).classes('w-full').props('outlined dense clearable')
|
||||
table_filter.add_slot('append', '<q-icon name="filter_list" />')
|
||||
|
||||
# Table definition
|
||||
columns = [
|
||||
{'name': 'title', 'label': 'Title', 'field': 'title', 'required': True, 'align': 'left', 'sortable': True},
|
||||
{'name': 'reasons', 'label': 'Matched In', 'field': 'reasons', 'align': 'left', 'sortable': True},
|
||||
{'name': 'snippet', 'label': 'Snippet', 'field': 'snippet', 'align': 'left'},
|
||||
{'name': 'docid', 'label': 'DocID', 'field': 'docid', 'align': 'left'},
|
||||
{'name': 'path', 'label': 'Path', 'field': 'path', 'align': 'left', 'sortable': True},
|
||||
]
|
||||
|
||||
table = ui.table(
|
||||
columns=columns,
|
||||
rows=[],
|
||||
row_key='path',
|
||||
selection='multiple',
|
||||
).classes('w-full shadow-lg')
|
||||
table.bind_filter_from(table_filter, 'value')
|
||||
|
||||
# Bind action bar visibility and selection text
|
||||
action_bar.bind_visibility_from(table, 'selected', lambda sel: len(sel) > 0)
|
||||
action_bar_text.bind_text_from(table, 'selected', lambda sel: f'{len(sel)} files selected')
|
||||
|
||||
# Add slot overrides to format table cells nicely
|
||||
table.add_slot('body-cell-title', '''
|
||||
<q-td :props="props">
|
||||
<a v-if="props.row.docid" :href="'https://app.statdx.com/document/' + props.row.docid" target="_blank" class="text-blue-400 hover:text-blue-300 font-semibold" style="text-decoration: none;">
|
||||
{{ props.row.title }}
|
||||
</a>
|
||||
<span v-else class="text-slate-200 font-semibold">{{ props.row.title }}</span>
|
||||
</q-td>
|
||||
''')
|
||||
table.add_slot('body-cell-reasons', '''
|
||||
<q-td :props="props">
|
||||
<span v-for="r in props.row.reasons" :key="r"
|
||||
class="badge-reason"
|
||||
:class="r === 'Title' ? 'badge-title' : r === 'Content' ? 'badge-content' : 'badge-other'">
|
||||
{{ r }}
|
||||
</span>
|
||||
</q-td>
|
||||
''')
|
||||
table.add_slot('body-cell-path', '''
|
||||
<q-td :props="props">
|
||||
<span class="text-slate-400 text-xs">
|
||||
{{ props.row.path.split('/').pop() }}
|
||||
</span>
|
||||
</q-td>
|
||||
''')
|
||||
|
||||
async def run_search_handler():
|
||||
if not qval_input.value:
|
||||
ui.notify('Please enter a search query', color='warning')
|
||||
return
|
||||
|
||||
status.set_text('Running search...')
|
||||
# run blocking search in thread
|
||||
results = await anyio.to_thread.run_sync(search_md.run_search, root_input.value, key_select.value, qval_input.value)
|
||||
# write output if requested
|
||||
if out_input.value:
|
||||
try:
|
||||
await anyio.to_thread.run_sync(search_md.write_output, results, out_input.value, fmt_select.value)
|
||||
status.set_text(f'Found {len(results)} matches. Wrote {out_input.value}')
|
||||
except Exception as e:
|
||||
status.set_text(f'Found {len(results)} matches. Failed to write output: {e}')
|
||||
else:
|
||||
status.set_text(f'Found {len(results)} matches (not written).')
|
||||
search_btn.set_enabled(False)
|
||||
|
||||
try:
|
||||
results = await anyio.to_thread.run_sync(
|
||||
search_md.run_search,
|
||||
root_input.value,
|
||||
key_select.value,
|
||||
qval_input.value,
|
||||
mode_select.value,
|
||||
targets_select.value
|
||||
)
|
||||
|
||||
table.rows = results
|
||||
table.selected.clear()
|
||||
|
||||
# write output if requested
|
||||
if out_input.value:
|
||||
try:
|
||||
await anyio.to_thread.run_sync(search_md.write_output, results, out_input.value, fmt_select.value)
|
||||
status.set_text(f'Found {len(results)} matches. Wrote {out_input.value}')
|
||||
except Exception as e:
|
||||
status.set_text(f'Found {len(results)} matches. Failed to write output: {e}')
|
||||
else:
|
||||
status.set_text(f'Found {len(results)} matches (not written).')
|
||||
|
||||
except Exception as e:
|
||||
status.set_text(f'Search failed: {e}')
|
||||
ui.notify(f'Search error: {e}', color='negative')
|
||||
finally:
|
||||
search_btn.set_enabled(True)
|
||||
|
||||
# optionally copy matched files
|
||||
if copy_input.value and results:
|
||||
try:
|
||||
os.makedirs(copy_input.value, exist_ok=True)
|
||||
|
||||
# Optionally clear the destination dir before copying
|
||||
if bool(copy_clear.value):
|
||||
def clear_dst():
|
||||
for name in os.listdir(copy_input.value):
|
||||
path = os.path.join(copy_input.value, name)
|
||||
try:
|
||||
if os.path.isfile(path) or os.path.islink(path):
|
||||
os.remove(path)
|
||||
elif os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
except Exception:
|
||||
pass
|
||||
await anyio.to_thread.run_sync(clear_dst)
|
||||
|
||||
def copy_files():
|
||||
for r in results:
|
||||
async def copy_selected():
|
||||
if not table.selected:
|
||||
ui.notify('No files selected', color='warning')
|
||||
return
|
||||
|
||||
dest = copy_input.value.strip() or 'out'
|
||||
status.set_text(f'Copying {len(table.selected)} files to {dest}...')
|
||||
|
||||
try:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
|
||||
if copy_clear.value:
|
||||
def clear_dst():
|
||||
for name in os.listdir(dest):
|
||||
path = os.path.join(dest, name)
|
||||
try:
|
||||
dst = os.path.join(copy_input.value, os.path.basename(r['path']))
|
||||
with open(r['path'], 'rb') as srcf, open(dst, 'wb') as dstf:
|
||||
dstf.write(srcf.read())
|
||||
if os.path.isfile(path) or os.path.islink(path):
|
||||
os.remove(path)
|
||||
elif os.path.isdir(path):
|
||||
shutil.rmtree(path)
|
||||
except Exception:
|
||||
pass
|
||||
await anyio.to_thread.run_sync(copy_files)
|
||||
status.set_text(status.text + f' Copied {len(results)} files to {copy_input.value}')
|
||||
except Exception as e:
|
||||
status.set_text(status.text + f' Copy failed: {e}')
|
||||
await anyio.to_thread.run_sync(clear_dst)
|
||||
|
||||
def copy_files():
|
||||
for r in table.selected:
|
||||
try:
|
||||
dst = os.path.join(dest, os.path.basename(r['path']))
|
||||
with open(r['path'], 'rb') as srcf, open(dst, 'wb') as dstf:
|
||||
dstf.write(srcf.read())
|
||||
except Exception:
|
||||
pass
|
||||
await anyio.to_thread.run_sync(copy_files)
|
||||
status.set_text(status.text + f' Copied {len(table.selected)} files to {dest}')
|
||||
ui.notify('Files copied successfully', color='positive')
|
||||
except Exception as e:
|
||||
status.set_text(status.text + f' Copy failed: {e}')
|
||||
ui.notify(f'Copy failed: {e}', color='negative')
|
||||
|
||||
md.set_content(format_result_md(results))
|
||||
async def export_selected():
|
||||
if not table.selected:
|
||||
ui.notify('No records selected', color='warning')
|
||||
return
|
||||
|
||||
outp = out_input.value or 'results.json'
|
||||
status.set_text(f'Exporting {len(table.selected)} records to {outp}...')
|
||||
|
||||
try:
|
||||
await anyio.to_thread.run_sync(search_md.write_output, table.selected, outp, fmt_select.value)
|
||||
status.set_text(f'Exported {len(table.selected)} records to {outp}.')
|
||||
ui.notify('Export successful', color='positive')
|
||||
except Exception as e:
|
||||
status.set_text(f'Export failed: {e}')
|
||||
ui.notify(f'Export failed: {e}', color='negative')
|
||||
|
||||
ui.button('Run search', on_click=lambda: asyncio.create_task(run_search_handler()))
|
||||
|
||||
ui.button('Open results.json (server)', on_click=lambda: ui.run_javascript("window.open(" + json.dumps(out_input.value) + ")"), color='secondary')
|
||||
|
||||
def open_output_dir() -> None:
|
||||
# Prefer the copy destination if provided, otherwise use the directory of the output file
|
||||
dest = copy_input.value.strip() if copy_input.value else ''
|
||||
if dest:
|
||||
path = os.path.abspath(dest)
|
||||
@@ -155,7 +384,6 @@ def search_page() -> None: # build UI
|
||||
path = os.path.abspath(dirname) if dirname else os.getcwd()
|
||||
status.set_text(f'Opening: {path} ...')
|
||||
|
||||
# Try to open with a server-side file manager (xdg-open/gio/gvfs-open) on POSIX
|
||||
tried = []
|
||||
try:
|
||||
if os.name == 'posix':
|
||||
@@ -171,17 +399,10 @@ def search_page() -> None: # build UI
|
||||
except Exception as e:
|
||||
status.set_text(status.text + f' Server-side open check failed: {e}')
|
||||
|
||||
# If we got here, server-side open didn't work. Try client-side fallback and show path.
|
||||
status.set_text(status.text + ' Falling back to browser open (may be blocked).')
|
||||
# Also render the path visibly so the user can copy it
|
||||
ui.notify(f'Path: {path}', color='primary')
|
||||
ui.run_javascript("window.open(" + json.dumps('file://' + path) + ")")
|
||||
|
||||
ui.button('Open output directory', on_click=open_output_dir, color='secondary')
|
||||
|
||||
ui.markdown('---')
|
||||
ui.markdown('Results:')
|
||||
|
||||
# Document -> Markdown conversion section
|
||||
ui.markdown('---')
|
||||
ui.markdown('# Convert captured JSON -> Markdown (document_to_markdown)')
|
||||
|
||||
Reference in New Issue
Block a user